4182979a19
- 原生会话快照拆成身份代次与写入 revision,安装与清除按 revision 排序、按身份归属判定 - 冻结平台会话校验改为只比身份,同账号 access token 轮换不再让在途生成、编辑、上传、确认和下载失败 - 同一身份代次禁止更换登录主体或服务 origin,换号、退出和 origin 变化继续失败关闭 - Runner attach 新增 platform_auth_revision,平台会话安装与校验按 revision 排序、按身份归属判定 - GUI owner 替换与清除改为计数器只增不减,同一主体重装保持身份代次,避免旧 epoch 迟到写入复活 - renderer 拆分平台原生身份代次与写入 revision,同账号续期只更新凭据,不推进身份代次 - 刷新失败结果新增权威失效判定,只有服务端明确 401 与 403 才清除本地会话,网络错误和 5xx 保留会话 - 收敛刷新判据,重复的刷新工具函数合并为一个对外判定,去掉一次性包装 - 补齐平台会话身份判据、续期与迟到写入的 Rust 与前端回归用例
1646 lines
56 KiB
TypeScript
1646 lines
56 KiB
TypeScript
import { afterEach } from 'vitest';
|
|
|
|
import {
|
|
AGC_DEVELOPMENT_API_BASE_URL,
|
|
AGC_RELEASE_API_BASE_URL,
|
|
resetClientServerSelectionForTests,
|
|
setClientServerSelection,
|
|
} 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();
|
|
resetClientServerSelectionForTests();
|
|
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<Response>((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 () => {
|
|
setClientServerSelection({ preset: 'dev', customBaseUrl: '' });
|
|
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<Response>((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('服务器') as HTMLSelectElement).disabled,
|
|
).toBe(true);
|
|
|
|
setClientServerSelection({ 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: AGC_RELEASE_API_BASE_URL }),
|
|
);
|
|
});
|
|
|
|
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();
|
|
window.localStorage.setItem(
|
|
'genarrative.auth.access-token.v1',
|
|
'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();
|
|
window.localStorage.setItem(
|
|
'genarrative.auth.access-token.v1',
|
|
'retry-floor-token',
|
|
);
|
|
|
|
await expect(
|
|
commitAuthenticatedPlatformSession(testAuthUser, firstGeneration),
|
|
).rejects.toThrow('runner not ready');
|
|
|
|
// 瞬时读取失败不能被缓存成永久失败:第二次登录必须重新读取并成功。
|
|
const secondGeneration = beginPlatformSessionTransition();
|
|
window.localStorage.setItem(
|
|
'genarrative.auth.access-token.v1',
|
|
'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<void>((resolve) => {
|
|
releaseStalledInstall = resolve;
|
|
});
|
|
}
|
|
}
|
|
return null;
|
|
});
|
|
window.__TAURI__ = { core: { invoke } };
|
|
|
|
const stalledGeneration = beginPlatformSessionTransition();
|
|
window.localStorage.setItem(
|
|
'genarrative.auth.access-token.v1',
|
|
'stalled-token',
|
|
);
|
|
const stalled = commitAuthenticatedPlatformSession(
|
|
testAuthUser,
|
|
stalledGeneration,
|
|
);
|
|
await vi.advanceTimersByTimeAsync(0);
|
|
expect(installedTokens).toEqual(['stalled-token']);
|
|
|
|
const retryGeneration = beginPlatformSessionTransition();
|
|
window.localStorage.setItem(
|
|
'genarrative.auth.access-token.v1',
|
|
'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<void>((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();
|
|
window.localStorage.setItem(
|
|
'genarrative.auth.access-token.v1',
|
|
'account-a-token',
|
|
);
|
|
await commitAuthenticatedPlatformSession(testAuthUser, accountAGeneration);
|
|
const accountB = { ...testAuthUser, id: 'user-b', displayName: '用户 B' };
|
|
const accountBGeneration = beginPlatformSessionTransition();
|
|
window.localStorage.setItem(
|
|
'genarrative.auth.access-token.v1',
|
|
'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<void>((resolve) => {
|
|
resolveAccountBInstall = resolve;
|
|
});
|
|
}
|
|
return null;
|
|
});
|
|
window.__TAURI__ = { core: { invoke } };
|
|
const accountAGeneration = beginPlatformSessionTransition();
|
|
window.localStorage.setItem(
|
|
'genarrative.auth.access-token.v1',
|
|
'account-a-token',
|
|
);
|
|
await commitAuthenticatedPlatformSession(testAuthUser, accountAGeneration);
|
|
const accountB = { ...testAuthUser, id: 'user-b', displayName: '用户 B' };
|
|
const accountBGeneration = beginPlatformSessionTransition();
|
|
window.localStorage.setItem(
|
|
'genarrative.auth.access-token.v1',
|
|
'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<void>((resolve) => {
|
|
resolveAccountBInstall = resolve;
|
|
});
|
|
}
|
|
return null;
|
|
});
|
|
window.__TAURI__ = { core: { invoke } };
|
|
const accountAGeneration = beginPlatformSessionTransition();
|
|
window.localStorage.setItem(
|
|
'genarrative.auth.access-token.v1',
|
|
'account-a-token',
|
|
);
|
|
await commitAuthenticatedPlatformSession(testAuthUser, accountAGeneration);
|
|
const accountB = { ...testAuthUser, id: 'user-b', displayName: '用户 B' };
|
|
const accountBGeneration = beginPlatformSessionTransition();
|
|
window.localStorage.setItem(
|
|
'genarrative.auth.access-token.v1',
|
|
'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<void>((resolve) => {
|
|
resolveAccountBInstall = resolve;
|
|
});
|
|
}
|
|
return null;
|
|
});
|
|
window.__TAURI__ = { core: { invoke } };
|
|
const accountAGeneration = beginPlatformSessionTransition();
|
|
window.localStorage.setItem(
|
|
'genarrative.auth.access-token.v1',
|
|
'account-a-token',
|
|
);
|
|
await commitAuthenticatedPlatformSession(testAuthUser, accountAGeneration);
|
|
|
|
const accountB = { ...testAuthUser, id: 'user-b', displayName: '用户 B' };
|
|
const accountBGeneration = beginPlatformSessionTransition();
|
|
window.localStorage.setItem(
|
|
'genarrative.auth.access-token.v1',
|
|
'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();
|
|
window.localStorage.setItem(
|
|
'genarrative.auth.access-token.v1',
|
|
'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();
|
|
window.localStorage.setItem(
|
|
'genarrative.auth.access-token.v1',
|
|
'account-a-token',
|
|
);
|
|
await commitAuthenticatedPlatformSession(testAuthUser, accountAGeneration);
|
|
|
|
const accountB = { ...testAuthUser, id: 'user-b', displayName: '用户 B' };
|
|
const accountBGeneration = beginPlatformSessionTransition();
|
|
window.localStorage.setItem(
|
|
'genarrative.auth.access-token.v1',
|
|
'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();
|
|
window.localStorage.setItem(
|
|
'genarrative.auth.access-token.v1',
|
|
'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<Response>((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();
|
|
window.localStorage.setItem(
|
|
'genarrative.auth.access-token.v1',
|
|
'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();
|
|
window.localStorage.setItem(
|
|
'genarrative.auth.access-token.v1',
|
|
'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<Response>((_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();
|
|
window.localStorage.setItem(
|
|
'genarrative.auth.access-token.v1',
|
|
'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();
|
|
window.localStorage.setItem(
|
|
'genarrative.auth.access-token.v1',
|
|
'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();
|
|
window.localStorage.setItem(
|
|
'genarrative.auth.access-token.v1',
|
|
'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();
|
|
window.localStorage.setItem(
|
|
'genarrative.auth.access-token.v1',
|
|
'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 () => {
|
|
setClientServerSelection({ preset: 'dev', customBaseUrl: '' });
|
|
const invoke = vi.fn(async () => null);
|
|
window.__TAURI__ = { core: { invoke } };
|
|
const generation = beginPlatformSessionTransition();
|
|
window.localStorage.setItem(
|
|
'genarrative.auth.access-token.v1',
|
|
'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<Response>((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);
|
|
setClientServerSelection({ 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 release, dev, and custom server choices on the login screen', 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: '登录' });
|
|
const server = screen.getByRole('combobox', { name: '服务器' });
|
|
expect(server).not.toBeNull();
|
|
expect(screen.getByRole('option', { name: 'release' })).not.toBeNull();
|
|
expect(screen.getByRole('option', { name: 'dev' })).not.toBeNull();
|
|
expect(screen.getByRole('option', { name: 'custom' })).not.toBeNull();
|
|
|
|
fireEvent.change(server, { target: { value: 'custom' } });
|
|
expect(screen.getByLabelText('自定义服务器地址')).not.toBeNull();
|
|
fireEvent.change(screen.getByLabelText('自定义服务器地址'), {
|
|
target: { value: 'https://staging.example.com' },
|
|
});
|
|
expect(
|
|
(screen.getByLabelText('自定义服务器地址') as HTMLInputElement).value,
|
|
).toBe('https://staging.example.com');
|
|
});
|
|
|
|
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 () => {
|
|
window.localStorage.setItem(
|
|
'genarrative.auth.access-token.v1',
|
|
'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 () => {
|
|
window.localStorage.setItem(
|
|
'genarrative.auth.access-token.v1',
|
|
'existing-token',
|
|
);
|
|
vi.spyOn(globalThis, 'fetch').mockImplementation(
|
|
async (input: RequestInfo | URL) => {
|
|
if (String(input) === '/api/auth/me') {
|
|
return new Response(
|
|
'<!doctype html><title>503 Service Unavailable</title>',
|
|
{ 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 () => {
|
|
window.localStorage.setItem(
|
|
'genarrative.auth.access-token.v1',
|
|
'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 () => {
|
|
window.localStorage.setItem(
|
|
'genarrative.auth.access-token.v1',
|
|
'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);
|
|
});
|
|
}
|