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 the current user module access without exposing the upstream profile', 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', one_api_token_key: 'must-not-reach-renderer', module_access: { programming: false, design: true, learning: false, robot: 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, moduleAccess: { programming: false, design: true, learning: false, robot: true, }, }); 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, moduleAccess: { programming: true, design: true, learning: true, robot: 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'), { 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('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'), { 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://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'), { 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 }), ); 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://square.nianxx.cn/api/auth/logout', expect.objectContaining({ method: 'POST' }), ); expect(response.statusCode).toBe(500); }); });