feat: add native password and sms login

This commit is contained in:
2026-08-19 12:00:34 +08:00
parent abece81fdb
commit 5a048da8a6
9 changed files with 1539 additions and 859 deletions

View File

@@ -1,6 +1,5 @@
import { EventEmitter } from 'node:events';
import type { IncomingMessage, ServerResponse } from 'http';
import { shell } from 'electron';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { handleAuthRoutes } from '@electron/api/routes/auth';
import {
@@ -173,7 +172,7 @@ describe('auth host api routes', () => {
expect(getWorksSquareSessionSnapshot()).toBeNull();
});
it('exchanges username and AES-encrypted password through the app SSO token endpoint', async () => {
it('proxies password login through Works and commits a redacted Main session', async () => {
const fetchMock = vi.fn().mockResolvedValueOnce(
new Response(JSON.stringify({
access_token: 'access-token',
@@ -182,7 +181,6 @@ describe('auth host api routes', () => {
expires_in: 43200,
username: 'zhangsan',
user_id: '1',
client_id: 'app',
}), { status: 200 }),
);
vi.stubGlobal('fetch', fetchMock);
@@ -192,9 +190,6 @@ describe('auth host api routes', () => {
createRequest('POST', {
username: 'zhangsan',
password: 'passw0rd',
code: 'a7k9',
randomStr: '333e6825-760c-4c1a-8b56-eb9539b43dbd',
scope: 'server',
}),
response.res,
new URL('http://127.0.0.1:13210/api/auth/login'),
@@ -211,7 +206,6 @@ describe('auth host api routes', () => {
expires_in: 43200,
username: 'zhangsan',
user_id: '1',
client_id: 'app',
},
session: {
accessToken: 'access-token',
@@ -224,16 +218,19 @@ describe('auth host api routes', () => {
expect(fetchMock).toHaveBeenCalledOnce();
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
expect(url).toBe('https://biz.nianxx.cn/auth/oauth2/token');
expect(url).toBe('https://square.nianxx.cn/api/auth/login');
expect(init.method).toBe('POST');
expect(init.headers).toEqual({
Authorization: `Basic ${Buffer.from('app:app').toString('base64')}`,
'Content-Type': 'application/x-www-form-urlencoded',
expect(init.headers).toEqual({ 'Content-Type': 'application/json' });
expect(JSON.parse(String(init.body))).toEqual({
username: 'zhangsan',
password: 'passw0rd',
});
expect(String(init.body)).toBe(
'grant_type=password&username=zhangsan&password=ILsdQ7Wp2P8%3D&scope=server&code=a7k9&randomStr=333e6825-760c-4c1a-8b56-eb9539b43dbd',
);
expect(String(init.body)).not.toContain('passw0rd');
expect(JSON.stringify(response.json())).not.toContain('refresh-token');
expect(getWorksSquareSessionSnapshot()).toMatchObject({
accessToken: 'access-token',
canRefresh: true,
});
expect(getWorksSquareSessionSnapshot()).not.toHaveProperty('refreshToken');
});
it('allows explicit reauthorization to replace an unreadable persisted session', async () => {
@@ -297,57 +294,8 @@ describe('auth host api routes', () => {
expect(JSON.stringify(response.json())).not.toContain('app:app');
});
it('replaces an upstream HTML gateway error with a concise browser-login message', async () => {
const fetchMock = vi.fn().mockResolvedValueOnce(
new Response(
'<html><head><title>502 Bad Gateway</title></head><body>upstream details</body></html>',
{ status: 502, headers: { 'content-type': 'text/html' } },
),
);
vi.stubGlobal('fetch', fetchMock);
const response = createResponse();
await handleAuthRoutes(
createRequest('POST'),
response.res,
new URL('http://127.0.0.1:13210/api/auth/browser/start'),
{} as never,
);
expect(response.statusCode).toBe(502);
expect(response.json()).toEqual({
success: false,
error: '登录服务暂时不可用,请稍后重试。',
});
expect(JSON.stringify(response.json())).not.toContain('<html>');
});
it('opens Works Square browser authorization and returns the approved desktop token', async () => {
const fetchMock = vi.fn()
.mockResolvedValueOnce(
new Response(JSON.stringify({
request_id: 'desktop-request-id',
device_secret: 'desktop-device-secret',
authorize_url: 'https://square.nianxx.cn/#desktop-auth?request_id=desktop-request-id',
poll_interval_seconds: 0,
}), { status: 200 }),
)
.mockResolvedValueOnce(
new Response(JSON.stringify({ status: 'pending' }), { status: 200 }),
)
.mockResolvedValueOnce(
new Response(JSON.stringify({
status: 'approved',
token: {
access_token: 'desktop-access-token',
refresh_token: 'desktop-refresh-token',
token_type: 'Bearer',
expires_in: 43200,
username: 'student',
user_id: '42',
},
}), { status: 200 }),
);
it('has no browser-start route', async () => {
const fetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock);
const response = createResponse();
@@ -359,74 +307,265 @@ describe('auth host api routes', () => {
);
expect(handled).toBe(true);
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual({
success: true,
token: {
access_token: 'desktop-access-token',
token_type: 'Bearer',
expires_in: 43200,
username: 'student',
user_id: '42',
},
session: {
accessToken: 'desktop-access-token',
tokenType: 'Bearer',
expiresAt: expect.any(Number),
lastActiveAt: expect.any(Number),
canRefresh: true,
},
});
expect(shell.openExternal).toHaveBeenCalledWith(
'https://square.nianxx.cn/#desktop-auth?request_id=desktop-request-id',
);
expect(fetchMock.mock.calls.map(([url]) => String(url))).toEqual([
'https://square.nianxx.cn/api/auth/desktop/start',
'https://square.nianxx.cn/api/auth/desktop/token?request_id=desktop-request-id&device_secret=desktop-device-secret',
'https://square.nianxx.cn/api/auth/desktop/token?request_id=desktop-request-id&device_secret=desktop-device-secret',
]);
expect(response.statusCode).toBe(404);
expect(fetchMock).not.toHaveBeenCalled();
});
it('stores the approved browser token bundle in the Main Works Square session cache', async () => {
vi.setSystemTime(new Date('2026-07-06T08:00:00.000Z'));
const fetchMock = vi.fn()
.mockResolvedValueOnce(
new Response(JSON.stringify({
request_id: 'desktop-request-id',
device_secret: 'desktop-device-secret',
authorize_url: 'https://square.nianxx.cn/#desktop-auth?request_id=desktop-request-id',
poll_interval_seconds: 0,
}), { status: 200 }),
)
.mockResolvedValueOnce(
new Response(JSON.stringify({
status: 'approved',
token: {
access_token: 'desktop-access-token',
refresh_token: 'desktop-refresh-token',
token_type: 'Bearer',
expires_in: 43200,
},
}), { status: 200 }),
it.each(['authBase', 'clientId', 'clientSecret', 'scope', 'passwordEncodeKey'])(
'rejects renderer-provided auth control field %s',
async (field) => {
const fetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock);
const response = createResponse();
await handleAuthRoutes(
createRequest('POST', {
username: 'zhangsan',
password: 'passw0rd',
[field]: 'renderer-controlled',
}),
response.res,
new URL('http://127.0.0.1:13210/api/auth/login'),
{} as never,
);
expect(response.statusCode).toBe(400);
expect(response.json()).toEqual({
success: false,
error: `Unexpected field: ${field}`,
});
expect(fetchMock).not.toHaveBeenCalled();
},
);
it('maps Works 5xx responses to a safe login service failure', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce(new Response(
JSON.stringify({ detail: 'internal host and credential details' }),
{ status: 503 },
)));
const response = createResponse();
await handleAuthRoutes(
createRequest('POST', { username: 'zhangsan', password: 'passw0rd' }),
response.res,
new URL('http://127.0.0.1:13210/api/auth/login'),
{} as never,
);
expect(response.statusCode).toBe(502);
expect(response.json()).toEqual({
success: false,
error: '登录服务暂时不可用,请稍后重试。',
});
expect(JSON.stringify(response.json())).not.toContain('internal host');
});
it('maps Works network failures to a safe login service failure', async () => {
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(
new Error('connect ECONNREFUSED 10.0.0.8 with password=secret'),
));
const response = createResponse();
await handleAuthRoutes(
createRequest('POST', { username: 'zhangsan', password: 'passw0rd' }),
response.res,
new URL('http://127.0.0.1:13210/api/auth/login'),
{} as never,
);
expect(response.statusCode).toBe(502);
expect(response.json()).toEqual({
success: false,
error: '登录服务暂时不可用,请稍后重试。',
});
expect(JSON.stringify(response.json())).not.toContain('10.0.0.8');
});
it('proxies mobile login and commits the returned token bundle', async () => {
const fetchMock = vi.fn().mockResolvedValueOnce(
new Response(JSON.stringify({
access_token: 'mobile-access-token',
refresh_token: 'mobile-refresh-token',
token_type: 'Bearer',
expires_in: 43200,
username: '13800000000',
}), { status: 200 }),
);
vi.stubGlobal('fetch', fetchMock);
const response = createResponse();
await handleAuthRoutes(
createRequest('POST'),
createRequest('POST', { phone: '13800000000', code: '123456' }),
response.res,
new URL('http://127.0.0.1:13210/api/auth/browser/start'),
new URL('http://127.0.0.1:13210/api/auth/mobile-login'),
{} as never,
);
expect(response.statusCode).toBe(200);
expect(getWorksSquareSessionSnapshot()).toMatchObject({
accessToken: 'desktop-access-token',
tokenType: 'Bearer',
expiresAt: Date.now() + 43_200_000,
canRefresh: true,
expect(response.json()).toMatchObject({
success: true,
token: { access_token: 'mobile-access-token' },
session: { accessToken: 'mobile-access-token', canRefresh: true },
});
expect(getWorksSquareSessionSnapshot()).not.toHaveProperty('refreshToken');
expect(JSON.stringify(response.json())).not.toContain('mobile-refresh-token');
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
expect(url).toBe('https://square.nianxx.cn/api/auth/mobile-login');
expect(JSON.parse(String(init.body))).toEqual({ phone: '13800000000', code: '123456' });
});
it('returns only success for a successful SMS send envelope', async () => {
const fetchMock = vi.fn().mockResolvedValueOnce(
new Response(JSON.stringify({ code: 0, msg: 'sent', data: true }), { status: 200 }),
);
vi.stubGlobal('fetch', fetchMock);
const response = createResponse();
await handleAuthRoutes(
createRequest('POST', {
phone: '13800000000',
imageRandomStr: '550e8400-e29b-41d4-a716-446655440000',
imageCode: '15',
}),
response.res,
new URL('http://127.0.0.1:13210/api/auth/mobile-code'),
{} as never,
);
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual({ success: true });
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
expect(url).toBe('https://square.nianxx.cn/api/auth/mobile-code');
expect(JSON.parse(String(init.body))).toEqual({
phone: '13800000000',
imageRandomStr: '550e8400-e29b-41d4-a716-446655440000',
imageCode: '15',
});
expect(JSON.stringify(response.json())).not.toContain('sent');
});
it('maps a failed SMS success envelope to an actionable 400', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce(
new Response(JSON.stringify({ code: 1, msg: '图形验证码不合法', data: false }), { status: 200 }),
));
const response = createResponse();
await handleAuthRoutes(
createRequest('POST', {
phone: '13800000000',
imageRandomStr: '550e8400-e29b-41d4-a716-446655440000',
imageCode: 'wrong',
}),
response.res,
new URL('http://127.0.0.1:13210/api/auth/mobile-code'),
{} as never,
);
expect(response.statusCode).toBe(400);
expect(response.json()).toEqual({ success: false, error: '图形验证码不合法' });
});
it('validates captcha UUID before contacting Works', async () => {
const fetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock);
const response = createResponse();
await handleAuthRoutes(
createRequest('GET'),
response.res,
new URL('http://127.0.0.1:13210/api/auth/mobile-image-code?randomStr=not-a-uuid'),
{} as never,
);
expect(response.statusCode).toBe(400);
expect(fetchMock).not.toHaveBeenCalled();
});
it('projects a bounded PNG captcha as no-store base64 JSON', async () => {
const png = Buffer.concat([
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
Buffer.from('captcha'),
]);
vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce(new Response(png, {
status: 200,
headers: { 'Content-Type': 'image/png' },
})));
const response = createResponse();
await handleAuthRoutes(
createRequest('GET'),
response.res,
new URL('http://127.0.0.1:13210/api/auth/mobile-image-code?randomStr=550e8400-e29b-41d4-a716-446655440000'),
{} as never,
);
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual({
success: true,
image: { mimeType: 'image/png', dataBase64: png.toString('base64') },
});
expect(response.res.setHeader).toHaveBeenCalledWith('Cache-Control', 'no-store');
});
it.each([
['wrong MIME', Buffer.from('not png'), 'image/jpeg'],
['wrong magic', Buffer.from('not png'), 'image/png'],
['too large', Buffer.concat([
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
Buffer.alloc(1024 * 1024),
]), 'image/png'],
])('rejects captcha images with %s', async (_label, bytes, contentType) => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce(new Response(bytes, {
status: 200,
headers: { 'Content-Type': contentType },
})));
const response = createResponse();
await handleAuthRoutes(
createRequest('GET'),
response.res,
new URL('http://127.0.0.1:13210/api/auth/mobile-image-code?randomStr=550e8400-e29b-41d4-a716-446655440000'),
{} as never,
);
expect(response.statusCode).toBe(502);
expect(response.json()).toEqual({
success: false,
error: '图形验证码服务返回了无效图片,请稍后重试。',
});
});
it('projects only safe public login links', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce(new Response(JSON.stringify({
downloads: { windows_url: 'https://secret.example/client.exe' },
legal: {
terms_url: '/legal/terms',
privacy_url: 'http://unsafe.example/privacy',
},
auth: {
forgot_password_url: 'https://accounts.example/forgot',
wechat_login_url: 'https://secret.example/wechat',
},
}), { status: 200 })));
const response = createResponse();
await handleAuthRoutes(
createRequest('GET'),
response.res,
new URL('http://127.0.0.1:13210/api/auth/public-config'),
{} as never,
);
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual({
success: true,
links: {
termsUrl: 'https://square.nianxx.cn/legal/terms',
privacyUrl: null,
forgotPasswordUrl: 'https://accounts.example/forgot',
},
});
expect(JSON.stringify(response.json())).not.toContain('secret.example');
expect(response.res.setHeader).toHaveBeenCalledWith('Cache-Control', 'no-store');
});
it('accepts renderer session sync after app restart', async () => {
@@ -776,6 +915,40 @@ describe('auth host api routes', () => {
});
});
it('rejects a renderer-controlled logout auth base without sending the Main token', async () => {
storeWorksSquareSession({
accessToken: 'main-secret-access-token',
refreshToken: 'main-secret-refresh-token',
expiresAt: Date.now() + 3600_000,
lastActiveAt: Date.now(),
});
const fetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock);
const stop = vi.fn();
const response = createResponse();
await handleAuthRoutes(
createRequest('POST', {
accessToken: 'renderer-stale-access-token',
authBase: 'https://attacker.example/collect',
}),
response.res,
new URL('http://127.0.0.1:13210/api/auth/logout'),
{ opencodeManager: { stop } } as never,
);
expect(response.statusCode).toBe(400);
expect(response.json()).toEqual({
success: false,
error: 'Unexpected field: authBase',
});
expect(fetchMock).not.toHaveBeenCalled();
expect(stop).not.toHaveBeenCalled();
expect(getWorksSquareSessionSnapshot()).toMatchObject({
accessToken: 'main-secret-access-token',
});
});
it('blocks a new login while cleanup of the previous runtime still fails', async () => {
const fetchMock = vi.fn().mockResolvedValueOnce(
new Response(JSON.stringify({ success: true }), { status: 200 }),

View File

@@ -43,7 +43,7 @@ describe('auth store', () => {
vi.useRealTimers();
});
it('starts browser authorization through the host api and stores the session', async () => {
it('logs in with a password through the host api and stores the hydrated session', async () => {
hostApiFetchMock
.mockResolvedValueOnce({
success: true,
@@ -77,12 +77,21 @@ describe('auth store', () => {
},
});
await useAuthStore.getState().loginWithBrowser();
expect(hostApiFetchMock).toHaveBeenCalledWith('/api/auth/browser/start', {
method: 'POST',
await useAuthStore.getState().loginWithPassword({
username: 'zhangsan',
password: 'secret',
});
expect(hostApiFetchMock).toHaveBeenNthCalledWith(1, '/api/auth/login', {
method: 'POST',
body: JSON.stringify({ username: 'zhangsan', password: 'secret' }),
});
expect(hostApiFetchMock).toHaveBeenNthCalledWith(2, '/api/auth/me');
expect(hostApiFetchMock).not.toHaveBeenCalledWith(
'/api/auth/browser/start',
expect.anything(),
);
const state = useAuthStore.getState();
expect(state.isAuthenticated()).toBe(true);
expect(state.accessToken).toBe('access-token');
@@ -107,6 +116,91 @@ describe('auth store', () => {
);
});
it('logs in with a mobile code using only the phone and code payload', async () => {
hostApiFetchMock
.mockResolvedValueOnce({
success: true,
token: {
access_token: 'mobile-access-token',
token_type: 'Bearer',
username: '13800138000',
user_id: '2',
},
session: {
accessToken: 'mobile-access-token',
tokenType: 'Bearer',
expiresAt: Date.now() + 60_000,
lastActiveAt: Date.now(),
canRefresh: true,
},
})
.mockResolvedValueOnce({
success: true,
moduleAccess: { learning: false },
});
await useAuthStore.getState().loginWithMobile({
phone: '13800138000',
code: '123456',
});
expect(hostApiFetchMock).toHaveBeenNthCalledWith(1, '/api/auth/mobile-login', {
method: 'POST',
body: JSON.stringify({ phone: '13800138000', code: '123456' }),
});
expect(hostApiFetchMock).toHaveBeenNthCalledWith(2, '/api/auth/me');
expect(useAuthStore.getState()).toMatchObject({
initialized: true,
loading: false,
accessToken: 'mobile-access-token',
user: { username: '13800138000', userId: '2' },
moduleAccess: {
programming: true,
design: true,
learning: false,
robot: true,
},
});
});
it('keeps a new session with safe defaults when module hydration is temporarily unavailable', async () => {
hostApiFetchMock
.mockResolvedValueOnce({
success: true,
token: {
access_token: 'access-token',
token_type: 'Bearer',
username: 'zhangsan',
},
session: {
accessToken: 'access-token',
tokenType: 'Bearer',
expiresAt: Date.now() + 60_000,
lastActiveAt: Date.now(),
canRefresh: true,
},
})
.mockRejectedValueOnce(new Error('Host API unavailable'));
await useAuthStore.getState().loginWithPassword({
username: 'zhangsan',
password: 'secret',
});
expect(useAuthStore.getState()).toMatchObject({
initialized: true,
loading: false,
error: null,
accessToken: 'access-token',
moduleAccess: {
programming: true,
design: true,
learning: true,
robot: true,
},
});
});
it('refreshes module access while restoring the session and defaults missing keys to enabled', async () => {
hostApiFetchMock
.mockResolvedValueOnce({
@@ -226,7 +320,10 @@ describe('auth store', () => {
details: { status: 401 },
}));
await expect(useAuthStore.getState().loginWithBrowser()).rejects.toThrow(
await expect(useAuthStore.getState().loginWithPassword({
username: 'zhangsan',
password: 'secret',
})).rejects.toThrow(
'登录已过期,请重新授权。',
);
@@ -244,23 +341,47 @@ describe('auth store', () => {
});
});
it('surfaces browser authorization failures and does not keep a partial session', async () => {
it('surfaces password login failures and does not keep a partial session', async () => {
hostApiFetchMock.mockResolvedValueOnce({
success: false,
error: 'Authorization timed out',
error: 'Invalid credentials',
});
await expect(useAuthStore.getState().loginWithBrowser()).rejects.toThrow(
'Authorization timed out',
await expect(useAuthStore.getState().loginWithPassword({
username: 'zhangsan',
password: 'wrong',
})).rejects.toThrow(
'Invalid credentials',
);
const state = useAuthStore.getState();
expect(state.loading).toBe(false);
expect(state.error).toBe('Authorization timed out');
expect(state.error).toBe('Invalid credentials');
expect(state.accessToken).toBeNull();
expect(state.isAuthenticated()).toBe(false);
});
it('rejects an invalid mobile login snapshot without retaining token data', async () => {
hostApiFetchMock.mockResolvedValueOnce({
success: true,
token: { access_token: 'partial-token', username: '13800138000' },
session: { accessToken: 'partial-token' },
});
await expect(useAuthStore.getState().loginWithMobile({
phone: '13800138000',
code: '123456',
})).rejects.toThrow('Login failed');
expect(hostApiFetchMock).toHaveBeenCalledTimes(1);
expect(useAuthStore.getState()).toMatchObject({
loading: false,
error: 'Login failed',
accessToken: null,
user: null,
});
});
it('clears a persisted session when the configured SSO gateway changes', async () => {
useAuthStore.setState({
initialized: false,
@@ -476,13 +597,16 @@ describe('auth store', () => {
});
});
it('does not let delayed browser login revive a Main-terminal session', async () => {
it('does not let a delayed password login revive a Main-terminal session', async () => {
let resolveLogin!: (value: unknown) => void;
hostApiFetchMock.mockImplementationOnce(() => new Promise((resolve) => {
resolveLogin = resolve;
}));
const login = useAuthStore.getState().loginWithBrowser();
const login = useAuthStore.getState().loginWithPassword({
username: 'zhangsan',
password: 'secret',
});
useAuthStore.getState().applyMainSession(null);
resolveLogin({
success: true,

View File

@@ -1,13 +1,9 @@
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { MemoryRouter, Route, Routes } from 'react-router-dom';
import { Login } from '@/pages/Login';
import { useAuthStore } from '@/stores/auth';
import App from '@/App';
import { useSettingsStore } from '@/stores/settings';
import { useOpencodeStore } from '@/stores/opencode';
import { useProjectConfigStore } from '@/stores/project-config';
import { createProjectConfig } from '../../shared/project-config';
import { useProviderStore } from '@/stores/providers';
const hostApiFetchMock = vi.hoisted(() => vi.fn());
@@ -15,6 +11,11 @@ vi.mock('@/lib/host-api', () => ({
hostApiFetch: (...args: unknown[]) => hostApiFetchMock(...args),
}));
const loginWithPassword = vi.fn();
const loginWithMobile = vi.fn();
const logout = vi.fn();
const importUserModelConfig = vi.fn();
function resetAuthStore() {
useAuthStore.setState({
initialized: true,
@@ -29,487 +30,318 @@ function resetAuthStore() {
canRefresh: false,
legacyRefreshToken: null,
user: null,
loginWithPassword,
loginWithMobile,
logout,
});
}
function renderLogin() {
return render(
<MemoryRouter initialEntries={['/login']}>
<Routes>
<Route path="/login" element={<Login />} />
<Route path="/module-select" element={<div>Module Selection</div>} />
</Routes>
</MemoryRouter>,
);
}
function switchToMobile() {
fireEvent.click(screen.getByRole('tab', { name: '验证码登录' }));
}
function resolveCaptcha(randomStr = 'captcha-id') {
return {
success: true,
image: { mimeType: 'image/png', dataBase64: `image-${randomStr}` },
};
}
function createDeferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((promiseResolve) => {
resolve = promiseResolve;
});
return { promise, resolve };
}
describe('Login page', () => {
beforeEach(() => {
window.electron.imageWorkspaceLocalDevelopment = false;
window.localStorage.clear();
hostApiFetchMock.mockReset();
vi.clearAllMocks();
vi.useRealTimers();
resetAuthStore();
useSettingsStore.getState().resetSettings();
useOpencodeStore.setState({ projects: [], activeProject: null });
useProjectConfigStore.setState({ configsByProjectId: {}, knowledgeByProjectId: {}, loadingProjectId: null, errorsByProjectId: {} });
vi.unstubAllGlobals();
});
it('keeps the login route reachable from the app router', async () => {
vi.stubGlobal('__NIANCODE_RENDERER_ONLY__', true);
render(
<MemoryRouter initialEntries={['/login']}>
<App />
</MemoryRouter>,
);
expect(await screen.findByRole('button', { name: '在浏览器中继续' })).toBeInTheDocument();
});
it('uses Chinese copy and keeps upstream HTML errors concise', () => {
useAuthStore.setState({
error: '<html><head><title>502 Bad Gateway</title></head><body>upstream details</body></html>',
useProviderStore.setState({ importUserModelConfig });
importUserModelConfig.mockResolvedValue(undefined);
logout.mockResolvedValue(undefined);
hostApiFetchMock.mockImplementation(async (path: string) => {
if (path === '/api/auth/public-config') {
return {
success: true,
links: {
termsUrl: 'https://works.example/terms',
privacyUrl: 'https://works.example/privacy',
forgotPasswordUrl: 'https://works.example/forgot',
},
};
}
if (path.startsWith('/api/auth/mobile-image-code?')) return resolveCaptcha();
return { success: true };
});
render(
<MemoryRouter initialEntries={['/login']}>
<Login />
</MemoryRouter>,
);
expect(screen.getByText('登录 Makelore 账户')).toBeInTheDocument();
expect(screen.getByText('请在浏览器中完成登录,并授权此桌面应用访问。')).toBeInTheDocument();
expect(screen.getByRole('button', { name: '在浏览器中继续' })).toBeInTheDocument();
expect(screen.getByRole('alert')).toHaveTextContent('登录服务暂时不可用,请稍后重试。');
expect(screen.queryByText(/<html>/i)).not.toBeInTheDocument();
vi.spyOn(globalThis.crypto, 'randomUUID').mockReturnValue('00000000-0000-4000-8000-000000000001');
});
it('redirects protected app routes to login when setup is complete but the user is signed out', async () => {
useSettingsStore.setState({ setupComplete: true });
render(
<MemoryRouter initialEntries={['/models']}>
<App />
</MemoryRouter>,
);
expect(await screen.findByRole('button', { name: '在浏览器中继续' })).toBeInTheDocument();
afterEach(() => {
vi.restoreAllMocks();
vi.useRealTimers();
});
it('opens the module chooser at the default root route', async () => {
vi.stubGlobal('__NIANCODE_RENDERER_ONLY__', true);
it('shows native Chinese tabs with password login as the default and no browser authorization surface', async () => {
renderLogin();
render(
<MemoryRouter initialEntries={['/']}>
<App />
</MemoryRouter>,
);
expect(await screen.findByTestId('ai-module-selection-page')).toBeInTheDocument();
expect(screen.getByRole('heading', { name: '今天,想创作什么?' })).toBeInTheDocument();
await screen.findByRole('link', { name: '用户协议' });
expect(screen.getByRole('tab', { name: '密码登录' })).toHaveAttribute('aria-selected', 'true');
expect(screen.getByRole('tab', { name: '验证码登录' })).toHaveAttribute('aria-selected', 'false');
expect(screen.getByLabelText('用户名')).toHaveAttribute('autocomplete', 'username');
expect(screen.getByLabelText('密码')).toHaveAttribute('autocomplete', 'current-password');
expect(screen.queryByText(/浏览器|微信|注册/)).not.toBeInTheDocument();
});
it('redirects an open protected route when the current auth session is invalidated', async () => {
useSettingsStore.setState({ setupComplete: true });
useAuthStore.setState({
initialized: true,
loading: false,
error: null,
authBase: 'https://biz.nianxx.cn/auth/',
clientId: 'app',
accessToken: 'access-token',
tokenType: 'Bearer',
expiresAt: Date.now() + 60_000,
lastActiveAt: Date.now(),
canRefresh: true,
legacyRefreshToken: null,
user: {
username: 'brother7',
userId: '1',
tenantId: null,
deptId: null,
authorities: [],
it('renders only safe projected links and degrades unavailable or unsafe links to plain text', async () => {
hostApiFetchMock.mockResolvedValueOnce({
success: true,
links: {
termsUrl: null,
privacyUrl: 'javascript:alert(1)',
forgotPasswordUrl: null,
},
});
hostApiFetchMock.mockResolvedValue({ success: true });
renderLogin();
render(
<MemoryRouter initialEntries={['/makelore-home']}>
<App />
</MemoryRouter>,
);
await waitFor(() => expect(hostApiFetchMock).toHaveBeenCalledWith(
'/api/auth/session/sync',
expect.any(Object),
));
act(() => {
useAuthStore.setState({
accessToken: null,
tokenType: null,
expiresAt: null,
lastActiveAt: null,
canRefresh: false,
legacyRefreshToken: null,
user: null,
});
});
expect(await screen.findByRole('button', { name: '在浏览器中继续' })).toBeInTheDocument();
await waitFor(() => expect(hostApiFetchMock).toHaveBeenCalledWith('/api/auth/public-config', { cache: 'no-store' }));
expect(screen.getByRole('checkbox').closest('label')).toHaveTextContent('用户协议');
expect(screen.getByRole('checkbox').closest('label')).toHaveTextContent('隐私政策');
expect(screen.queryByText('忘记密码?')).not.toBeInTheDocument();
expect(screen.queryByRole('link')).not.toBeInTheDocument();
});
it('does not record synthetic DOM events as user activity', async () => {
useSettingsStore.setState({ setupComplete: true });
const lastActiveAt = Date.now() - 24 * 60 * 60 * 1000;
useAuthStore.setState({
initialized: true,
loading: false,
error: null,
authBase: 'https://biz.nianxx.cn/auth/',
clientId: 'app',
accessToken: 'access-token',
tokenType: 'Bearer',
expiresAt: Date.now() + 3600_000,
lastActiveAt,
canRefresh: true,
legacyRefreshToken: null,
user: {
username: 'brother7',
userId: '1',
tenantId: null,
deptId: null,
authorities: [],
},
});
hostApiFetchMock.mockImplementation(async (path: string) => {
if (path === '/api/auth/session/activity') {
return {
success: true,
session: {
accessToken: 'access-token',
tokenType: 'Bearer',
expiresAt: Date.now() + 3600_000,
lastActiveAt: Date.now(),
canRefresh: true,
},
};
}
if (path === '/api/auth/session/sync') {
return {
success: true,
session: {
accessToken: 'access-token',
tokenType: 'Bearer',
expiresAt: Date.now() + 3600_000,
lastActiveAt,
canRefresh: true,
},
};
}
return { success: true };
});
it('opens projected agreement and recovery links with safe external-link attributes', async () => {
renderLogin();
render(
<MemoryRouter initialEntries={['/makelore-home']}>
<App />
</MemoryRouter>,
);
await waitFor(() => expect(hostApiFetchMock).toHaveBeenCalledWith(
'/api/auth/session/sync',
expect.any(Object),
));
expect(hostApiFetchMock.mock.calls.some(([path]) => path === '/api/auth/session/activity')).toBe(false);
fireEvent.keyDown(window, { key: 'a' });
expect(hostApiFetchMock.mock.calls.some(([path]) => path === '/api/auth/session/activity')).toBe(false);
for (const name of ['用户协议', '隐私政策', '忘记密码?']) {
const link = await screen.findByRole('link', { name });
expect(link).toHaveAttribute('target', '_blank');
expect(link).toHaveAttribute('rel', 'noopener noreferrer');
}
});
it('opens only the local image workspace anonymously when the explicit development mode is active', async () => {
useSettingsStore.setState({ setupComplete: true });
window.electron.imageWorkspaceLocalDevelopment = true;
hostApiFetchMock.mockImplementation(async (path: string) => {
if (path === '/api/works/image-workspace') {
return {
success: true,
status: 200,
data: {
capabilities: {
conversation: true,
generation: true,
image: true,
video: true,
},
workspaces: [],
},
};
}
return { success: true };
it('gates password login on agreement and submits the exact credentials before syncing models', async () => {
loginWithPassword.mockImplementation(async () => {
useAuthStore.setState({ accessToken: 'password-access-token' });
});
renderLogin();
const { unmount } = render(
<MemoryRouter initialEntries={['/image-canvas']}>
<App />
</MemoryRouter>,
);
expect(await screen.findByText('创建第一个设计项目')).toBeInTheDocument();
expect(screen.queryByText('本地开发')).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: '在浏览器中继续' })).not.toBeInTheDocument();
unmount();
render(
<MemoryRouter initialEntries={['/models']}>
<App />
</MemoryRouter>,
);
expect(await screen.findByRole('button', { name: '在浏览器中继续' })).toBeInTheDocument();
});
it('opens the project conversation from its direct route for an initialized active project', async () => {
vi.stubGlobal('__NIANCODE_RENDERER_ONLY__', true);
const project = {
id: 'project-default-chat',
path: '/tmp/project-default-chat',
name: 'default-chat',
createdAt: '2026-07-12T00:00:00.000Z',
updatedAt: '2026-07-12T00:00:00.000Z',
lastOpenedAt: '2026-07-12T00:00:00.000Z',
};
const config = { ...createProjectConfig(), initialized: true };
useOpencodeStore.setState({ projects: [project], activeProject: project });
hostApiFetchMock.mockImplementation(async (path: string) => {
if (path === '/api/opencode/projects') return { projects: [project], activeProject: project };
if (path.startsWith('/api/opencode/projects/config?')) return { status: 'valid', config, knowledgeFiles: [] };
if (path === '/api/opencode/status') return { state: 'stopped', port: 4096 };
if (path === '/api/opencode/config-summary') return { providerIds: [], providerCount: 0, envKeys: [] };
if (path === '/api/provider-accounts') return [];
if (path === '/api/provider-accounts/key-info') return [];
if (path === '/api/provider-vendors') return [];
if (path === '/api/provider-accounts/default') return { accountId: null };
return { success: true };
});
render(
<MemoryRouter initialEntries={['/opencode-chat']}>
<App />
</MemoryRouter>,
);
expect(await screen.findByTestId('chat-operation-page')).toBeInTheDocument();
});
it('keeps the module chooser at the default route without an active project', async () => {
vi.stubGlobal('__NIANCODE_RENDERER_ONLY__', true);
render(
<MemoryRouter initialEntries={['/']}>
<App />
</MemoryRouter>,
);
expect(await screen.findByTestId('ai-module-selection-page')).toBeInTheDocument();
expect(screen.getByRole('heading', { name: '今天,想创作什么?' })).toBeInTheDocument();
});
it('keeps an uninitialized active project on project configuration from its direct route', async () => {
vi.stubGlobal('__NIANCODE_RENDERER_ONLY__', true);
const project = {
id: 'project-uninitialized',
path: '/tmp/project-uninitialized',
name: 'uninitialized',
createdAt: '2026-07-12T00:00:00.000Z',
updatedAt: '2026-07-12T00:00:00.000Z',
lastOpenedAt: '2026-07-12T00:00:00.000Z',
};
const config = { ...createProjectConfig(), initialized: false };
useOpencodeStore.setState({ projects: [project], activeProject: project });
hostApiFetchMock.mockImplementation(async (path: string) => {
if (path === '/api/opencode/projects') return { projects: [project], activeProject: project };
if (path.startsWith('/api/opencode/projects/config?')) return { status: 'valid', config, knowledgeFiles: [] };
if (path === '/api/opencode/status') return { state: 'stopped', port: 4096 };
if (path === '/api/opencode/config-summary') return { providerIds: [], providerCount: 0, envKeys: [] };
if (path === '/api/provider-accounts') return [];
if (path === '/api/provider-accounts/key-info') return [];
if (path === '/api/provider-vendors') return [];
if (path === '/api/provider-accounts/default') return { accountId: null };
return { success: true };
});
render(
<MemoryRouter initialEntries={['/opencode-chat']}>
<App />
</MemoryRouter>,
);
expect(await screen.findByText('配置你的项目空间')).toBeInTheDocument();
expect(screen.queryByTestId('chat-operation-page')).not.toBeInTheDocument();
});
it('starts browser authorization and enters the app after success', async () => {
hostApiFetchMock.mockImplementation(async (path: string) => {
if (path === '/api/auth/browser/start') {
return {
success: true,
token: {
access_token: 'access-token',
token_type: 'Bearer',
expires_in: 43200,
username: 'zhangsan',
user_id: '1',
},
session: {
accessToken: 'access-token',
tokenType: 'Bearer',
expiresAt: Date.now() + 43_200_000,
lastActiveAt: Date.now(),
canRefresh: true,
},
};
}
if (path === '/api/provider-accounts/import-user-model-config') {
return {
success: true,
account: {
id: 'niancode-user-models',
vendorId: 'custom',
label: 'Makelore Models',
authMode: 'api_key',
model: 'gpt-4.1-mini',
enabled: true,
isDefault: true,
createdAt: '2026-07-07T00:00:00.000Z',
updatedAt: '2026-07-07T00:00:00.000Z',
},
importedModels: ['gpt-4.1-mini'],
};
}
if (path === '/api/provider-accounts') return [];
if (path === '/api/provider-accounts/key-info') return [];
if (path === '/api/provider-vendors') return [];
if (path === '/api/provider-accounts/default') return { accountId: null };
return { success: true };
});
render(
<MemoryRouter initialEntries={['/login']}>
<Routes>
<Route path="/login" element={<Login />} />
<Route path="/module-select" element={<div>Module Selection</div>} />
</Routes>
</MemoryRouter>,
);
expect(screen.queryByLabelText('Username')).not.toBeInTheDocument();
expect(screen.queryByLabelText('Password')).not.toBeInTheDocument();
expect(screen.queryByLabelText('Captcha')).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '在浏览器中继续' }));
fireEvent.change(screen.getByLabelText('用户名'), { target: { value: ' zhangsan ' } });
fireEvent.change(screen.getByLabelText('密码'), { target: { value: 'secret-password' } });
const submit = screen.getByRole('button', { name: '登录' });
expect(submit).toBeDisabled();
fireEvent.click(screen.getByRole('checkbox'));
expect(submit).toBeEnabled();
fireEvent.click(submit);
await screen.findByText('Module Selection');
await waitFor(() => {
expect(hostApiFetchMock).toHaveBeenCalledWith('/api/auth/browser/start', {
method: 'POST',
});
});
expect(loginWithPassword).toHaveBeenCalledWith({ username: 'zhangsan', password: 'secret-password' });
expect(importUserModelConfig).toHaveBeenCalledWith('password-access-token');
});
it('syncs the current Works Square user model config after browser login', async () => {
it('validates a Chinese mobile number, uses one-time-code autocomplete, and submits only phone and SMS code', async () => {
loginWithMobile.mockImplementation(async () => {
useAuthStore.setState({ accessToken: 'mobile-access-token' });
});
renderLogin();
switchToMobile();
await screen.findByAltText('图形验证码');
expect(screen.getByLabelText('短信验证码')).toHaveAttribute('autocomplete', 'one-time-code');
fireEvent.change(screen.getByLabelText('手机号'), { target: { value: '20123456789' } });
fireEvent.change(screen.getByLabelText('短信验证码'), { target: { value: '123456' } });
fireEvent.click(screen.getByRole('checkbox'));
expect(screen.getByRole('button', { name: '登录' })).toBeDisabled();
fireEvent.change(screen.getByLabelText('手机号'), { target: { value: '13800138000' } });
fireEvent.click(screen.getByRole('button', { name: '登录' }));
await screen.findByText('Module Selection');
expect(loginWithMobile).toHaveBeenCalledWith({ phone: '13800138000', code: '123456' });
expect(importUserModelConfig).toHaveBeenCalledWith('mobile-access-token');
});
it('fetches an uncached UUID captcha and sends the exact image challenge without consuming an SMS code response', async () => {
hostApiFetchMock.mockImplementation(async (path: string) => {
if (path === '/api/auth/browser/start') {
return {
success: true,
token: {
access_token: 'fresh-access-token',
token_type: 'Bearer',
expires_in: 43200,
username: 'zhangsan',
user_id: '1',
},
session: {
accessToken: 'fresh-access-token',
tokenType: 'Bearer',
expiresAt: Date.now() + 43_200_000,
lastActiveAt: Date.now(),
canRefresh: true,
},
};
}
if (path === '/api/provider-accounts/import-user-model-config') {
return {
success: true,
account: {
id: 'niancode-user-models',
vendorId: 'custom',
label: 'Makelore Models',
authMode: 'api_key',
model: 'gpt-4.1-mini',
fallbackModels: ['claude-3-5-haiku'],
enabled: true,
isDefault: true,
createdAt: '2026-07-07T00:00:00.000Z',
updatedAt: '2026-07-07T00:00:00.000Z',
},
importedModels: ['gpt-4.1-mini', 'claude-3-5-haiku'],
};
}
if (path === '/api/provider-accounts/default') {
return { accountId: 'niancode-user-models' };
}
return [];
if (path === '/api/auth/public-config') return { success: true, links: {} };
if (path.includes('/mobile-image-code?')) return resolveCaptcha();
if (path === '/api/auth/mobile-code') return { success: true, code: 'server-mock-code-must-not-be-used' };
return { success: true };
});
renderLogin();
switchToMobile();
render(
<MemoryRouter initialEntries={['/login']}>
<Routes>
<Route path="/login" element={<Login />} />
<Route path="/module-select" element={<div>Module Selection</div>} />
</Routes>
</MemoryRouter>,
await screen.findByAltText('图形验证码');
expect(hostApiFetchMock).toHaveBeenCalledWith(
'/api/auth/mobile-image-code?randomStr=00000000-0000-4000-8000-000000000001',
{ cache: 'no-store' },
);
fireEvent.change(screen.getByLabelText('手机号'), { target: { value: '13800138000' } });
fireEvent.change(screen.getByLabelText('图形验证码'), { target: { value: ' AbCd ' } });
fireEvent.click(screen.getByRole('button', { name: '获取验证码' }));
fireEvent.click(screen.getByRole('button', { name: '在浏览器中继续' }));
await waitFor(() => {
expect(hostApiFetchMock).toHaveBeenCalledWith('/api/provider-accounts/import-user-model-config', {
method: 'POST',
body: JSON.stringify({
accessToken: 'fresh-access-token',
runtimeRefresh: 'apply',
}),
});
});
await waitFor(() => expect(hostApiFetchMock).toHaveBeenCalledWith('/api/auth/mobile-code', {
method: 'POST',
cache: 'no-store',
body: JSON.stringify({
phone: '13800138000',
imageRandomStr: '00000000-0000-4000-8000-000000000001',
imageCode: 'AbCd',
}),
}));
expect(screen.getByLabelText('短信验证码')).toHaveValue('');
expect(screen.getByRole('button', { name: '60 秒' })).toBeDisabled();
expect(screen.queryByAltText('图形验证码')).not.toBeInTheDocument();
});
it('keeps the user on login when model config sync fails after browser login', async () => {
hostApiFetchMock
.mockResolvedValueOnce({
success: true,
token: {
access_token: 'fresh-access-token',
token_type: 'Bearer',
expires_in: 43200,
username: 'zhangsan',
user_id: '1',
},
session: {
accessToken: 'fresh-access-token',
tokenType: 'Bearer',
expiresAt: Date.now() + 43_200_000,
lastActiveAt: Date.now(),
canRefresh: true,
},
})
.mockRejectedValueOnce(new Error('sync failed'))
.mockResolvedValueOnce({ success: true });
render(
<MemoryRouter initialEntries={['/login']}>
<Routes>
<Route path="/login" element={<Login />} />
<Route path="/module-select" element={<div>Module Selection</div>} />
</Routes>
</MemoryRouter>,
);
fireEvent.click(screen.getByRole('button', { name: '在浏览器中继续' }));
expect(await screen.findByText('登录失败,请稍后重试。')).toBeInTheDocument();
expect(screen.queryByText('Module Selection')).not.toBeInTheDocument();
expect(hostApiFetchMock).toHaveBeenCalledWith('/api/auth/logout', {
method: 'POST',
body: JSON.stringify({ accessToken: 'fresh-access-token' }),
it('rejects a stale captcha response after manual refresh', async () => {
const first = createDeferred<ReturnType<typeof resolveCaptcha>>();
const second = createDeferred<ReturnType<typeof resolveCaptcha>>();
vi.spyOn(globalThis.crypto, 'randomUUID')
.mockReturnValueOnce('00000000-0000-4000-8000-000000000001')
.mockReturnValueOnce('00000000-0000-4000-8000-000000000002');
hostApiFetchMock.mockImplementation((path: string) => {
if (path === '/api/auth/public-config') return Promise.resolve({ success: true, links: {} });
if (path.includes('000000000001')) return first.promise;
if (path.includes('000000000002')) return second.promise;
return Promise.resolve({ success: true });
});
renderLogin();
switchToMobile();
await waitFor(() => expect(hostApiFetchMock).toHaveBeenCalledWith(expect.stringContaining('000000000001'), { cache: 'no-store' }));
fireEvent.click(screen.getByRole('button', { name: '刷新图形验证码' }));
await act(async () => second.resolve({ success: true, image: { mimeType: 'image/png', dataBase64: 'new-image' } }));
expect(await screen.findByAltText('图形验证码')).toHaveAttribute('src', 'data:image/png;base64,new-image');
await act(async () => first.resolve({ success: true, image: { mimeType: 'image/png', dataBase64: 'stale-image' } }));
expect(screen.getByAltText('图形验证码')).toHaveAttribute('src', 'data:image/png;base64,new-image');
});
it('loads a fresh UUID captcha each time the user switches into SMS login outside cooldown', async () => {
vi.spyOn(globalThis.crypto, 'randomUUID')
.mockReturnValueOnce('00000000-0000-4000-8000-000000000001')
.mockReturnValueOnce('00000000-0000-4000-8000-000000000002');
hostApiFetchMock.mockImplementation(async (path: string) => {
if (path === '/api/auth/public-config') return { success: true, links: {} };
if (path.includes('000000000001')) {
return { success: true, image: { mimeType: 'image/png', dataBase64: 'first-image' } };
}
if (path.includes('000000000002')) {
return { success: true, image: { mimeType: 'image/png', dataBase64: 'second-image' } };
}
return { success: true };
});
renderLogin();
switchToMobile();
expect(await screen.findByAltText('图形验证码')).toHaveAttribute('src', 'data:image/png;base64,first-image');
fireEvent.click(screen.getByRole('tab', { name: '密码登录' }));
switchToMobile();
await waitFor(() => expect(screen.getByAltText('图形验证码')).toHaveAttribute(
'src',
'data:image/png;base64,second-image',
));
expect(hostApiFetchMock).toHaveBeenCalledWith(
'/api/auth/mobile-image-code?randomStr=00000000-0000-4000-8000-000000000002',
{ cache: 'no-store' },
);
});
it('immediately replaces the image challenge when SMS sending fails and presents a safe error', async () => {
vi.spyOn(globalThis.crypto, 'randomUUID')
.mockReturnValueOnce('00000000-0000-4000-8000-000000000001')
.mockReturnValueOnce('00000000-0000-4000-8000-000000000002');
hostApiFetchMock.mockImplementation(async (path: string) => {
if (path === '/api/auth/public-config') return { success: true, links: {} };
if (path.includes('/mobile-image-code?')) return resolveCaptcha(path);
if (path === '/api/auth/mobile-code') return { success: false, error: '<html>502 Bad Gateway secret</html>' };
return { success: true };
});
renderLogin();
switchToMobile();
await screen.findByAltText('图形验证码');
fireEvent.change(screen.getByLabelText('手机号'), { target: { value: '13800138000' } });
fireEvent.change(screen.getByLabelText('图形验证码'), { target: { value: 'abcd' } });
fireEvent.click(screen.getByRole('button', { name: '获取验证码' }));
await waitFor(() => expect(hostApiFetchMock).toHaveBeenCalledWith(
expect.stringContaining('000000000002'),
{ cache: 'no-store' },
));
expect(screen.getByRole('alert')).toHaveTextContent('验证码发送失败,请稍后重试。');
expect(screen.getByLabelText('图形验证码')).toHaveValue('');
});
it('starts a 60-second cooldown after SMS send and fetches a fresh challenge when it expires', async () => {
vi.useFakeTimers();
vi.spyOn(globalThis.crypto, 'randomUUID')
.mockReturnValueOnce('00000000-0000-4000-8000-000000000001')
.mockReturnValueOnce('00000000-0000-4000-8000-000000000002');
renderLogin();
switchToMobile();
await act(async () => Promise.resolve());
fireEvent.change(screen.getByLabelText('手机号'), { target: { value: '13800138000' } });
fireEvent.change(screen.getByLabelText('图形验证码'), { target: { value: 'abcd' } });
fireEvent.click(screen.getByRole('button', { name: '获取验证码' }));
await act(async () => Promise.resolve());
expect(screen.getByRole('button', { name: '60 秒' })).toBeDisabled();
await act(async () => {
await vi.advanceTimersByTimeAsync(60_000);
});
expect(hostApiFetchMock).toHaveBeenCalledWith(
'/api/auth/mobile-image-code?randomStr=00000000-0000-4000-8000-000000000002',
{ cache: 'no-store' },
);
expect(screen.getByAltText('图形验证码')).toBeInTheDocument();
});
it('logs out and stays on login when post-login model synchronization fails', async () => {
loginWithPassword.mockImplementation(async () => {
useAuthStore.setState({ accessToken: 'fresh-access-token' });
});
importUserModelConfig.mockRejectedValue(new Error('sync failed with token fresh-access-token'));
logout.mockImplementation(async () => {
useAuthStore.setState({ accessToken: null });
});
renderLogin();
fireEvent.change(screen.getByLabelText('用户名'), { target: { value: 'zhangsan' } });
fireEvent.change(screen.getByLabelText('密码'), { target: { value: 'secret-password' } });
fireEvent.click(screen.getByRole('checkbox'));
fireEvent.click(screen.getByRole('button', { name: '登录' }));
expect(await screen.findByRole('alert')).toHaveTextContent('登录失败,请稍后重试。');
expect(screen.queryByText('fresh-access-token')).not.toBeInTheDocument();
expect(screen.queryByText('Module Selection')).not.toBeInTheDocument();
expect(logout).toHaveBeenCalledOnce();
});
it('keeps upstream HTML errors concise without exposing their contents', async () => {
useAuthStore.setState({ error: '<html><head><title>502 Bad Gateway</title></head><body>upstream secret</body></html>' });
renderLogin();
await screen.findByRole('link', { name: '用户协议' });
expect(screen.getByRole('alert')).toHaveTextContent('登录服务暂时不可用,请稍后重试。');
expect(screen.queryByText(/upstream secret/i)).not.toBeInTheDocument();
});
});