3d4c42901c
- auth.suite:启动超时用例先断言恰好捕获 1 条拒绝,再断言它是含「检查登录状态超时」的 Error - 原来的 some(...) 只看有没有命中,无关 unhandledrejection 会被这段捕获静默吞掉、测不出回归
451 lines
15 KiB
TypeScript
451 lines
15 KiB
TypeScript
import { afterEach } from 'vitest';
|
||
|
||
import { ClientAuthErrorWrapper } from '../../src/services/clientAuthErrorWrapper';
|
||
import { AGC_DEVELOPMENT_API_BASE_URL } from '../../src/services/clientHttp';
|
||
import {
|
||
currentPlatformSessionGeneration,
|
||
resetPlatformSessionForTests,
|
||
} from '../../src/services/platformSession';
|
||
import { installUnhandledRejectionBridge } from '../unhandledRejectionBridge';
|
||
import {
|
||
act,
|
||
AuthenticatedClient,
|
||
expect,
|
||
fireEvent,
|
||
it,
|
||
React,
|
||
render,
|
||
screen,
|
||
testAuthUser,
|
||
vi,
|
||
waitFor,
|
||
} from './harness';
|
||
|
||
type AuthEventBridge = {
|
||
listeners: Map<string, (event: { payload: unknown }) => void>;
|
||
emit: (event: string, payload: unknown) => void;
|
||
};
|
||
|
||
/**
|
||
* 注入 Tauri 事件替身。
|
||
*
|
||
* jsdom 没有 `__TAURI_INTERNALS__`,`subscribeTauriEvent` 会退回
|
||
* `window.__TAURI__.event.listen`,所以这里按同一入口登记回调。
|
||
*/
|
||
function installTauriBridge(
|
||
invoke: (command: string, args?: Record<string, unknown>) => Promise<unknown>,
|
||
options: { withEvents?: boolean } = {},
|
||
): AuthEventBridge {
|
||
const listeners = new Map<string, (event: { payload: unknown }) => void>();
|
||
window.__TAURI__ = {
|
||
core: { invoke },
|
||
...(options.withEvents
|
||
? {
|
||
event: {
|
||
listen: async (
|
||
event: string,
|
||
handler: (payload: { payload: unknown }) => void,
|
||
) => {
|
||
listeners.set(event, handler);
|
||
return () => listeners.delete(event);
|
||
},
|
||
},
|
||
}
|
||
: {}),
|
||
} as never;
|
||
return {
|
||
listeners,
|
||
emit: (event, payload) => {
|
||
const handler = listeners.get(event);
|
||
if (handler) handler({ payload });
|
||
},
|
||
};
|
||
}
|
||
|
||
function renderAuthSurface(
|
||
invoke: (command: string, args?: Record<string, unknown>) => Promise<unknown>,
|
||
options: { withEvents?: boolean } = {},
|
||
) {
|
||
const bridge = installTauriBridge(invoke, options);
|
||
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 },
|
||
'退出',
|
||
),
|
||
),
|
||
),
|
||
);
|
||
return bridge;
|
||
}
|
||
|
||
async function loginWithCode() {
|
||
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: '登录' }));
|
||
}
|
||
|
||
/**
|
||
* 捕获 window 侧 `unhandledrejection`:返回已收到的 `reason` 列表与释放函数,
|
||
* 释放时同时摘掉监听和 Node 侧桥接。
|
||
*/
|
||
function captureWindowRejections() {
|
||
const uninstallBridge = installUnhandledRejectionBridge();
|
||
const reasons: unknown[] = [];
|
||
const onRejection = (event: Event) => {
|
||
reasons.push((event as Event & { reason?: unknown }).reason);
|
||
};
|
||
window.addEventListener('unhandledrejection', onRejection);
|
||
return {
|
||
reasons,
|
||
uninstall() {
|
||
window.removeEventListener('unhandledrejection', onRejection);
|
||
uninstallBridge();
|
||
},
|
||
};
|
||
}
|
||
|
||
export function registerAuthTests() {
|
||
afterEach(() => {
|
||
resetPlatformSessionForTests();
|
||
delete window.__TAURI__;
|
||
});
|
||
|
||
it('throws a startup timeout through to unhandledrejection and leaves the loading state', async () => {
|
||
const rejections = captureWindowRejections();
|
||
try {
|
||
renderAuthSurface(async (command: string) => {
|
||
if (command === 'read_client_auth_state') {
|
||
return await new Promise(() => {});
|
||
}
|
||
return null;
|
||
});
|
||
|
||
await waitFor(() => expect(rejections.reasons).toHaveLength(1), {
|
||
timeout: 20_000,
|
||
});
|
||
expect(rejections.reasons[0]).toBeInstanceOf(Error);
|
||
expect((rejections.reasons[0] as Error).message).toContain(
|
||
'检查登录状态超时',
|
||
);
|
||
// 启动检查失败在改动前就会显示原因 + 重试按钮;上报是叠加的,不是替换。
|
||
expect(
|
||
await screen.findByText(
|
||
'检查登录状态超时,请检查服务器地址和网络后重试',
|
||
),
|
||
).not.toBeNull();
|
||
expect(
|
||
screen.getByRole('button', { name: '重试登录状态检查' }),
|
||
).toHaveProperty('disabled', false);
|
||
await screen.findByRole('main', { name: '登录' });
|
||
} finally {
|
||
rejections.uninstall();
|
||
}
|
||
}, 30_000);
|
||
|
||
it('renders the unauthenticated client with the shared light platform theme and product image', async () => {
|
||
renderAuthSurface(async () => ({ status: 'unauthenticated' }));
|
||
|
||
const loginPanel = await screen.findByRole('main', { name: '登录' });
|
||
expect(loginPanel.className).toContain('platform-theme--light');
|
||
const logo = loginPanel.querySelector('img');
|
||
expect(logo?.getAttribute('alt')).toBe('陶泥儿');
|
||
expect(logo?.getAttribute('src')).toContain('taonier-product-ip.png');
|
||
});
|
||
|
||
it('logs in with a phone code and never persists a token in the renderer', async () => {
|
||
const invoke = vi.fn(async (command: string) => {
|
||
if (command === 'read_client_auth_state') {
|
||
return { status: 'unauthenticated' };
|
||
}
|
||
if (command === 'login_client_with_phone_code') {
|
||
return { ...testAuthUser, loginMethod: 'phone' };
|
||
}
|
||
return null;
|
||
});
|
||
renderAuthSurface(invoke);
|
||
await loginWithCode();
|
||
|
||
expect(await screen.findByLabelText('已登录')).not.toBeNull();
|
||
expect(invoke).toHaveBeenCalledWith('login_client_with_phone_code', {
|
||
apiBaseUrl: AGC_DEVELOPMENT_API_BASE_URL,
|
||
phone: '13800000000',
|
||
code: '123456',
|
||
});
|
||
// 渲染层不再持久化任何凭据。
|
||
expect(window.localStorage.length).toBe(0);
|
||
expect(
|
||
window.localStorage.getItem('genarrative.auth.access-token.v1'),
|
||
).toBe(null);
|
||
});
|
||
|
||
it('logs in with the password contract on the frozen server origin', async () => {
|
||
const invoke = vi.fn(async (command: string) => {
|
||
if (command === 'read_client_auth_state') {
|
||
return { status: 'unauthenticated' };
|
||
}
|
||
if (command === 'login_client_with_password') {
|
||
return { ...testAuthUser, loginMethod: 'password' };
|
||
}
|
||
return null;
|
||
});
|
||
renderAuthSurface(invoke);
|
||
await screen.findByRole('main', { name: '登录' });
|
||
fireEvent.click(screen.getByRole('button', { name: '密码登录' }));
|
||
fireEvent.change(screen.getByLabelText('手机号'), {
|
||
target: { value: '138 0000 0000' },
|
||
});
|
||
fireEvent.change(screen.getByLabelText('密码'), {
|
||
target: { value: ' secret ' },
|
||
});
|
||
fireEvent.click(screen.getByRole('button', { name: '登录' }));
|
||
|
||
expect(await screen.findByLabelText('已登录')).not.toBeNull();
|
||
expect(invoke).toHaveBeenCalledWith('login_client_with_password', {
|
||
apiBaseUrl: AGC_DEVELOPMENT_API_BASE_URL,
|
||
phone: '13800000000',
|
||
password: 'secret',
|
||
});
|
||
});
|
||
|
||
it('shows the debug server selection and uses the custom origin for login', async () => {
|
||
const invoke = vi.fn(async (command: string) => {
|
||
if (command === 'read_workspace_preferences') {
|
||
return {
|
||
projectCreationDirectory: null,
|
||
recentWorkspaces: [],
|
||
chatPromptPolishReminderDisabled: false,
|
||
clientServerSelection: null,
|
||
};
|
||
}
|
||
if (command === 'read_client_auth_state') {
|
||
return { status: 'unauthenticated' };
|
||
}
|
||
if (command === 'login_client_with_phone_code') {
|
||
return testAuthUser;
|
||
}
|
||
return null;
|
||
});
|
||
renderAuthSurface(invoke);
|
||
await screen.findByRole('main', { name: '登录' });
|
||
|
||
fireEvent.change(screen.getByLabelText('服务器'), {
|
||
target: { value: 'custom' },
|
||
});
|
||
fireEvent.change(screen.getByLabelText('自定义服务器地址'), {
|
||
target: { value: 'http://127.0.0.1:10001' },
|
||
});
|
||
fireEvent.blur(screen.getByLabelText('自定义服务器地址'));
|
||
await loginWithCode();
|
||
|
||
expect(await screen.findByLabelText('已登录')).not.toBeNull();
|
||
expect(invoke).toHaveBeenCalledWith('login_client_with_phone_code', {
|
||
apiBaseUrl: 'http://127.0.0.1:10001',
|
||
phone: '13800000000',
|
||
code: '123456',
|
||
});
|
||
expect(invoke).toHaveBeenCalledWith('set_client_server_selection', {
|
||
preset: 'custom',
|
||
customBaseUrl: 'http://127.0.0.1:10001',
|
||
});
|
||
});
|
||
|
||
it('shows the typed business reason when the password login is rejected', async () => {
|
||
const invoke = vi.fn(async (command: string) => {
|
||
if (command === 'read_client_auth_state') {
|
||
return { status: 'unauthenticated' };
|
||
}
|
||
if (command === 'login_client_with_password') {
|
||
// Rust 命令的结构化拒绝:前端只按 `type` 分流,不解析文案。
|
||
throw {
|
||
type: 'passwordLoginRejected',
|
||
serverMessage: '手机号或密码错误',
|
||
};
|
||
}
|
||
return null;
|
||
});
|
||
renderAuthSurface(invoke);
|
||
await screen.findByRole('main', { name: '登录' });
|
||
fireEvent.click(screen.getByRole('button', { name: '密码登录' }));
|
||
fireEvent.change(screen.getByLabelText('手机号'), {
|
||
target: { value: '13800000000' },
|
||
});
|
||
fireEvent.change(screen.getByLabelText('密码'), {
|
||
target: { value: 'wrong' },
|
||
});
|
||
fireEvent.click(screen.getByRole('button', { name: '登录' }));
|
||
|
||
expect(
|
||
await screen.findByText('登录失败: 手机号或密码错误'),
|
||
).not.toBeNull();
|
||
expect(screen.queryByLabelText('已登录')).toBeNull();
|
||
});
|
||
|
||
it('shows the system variant copy and still throws it through', async () => {
|
||
const rejections = captureWindowRejections();
|
||
try {
|
||
const invoke = vi.fn(async (command: string) => {
|
||
if (command === 'read_client_auth_state') {
|
||
return { status: 'unauthenticated' };
|
||
}
|
||
if (command === 'login_client_with_phone_code') {
|
||
throw { type: 'authNetworkFailure', reason: 'unreachable' };
|
||
}
|
||
return null;
|
||
});
|
||
renderAuthSurface(invoke);
|
||
await loginWithCode();
|
||
|
||
// 先等系统变体真的经 unhandledrejection 抛出。
|
||
await waitFor(() => expect(rejections.reasons).toHaveLength(1));
|
||
// 载体 message 是载荷序列化(上报链路直接取用);结构化诊断在 error 字段里。
|
||
expect(rejections.reasons[0]).toBeInstanceOf(ClientAuthErrorWrapper);
|
||
expect(
|
||
(rejections.reasons[0] as ClientAuthErrorWrapper).error,
|
||
).toMatchObject({
|
||
type: 'authNetworkFailure',
|
||
reason: 'unreachable',
|
||
});
|
||
// 改动前系统失败也会在登录页给出一行提示;这里恢复同样的可见反馈,只是不泄露传输层细节。
|
||
expect(
|
||
await screen.findByText(
|
||
'登录失败:无法连接登录服务,请确认配套后端或 API 代理已启动后重试',
|
||
),
|
||
).not.toBeNull();
|
||
expect(document.body.textContent).not.toContain('ECONNREFUSED');
|
||
await screen.findByRole('main', { name: '登录' });
|
||
} finally {
|
||
rejections.uninstall();
|
||
}
|
||
});
|
||
|
||
it('keeps a retry when the startup check is rejected by a business variant', async () => {
|
||
let reads = 0;
|
||
const invoke = vi.fn(async (command: string) => {
|
||
if (command === 'read_client_auth_state') {
|
||
reads += 1;
|
||
if (reads === 1) {
|
||
throw { type: 'serverAddressRejected', reason: 'notHttps' };
|
||
}
|
||
return { status: 'unauthenticated' };
|
||
}
|
||
return null;
|
||
});
|
||
renderAuthSurface(invoke);
|
||
|
||
expect(
|
||
await screen.findByText('服务器地址非法: 远程地址必须使用 https'),
|
||
).not.toBeNull();
|
||
expect(screen.queryByLabelText('已登录')).toBeNull();
|
||
|
||
fireEvent.click(screen.getByRole('button', { name: '重试登录状态检查' }));
|
||
await waitFor(() => expect(reads).toBe(2));
|
||
await screen.findByRole('main', { name: '登录' });
|
||
});
|
||
|
||
it('starts every login attempt from a fresh identity generation', async () => {
|
||
const invoke = vi.fn(async (command: string) => {
|
||
if (command === 'read_client_auth_state') {
|
||
return { status: 'unauthenticated' };
|
||
}
|
||
if (command === 'login_client_with_phone_code') {
|
||
return testAuthUser;
|
||
}
|
||
return null;
|
||
});
|
||
renderAuthSurface(invoke);
|
||
await screen.findByRole('main', { name: '登录' });
|
||
const generationBeforeLogin = currentPlatformSessionGeneration();
|
||
await loginWithCode();
|
||
await screen.findByLabelText('已登录');
|
||
|
||
expect(currentPlatformSessionGeneration()).toBeGreaterThan(
|
||
generationBeforeLogin,
|
||
);
|
||
});
|
||
|
||
it('calls the Rust logout command and returns to the login surface', async () => {
|
||
const invoke = vi.fn(async (command: string) => {
|
||
if (command === 'read_client_auth_state') {
|
||
return {
|
||
status: 'authenticated',
|
||
user: testAuthUser,
|
||
apiBaseUrl: AGC_DEVELOPMENT_API_BASE_URL,
|
||
};
|
||
}
|
||
if (command === 'logout_client_session') return null;
|
||
return null;
|
||
});
|
||
renderAuthSurface(invoke);
|
||
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(invoke).toHaveBeenCalledWith('logout_client_session');
|
||
});
|
||
|
||
it('still leaves the workspace when the local runtime clear fails during logout', async () => {
|
||
const invoke = vi.fn(async (command: string) => {
|
||
if (command === 'read_client_auth_state') {
|
||
return {
|
||
status: 'authenticated',
|
||
user: testAuthUser,
|
||
apiBaseUrl: AGC_DEVELOPMENT_API_BASE_URL,
|
||
};
|
||
}
|
||
if (command === 'logout_client_session') {
|
||
throw new Error('runner clear rejected');
|
||
}
|
||
return null;
|
||
});
|
||
renderAuthSurface(invoke);
|
||
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();
|
||
});
|
||
|
||
it('returns to the login surface when Rust reports the session ended', async () => {
|
||
const invoke = vi.fn(async (command: string) => {
|
||
if (command === 'read_client_auth_state') {
|
||
return {
|
||
status: 'authenticated',
|
||
user: testAuthUser,
|
||
apiBaseUrl: AGC_DEVELOPMENT_API_BASE_URL,
|
||
};
|
||
}
|
||
return null;
|
||
});
|
||
const bridge = renderAuthSurface(invoke, { withEvents: true });
|
||
expect(await screen.findByLabelText('已登录')).not.toBeNull();
|
||
|
||
await act(async () => {
|
||
bridge.emit('agc-client-auth-state-changed', {
|
||
status: 'unauthenticated',
|
||
});
|
||
await Promise.resolve();
|
||
});
|
||
|
||
expect(await screen.findByRole('main', { name: '登录' })).not.toBeNull();
|
||
expect(screen.getByText('登录已失效,请重新登录')).not.toBeNull();
|
||
});
|
||
}
|