ce54c729e8
- 新增 ClientAuthFailure:payload 为 ts-rs 判别联合,context 固定 source=auth + 命令名,cause 为原始拒绝值 - clientAuth 的六个命令统一走 invokeClientAuth:结构化拒绝转成 ClientAuthFailure,非结构化拒绝原样抛出,不兜底文案 - 删除 clientAuthError.ts 的形状读取与 getClientAuthErrorMessage 文案回落层 - AuthenticatedClient 的两个 catch 内联 19 个变体的 switch:业务 / 会话变体原样展示载荷 message,系统变体原样抛出经 unhandledrejection 入池,default: expectNever 保证漏接变体编译失败 - hydrateAuth 失败先离开 loading 态;系统变体不再伪装成 unavailable 投影 - ClientAuthState 收窄为 authenticated / unauthenticated,ClientAuthRefreshResult 收窄为 refreshed / unauthenticated / stale - platformSession 续期只保留 refreshed / unauthenticated / stale 三条路径 - check-config 的 App 调用扫描登记 invokeClientAuth,继续静态证明命令名可达 - 测试:新增 clientAuthFailure 载体与编译期穷尽用例、jsdom unhandledrejection 桥;删除 clientAuthError.test.ts;clientAuthHost / authFailureReporting / authSurface 跟改新口径 - 共享记忆记录 check-config 扫描形态新增 invokeClientAuth
158 lines
5.2 KiB
TypeScript
158 lines
5.2 KiB
TypeScript
/** @vitest-environment jsdom */
|
|
|
|
import {
|
|
cleanup,
|
|
fireEvent,
|
|
render,
|
|
screen,
|
|
waitFor,
|
|
} from '@testing-library/react';
|
|
import { createElement } from 'react';
|
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
/**
|
|
* 认证失败分流的上报判据(端到端,判据是"这条失败到底有没有进错误池")。
|
|
*
|
|
* 认证命令与错误上报走的不是同一个 invoke 入口:认证命令来自 `window.__TAURI__.core.invoke`
|
|
* (本文件注入替身),错误上报来自 `@tauri-apps/api/core` 的模块级 `invoke`。所以只有 mock 掉
|
|
* 模块级 invoke,才能观察到 `report_client_error` 是否真的被调用。
|
|
*/
|
|
vi.mock('@tauri-apps/api/core', () => ({
|
|
invoke: vi.fn(async (command: string, _args?: Record<string, unknown>) => {
|
|
if (command === 'get_pending_error_reports') return [];
|
|
return undefined;
|
|
}),
|
|
}));
|
|
|
|
import { invoke } from '@tauri-apps/api/core';
|
|
|
|
import { AuthenticatedClient } from '../src/app/AuthenticatedClient';
|
|
import { resetPlatformSessionForTests } from '../src/services/platformSession';
|
|
import { installUnhandledRejectionBridge } from './unhandledRejectionBridge';
|
|
|
|
type AuthInvoke = (
|
|
command: string,
|
|
args?: Record<string, unknown>,
|
|
) => Promise<unknown>;
|
|
|
|
function reportCalls() {
|
|
return vi
|
|
.mocked(invoke)
|
|
.mock.calls.filter(([command]) => command === 'report_client_error');
|
|
}
|
|
|
|
function renderLogin(invokeAuth: AuthInvoke) {
|
|
window.__TAURI__ = { core: { invoke: invokeAuth } } as never;
|
|
render(
|
|
createElement(AuthenticatedClient, null, () =>
|
|
createElement('main', { 'aria-label': '已登录' }),
|
|
),
|
|
);
|
|
}
|
|
|
|
async function submitPasswordLogin(invokeAuth: AuthInvoke) {
|
|
renderLogin(invokeAuth);
|
|
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-password' },
|
|
});
|
|
fireEvent.click(screen.getByRole('button', { name: '登录' }));
|
|
}
|
|
|
|
describe('认证失败的上报判据', () => {
|
|
afterEach(() => {
|
|
cleanup();
|
|
resetPlatformSessionForTests();
|
|
delete window.__TAURI__;
|
|
vi.mocked(invoke).mockClear();
|
|
});
|
|
|
|
it('业务拒绝(手机号或密码错误)只显示文案,不写错误池', async () => {
|
|
await submitPasswordLogin(async (command) => {
|
|
if (command === 'read_client_auth_state') {
|
|
return { status: 'unauthenticated' };
|
|
}
|
|
if (command === 'login_client_with_password') {
|
|
throw { type: 'phoneOrPasswordMismatch', message: '手机号或密码错误' };
|
|
}
|
|
return null;
|
|
});
|
|
|
|
expect(await screen.findByText('手机号或密码错误')).not.toBeNull();
|
|
expect(reportCalls()).toHaveLength(0);
|
|
});
|
|
|
|
it('发码限流同样只显示文案,不写错误池', async () => {
|
|
renderLogin(async (command) => {
|
|
if (command === 'read_client_auth_state') {
|
|
return { status: 'unauthenticated' };
|
|
}
|
|
if (command === 'send_client_phone_login_code') {
|
|
throw { type: 'smsCodeThrottled', message: '发送过于频繁,请稍后再试' };
|
|
}
|
|
return null;
|
|
});
|
|
await screen.findByRole('main', { name: '登录' });
|
|
fireEvent.change(screen.getByLabelText('手机号'), {
|
|
target: { value: '13800000000' },
|
|
});
|
|
fireEvent.click(screen.getByRole('button', { name: '获取验证码' }));
|
|
|
|
expect(await screen.findByText('发送过于频繁,请稍后再试')).not.toBeNull();
|
|
expect(reportCalls()).toHaveLength(0);
|
|
});
|
|
|
|
it('系统变体(网络不可达)原样抛出,经 unhandledrejection 上报一次', async () => {
|
|
const message = '无法连接登录服务,请确认配套后端或 API 代理已启动后重试';
|
|
const uninstall = installUnhandledRejectionBridge();
|
|
try {
|
|
await submitPasswordLogin(async (command) => {
|
|
if (command === 'read_client_auth_state') {
|
|
return { status: 'unauthenticated' };
|
|
}
|
|
if (command === 'login_client_with_password') {
|
|
throw { type: 'authNetworkUnavailable', message };
|
|
}
|
|
return null;
|
|
});
|
|
|
|
await waitFor(() => expect(reportCalls()).toHaveLength(1));
|
|
expect(reportCalls()[0]?.[1]).toMatchObject({
|
|
source: 'auth',
|
|
action: 'login_client_with_password',
|
|
message,
|
|
});
|
|
expect(screen.queryByText(message)).toBeNull();
|
|
} finally {
|
|
uninstall();
|
|
}
|
|
});
|
|
|
|
it('非结构化拒绝原样抛出,由 unhandledrejection 兜底上报', async () => {
|
|
const uninstall = installUnhandledRejectionBridge();
|
|
try {
|
|
await submitPasswordLogin(async (command) => {
|
|
if (command === 'read_client_auth_state') {
|
|
return { status: 'unauthenticated' };
|
|
}
|
|
if (command === 'login_client_with_password') {
|
|
throw new Error('IPC 桥接不可用');
|
|
}
|
|
return null;
|
|
});
|
|
|
|
await waitFor(() => expect(reportCalls()).toHaveLength(1));
|
|
expect(reportCalls()[0]?.[1]).toMatchObject({
|
|
source: 'unhandledrejection',
|
|
message: 'IPC 桥接不可用',
|
|
});
|
|
} finally {
|
|
uninstall();
|
|
}
|
|
});
|
|
});
|