Files
makelore/tests/unit/auth-routes.test.ts
brother7 86ece3a430 实现客户端登录七天滑动续期
需求:解决短效访问令牌到期后客户端一小时掉登录的问题。

实现:由 Electron Main 加密管理并轮换刷新凭据,按真实用户活动续期,七天闲置后清理会话,并补齐并发、迁移和终态回归测试。
2026-08-07 16:11:10 +08:00

735 lines
24 KiB
TypeScript

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 {
commitWorksSquareSession,
flushWorksSquareSessionPersistence,
getWorksSquareSessionSnapshot,
initializeWorksSquareSession,
resetWorksSquareSessionForTests,
storeWorksSquareSession,
} from '@electron/services/works-square-session';
import { resetManagedWorksSquareRuntimeForTests } from '@electron/services/works-square-runtime';
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();
providerServiceMock.deleteAccountApiKey.mockReset();
providerServiceMock.deleteAccountApiKey.mockResolvedValue(true);
});
it('exchanges username and AES-encrypted password through the app SSO token endpoint', async () => {
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',
client_id: 'app',
}), { status: 200 }),
);
vi.stubGlobal('fetch', fetchMock);
const response = createResponse();
const handled = await handleAuthRoutes(
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'),
{} 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',
client_id: 'app',
},
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://biz.nianxx.cn/auth/oauth2/token');
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(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');
});
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'),
{
opencodeManager: { stop: 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('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('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 }),
);
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(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',
]);
});
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 }),
);
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(200);
expect(getWorksSquareSessionSnapshot()).toMatchObject({
accessToken: 'desktop-access-token',
tokenType: 'Bearer',
expiresAt: Date.now() + 43_200_000,
canRefresh: true,
});
expect(getWorksSquareSessionSnapshot()).not.toHaveProperty('refreshToken');
});
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 [, refreshInit] = fetchMock.mock.calls[0] as [string, RequestInit];
expect(String(refreshInit.body)).toBe(
'grant_type=refresh_token&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'),
{
opencodeManager: { stop },
imageWorkspace: { closeEventSessions },
} as never,
);
expect(handled).toBe(true);
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual({ success: true });
expect(stop).toHaveBeenCalledOnce();
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'),
{
opencodeManager: { stop },
} as never,
);
expect(response.statusCode).toBe(500);
expect(response.json()).toEqual({
success: false,
error: 'Failed to clear local AI runtime state',
});
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'),
{
opencodeManager: { stop: 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'),
{
opencodeManager: { stop: vi.fn().mockResolvedValue(undefined) },
imageWorkspace: { closeEventSessions },
} as never,
);
expect(response.statusCode).toBe(200);
expect(fetchMock).toHaveBeenCalledWith(
'https://biz.nianxx.cn/auth/token/logout',
expect.objectContaining({
headers: { Authorization: 'Bearer main-current-access-token' },
}),
);
expect(closeEventSessions).toHaveBeenCalledWith({
accessToken: 'main-current-access-token',
tolerateRemoteFailure: false,
});
});
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 = { opencodeManager: { 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'),
{
opencodeManager: { stop: 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'),
{
opencodeManager: { stop: vi.fn().mockResolvedValue(undefined) },
} as never,
);
expect(fetchMock).toHaveBeenCalledWith(
'https://biz.nianxx.cn/auth/token/logout',
expect.objectContaining({ method: 'DELETE' }),
);
expect(response.statusCode).toBe(500);
});
});