1140 lines
37 KiB
TypeScript
1140 lines
37 KiB
TypeScript
import { EventEmitter } from 'node:events';
|
|
import type { IncomingMessage, ServerResponse } from 'http';
|
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import { handleAuthRoutes } from '@electron/api/routes/auth';
|
|
import {
|
|
commitWorksSquareSession,
|
|
flushWorksSquareSessionPersistence,
|
|
getWorksSquareSessionSnapshot,
|
|
initializeWorksSquareSession,
|
|
resetWorksSquareSessionForTests,
|
|
storeWorksSquareSession,
|
|
} from '@electron/services/works-square-session';
|
|
import { resetManagedWorksSquareRuntimeForTests } from '@electron/services/works-square-runtime';
|
|
import {
|
|
getRememberedPasswordState,
|
|
initializeRememberedPassword,
|
|
resetRememberedPasswordForTests,
|
|
} from '@electron/services/remembered-password';
|
|
|
|
const providerServiceMock = vi.hoisted(() => ({
|
|
deleteAccountApiKey: vi.fn(),
|
|
}));
|
|
|
|
vi.mock('@electron/services/providers/provider-service', () => ({
|
|
getProviderService: () => providerServiceMock,
|
|
}));
|
|
|
|
function createResponse() {
|
|
const chunks: string[] = [];
|
|
const res = {
|
|
statusCode: 0,
|
|
setHeader: vi.fn(),
|
|
end: vi.fn((chunk?: string) => {
|
|
if (chunk) chunks.push(chunk);
|
|
}),
|
|
} as unknown as ServerResponse;
|
|
|
|
return {
|
|
res,
|
|
get statusCode() {
|
|
return res.statusCode;
|
|
},
|
|
json: () => JSON.parse(chunks.join('')) as unknown,
|
|
};
|
|
}
|
|
|
|
function createRequest(method: string, body?: unknown): IncomingMessage {
|
|
const req = new EventEmitter();
|
|
Object.assign(req, {
|
|
method,
|
|
headers: body === undefined ? {} : { 'content-type': 'application/json' },
|
|
[Symbol.asyncIterator]: async function* () {
|
|
if (body !== undefined) {
|
|
yield Buffer.from(JSON.stringify(body));
|
|
}
|
|
},
|
|
});
|
|
return req as IncomingMessage;
|
|
}
|
|
|
|
describe('auth host api routes', () => {
|
|
beforeEach(() => {
|
|
vi.restoreAllMocks();
|
|
resetWorksSquareSessionForTests();
|
|
resetManagedWorksSquareRuntimeForTests();
|
|
resetRememberedPasswordForTests();
|
|
providerServiceMock.deleteAccountApiKey.mockReset();
|
|
providerServiceMock.deleteAccountApiKey.mockResolvedValue(true);
|
|
});
|
|
|
|
it('projects a safe current-user identity and module access without exposing secrets', async () => {
|
|
storeWorksSquareSession({
|
|
accessToken: 'main-access-token',
|
|
refreshToken: 'main-refresh-token',
|
|
expiresAt: Date.now() + 60_000,
|
|
lastActiveAt: Date.now(),
|
|
});
|
|
const fetchMock = vi.fn().mockResolvedValueOnce(
|
|
new Response(JSON.stringify({
|
|
id: 42,
|
|
username: 'student',
|
|
tenant_id: 7,
|
|
dept_id: 'engineering',
|
|
authorities: ['ROLE_USER'],
|
|
one_api_token_key: 'must-not-reach-renderer',
|
|
module_access: {
|
|
programming: false,
|
|
design: true,
|
|
learning: false,
|
|
robot: true,
|
|
cloud_agents: true,
|
|
},
|
|
}), { status: 200 }),
|
|
);
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
const response = createResponse();
|
|
|
|
const handled = await handleAuthRoutes(
|
|
createRequest('GET'),
|
|
response.res,
|
|
new URL('http://127.0.0.1:13210/api/auth/me'),
|
|
{} as never,
|
|
);
|
|
|
|
expect(handled).toBe(true);
|
|
expect(response.statusCode).toBe(200);
|
|
expect(response.json()).toEqual({
|
|
success: true,
|
|
user: {
|
|
username: 'student',
|
|
userId: '42',
|
|
tenantId: 7,
|
|
deptId: 'engineering',
|
|
authorities: ['ROLE_USER'],
|
|
},
|
|
moduleAccess: {
|
|
programming: false,
|
|
design: true,
|
|
robot: true,
|
|
cloud_agents: true,
|
|
},
|
|
});
|
|
expect(response.json()).not.toHaveProperty('moduleAccess.learning');
|
|
expect(JSON.stringify(response.json())).not.toContain('must-not-reach-renderer');
|
|
expect(fetchMock).toHaveBeenCalledWith(
|
|
'https://square.nianxx.cn/api/auth/me',
|
|
{
|
|
method: 'GET',
|
|
headers: { Authorization: 'Bearer main-access-token' },
|
|
},
|
|
);
|
|
});
|
|
|
|
it('keeps module access enabled when an older profile omits the policy', async () => {
|
|
storeWorksSquareSession({
|
|
accessToken: 'main-access-token',
|
|
refreshToken: 'main-refresh-token',
|
|
expiresAt: Date.now() + 60_000,
|
|
lastActiveAt: Date.now(),
|
|
});
|
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce(
|
|
new Response(JSON.stringify({ username: 'student' }), { status: 200 }),
|
|
));
|
|
const response = createResponse();
|
|
|
|
await handleAuthRoutes(
|
|
createRequest('GET'),
|
|
response.res,
|
|
new URL('http://127.0.0.1:13210/api/auth/me'),
|
|
{} as never,
|
|
);
|
|
|
|
expect(response.statusCode).toBe(200);
|
|
expect(response.json()).toEqual({
|
|
success: true,
|
|
user: {
|
|
username: 'student',
|
|
userId: null,
|
|
tenantId: null,
|
|
deptId: null,
|
|
authorities: [],
|
|
},
|
|
moduleAccess: {
|
|
programming: true,
|
|
design: true,
|
|
robot: true,
|
|
cloud_agents: true,
|
|
},
|
|
});
|
|
});
|
|
|
|
it('clears the Main session when the current-user lookup is unauthorized', async () => {
|
|
storeWorksSquareSession({
|
|
accessToken: 'expired-access-token',
|
|
refreshToken: 'expired-refresh-token',
|
|
expiresAt: Date.now() + 60_000,
|
|
lastActiveAt: Date.now(),
|
|
});
|
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce(
|
|
new Response(JSON.stringify({ detail: 'upstream secret' }), { status: 401 }),
|
|
));
|
|
const response = createResponse();
|
|
|
|
await handleAuthRoutes(
|
|
createRequest('GET'),
|
|
response.res,
|
|
new URL('http://127.0.0.1:13210/api/auth/me'),
|
|
{} as never,
|
|
);
|
|
|
|
expect(response.statusCode).toBe(401);
|
|
expect(response.json()).toEqual({
|
|
success: false,
|
|
error: '登录已过期,请重新授权。',
|
|
});
|
|
expect(JSON.stringify(response.json())).not.toContain('upstream secret');
|
|
expect(getWorksSquareSessionSnapshot()).toBeNull();
|
|
});
|
|
|
|
it('proxies password login through Works and commits a redacted Main session', async () => {
|
|
const credentialPersistence = {
|
|
load: vi.fn().mockResolvedValue(null),
|
|
save: vi.fn().mockResolvedValue(undefined),
|
|
};
|
|
await initializeRememberedPassword({ persistence: credentialPersistence });
|
|
const fetchMock = vi.fn().mockResolvedValueOnce(
|
|
new Response(JSON.stringify({
|
|
access_token: 'access-token',
|
|
refresh_token: 'refresh-token',
|
|
token_type: 'Bearer',
|
|
expires_in: 43200,
|
|
username: 'zhangsan',
|
|
user_id: '1',
|
|
}), { status: 200 }),
|
|
);
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
const response = createResponse();
|
|
|
|
const handled = await handleAuthRoutes(
|
|
createRequest('POST', {
|
|
username: 'zhangsan',
|
|
password: 'passw0rd',
|
|
rememberPassword: true,
|
|
}),
|
|
response.res,
|
|
new URL('http://127.0.0.1:13210/api/auth/login'),
|
|
{} as never,
|
|
);
|
|
|
|
expect(handled).toBe(true);
|
|
expect(response.statusCode).toBe(200);
|
|
expect(response.json()).toEqual({
|
|
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: expect.any(Number),
|
|
lastActiveAt: expect.any(Number),
|
|
canRefresh: true,
|
|
},
|
|
});
|
|
|
|
expect(fetchMock).toHaveBeenCalledOnce();
|
|
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
|
expect(url).toBe('https://square.nianxx.cn/api/auth/login');
|
|
expect(init.method).toBe('POST');
|
|
expect(init.headers).toEqual({ 'Content-Type': 'application/json' });
|
|
expect(JSON.parse(String(init.body))).toEqual({
|
|
username: 'zhangsan',
|
|
password: 'passw0rd',
|
|
});
|
|
expect(JSON.stringify(response.json())).not.toContain('refresh-token');
|
|
expect(JSON.stringify(response.json())).not.toContain('passw0rd');
|
|
expect(getWorksSquareSessionSnapshot()).toMatchObject({
|
|
accessToken: 'access-token',
|
|
canRefresh: true,
|
|
});
|
|
expect(getWorksSquareSessionSnapshot()).not.toHaveProperty('refreshToken');
|
|
expect(credentialPersistence.save).toHaveBeenCalledWith({
|
|
username: 'zhangsan',
|
|
password: 'passw0rd',
|
|
});
|
|
await expect(getRememberedPasswordState()).resolves.toEqual({
|
|
available: true,
|
|
credentials: { username: 'zhangsan', password: 'passw0rd' },
|
|
});
|
|
|
|
const rememberedResponse = createResponse();
|
|
await handleAuthRoutes(
|
|
createRequest('GET'),
|
|
rememberedResponse.res,
|
|
new URL('http://127.0.0.1:13210/api/auth/remembered-password'),
|
|
{} as never,
|
|
);
|
|
expect(rememberedResponse.statusCode).toBe(200);
|
|
expect(rememberedResponse.res.setHeader).toHaveBeenCalledWith('Cache-Control', 'no-store');
|
|
expect(rememberedResponse.json()).toEqual({
|
|
success: true,
|
|
available: true,
|
|
credentials: { username: 'zhangsan', password: 'passw0rd' },
|
|
});
|
|
});
|
|
|
|
it('allows explicit reauthorization to replace an unreadable persisted session', async () => {
|
|
const persistence = {
|
|
load: vi.fn().mockRejectedValue(new Error('credential cannot be decrypted')),
|
|
save: vi.fn().mockResolvedValue(undefined),
|
|
};
|
|
await initializeWorksSquareSession({ persistence });
|
|
const fetchMock = vi.fn().mockResolvedValueOnce(
|
|
new Response(JSON.stringify({
|
|
access_token: 'replacement-access-token',
|
|
refresh_token: 'replacement-refresh-token',
|
|
token_type: 'Bearer',
|
|
expires_in: 3600,
|
|
username: 'zhangsan',
|
|
}), { status: 200 }),
|
|
);
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
const response = createResponse();
|
|
|
|
await handleAuthRoutes(
|
|
createRequest('POST', { username: 'zhangsan', password: 'passw0rd' }),
|
|
response.res,
|
|
new URL('http://127.0.0.1:13210/api/auth/login'),
|
|
{
|
|
codingProducts: { sleep: vi.fn().mockResolvedValue(undefined) },
|
|
} as never,
|
|
);
|
|
|
|
expect(response.statusCode).toBe(200);
|
|
expect(persistence.save).toHaveBeenNthCalledWith(1, null);
|
|
expect(persistence.save).toHaveBeenLastCalledWith(expect.objectContaining({
|
|
accessToken: 'replacement-access-token',
|
|
refreshToken: 'replacement-refresh-token',
|
|
}));
|
|
});
|
|
|
|
it('clears an old remembered password after a successful unremembered password login', async () => {
|
|
const credentialPersistence = {
|
|
load: vi.fn().mockResolvedValue({
|
|
username: 'old-user',
|
|
password: 'old-password',
|
|
}),
|
|
save: vi.fn().mockResolvedValue(undefined),
|
|
};
|
|
await initializeRememberedPassword({ persistence: credentialPersistence });
|
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce(
|
|
new Response(JSON.stringify({
|
|
access_token: 'access-token',
|
|
refresh_token: 'refresh-token',
|
|
expires_in: 3600,
|
|
username: 'new-user',
|
|
}), { status: 200 }),
|
|
));
|
|
const response = createResponse();
|
|
|
|
await handleAuthRoutes(
|
|
createRequest('POST', {
|
|
username: 'new-user',
|
|
password: 'new-password',
|
|
rememberPassword: false,
|
|
}),
|
|
response.res,
|
|
new URL('http://127.0.0.1:13210/api/auth/login'),
|
|
{} as never,
|
|
);
|
|
|
|
expect(response.statusCode).toBe(200);
|
|
expect(credentialPersistence.save).toHaveBeenCalledWith(null);
|
|
await expect(getRememberedPasswordState()).resolves.toEqual({
|
|
available: true,
|
|
credentials: null,
|
|
});
|
|
});
|
|
|
|
it('passes through SSO credential errors without exposing secrets', async () => {
|
|
const fetchMock = vi.fn().mockResolvedValueOnce(
|
|
new Response(JSON.stringify({ code: 1, msg: 'Bad credentials', data: null }), { status: 401 }),
|
|
);
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
const response = createResponse();
|
|
|
|
const handled = await handleAuthRoutes(
|
|
createRequest('POST', {
|
|
username: 'zhangsan',
|
|
password: 'wrong',
|
|
}),
|
|
response.res,
|
|
new URL('http://127.0.0.1:13210/api/auth/login'),
|
|
{} as never,
|
|
);
|
|
|
|
expect(handled).toBe(true);
|
|
expect(response.statusCode).toBe(401);
|
|
expect(response.json()).toEqual({
|
|
success: false,
|
|
error: 'Bad credentials',
|
|
});
|
|
expect(JSON.stringify(response.json())).not.toContain('app:app');
|
|
});
|
|
|
|
it('has no browser-start route', async () => {
|
|
const fetchMock = vi.fn();
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
const response = createResponse();
|
|
|
|
const handled = await handleAuthRoutes(
|
|
createRequest('POST'),
|
|
response.res,
|
|
new URL('http://127.0.0.1:13210/api/auth/browser/start'),
|
|
{} as never,
|
|
);
|
|
|
|
expect(handled).toBe(true);
|
|
expect(response.statusCode).toBe(404);
|
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
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', { phone: '13800000000', code: '123456' }),
|
|
response.res,
|
|
new URL('http://127.0.0.1:13210/api/auth/mobile-login'),
|
|
{} as never,
|
|
);
|
|
|
|
expect(response.statusCode).toBe(200);
|
|
expect(response.json()).toMatchObject({
|
|
success: true,
|
|
token: { access_token: 'mobile-access-token' },
|
|
session: { accessToken: 'mobile-access-token', canRefresh: true },
|
|
});
|
|
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 () => {
|
|
vi.setSystemTime(new Date('2026-07-06T08:00:00.000Z'));
|
|
const lastActiveAt = Date.now() - 24 * 60 * 60 * 1000;
|
|
const response = createResponse();
|
|
|
|
const handled = await handleAuthRoutes(
|
|
createRequest('POST', {
|
|
accessToken: 'persisted-access-token',
|
|
refreshToken: 'persisted-refresh-token',
|
|
tokenType: 'Bearer',
|
|
expiresAt: 1_783_000_000_000,
|
|
lastActiveAt,
|
|
}),
|
|
response.res,
|
|
new URL('http://127.0.0.1:13210/api/auth/session/sync'),
|
|
{} as never,
|
|
);
|
|
|
|
expect(handled).toBe(true);
|
|
expect(response.statusCode).toBe(200);
|
|
expect(response.json()).toEqual({
|
|
success: true,
|
|
session: {
|
|
accessToken: 'persisted-access-token',
|
|
tokenType: 'Bearer',
|
|
expiresAt: 1_783_000_000_000,
|
|
lastActiveAt,
|
|
canRefresh: true,
|
|
},
|
|
});
|
|
expect(getWorksSquareSessionSnapshot()).toEqual({
|
|
accessToken: 'persisted-access-token',
|
|
tokenType: 'Bearer',
|
|
expiresAt: 1_783_000_000_000,
|
|
lastActiveAt,
|
|
canRefresh: true,
|
|
});
|
|
});
|
|
|
|
it('does not overwrite recoverable Main credentials when restore is temporarily unavailable', async () => {
|
|
const persistence = {
|
|
load: vi.fn().mockRejectedValue(new Error('credential store locked')),
|
|
save: vi.fn(),
|
|
};
|
|
await initializeWorksSquareSession({ persistence });
|
|
const response = createResponse();
|
|
|
|
await handleAuthRoutes(
|
|
createRequest('POST', {
|
|
accessToken: 'renderer-access-without-refresh',
|
|
refreshToken: null,
|
|
tokenType: 'Bearer',
|
|
expiresAt: Date.now() + 3600_000,
|
|
lastActiveAt: Date.now(),
|
|
}),
|
|
response.res,
|
|
new URL('http://127.0.0.1:13210/api/auth/session/sync'),
|
|
{} as never,
|
|
);
|
|
|
|
expect(response.statusCode).toBe(503);
|
|
expect(persistence.load).toHaveBeenCalledTimes(2);
|
|
expect(persistence.save).not.toHaveBeenCalled();
|
|
expect(getWorksSquareSessionSnapshot()).toBeNull();
|
|
});
|
|
|
|
it('retries Main restore before treating user activity as an expired session', async () => {
|
|
const persistedSession = {
|
|
accessToken: 'restored-access-token',
|
|
refreshToken: 'restored-refresh-token',
|
|
tokenType: 'Bearer',
|
|
expiresAt: Date.now() + 3600_000,
|
|
lastActiveAt: Date.now() - 60_000,
|
|
};
|
|
const persistence = {
|
|
load: vi.fn()
|
|
.mockRejectedValueOnce(new Error('credential store locked'))
|
|
.mockRejectedValueOnce(new Error('credential store still locked'))
|
|
.mockResolvedValue(persistedSession),
|
|
save: vi.fn().mockResolvedValue(undefined),
|
|
};
|
|
await initializeWorksSquareSession({ persistence });
|
|
|
|
const unavailableResponse = createResponse();
|
|
await handleAuthRoutes(
|
|
createRequest('POST'),
|
|
unavailableResponse.res,
|
|
new URL('http://127.0.0.1:13210/api/auth/session/sync'),
|
|
{} as never,
|
|
);
|
|
expect(unavailableResponse.statusCode).toBe(503);
|
|
|
|
const activityResponse = createResponse();
|
|
await handleAuthRoutes(
|
|
createRequest('POST'),
|
|
activityResponse.res,
|
|
new URL('http://127.0.0.1:13210/api/auth/session/activity'),
|
|
{} as never,
|
|
);
|
|
|
|
expect(activityResponse.statusCode).toBe(200);
|
|
expect(activityResponse.json()).toMatchObject({
|
|
success: true,
|
|
session: {
|
|
accessToken: 'restored-access-token',
|
|
canRefresh: true,
|
|
},
|
|
});
|
|
expect(persistence.load).toHaveBeenCalledTimes(3);
|
|
});
|
|
|
|
it('does not let a stale renderer session roll back the Main-owned refresh token', async () => {
|
|
vi.setSystemTime(new Date('2026-07-06T08:00:00.000Z'));
|
|
const lastActiveAt = Date.now() - 24 * 60 * 60 * 1000;
|
|
storeWorksSquareSession({
|
|
accessToken: 'main-access-r1',
|
|
refreshToken: 'main-refresh-r1',
|
|
tokenType: 'Bearer',
|
|
expiresAt: Date.now() + 3600_000,
|
|
lastActiveAt,
|
|
});
|
|
const syncResponse = createResponse();
|
|
|
|
await handleAuthRoutes(
|
|
createRequest('POST', {
|
|
accessToken: 'renderer-access-r0',
|
|
refreshToken: 'renderer-refresh-r0',
|
|
tokenType: 'Bearer',
|
|
expiresAt: Date.now() + 7200_000,
|
|
lastActiveAt: Date.now(),
|
|
}),
|
|
syncResponse.res,
|
|
new URL('http://127.0.0.1:13210/api/auth/session/sync'),
|
|
{} as never,
|
|
);
|
|
|
|
expect(syncResponse.statusCode).toBe(200);
|
|
expect(syncResponse.json()).toEqual({
|
|
success: true,
|
|
session: {
|
|
accessToken: 'main-access-r1',
|
|
tokenType: 'Bearer',
|
|
expiresAt: Date.now() + 3600_000,
|
|
lastActiveAt,
|
|
canRefresh: true,
|
|
},
|
|
});
|
|
|
|
const fetchMock = vi.fn().mockResolvedValueOnce(
|
|
new Response(JSON.stringify({
|
|
access_token: 'main-access-r2',
|
|
token_type: 'Bearer',
|
|
expires_in: 3600,
|
|
}), { status: 200 }),
|
|
);
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
const refreshResponse = createResponse();
|
|
|
|
await handleAuthRoutes(
|
|
createRequest('POST', { forceRefresh: true }),
|
|
refreshResponse.res,
|
|
new URL('http://127.0.0.1:13210/api/auth/session/refresh'),
|
|
{} as never,
|
|
);
|
|
|
|
expect(refreshResponse.statusCode).toBe(200);
|
|
const [refreshUrl, refreshInit] = fetchMock.mock.calls[0] as [string, RequestInit];
|
|
expect(refreshUrl).toBe('https://square.nianxx.cn/api/auth/refresh');
|
|
expect(refreshInit.body).toBe(
|
|
JSON.stringify({ refresh_token: 'main-refresh-r1' }),
|
|
);
|
|
expect(String(refreshInit.body)).not.toContain('renderer-refresh-r0');
|
|
});
|
|
|
|
it('refreshes through the Main-owned session and returns the rotated token bundle', async () => {
|
|
vi.setSystemTime(new Date('2026-07-06T08:00:00.000Z'));
|
|
const lastActiveAt = Date.now() - 24 * 60 * 60 * 1000;
|
|
const fetchMock = vi.fn().mockResolvedValueOnce(
|
|
new Response(JSON.stringify({
|
|
access_token: 'new-access-token',
|
|
refresh_token: 'rotated-refresh-token',
|
|
token_type: 'Bearer',
|
|
expires_in: 3600,
|
|
}), { status: 200 }),
|
|
);
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
storeWorksSquareSession({
|
|
accessToken: 'expired-access-token',
|
|
refreshToken: 'old-refresh-token',
|
|
expiresAt: Date.now() - 1,
|
|
lastActiveAt,
|
|
});
|
|
const response = createResponse();
|
|
|
|
await handleAuthRoutes(
|
|
createRequest('POST', { forceRefresh: false }),
|
|
response.res,
|
|
new URL('http://127.0.0.1:13210/api/auth/session/refresh'),
|
|
{} as never,
|
|
);
|
|
|
|
expect(response.statusCode).toBe(200);
|
|
expect(response.json()).toEqual({
|
|
success: true,
|
|
session: {
|
|
accessToken: 'new-access-token',
|
|
tokenType: 'Bearer',
|
|
expiresAt: Date.now() + 3600_000,
|
|
lastActiveAt,
|
|
canRefresh: true,
|
|
},
|
|
});
|
|
expect(fetchMock).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it('updates activity only through the explicit activity route', async () => {
|
|
vi.setSystemTime(new Date('2026-07-06T08:00:00.000Z'));
|
|
storeWorksSquareSession({
|
|
accessToken: 'access-token',
|
|
refreshToken: 'refresh-token',
|
|
expiresAt: Date.now() + 3600_000,
|
|
lastActiveAt: Date.now() - 24 * 60 * 60 * 1000,
|
|
});
|
|
const response = createResponse();
|
|
|
|
await handleAuthRoutes(
|
|
createRequest('POST'),
|
|
response.res,
|
|
new URL('http://127.0.0.1:13210/api/auth/session/activity'),
|
|
{} as never,
|
|
);
|
|
|
|
expect(response.statusCode).toBe(200);
|
|
expect(getWorksSquareSessionSnapshot()?.lastActiveAt).toBe(Date.now());
|
|
});
|
|
|
|
it('stops the runtime and clears the managed Works Square key on logout', async () => {
|
|
const fetchMock = vi.fn().mockResolvedValueOnce(
|
|
new Response(JSON.stringify({ success: true }), { status: 200 }),
|
|
);
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
const stop = vi.fn(async () => undefined);
|
|
const closeEventSessions = vi.fn(async () => undefined);
|
|
const response = createResponse();
|
|
|
|
const handled = await handleAuthRoutes(
|
|
createRequest('POST', { accessToken: 'access-token' }),
|
|
response.res,
|
|
new URL('http://127.0.0.1:13210/api/auth/logout'),
|
|
{
|
|
codingProducts: { sleep: stop },
|
|
imageWorkspace: { closeEventSessions },
|
|
} as never,
|
|
);
|
|
|
|
expect(handled).toBe(true);
|
|
expect(response.statusCode).toBe(200);
|
|
expect(response.json()).toEqual({ success: true });
|
|
expect(stop).toHaveBeenCalledOnce();
|
|
expect(stop).toHaveBeenCalledWith('auth_cleanup');
|
|
expect(closeEventSessions).toHaveBeenCalledOnce();
|
|
expect(providerServiceMock.deleteAccountApiKey).toHaveBeenCalledWith('niancode-user-models');
|
|
});
|
|
|
|
it('does not report logout success when managed runtime cleanup fails', async () => {
|
|
const fetchMock = vi.fn().mockResolvedValueOnce(
|
|
new Response(JSON.stringify({ success: true }), { status: 200 }),
|
|
);
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
const stop = vi.fn().mockRejectedValue(new Error('runtime stop failed'));
|
|
const response = createResponse();
|
|
|
|
await handleAuthRoutes(
|
|
createRequest('POST', { accessToken: 'access-token' }),
|
|
response.res,
|
|
new URL('http://127.0.0.1:13210/api/auth/logout'),
|
|
{
|
|
codingProducts: { sleep: stop },
|
|
} as never,
|
|
);
|
|
|
|
expect(response.statusCode).toBe(500);
|
|
expect(response.json()).toEqual({
|
|
success: false,
|
|
error: 'Failed to clear local AI runtime state',
|
|
});
|
|
expect(stop).toHaveBeenCalledWith('auth_cleanup');
|
|
expect(providerServiceMock.deleteAccountApiKey).toHaveBeenCalledWith('niancode-user-models');
|
|
});
|
|
|
|
it('does not report logout success when a remote event session cannot be closed', async () => {
|
|
const fetchMock = vi.fn().mockResolvedValueOnce(
|
|
new Response(JSON.stringify({ success: true }), { status: 200 }),
|
|
);
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
const response = createResponse();
|
|
|
|
await handleAuthRoutes(
|
|
createRequest('POST', { accessToken: 'access-token' }),
|
|
response.res,
|
|
new URL('http://127.0.0.1:13210/api/auth/logout'),
|
|
{
|
|
codingProducts: { sleep: vi.fn().mockResolvedValue(undefined) },
|
|
imageWorkspace: {
|
|
closeEventSessions: vi.fn().mockRejectedValue(new Error('remote close failed')),
|
|
},
|
|
} as never,
|
|
);
|
|
|
|
expect(response.statusCode).toBe(500);
|
|
expect(fetchMock).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it('uses the Main session token for logout instead of a stale Renderer token', async () => {
|
|
storeWorksSquareSession({
|
|
accessToken: 'main-current-access-token',
|
|
refreshToken: 'main-current-refresh-token',
|
|
expiresAt: Date.now() + 3600_000,
|
|
lastActiveAt: Date.now(),
|
|
});
|
|
const fetchMock = vi.fn().mockResolvedValueOnce(
|
|
new Response(JSON.stringify({ success: true }), { status: 200 }),
|
|
);
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
const closeEventSessions = vi.fn(async () => undefined);
|
|
const response = createResponse();
|
|
|
|
await handleAuthRoutes(
|
|
createRequest('POST', { accessToken: 'renderer-stale-access-token' }),
|
|
response.res,
|
|
new URL('http://127.0.0.1:13210/api/auth/logout'),
|
|
{
|
|
codingProducts: { sleep: vi.fn().mockResolvedValue(undefined) },
|
|
imageWorkspace: { closeEventSessions },
|
|
} as never,
|
|
);
|
|
|
|
expect(response.statusCode).toBe(200);
|
|
expect(fetchMock).toHaveBeenCalledWith(
|
|
'https://square.nianxx.cn/api/auth/logout',
|
|
expect.objectContaining({
|
|
method: 'POST',
|
|
headers: { Authorization: 'Bearer main-current-access-token' },
|
|
}),
|
|
);
|
|
expect(closeEventSessions).toHaveBeenCalledWith({
|
|
accessToken: 'main-current-access-token',
|
|
tolerateRemoteFailure: false,
|
|
});
|
|
});
|
|
|
|
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'),
|
|
{ codingProducts: { sleep: 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 }),
|
|
);
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
const stop = vi.fn().mockRejectedValue(new Error('runtime stop failed'));
|
|
const context = { codingProducts: { sleep: stop } } as never;
|
|
const logoutResponse = createResponse();
|
|
|
|
await handleAuthRoutes(
|
|
createRequest('POST', { accessToken: 'old-access-token' }),
|
|
logoutResponse.res,
|
|
new URL('http://127.0.0.1:13210/api/auth/logout'),
|
|
context,
|
|
);
|
|
expect(logoutResponse.statusCode).toBe(500);
|
|
fetchMock.mockClear();
|
|
|
|
const loginResponse = createResponse();
|
|
await handleAuthRoutes(
|
|
createRequest('POST', { username: 'zhangsan', password: 'passw0rd' }),
|
|
loginResponse.res,
|
|
new URL('http://127.0.0.1:13210/api/auth/login'),
|
|
context,
|
|
);
|
|
|
|
expect(loginResponse.statusCode).toBe(503);
|
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
expect(stop).toHaveBeenCalledTimes(2);
|
|
expect(getWorksSquareSessionSnapshot()).toBeNull();
|
|
});
|
|
|
|
it('does not report logout success when secure storage rejects API key deletion', async () => {
|
|
providerServiceMock.deleteAccountApiKey.mockResolvedValueOnce(false);
|
|
const fetchMock = vi.fn().mockResolvedValueOnce(
|
|
new Response(JSON.stringify({ success: true }), { status: 200 }),
|
|
);
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
const response = createResponse();
|
|
|
|
await handleAuthRoutes(
|
|
createRequest('POST', { accessToken: 'access-token' }),
|
|
response.res,
|
|
new URL('http://127.0.0.1:13210/api/auth/logout'),
|
|
{
|
|
codingProducts: { sleep: vi.fn().mockResolvedValue(undefined) },
|
|
} as never,
|
|
);
|
|
|
|
expect(response.statusCode).toBe(500);
|
|
expect(fetchMock).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it('still calls remote logout when clearing the persisted local session fails', async () => {
|
|
const persistence = {
|
|
load: vi.fn().mockResolvedValue(null),
|
|
save: vi.fn()
|
|
.mockResolvedValueOnce(undefined)
|
|
.mockRejectedValueOnce(new Error('credential store locked')),
|
|
};
|
|
await initializeWorksSquareSession({ persistence });
|
|
await commitWorksSquareSession({
|
|
accessToken: 'access-token',
|
|
refreshToken: 'refresh-token',
|
|
expiresAt: Date.now() + 3600_000,
|
|
lastActiveAt: Date.now(),
|
|
});
|
|
await flushWorksSquareSessionPersistence();
|
|
|
|
const fetchMock = vi.fn().mockResolvedValueOnce(
|
|
new Response(JSON.stringify({ success: true }), { status: 200 }),
|
|
);
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
const response = createResponse();
|
|
|
|
await handleAuthRoutes(
|
|
createRequest('POST', { accessToken: 'access-token' }),
|
|
response.res,
|
|
new URL('http://127.0.0.1:13210/api/auth/logout'),
|
|
{
|
|
codingProducts: { sleep: vi.fn().mockResolvedValue(undefined) },
|
|
} as never,
|
|
);
|
|
|
|
expect(fetchMock).toHaveBeenCalledWith(
|
|
'https://square.nianxx.cn/api/auth/logout',
|
|
expect.objectContaining({ method: 'POST' }),
|
|
);
|
|
expect(response.statusCode).toBe(500);
|
|
});
|
|
});
|