需求:服务端统一 Agent Gateway 将设计任务状态流切换为 WebSocket,客户端需要实时展示生成任务并支持断线恢复。 实现:Electron Main 管理 Session、一次性 Ticket、WebSocket 心跳与游标续传,按关闭码回收会话;Renderer 继续通过本机 Host API 的 SSE 投影接收任务事件,并保留 REST 降级同步。 验证:typecheck、变更文件 ESLint、37 个聚焦测试及 build:vite 通过。
321 lines
10 KiB
TypeScript
321 lines
10 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 {
|
|
clearWorksSquareSession,
|
|
getWorksSquareSessionSnapshot,
|
|
} from '@electron/services/works-square-session';
|
|
|
|
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();
|
|
clearWorksSquareSession();
|
|
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',
|
|
refresh_token: 'refresh-token',
|
|
token_type: 'Bearer',
|
|
expires_in: 43200,
|
|
username: 'zhangsan',
|
|
user_id: '1',
|
|
client_id: 'app',
|
|
},
|
|
});
|
|
|
|
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('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',
|
|
refresh_token: 'desktop-refresh-token',
|
|
token_type: 'Bearer',
|
|
expires_in: 43200,
|
|
username: 'student',
|
|
user_id: '42',
|
|
},
|
|
});
|
|
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',
|
|
refreshToken: 'desktop-refresh-token',
|
|
tokenType: 'Bearer',
|
|
expiresAt: Date.now() + 43_200_000,
|
|
});
|
|
});
|
|
|
|
it('accepts renderer session sync after app restart', async () => {
|
|
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,
|
|
}),
|
|
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 });
|
|
expect(getWorksSquareSessionSnapshot()).toEqual({
|
|
accessToken: 'persisted-access-token',
|
|
refreshToken: 'persisted-refresh-token',
|
|
tokenType: 'Bearer',
|
|
expiresAt: 1_783_000_000_000,
|
|
});
|
|
});
|
|
|
|
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');
|
|
});
|
|
});
|