/** @vitest-environment jsdom */ import { afterEach, beforeEach, expect, it, vi } from 'vitest'; import type { AuthUser } from '../../../packages/shared/src/contracts/auth'; import { requestClientApi, setStoredAuthAccessToken, } from '../src/services/clientApi'; import { getClientAuthRefreshOperation, getStoredAuthAccessToken, refreshClientAuthAccessToken, } from '../src/services/clientAuth'; import { CLIENT_HTTP_DEFAULT_TIMEOUT_MS } from '../src/services/clientHttp'; import { cachedLlmModelCatalog, refreshLlmModelCatalog, resetLlmModelCatalogCacheForTest, } from '../src/services/llmModelCatalog'; import { beginPlatformSessionTransition, commitAuthenticatedPlatformSession, currentPlatformSessionGeneration, resetPlatformSessionStateForTests, } from '../src/services/platformSession'; vi.mock('@tauri-apps/plugin-http', () => ({ fetch: vi.fn() })); vi.mock( '../../../packages/shared/src', () => import('../../../packages/shared/src/http'), ); vi.mock('../src/services/errorReporting', () => ({ captureClientError: vi.fn(), })); const user = { id: 'session-user' } as AuthUser; const nativeInvoke = vi.fn(async () => null); const catalog = { models: [{ id: 'quality', displayName: '高质量' }] }; const json = (value: unknown, status = 200) => new Response(JSON.stringify(value), { status }); beforeEach(async () => { resetLlmModelCatalogCacheForTest(); resetPlatformSessionStateForTests(); window.localStorage.clear(); nativeInvoke.mockClear(); window.__TAURI__ = { core: { invoke: nativeInvoke } }; setStoredAuthAccessToken('expired-token'); await commitAuthenticatedPlatformSession( user, currentPlatformSessionGeneration(), ); nativeInvoke.mockClear(); }); afterEach(() => { vi.useRealTimers(); resetLlmModelCatalogCacheForTest(); resetPlatformSessionStateForTests(); window.localStorage.clear(); delete window.__TAURI__; vi.restoreAllMocks(); }); it.each([200, 503])( '模型目录 HTTP %s 响应体卡住后超时,保留缓存且能再次刷新', async (status) => { vi.useFakeTimers(); const previous = { ...catalog, defaultModelId: 'quality', revision: 1 }; const updated = { defaultModelId: 'fast', models: [{ id: 'fast', displayName: '快速' }], revision: 2, }; let body!: ReadableStreamDefaultController; const stalledResponse = new Response( new ReadableStream({ start(controller) { body = controller; }, }), { status }, ); const fetch = vi .spyOn(globalThis, 'fetch') .mockResolvedValueOnce(json(previous)) .mockResolvedValueOnce(stalledResponse) .mockResolvedValueOnce(json(updated)); await expect(refreshLlmModelCatalog()).resolves.toEqual(previous); let failure: unknown; const pending = refreshLlmModelCatalog().catch((error: unknown) => { failure = error; }); try { await vi.advanceTimersByTimeAsync(CLIENT_HTTP_DEFAULT_TIMEOUT_MS); expect(failure).toMatchObject({ code: 'CLIENT_HTTP_TIMEOUT' }); await pending; expect(cachedLlmModelCatalog()).toEqual(previous); await expect(refreshLlmModelCatalog()).resolves.toEqual(updated); expect(fetch).toHaveBeenCalledTimes(3); } finally { // 迟到的响应不能在新刷新完成后覆盖缓存,同时释放测试流。 body.enqueue(new TextEncoder().encode(JSON.stringify(previous))); body.close(); await pending; } expect(cachedLlmModelCatalog()).toEqual(updated); }, ); it('并发模型请求共享续期,并在安装 Rust 会话后使用新 token 重试', async () => { let refreshCalls = 0; let modelCalls = 0; const fetch = vi .spyOn(globalThis, 'fetch') .mockImplementation(async (input, init) => { if (input === '/api/auth/refresh') { refreshCalls += 1; return json({ token: 'fresh-token' }); } if (input === '/api/auth/me') return json({ user }); modelCalls += 1; const token = new Headers(init?.headers).get('Authorization'); if (token === 'Bearer expired-token') return json({}, 401); expect(token).toBe('Bearer fresh-token'); expect(nativeInvoke).toHaveBeenCalledWith( 'install_platform_account_session', expect.objectContaining({ accessToken: 'fresh-token', userId: user.id, }), ); return json(catalog); }); const results = await Promise.all([ requestClientApi('/api/llm/models', { method: 'GET' }, '读取失败'), requestClientApi('/api/llm/models', { method: 'GET' }, '读取失败'), ]); expect(results).toEqual([catalog, catalog]); expect(refreshCalls).toBe(1); expect(modelCalls).toBe(4); expect(fetch).toHaveBeenCalledTimes(6); }); it.each([401])( '续期被明确拒绝时保留原 HTTP %s,且不重发业务请求', async (status) => { let refreshCalls = 0; const fetch = vi .spyOn(globalThis, 'fetch') .mockImplementation(async (input) => { if (String(input) === '/api/auth/refresh') { refreshCalls += 1; return json({}, 401); } return json({}, status); }); await expect( requestClientApi('/api/llm/models', { method: 'GET' }, '读取失败'), ).rejects.toMatchObject({ status }); // 401 刷新先用当前 cookie 收敛重试一次;重试仍被拒绝才算登录态权威失效, // 而且不能把一次卡片级失败放大成全局登出。 expect(refreshCalls).toBe(2); expect(fetch).toHaveBeenCalledTimes(3); expect(getStoredAuthAccessToken()).toBe(''); }, ); it('续期 401 后用当前 cookie 收敛重试并继续业务请求', async () => { let refreshCalls = 0; const fetch = vi .spyOn(globalThis, 'fetch') .mockImplementation(async (input, init) => { if (String(input) === '/api/auth/refresh') { refreshCalls += 1; return refreshCalls === 1 ? json({}, 401) : json({ token: 'rotated-token' }); } if (String(input) === '/api/auth/me') return json({ user }); const token = new Headers(init?.headers).get('Authorization'); if (token === 'Bearer expired-token') return json({}, 401); expect(token).toBe('Bearer rotated-token'); return json(catalog); }); await expect( requestClientApi('/api/llm/models', { method: 'GET' }, '读取失败'), ).resolves.toEqual(catalog); expect(refreshCalls).toBe(2); expect(getStoredAuthAccessToken()).toBe('rotated-token'); }); it('跳过鉴权的请求不触发续期', async () => { const fetch = vi.spyOn(globalThis, 'fetch').mockResolvedValue(json({}, 401)); await expect( requestClientApi('/api/example', {}, '读取失败', { skipAuth: true }), ).rejects.toMatchObject({ status: 401 }); expect(fetch).toHaveBeenCalledTimes(1); }); it('403 权限拒绝不触发续期或重发写请求', async () => { const fetch = vi.spyOn(globalThis, 'fetch').mockResolvedValue(json({}, 403)); await expect( requestClientApi( '/api/example', { method: 'POST', body: '{}' }, '权限不足', ), ).rejects.toMatchObject({ status: 403 }); expect(fetch).toHaveBeenCalledTimes(1); expect(nativeInvoke).not.toHaveBeenCalled(); }); it('续期成功后的再次未授权不循环重试', async () => { const fetch = vi .spyOn(globalThis, 'fetch') .mockResolvedValueOnce(json({}, 401)) .mockResolvedValueOnce(json({ token: 'fresh-token' })) .mockResolvedValueOnce(json({ user })) .mockResolvedValueOnce(json({}, 401)); await expect( requestClientApi('/api/llm/models', {}, '读取失败'), ).rejects.toMatchObject({ status: 401 }); expect(fetch).toHaveBeenCalledTimes(4); }); it('请求期间账号切换后,不替新账号续期或重发旧请求', async () => { let finish!: (response: Response) => void; const fetch = vi.spyOn(globalThis, 'fetch').mockImplementation( () => new Promise((resolve) => { finish = resolve; }), ); const pending = requestClientApi('/api/llm/models', {}, '读取失败'); const rejection = expect(pending).rejects.toMatchObject({ status: 401 }); const generation = beginPlatformSessionTransition(); setStoredAuthAccessToken('other-token'); await commitAuthenticatedPlatformSession( { ...user, id: 'other-user' }, generation, ); finish(json({}, 401)); await rejection; expect(fetch).toHaveBeenCalledTimes(1); }); it('响应体卡住超时后,下一次续期会重新发起请求', async () => { vi.useFakeTimers(); let refreshCalls = 0; vi.spyOn(globalThis, 'fetch').mockImplementation((input) => { if (input === '/api/auth/refresh') { refreshCalls += 1; } return Promise.resolve( new Response( new ReadableStream({ start() { // Simulate headers returned while the body remains open. }, }), { status: 200 }, ), ); }); const first = refreshClientAuthAccessToken('http://localhost:3000'); const firstAssertion = expect(first).rejects.toThrow(); await vi.advanceTimersByTimeAsync(15_000); await firstAssertion; expect(getClientAuthRefreshOperation('http://localhost:3000')).toMatchObject({ kind: 'auth-refresh', phase: 'retryable-failure', }); const second = refreshClientAuthAccessToken('http://localhost:3000'); const secondAssertion = expect(second).rejects.toThrow(); expect(refreshCalls).toBe(2); await vi.advanceTimersByTimeAsync(15_000); await secondAssertion; });