feat: add native password and sms login
This commit is contained in:
@@ -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 }),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user