import { afterEach } from 'vitest'; import { setStoredAuthAccessToken } from '../../src/services/clientAuth'; import { AGC_DEVELOPMENT_API_BASE_URL } from '../../src/services/clientHttp'; import { beginPlatformSessionClearTransition, beginPlatformSessionTransition, clearCommittedPlatformSession, commitAuthenticatedPlatformSession, currentPlatformNativeIdentityGenerationForTests, currentPlatformSessionGeneration, requestPlatformSessionRefresh, resetPlatformSessionStateForTests, } from '../../src/services/platformSession'; import { act, AuthenticatedClient, expect, fireEvent, it, React, render, screen, testAuthUser, vi, waitFor, } from './harness'; export function registerAuthTests() { afterEach(() => { resetPlatformSessionStateForTests(); delete window.__TAURI__; }); it('leaves startup loading with an actionable retry after auth service timeout', async () => { vi.useFakeTimers(); let releaseRefresh: (() => void) | null = null; vi.spyOn(globalThis, 'fetch').mockImplementation( async (input: RequestInfo | URL) => { if (String(input) === '/api/auth/refresh') { return new Promise((resolve) => { releaseRefresh = () => resolve(new Response('', { status: 401 })); }); } throw new Error(`unexpected fetch ${String(input)}`); }, ); render( React.createElement(AuthenticatedClient, null, () => React.createElement('main', { 'aria-label': '已登录' }), ), ); expect(screen.getByRole('main', { name: '登录状态检查' })).not.toBeNull(); await act(async () => { await vi.advanceTimersByTimeAsync(15_000); }); expect(screen.getByRole('main', { name: '登录' })).not.toBeNull(); expect(screen.getByRole('alert')).not.toBeNull(); expect( screen.getByRole('button', { name: '重试登录状态检查' }), ).not.toBeNull(); releaseRefresh?.(); await Promise.resolve(); vi.useRealTimers(); }); it('renders the unauthenticated client with the shared light platform theme and product image', async () => { vi.spyOn(globalThis, 'fetch').mockImplementation( async (input: RequestInfo | URL) => { if (String(input) === '/api/auth/refresh') { return new Response('', { status: 401 }); } throw new Error(`unexpected fetch ${String(input)}`); }, ); render( React.createElement(AuthenticatedClient, null, () => React.createElement('main', { 'aria-label': '已登录' }), ), ); const login = await screen.findByRole('main', { name: '登录' }); expect(login.className).toContain('platform-theme'); expect(login.className).toContain('platform-theme--light'); expect(screen.getByRole('img', { name: '陶泥儿' })).not.toBeNull(); expect( screen.getByRole('heading', { name: '登录陶泥儿 GameAgent' }), ).not.toBeNull(); }); it('keeps the client at login when the native platform session cannot be installed', async () => { window.__TAURI__ = { core: { invoke: vi.fn(async (command: string) => { if (command === 'install_platform_account_session') { throw new Error('runner unavailable'); } return null; }), }, }; vi.spyOn(globalThis, 'fetch').mockImplementation( async (input: RequestInfo | URL) => { const url = String(input); if (url === '/api/auth/refresh') { return new Response('', { status: 401 }); } if (url === '/api/auth/phone/login') { return new Response( JSON.stringify({ token: 'phone-token', user: { ...testAuthUser, loginMethod: 'phone' }, created: false, referral: null, }), { status: 200 }, ); } throw new Error(`unexpected fetch ${url}`); }, ); render( React.createElement(AuthenticatedClient, null, ({ user }) => React.createElement('main', { 'aria-label': '已登录' }, user.id), ), ); await screen.findByRole('main', { name: '登录' }); fireEvent.change(screen.getByLabelText('手机号'), { target: { value: '13800000000' }, }); fireEvent.change(screen.getByLabelText('验证码'), { target: { value: '123456' }, }); fireEvent.click(screen.getByRole('button', { name: '登录' })); expect(await screen.findByText('runner unavailable')).not.toBeNull(); expect(screen.queryByLabelText('已登录')).toBeNull(); expect( window.localStorage.getItem('genarrative.auth.access-token.v1'), ).toBe(null); }); it('keeps login HTTP and native commit bound to the origin frozen before the request', async () => { const invoke = vi.fn(async () => null); window.__TAURI__ = { core: { invoke } }; let resolveLogin: ((response: Response) => void) | null = null; vi.spyOn(globalThis, 'fetch').mockImplementation( async (input: RequestInfo | URL) => { const url = String(input); if (url === '/api/auth/refresh') { return new Response('', { status: 401 }); } if (url === '/api/auth/phone/login') { return await new Promise((resolve) => { resolveLogin = resolve; }); } throw new Error(`unexpected fetch ${url}`); }, ); render( React.createElement(AuthenticatedClient, null, ({ user }) => React.createElement('main', { 'aria-label': '已登录' }, user.id), ), ); await screen.findByRole('main', { name: '登录' }); fireEvent.change(screen.getByLabelText('手机号'), { target: { value: '13800000000' }, }); fireEvent.change(screen.getByLabelText('验证码'), { target: { value: '123456' }, }); fireEvent.click(screen.getByRole('button', { name: '登录' })); await waitFor(() => expect(resolveLogin).not.toBeNull()); expect(screen.getByLabelText('服务器')).not.toBeNull(); window.localStorage.setItem( 'genarrative.client.server-selection.v1', JSON.stringify({ preset: 'release', customBaseUrl: '' }), ); resolveLogin?.( new Response( JSON.stringify({ token: 'origin-a-token', user: { ...testAuthUser, loginMethod: 'phone' }, created: false, referral: null, }), { status: 200 }, ), ); expect(await screen.findByRole('main', { name: '已登录' })).not.toBeNull(); expect(invoke).toHaveBeenLastCalledWith( 'install_platform_account_session', expect.objectContaining({ accessToken: 'origin-a-token', apiBaseUrl: AGC_DEVELOPMENT_API_BASE_URL, }), ); expect(invoke).not.toHaveBeenCalledWith( 'install_platform_account_session', expect.objectContaining({ apiBaseUrl: 'https://www.genarrative.world' }), ); }); it('reserves install and clear writes above the native floor after renderer state resets', async () => { let nativeFloor = { identityGeneration: 57, revision: 57 }; const mutations: Array<{ command: string; identityGeneration: number; revision: number; }> = []; const invoke = vi.fn(async (command: string, payload?: unknown) => { if (command === 'read_platform_account_session_state') { return nativeFloor; } if ( command === 'install_platform_account_session' || command === 'clear_platform_account_session' ) { const write = payload as | { identityGeneration?: number; revision?: number } | undefined; if ( write?.identityGeneration === undefined || write?.revision === undefined ) { throw new Error('missing native session write identity'); } mutations.push({ command, identityGeneration: write.identityGeneration, revision: write.revision, }); nativeFloor = { identityGeneration: write.identityGeneration, revision: write.revision, }; } return null; }); window.__TAURI__ = { core: { invoke } }; resetPlatformSessionStateForTests(); const installFloor = nativeFloor.revision; const installIdentityFloor = nativeFloor.identityGeneration; const loginGeneration = beginPlatformSessionTransition(); setStoredAuthAccessToken('renderer-reload-token'); await commitAuthenticatedPlatformSession(testAuthUser, loginGeneration); expect(mutations[0]?.command).toBe('install_platform_account_session'); expect(mutations[0]?.identityGeneration).toBeGreaterThan( installIdentityFloor, ); expect(mutations[0]?.revision).toBeGreaterThan(installFloor); resetPlatformSessionStateForTests(); const clearFloor = nativeFloor.revision; const clearIdentityFloor = nativeFloor.identityGeneration; const logoutGeneration = beginPlatformSessionClearTransition(); await clearCommittedPlatformSession(logoutGeneration); expect(mutations[1]?.command).toBe('clear_platform_account_session'); expect(mutations[1]?.revision).toBeGreaterThan(clearFloor); expect(mutations[1]?.identityGeneration).toBeGreaterThan( clearIdentityFloor, ); expect( invoke.mock.calls.filter( ([command]) => command === 'read_platform_account_session_state', ), ).toHaveLength(2); }); it('retries the native session write floor read after a transient failure', async () => { let floorReads = 0; const invoke = vi.fn(async (command: string, payload?: unknown) => { if (command === 'read_platform_account_session_state') { floorReads += 1; if (floorReads === 1) { throw new Error('runner not ready'); } return { identityGeneration: 12, revision: 12 }; } if ( command === 'install_platform_account_session' || command === 'clear_platform_account_session' ) { expect(payload).toEqual( expect.objectContaining({ identityGeneration: expect.any(Number), revision: expect.any(Number), }), ); } return null; }); window.__TAURI__ = { core: { invoke } }; const firstGeneration = beginPlatformSessionTransition(); setStoredAuthAccessToken('retry-floor-token'); await expect( commitAuthenticatedPlatformSession(testAuthUser, firstGeneration), ).rejects.toThrow('runner not ready'); // 瞬时读取失败不能被缓存成永久失败:第二次登录必须重新读取并成功。 const secondGeneration = beginPlatformSessionTransition(); setStoredAuthAccessToken('retry-floor-token'); await expect( commitAuthenticatedPlatformSession(testAuthUser, secondGeneration), ).resolves.toEqual(expect.any(Number)); expect(floorReads).toBeGreaterThan(1); expect(invoke).toHaveBeenLastCalledWith( 'install_platform_account_session', expect.objectContaining({ userId: testAuthUser.id, accessToken: 'retry-floor-token', }), ); }); it('does not let a stalled native install wedge the next login', async () => { vi.useFakeTimers(); const installedTokens: string[] = []; let releaseStalledInstall: (() => void) | null = null; const invoke = vi.fn(async (command: string, payload?: unknown) => { if (command === 'install_platform_account_session') { installedTokens.push( String((payload as { accessToken?: string })?.accessToken), ); if (installedTokens.length === 1) { await new Promise((resolve) => { releaseStalledInstall = resolve; }); } } return null; }); window.__TAURI__ = { core: { invoke } }; const stalledGeneration = beginPlatformSessionTransition(); setStoredAuthAccessToken('stalled-token'); const stalled = commitAuthenticatedPlatformSession( testAuthUser, stalledGeneration, ); await vi.advanceTimersByTimeAsync(0); expect(installedTokens).toEqual(['stalled-token']); const retryGeneration = beginPlatformSessionTransition(); setStoredAuthAccessToken('retry-token'); const retry = commitAuthenticatedPlatformSession( testAuthUser, retryGeneration, ); await vi.advanceTimersByTimeAsync(60_000); expect(installedTokens).toEqual(['stalled-token', 'retry-token']); await expect(retry).resolves.toEqual(expect.any(Number)); expect( window.localStorage.getItem('genarrative.auth.access-token.v1'), ).toBe('retry-token'); // 迟到的旧 install 只影响它自己:Rust 按 generation 拒绝过期写入, // 渲染层保持新会话为准。 releaseStalledInstall?.(); await expect(stalled).resolves.toBeNull(); expect( window.localStorage.getItem('genarrative.auth.access-token.v1'), ).toBe('retry-token'); vi.useRealTimers(); }); it('adopts a login whose native session install finishes after the UI fence', async () => { vi.useFakeTimers(); let releaseInstall: (() => void) | null = null; const invoke = vi.fn(async (command: string) => { if (command === 'install_platform_account_session') { await new Promise((resolve) => { releaseInstall = resolve; }); } return null; }); window.__TAURI__ = { core: { invoke } }; vi.spyOn(globalThis, 'fetch').mockImplementation( async (input: RequestInfo | URL) => { const url = String(input); if (url === '/api/auth/refresh') { return new Response('', { status: 401 }); } if (url === '/api/auth/phone/login') { return new Response( JSON.stringify({ token: 'late-install-token', user: { ...testAuthUser, loginMethod: 'phone' }, created: false, referral: null, }), { status: 200 }, ); } throw new Error(`unexpected fetch ${url}`); }, ); render( React.createElement(AuthenticatedClient, null, ({ user }) => React.createElement('main', { 'aria-label': '已登录' }, user.id), ), ); for (let i = 0; i < 5; i += 1) { await act(async () => { await vi.advanceTimersByTimeAsync(0); }); } fireEvent.change(screen.getByLabelText('手机号'), { target: { value: '13800000000' }, }); fireEvent.change(screen.getByLabelText('验证码'), { target: { value: '123456' }, }); fireEvent.click(screen.getByRole('button', { name: '登录' })); await act(async () => { await vi.advanceTimersByTimeAsync(45_000); }); expect( screen.getByText('连接本地运行时超时,请重试或重启客户端'), ).not.toBeNull(); expect(screen.queryByLabelText('已登录')).toBeNull(); // 围栏只放弃等待:本地运行时确实装好会话后,界面必须跟着进工作区。 releaseInstall?.(); await act(async () => { await vi.advanceTimersByTimeAsync(0); }); expect(screen.queryByLabelText('已登录')).not.toBeNull(); expect( window.localStorage.getItem('genarrative.auth.access-token.v1'), ).toBe('late-install-token'); vi.useRealTimers(); }); it('keeps the previous renderer session authoritative when replacement install is rejected', async () => { const invoke = vi.fn(async (_command: string, payload?: unknown) => { const userId = (payload as { userId?: string } | undefined)?.userId; if (userId === 'user-b') { throw new Error('replacement rejected'); } return null; }); window.__TAURI__ = { core: { invoke } }; const accountAGeneration = beginPlatformSessionTransition(); setStoredAuthAccessToken('account-a-token'); await commitAuthenticatedPlatformSession(testAuthUser, accountAGeneration); const accountB = { ...testAuthUser, id: 'user-b', displayName: '用户 B' }; const accountBGeneration = beginPlatformSessionTransition(); setStoredAuthAccessToken('account-b-token'); await expect( commitAuthenticatedPlatformSession(accountB, accountBGeneration), ).rejects.toThrow('replacement rejected'); expect( window.localStorage.getItem('genarrative.auth.access-token.v1'), ).toBe('account-a-token'); }); it('reconciles a stale native install back to the last committed renderer authority', async () => { let resolveAccountBInstall: (() => void) | null = null; const invoke = vi.fn(async (command: string, payload?: unknown) => { const userId = (payload as { userId?: string } | undefined)?.userId; if ( command === 'install_platform_account_session' && userId === 'user-b' ) { await new Promise((resolve) => { resolveAccountBInstall = resolve; }); } return null; }); window.__TAURI__ = { core: { invoke } }; const accountAGeneration = beginPlatformSessionTransition(); setStoredAuthAccessToken('account-a-token'); await commitAuthenticatedPlatformSession(testAuthUser, accountAGeneration); const accountB = { ...testAuthUser, id: 'user-b', displayName: '用户 B' }; const accountBGeneration = beginPlatformSessionTransition(); setStoredAuthAccessToken('account-b-token'); const accountBCommit = commitAuthenticatedPlatformSession( accountB, accountBGeneration, ); await waitFor(() => expect(resolveAccountBInstall).not.toBeNull()); beginPlatformSessionTransition(); resolveAccountBInstall?.(); await expect(accountBCommit).resolves.toBeNull(); expect(invoke).toHaveBeenLastCalledWith( 'install_platform_account_session', expect.objectContaining({ userId: testAuthUser.id, accessToken: 'account-a-token', }), ); expect( window.localStorage.getItem('genarrative.auth.access-token.v1'), ).toBe('account-a-token'); }); it('keeps a queued logout authoritative after an older native install completes', async () => { let resolveAccountBInstall: (() => void) | null = null; const invoke = vi.fn(async (command: string, payload?: unknown) => { const userId = (payload as { userId?: string } | undefined)?.userId; if ( command === 'install_platform_account_session' && userId === 'user-b' ) { await new Promise((resolve) => { resolveAccountBInstall = resolve; }); } return null; }); window.__TAURI__ = { core: { invoke } }; const accountAGeneration = beginPlatformSessionTransition(); setStoredAuthAccessToken('account-a-token'); await commitAuthenticatedPlatformSession(testAuthUser, accountAGeneration); const accountB = { ...testAuthUser, id: 'user-b', displayName: '用户 B' }; const accountBGeneration = beginPlatformSessionTransition(); setStoredAuthAccessToken('account-b-token'); const accountBCommit = commitAuthenticatedPlatformSession( accountB, accountBGeneration, ); await waitFor(() => expect(resolveAccountBInstall).not.toBeNull()); const logoutGeneration = beginPlatformSessionClearTransition(); const clear = clearCommittedPlatformSession(logoutGeneration); resolveAccountBInstall?.(); await expect(accountBCommit).resolves.toBeNull(); await expect(clear).resolves.toBeUndefined(); expect(invoke).toHaveBeenLastCalledWith( 'clear_platform_account_session', expect.objectContaining({ identityGeneration: expect.any(Number), revision: expect.any(Number), }), ); expect( window.localStorage.getItem('genarrative.auth.access-token.v1'), ).toBe(null); }); it('keeps a newer queued login valid after reconciling an older native install', async () => { let resolveAccountBInstall: (() => void) | null = null; const invoke = vi.fn(async (command: string, payload?: unknown) => { const userId = (payload as { userId?: string } | undefined)?.userId; if ( command === 'install_platform_account_session' && userId === 'user-b' ) { await new Promise((resolve) => { resolveAccountBInstall = resolve; }); } return null; }); window.__TAURI__ = { core: { invoke } }; const accountAGeneration = beginPlatformSessionTransition(); setStoredAuthAccessToken('account-a-token'); await commitAuthenticatedPlatformSession(testAuthUser, accountAGeneration); const accountB = { ...testAuthUser, id: 'user-b', displayName: '用户 B' }; const accountBGeneration = beginPlatformSessionTransition(); setStoredAuthAccessToken('account-b-token'); const accountBCommit = commitAuthenticatedPlatformSession( accountB, accountBGeneration, ); await waitFor(() => expect(resolveAccountBInstall).not.toBeNull()); const accountC = { ...testAuthUser, id: 'user-c', displayName: '用户 C' }; const accountCGeneration = beginPlatformSessionTransition(); setStoredAuthAccessToken('account-c-token'); const accountCCommit = commitAuthenticatedPlatformSession( accountC, accountCGeneration, ); resolveAccountBInstall?.(); await expect(accountBCommit).resolves.toBeNull(); await expect(accountCCommit).resolves.not.toBeNull(); expect(invoke).toHaveBeenLastCalledWith( 'install_platform_account_session', expect.objectContaining({ userId: 'user-c', accessToken: 'account-c-token', }), ); expect( window.localStorage.getItem('genarrative.auth.access-token.v1'), ).toBe('account-c-token'); }); it('restores renderer authority when a queued login is stale before it starts', async () => { const invoke = vi.fn(async () => null); window.__TAURI__ = { core: { invoke } }; const accountAGeneration = beginPlatformSessionTransition(); setStoredAuthAccessToken('account-a-token'); await commitAuthenticatedPlatformSession(testAuthUser, accountAGeneration); const accountB = { ...testAuthUser, id: 'user-b', displayName: '用户 B' }; const accountBGeneration = beginPlatformSessionTransition(); setStoredAuthAccessToken('account-b-token'); const accountBCommit = commitAuthenticatedPlatformSession( accountB, accountBGeneration, ); beginPlatformSessionTransition(); await expect(accountBCommit).resolves.toBeNull(); expect( window.localStorage.getItem('genarrative.auth.access-token.v1'), ).toBe('account-a-token'); expect( invoke.mock.calls.filter( ([command]) => command === 'install_platform_account_session', ), ).toHaveLength(1); }); it('singleflights 401 refresh, installs the new token, and rejects a late old-account refresh', async () => { const invoke = vi.fn(async () => null); window.__TAURI__ = { core: { invoke } }; const initialGeneration = beginPlatformSessionTransition(); setStoredAuthAccessToken('account-a-token'); await commitAuthenticatedPlatformSession(testAuthUser, initialGeneration); let resolveRefresh: ((response: Response) => void) | null = null; let refreshCalls = 0; vi.spyOn(globalThis, 'fetch').mockImplementation( async (input: RequestInfo | URL) => { const url = String(input); if (url === '/api/auth/refresh') { refreshCalls += 1; return await new Promise((resolve) => { resolveRefresh = resolve; }); } if (url === '/api/auth/me') { return new Response( JSON.stringify({ user: testAuthUser, availableLoginMethods: ['password'], }), { status: 200 }, ); } throw new Error(`unexpected fetch ${url}`); }, ); const first = requestPlatformSessionRefresh(testAuthUser.id); const second = requestPlatformSessionRefresh(testAuthUser.id); expect(first).toBe(second); expect(refreshCalls).toBe(1); const accountB = { ...testAuthUser, id: 'user-b', displayName: '用户 B' }; const accountBGeneration = beginPlatformSessionTransition(); setStoredAuthAccessToken('account-b-token'); await commitAuthenticatedPlatformSession(accountB, accountBGeneration); resolveRefresh?.( new Response(JSON.stringify({ token: 'late-account-a-token' }), { status: 200, }), ); await expect(first).resolves.toEqual({ status: 'stale' }); expect( window.localStorage.getItem('genarrative.auth.access-token.v1'), ).toBe('account-b-token'); expect(invoke).toHaveBeenLastCalledWith( 'install_platform_account_session', expect.objectContaining({ userId: 'user-b', accessToken: 'account-b-token', identityGeneration: expect.any(Number), revision: expect.any(Number), }), ); // 换号必须推进身份代次:旧账号在途 operation 不能拿到新账号凭据。 const lastInstall = invoke.mock.calls.at(-1)?.[1] as | { identityGeneration?: number } | undefined; expect(lastInstall?.identityGeneration).toBe( currentPlatformNativeIdentityGenerationForTests(), ); }); it('treats a late old-account refresh failure as stale after switching accounts', async () => { const invoke = vi.fn(async () => null); window.__TAURI__ = { core: { invoke } }; const initialGeneration = beginPlatformSessionTransition(); setStoredAuthAccessToken('account-a-token'); await commitAuthenticatedPlatformSession(testAuthUser, initialGeneration); let rejectRefresh: ((error: Error) => void) | null = null; vi.spyOn(globalThis, 'fetch').mockImplementation( async (input: RequestInfo | URL) => { if (String(input) === '/api/auth/refresh') { return await new Promise((_resolve, reject) => { rejectRefresh = reject; }); } throw new Error(`unexpected fetch ${String(input)}`); }, ); const staleRefresh = requestPlatformSessionRefresh(testAuthUser.id); const accountB = { ...testAuthUser, id: 'user-b', displayName: '用户 B' }; const accountBGeneration = beginPlatformSessionTransition(); setStoredAuthAccessToken('account-b-token'); await commitAuthenticatedPlatformSession(accountB, accountBGeneration); rejectRefresh?.(new Error('late account A refresh failed')); await expect(staleRefresh).resolves.toEqual({ status: 'stale' }); expect( window.localStorage.getItem('genarrative.auth.access-token.v1'), ).toBe('account-b-token'); }); it('refreshes the current account once and synchronizes the replacement token', async () => { const invoke = vi.fn(async () => null); window.__TAURI__ = { core: { invoke } }; const generation = beginPlatformSessionTransition(); setStoredAuthAccessToken('expired-token'); await commitAuthenticatedPlatformSession(testAuthUser, generation); let refreshCalls = 0; vi.spyOn(globalThis, 'fetch').mockImplementation( async (input: RequestInfo | URL) => { const url = String(input); if (url === '/api/auth/refresh') { refreshCalls += 1; return new Response(JSON.stringify({ token: 'replacement-token' }), { status: 200, }); } if (url === '/api/auth/me') { return new Response( JSON.stringify({ user: testAuthUser, availableLoginMethods: ['password'], }), { status: 200 }, ); } throw new Error(`unexpected fetch ${url}`); }, ); const result = await requestPlatformSessionRefresh(testAuthUser.id); expect(result).toEqual( expect.objectContaining({ status: 'refreshed', user: testAuthUser }), ); expect(refreshCalls).toBe(1); await waitFor(() => { expect(invoke).toHaveBeenLastCalledWith( 'install_platform_account_session', expect.objectContaining({ userId: testAuthUser.id, accessToken: 'replacement-token', }), ); }); }); it('keeps the identity generation stable when the same account renews its credential', async () => { const installs: Array<{ identityGeneration?: number; revision?: number }> = []; const invoke = vi.fn(async (command: string, payload?: unknown) => { if (command === 'install_platform_account_session') { installs.push( payload as { identityGeneration?: number; revision?: number }, ); } return null; }); window.__TAURI__ = { core: { invoke } }; const generation = beginPlatformSessionTransition(); setStoredAuthAccessToken('expired-token'); await commitAuthenticatedPlatformSession(testAuthUser, generation); const identityGenerationAfterLogin = currentPlatformNativeIdentityGenerationForTests(); const sessionGenerationAfterLogin = currentPlatformSessionGeneration(); vi.spyOn(globalThis, 'fetch').mockImplementation( async (input: RequestInfo | URL) => { const url = String(input); if (url === '/api/auth/refresh') { return new Response(JSON.stringify({ token: 'renewed-token' }), { status: 200, }); } if (url === '/api/auth/me') { return new Response( JSON.stringify({ user: testAuthUser, availableLoginMethods: ['password'], }), { status: 200 }, ); } throw new Error(`unexpected fetch ${url}`); }, ); const result = await requestPlatformSessionRefresh(testAuthUser.id); expect(result).toEqual( expect.objectContaining({ status: 'refreshed', user: testAuthUser }), ); // 续期只换凭据:身份代次与平台会话代次都不推进,在途生成 operation 不会被判成 // 旧账号请求;native 写入仍然用更高的 revision 拒绝迟到写入。 expect(currentPlatformNativeIdentityGenerationForTests()).toBe( identityGenerationAfterLogin, ); expect(currentPlatformSessionGeneration()).toBe( sessionGenerationAfterLogin, ); expect(installs).toHaveLength(2); expect(installs[1]?.identityGeneration).toBe( installs[0]?.identityGeneration, ); expect(installs[1]?.revision).toBeGreaterThan(installs[0]?.revision ?? 0); }); it('keeps the session when a refresh fails for a transient reason', async () => { const invoke = vi.fn(async () => null); window.__TAURI__ = { core: { invoke } }; const generation = beginPlatformSessionTransition(); setStoredAuthAccessToken('still-valid-token'); await commitAuthenticatedPlatformSession(testAuthUser, generation); const sessionGeneration = currentPlatformSessionGeneration(); vi.spyOn(globalThis, 'fetch').mockImplementation( async (input: RequestInfo | URL) => { const url = String(input); if (url === '/api/auth/refresh') { return new Response('', { status: 503 }); } throw new Error(`unexpected fetch ${url}`); }, ); const result = await requestPlatformSessionRefresh(testAuthUser.id); // 刷新暂时不可用不等于登录态权威失效:保留会话与 access token,只让本次动作失败。 expect(result).toMatchObject({ status: 'failed', authoritative: false }); expect( window.localStorage.getItem('genarrative.auth.access-token.v1'), ).toBe('still-valid-token'); expect(currentPlatformSessionGeneration()).toBe(sessionGeneration); expect( invoke.mock.calls.filter( ([command]) => command === 'clear_platform_account_session', ), ).toHaveLength(0); }); it('keeps refresh, current-user lookup, and native commit on the frozen origin', async () => { const invoke = vi.fn(async () => null); window.__TAURI__ = { core: { invoke } }; const generation = beginPlatformSessionTransition(); setStoredAuthAccessToken('expired-token'); await commitAuthenticatedPlatformSession( testAuthUser, generation, AGC_DEVELOPMENT_API_BASE_URL, ); let resolveRefresh: ((response: Response) => void) | null = null; vi.spyOn(globalThis, 'fetch').mockImplementation( async (input: RequestInfo | URL) => { const url = String(input); if (url === '/api/auth/refresh') { return await new Promise((resolve) => { resolveRefresh = resolve; }); } if (url === '/api/auth/me') { return new Response( JSON.stringify({ user: testAuthUser, availableLoginMethods: ['password'], }), { status: 200 }, ); } throw new Error(`unexpected fetch ${url}`); }, ); const refresh = requestPlatformSessionRefresh(testAuthUser.id); window.localStorage.setItem( 'genarrative.client.server-selection.v1', JSON.stringify({ preset: 'release', customBaseUrl: '' }), ); resolveRefresh?.( new Response(JSON.stringify({ token: 'replacement-token' }), { status: 200, }), ); await expect(refresh).resolves.toEqual( expect.objectContaining({ status: 'refreshed' }), ); expect(invoke).toHaveBeenLastCalledWith( 'install_platform_account_session', expect.objectContaining({ accessToken: 'replacement-token', apiBaseUrl: AGC_DEVELOPMENT_API_BASE_URL, }), ); }); it('deduplicates startup auth refresh when React StrictMode hydrates twice', async () => { const fetchSpy = vi .spyOn(globalThis, 'fetch') .mockImplementation(async (input: RequestInfo | URL) => { const url = String(input); if (url === '/api/auth/refresh') { return new Response(JSON.stringify({ token: 'fresh-token' }), { status: 200, }); } if (url === '/api/auth/me') { return new Response( JSON.stringify({ user: testAuthUser, availableLoginMethods: ['password'], }), { status: 200 }, ); } throw new Error(`unexpected fetch ${url}`); }); render( React.createElement( React.StrictMode, null, React.createElement(AuthenticatedClient, null, ({ user }) => React.createElement( 'main', { 'aria-label': '已登录' }, user.displayName, ), ), ), ); expect(await screen.findByLabelText('已登录')).not.toBeNull(); expect( fetchSpy.mock.calls.filter( ([input]) => String(input) === '/api/auth/refresh', ), ).toHaveLength(1); expect( window.localStorage.getItem('genarrative.auth.access-token.v1'), ).toBe('fresh-token'); }); it('shows phone code login before entering the workspace', async () => { vi.spyOn(globalThis, 'fetch').mockImplementation( async (input: RequestInfo | URL) => { const url = String(input); if (url === '/api/auth/refresh') { return new Response('', { status: 401 }); } throw new Error(`unexpected fetch ${url}`); }, ); render( React.createElement(AuthenticatedClient, null, () => React.createElement('main', { 'aria-label': '已登录' }, 'ready'), ), ); expect(await screen.findByRole('main', { name: '登录' })).not.toBeNull(); expect(screen.getByRole('button', { name: '验证码登录' })).not.toBeNull(); expect(screen.getByRole('button', { name: '密码登录' })).not.toBeNull(); expect(screen.getByLabelText('手机号')).not.toBeNull(); expect(screen.getByLabelText('验证码')).not.toBeNull(); expect(screen.queryByLabelText('已登录')).toBeNull(); }); it('shows debug server selection and keeps custom address collapsed by default', async () => { vi.spyOn(globalThis, 'fetch').mockImplementation( async (input: RequestInfo | URL) => { if (String(input) === '/api/auth/refresh') { return new Response('', { status: 401 }); } throw new Error(`unexpected fetch ${String(input)}`); }, ); render( React.createElement(AuthenticatedClient, null, () => React.createElement('main', { 'aria-label': '已登录' }, 'ready'), ), ); await screen.findByRole('main', { name: '登录' }); expect(screen.getByRole('combobox', { name: '服务器' })).not.toBeNull(); expect(screen.queryByLabelText('自定义服务器地址')).toBeNull(); }); it.each(['release', 'dev'])( 'restores the selected server without forwarding a bare credential (%s)', async (preset) => { window.localStorage.setItem( 'genarrative.auth.access-token.v1', 'release-token', ); window.localStorage.setItem( 'genarrative.client.server-selection.v1', JSON.stringify({ preset, customBaseUrl: '' }), ); const invoke = vi.fn(async () => null); window.__TAURI__ = { core: { invoke } }; const fetchSpy = vi .spyOn(globalThis, 'fetch') .mockImplementation( async (input: RequestInfo | URL, init?: RequestInit) => { const url = String(input); expect(new Headers(init?.headers).get('Authorization')).not.toBe( 'Bearer release-token', ); if (url === '/api/auth/refresh') { expect( new Headers(init?.headers).get('Authorization'), ).toBeNull(); return new Response(JSON.stringify({ token: 'dev-token' }), { status: 200, }); } if (url === '/api/auth/me') { expect(new Headers(init?.headers).get('Authorization')).toBe( 'Bearer dev-token', ); return new Response(JSON.stringify({ user: testAuthUser }), { status: 200, }); } throw new Error(`unexpected fetch ${url}`); }, ); render( React.createElement(AuthenticatedClient, null, () => React.createElement('main', { 'aria-label': '已登录' }), ), ); expect( await screen.findByRole('main', { name: '已登录' }), ).not.toBeNull(); expect(fetchSpy.mock.calls.map(([url]) => String(url))).toEqual([ '/api/auth/refresh', '/api/auth/me', ]); expect(invoke).toHaveBeenLastCalledWith( 'install_platform_account_session', expect.objectContaining({ accessToken: 'dev-token', apiBaseUrl: preset === 'release' ? 'https://www.genarrative.world' : AGC_DEVELOPMENT_API_BASE_URL, }), ); }, ); it('logs in with a phone code and stores the returned token', async () => { const fetchSpy = vi .spyOn(globalThis, 'fetch') .mockImplementation( async (input: RequestInfo | URL, init?: RequestInit) => { const url = String(input); if (url === '/api/auth/refresh') { return new Response('', { status: 401 }); } if (url === '/api/auth/phone/send-code') { expect(JSON.parse(String(init?.body))).toMatchObject({ countryCode: '86', purePhoneNumber: '13800000000', scene: 'login', }); return new Response( JSON.stringify({ ok: true, cooldownSeconds: 60, expiresInSeconds: 300, providerRequestId: 'sms-1', }), { status: 200 }, ); } if (url === '/api/auth/phone/login') { expect(JSON.parse(String(init?.body))).toMatchObject({ countryCode: '86', purePhoneNumber: '13800000000', code: '123456', }); return new Response( JSON.stringify({ token: 'phone-token', user: { ...testAuthUser, loginMethod: 'phone' }, created: false, referral: null, }), { status: 200 }, ); } throw new Error(`unexpected fetch ${url}`); }, ); render( React.createElement(AuthenticatedClient, null, ({ user }) => React.createElement( 'main', { 'aria-label': '已登录' }, user.loginMethod, ), ), ); await screen.findByRole('main', { name: '登录' }); fireEvent.change(screen.getByLabelText('手机号'), { target: { value: '+86 138 0000 0000' }, }); fireEvent.click(screen.getByRole('button', { name: '获取验证码' })); expect( await screen.findByText('验证码已发送,300 秒内有效'), ).not.toBeNull(); fireEvent.change(screen.getByLabelText('验证码'), { target: { value: '123456' }, }); fireEvent.click(screen.getByRole('button', { name: '登录' })); expect(await screen.findByLabelText('已登录')).not.toBeNull(); expect(screen.getByLabelText('已登录').textContent).toBe('phone'); expect( window.localStorage.getItem('genarrative.auth.access-token.v1'), ).toBe('phone-token'); expect( fetchSpy.mock.calls.filter( ([input]) => String(input) === '/api/auth/phone/send-code', ), ).toHaveLength(1); expect( fetchSpy.mock.calls.filter( ([input]) => String(input) === '/api/auth/phone/login', ), ).toHaveLength(1); }); it('logs in with the current password phone contract', async () => { vi.spyOn(globalThis, 'fetch').mockImplementation( async (input: RequestInfo | URL, init?: RequestInit) => { const url = String(input); if (url === '/api/auth/refresh') { return new Response('', { status: 401 }); } if (url === '/api/auth/entry') { expect(JSON.parse(String(init?.body))).toEqual({ countryCode: '86', purePhoneNumber: '13800000000', password: 'secret123', }); return new Response( JSON.stringify({ token: 'password-token', user: { ...testAuthUser, loginMethod: 'password' }, }), { status: 200 }, ); } throw new Error(`unexpected fetch ${url}`); }, ); render( React.createElement(AuthenticatedClient, null, ({ user }) => React.createElement( 'main', { 'aria-label': '已登录' }, user.loginMethod, ), ), ); await screen.findByRole('main', { name: '登录' }); fireEvent.click(screen.getByRole('button', { name: '密码登录' })); fireEvent.change(screen.getByLabelText('手机号'), { target: { value: '+86 138 0000 0000' }, }); fireEvent.change(screen.getByLabelText('密码'), { target: { value: ' secret123 ' }, }); fireEvent.click(screen.getByRole('button', { name: '登录' })); expect(await screen.findByLabelText('已登录')).not.toBeNull(); expect( window.localStorage.getItem('genarrative.auth.access-token.v1'), ).toBe('password-token'); }); it('falls back to the localized action error for non-JSON auth failures', async () => { vi.spyOn(globalThis, 'fetch').mockImplementation( async (input: RequestInfo | URL) => { const url = String(input); if (url === '/api/auth/refresh') { return new Response('', { status: 401 }); } if (url === '/api/auth/phone/login') { return new Response( 'Failed to deserialize the JSON body into the target type', { status: 422, headers: { 'Content-Type': 'text/plain' } }, ); } throw new Error(`unexpected fetch ${url}`); }, ); render( React.createElement(AuthenticatedClient, null, () => React.createElement('main', { 'aria-label': '已登录' }, 'ready'), ), ); await screen.findByRole('main', { name: '登录' }); fireEvent.change(screen.getByLabelText('手机号'), { target: { value: '13800000000' }, }); fireEvent.change(screen.getByLabelText('验证码'), { target: { value: '123456' }, }); fireEvent.click(screen.getByRole('button', { name: '登录' })); expect(await screen.findByText('登录失败')).not.toBeNull(); expect(screen.queryByText(/Unexpected|Failed to deserialize/u)).toBeNull(); }); it('shows the backend reason when the password login is rejected', async () => { vi.spyOn(globalThis, 'fetch').mockImplementation( async (input: RequestInfo | URL) => { const url = String(input); if (url === '/api/auth/refresh') { return new Response('', { status: 401 }); } if (url === '/api/auth/entry') { return new Response( JSON.stringify({ ok: false, data: null, error: { code: 'unauthorized', message: '手机号或密码错误' }, meta: { apiVersion: '2026-06-16', routeVersion: 'v1' }, }), { status: 401, headers: { 'Content-Type': 'application/json' }, }, ); } throw new Error(`unexpected fetch ${url}`); }, ); render( React.createElement(AuthenticatedClient, null, () => React.createElement('main', { 'aria-label': '已登录' }, 'ready'), ), ); await screen.findByRole('main', { name: '登录' }); fireEvent.click(screen.getByRole('button', { name: '密码登录' })); fireEvent.change(screen.getByLabelText('手机号'), { target: { value: '15801783533' }, }); fireEvent.change(screen.getByLabelText('密码'), { target: { value: 'wrong-password' }, }); fireEvent.click(screen.getByRole('button', { name: '登录' })); expect(await screen.findByText('手机号或密码错误')).not.toBeNull(); expect(screen.queryByText('登录失败')).toBeNull(); }); it('keeps the backend reason when the error body carries no envelope', async () => { vi.spyOn(globalThis, 'fetch').mockImplementation( async (input: RequestInfo | URL) => { const url = String(input); if (url === '/api/auth/refresh') { return new Response('', { status: 401 }); } if (url === '/api/auth/entry') { return new Response( JSON.stringify({ error: { code: 'unauthorized', message: '手机号或密码错误' }, meta: { apiVersion: '2026-06-16', routeVersion: 'v1' }, }), { status: 401, headers: { 'Content-Type': 'application/json' }, }, ); } throw new Error(`unexpected fetch ${url}`); }, ); render( React.createElement(AuthenticatedClient, null, () => React.createElement('main', { 'aria-label': '已登录' }, 'ready'), ), ); await screen.findByRole('main', { name: '登录' }); fireEvent.click(screen.getByRole('button', { name: '密码登录' })); fireEvent.change(screen.getByLabelText('手机号'), { target: { value: '15801783533' }, }); fireEvent.change(screen.getByLabelText('密码'), { target: { value: 'wrong-password' }, }); fireEvent.click(screen.getByRole('button', { name: '登录' })); expect(await screen.findByText('手机号或密码错误')).not.toBeNull(); expect(screen.queryByText('登录失败')).toBeNull(); }); it('shows a clear login service error instead of raw Load failed', async () => { vi.spyOn(globalThis, 'fetch').mockImplementation( async (input: RequestInfo | URL) => { const url = String(input); if (url === '/api/auth/refresh') { return new Response('', { status: 401 }); } if (url === '/api/auth/phone/login') { throw new TypeError('Load failed'); } throw new Error(`unexpected fetch ${url}`); }, ); render( React.createElement(AuthenticatedClient, null, () => React.createElement('main', { 'aria-label': '已登录' }, 'ready'), ), ); await screen.findByRole('main', { name: '登录' }); fireEvent.change(screen.getByLabelText('手机号'), { target: { value: '13800000000' }, }); fireEvent.change(screen.getByLabelText('验证码'), { target: { value: '123456' }, }); fireEvent.click(screen.getByRole('button', { name: '登录' })); expect( await screen.findByText( '无法连接登录服务,请确认配套后端或 API 代理已启动后重试', ), ).not.toBeNull(); expect(screen.queryByText(/Load failed/u)).toBeNull(); expect(screen.queryByLabelText('已登录')).toBeNull(); }); it('explains a refused login connection without exposing transport details', async () => { vi.spyOn(globalThis, 'fetch').mockImplementation( async (input: RequestInfo | URL) => { const url = String(input); if (url === '/api/auth/refresh') { return new Response('', { status: 401 }); } if (url === '/api/auth/phone/login') { throw new TypeError('connect ECONNREFUSED 127.0.0.1:8082'); } throw new Error(`unexpected fetch ${url}`); }, ); render( React.createElement(AuthenticatedClient, null, () => React.createElement('main', { 'aria-label': '已登录' }, 'ready'), ), ); await screen.findByRole('main', { name: '登录' }); fireEvent.change(screen.getByLabelText('手机号'), { target: { value: '13800000000' }, }); fireEvent.change(screen.getByLabelText('验证码'), { target: { value: '123456' }, }); fireEvent.click(screen.getByRole('button', { name: '登录' })); expect( await screen.findByText( '无法连接登录服务:服务器拒绝连接,请确认服务已启动并检查端口', ), ).not.toBeNull(); expect(screen.queryByText(/ECONNREFUSED|127\.0\.0\.1:8082/u)).toBeNull(); expect(screen.queryByLabelText('已登录')).toBeNull(); }); it('keeps the stored token when startup auth check cannot reach the service', async () => { setStoredAuthAccessToken('existing-token'); const fetchSpy = vi .spyOn(globalThis, 'fetch') .mockImplementation(async (input: RequestInfo | URL) => { const url = String(input); if (url === '/api/auth/me') { throw new TypeError('Load failed'); } throw new Error(`unexpected fetch ${url}`); }); render( React.createElement(AuthenticatedClient, null, ({ user }) => React.createElement( 'main', { 'aria-label': '已登录' }, user.displayName, ), ), ); expect(await screen.findByRole('main', { name: '登录' })).not.toBeNull(); expect( screen.getByText( '无法连接登录服务,请确认配套后端或 API 代理已启动后重试', ), ).not.toBeNull(); expect( window.localStorage.getItem('genarrative.auth.access-token.v1'), ).toBe('existing-token'); expect(screen.queryByLabelText('已登录')).toBeNull(); expect( fetchSpy.mock.calls.filter(([input]) => String(input) === '/api/auth/me'), ).toHaveLength(1); }); it('shows the HTTP maintenance error when startup auth receives a 503', async () => { setStoredAuthAccessToken('existing-token'); vi.spyOn(globalThis, 'fetch').mockImplementation( async (input: RequestInfo | URL) => { if (String(input) === '/api/auth/me') { return new Response( '503 Service Unavailable', { status: 503, headers: { 'Content-Type': 'text/html' } }, ); } throw new Error(`unexpected fetch ${String(input)}`); }, ); render( React.createElement(AuthenticatedClient, null, () => React.createElement('main', { 'aria-label': '已登录' }), ), ); expect(await screen.findByRole('main', { name: '登录' })).not.toBeNull(); expect( screen.getByText( '登录服务暂不可用(HTTP 503),服务器可能正在维护,请稍后重试', ), ).not.toBeNull(); }); it('still calls logout when token refresh fails during logout retry', async () => { setStoredAuthAccessToken('existing-token'); let logoutCalls = 0; const fetchSpy = vi .spyOn(globalThis, 'fetch') .mockImplementation(async (input: RequestInfo | URL) => { const url = String(input); if (url === '/api/auth/me') { return new Response( JSON.stringify({ user: testAuthUser, availableLoginMethods: ['password'], }), { status: 200 }, ); } if (url === '/api/auth/logout') { logoutCalls += 1; return new Response('', { status: logoutCalls === 1 ? 500 : 200 }); } if (url === '/api/auth/refresh') { return new Response('', { status: 401 }); } throw new Error(`unexpected fetch ${url}`); }); render( React.createElement(AuthenticatedClient, null, ({ user, logout }) => React.createElement( 'main', { 'aria-label': '已登录' }, React.createElement('span', null, user.displayName), React.createElement( 'button', { type: 'button', onClick: logout }, '退出', ), ), ), ); expect(await screen.findByLabelText('已登录')).not.toBeNull(); fireEvent.click(screen.getByRole('button', { name: '退出' })); expect(await screen.findByRole('main', { name: '登录' })).not.toBeNull(); expect(screen.getByText('已退出登录')).not.toBeNull(); expect( window.localStorage.getItem('genarrative.auth.access-token.v1'), ).toBe(null); expect( fetchSpy.mock.calls.filter( ([input]) => String(input) === '/api/auth/logout', ), ).toHaveLength(2); expect( fetchSpy.mock.calls.filter( ([input]) => String(input) === '/api/auth/refresh', ), // 401 刷新先用当前 cookie 收敛重试一次,重试仍被拒绝才算权威失效。 ).toHaveLength(2); }); it('fails the renderer closed when native session clear is rejected during logout', async () => { setStoredAuthAccessToken('existing-token'); window.__TAURI__ = { core: { invoke: vi.fn(async (command: string) => { if (command === 'clear_platform_account_session') { throw new Error('runner clear rejected'); } return null; }), }, }; vi.spyOn(globalThis, 'fetch').mockImplementation( async (input: RequestInfo | URL) => { const url = String(input); if (url === '/api/auth/me') { return new Response( JSON.stringify({ user: testAuthUser, availableLoginMethods: ['password'], }), { status: 200 }, ); } if (url === '/api/auth/logout') { return new Response('', { status: 200 }); } throw new Error(`unexpected fetch ${url}`); }, ); render( React.createElement(AuthenticatedClient, null, ({ user, logout }) => React.createElement( 'main', { 'aria-label': '已登录' }, React.createElement('span', null, user.displayName), React.createElement( 'button', { type: 'button', onClick: logout }, '退出', ), ), ), ); expect(await screen.findByLabelText('已登录')).not.toBeNull(); fireEvent.click(screen.getByRole('button', { name: '退出' })); expect(await screen.findByRole('main', { name: '登录' })).not.toBeNull(); expect( screen.getByText( '已退出登录;本地运行时登录态同步失败,请重启客户端后再登录', ), ).not.toBeNull(); expect( window.localStorage.getItem('genarrative.auth.access-token.v1'), ).toBe(null); }); }