AGC认证错误用例对齐拍平变体
- 载体用例覆盖变体全集:无字段变体取固定文案,带载荷变体 as 后读 serverMessage/status/detail - 上报判据新增 passwordLoginRejected:服务端 400 原文只展示、不写错误池 - 网关用例改用 authNetworkUnreachable / serverAddressNotHttps 等新变体名
This commit is contained in:
@@ -251,7 +251,10 @@ export function registerAuthTests() {
|
||||
}
|
||||
if (command === 'login_client_with_password') {
|
||||
// Rust 命令的结构化拒绝:前端只按 `type` 分流,不解析文案。
|
||||
throw { type: 'phoneOrPasswordMismatch', message: '手机号或密码错误' };
|
||||
throw {
|
||||
type: 'passwordLoginRejected',
|
||||
serverMessage: '手机号或密码错误',
|
||||
};
|
||||
}
|
||||
return null;
|
||||
});
|
||||
@@ -266,7 +269,9 @@ export function registerAuthTests() {
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: '登录' }));
|
||||
|
||||
expect(await screen.findByText('手机号或密码错误')).not.toBeNull();
|
||||
expect(
|
||||
await screen.findByText('登录失败: 手机号或密码错误'),
|
||||
).not.toBeNull();
|
||||
expect(screen.queryByLabelText('已登录')).toBeNull();
|
||||
});
|
||||
|
||||
@@ -283,10 +288,7 @@ export function registerAuthTests() {
|
||||
return { status: 'unauthenticated' };
|
||||
}
|
||||
if (command === 'login_client_with_phone_code') {
|
||||
throw {
|
||||
type: 'authNetworkUnavailable',
|
||||
message: '无法连接登录服务,请确认配套后端或 API 代理已启动后重试',
|
||||
};
|
||||
throw { type: 'authNetworkUnreachable' };
|
||||
}
|
||||
return null;
|
||||
});
|
||||
@@ -298,8 +300,7 @@ export function registerAuthTests() {
|
||||
// 载体自身 message 留空;诊断信息在 error 字段里,由上报链路取。
|
||||
expect(reasons[0]).toBeInstanceOf(ClientAuthErrorWrapper);
|
||||
expect((reasons[0] as ClientAuthErrorWrapper).error).toMatchObject({
|
||||
type: 'authNetworkUnavailable',
|
||||
message: '无法连接登录服务,请确认配套后端或 API 代理已启动后重试',
|
||||
type: 'authNetworkUnreachable',
|
||||
});
|
||||
expect(screen.queryByText(/无法连接登录服务/u)).toBeNull();
|
||||
expect(document.body.textContent).not.toContain('ECONNREFUSED');
|
||||
@@ -317,8 +318,7 @@ export function registerAuthTests() {
|
||||
reads += 1;
|
||||
return reads === 1
|
||||
? Promise.reject({
|
||||
type: 'serverAddressRejected',
|
||||
message: '服务器地址无效',
|
||||
type: 'serverAddressNotHttps',
|
||||
})
|
||||
: { status: 'unauthenticated' };
|
||||
}
|
||||
@@ -326,7 +326,9 @@ export function registerAuthTests() {
|
||||
});
|
||||
renderAuthSurface(invoke);
|
||||
|
||||
expect(await screen.findByText('服务器地址无效')).not.toBeNull();
|
||||
expect(
|
||||
await screen.findByText('服务器地址非法: 远程地址必须使用 https'),
|
||||
).not.toBeNull();
|
||||
expect(screen.queryByLabelText('已登录')).toBeNull();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '重试登录状态检查' }));
|
||||
|
||||
@@ -77,7 +77,7 @@ describe('认证失败的上报判据', () => {
|
||||
return { status: 'unauthenticated' };
|
||||
}
|
||||
if (command === 'login_client_with_password') {
|
||||
throw { type: 'phoneOrPasswordMismatch', message: '手机号或密码错误' };
|
||||
throw { type: 'phoneOrPasswordMismatch' };
|
||||
}
|
||||
return null;
|
||||
});
|
||||
@@ -86,13 +86,33 @@ describe('认证失败的上报判据', () => {
|
||||
expect(reportCalls()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('服务端 400 的原文经 passwordLoginRejected 透出,仍不写错误池', async () => {
|
||||
await submitPasswordLogin(async (command) => {
|
||||
if (command === 'read_client_auth_state') {
|
||||
return { status: 'unauthenticated' };
|
||||
}
|
||||
if (command === 'login_client_with_password') {
|
||||
throw {
|
||||
type: 'passwordLoginRejected',
|
||||
serverMessage: '密码长度需要在 6 到 128 位之间',
|
||||
};
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
expect(
|
||||
await screen.findByText('登录失败: 密码长度需要在 6 到 128 位之间'),
|
||||
).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: '发送过于频繁,请稍后再试' };
|
||||
throw { type: 'smsCodeThrottled' };
|
||||
}
|
||||
return null;
|
||||
});
|
||||
@@ -102,12 +122,13 @@ describe('认证失败的上报判据', () => {
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: '获取验证码' }));
|
||||
|
||||
expect(await screen.findByText('发送过于频繁,请稍后再试')).not.toBeNull();
|
||||
expect(
|
||||
await screen.findByText('发送验证码过于频繁,请稍后再试'),
|
||||
).not.toBeNull();
|
||||
expect(reportCalls()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('系统变体(网络不可达)原样抛出,经 unhandledrejection 上报一次', async () => {
|
||||
const message = '无法连接登录服务,请确认配套后端或 API 代理已启动后重试';
|
||||
const uninstall = installUnhandledRejectionBridge();
|
||||
try {
|
||||
await submitPasswordLogin(async (command) => {
|
||||
@@ -115,7 +136,7 @@ describe('认证失败的上报判据', () => {
|
||||
return { status: 'unauthenticated' };
|
||||
}
|
||||
if (command === 'login_client_with_password') {
|
||||
throw { type: 'authNetworkUnavailable', message };
|
||||
throw { type: 'authNetworkUnreachable' };
|
||||
}
|
||||
return null;
|
||||
});
|
||||
@@ -125,7 +146,6 @@ describe('认证失败的上报判据', () => {
|
||||
source: 'unhandledrejection',
|
||||
message: '未知客户端错误',
|
||||
});
|
||||
expect(screen.queryByText(message)).toBeNull();
|
||||
} finally {
|
||||
uninstall();
|
||||
}
|
||||
|
||||
@@ -3,80 +3,95 @@
|
||||
* 认证命令失败的 JS 侧载体。
|
||||
*
|
||||
* `invokeClientAuth` 是薄包装:Rust 的结构化拒绝原样装进 `ClientAuthErrorWrapper.error`,不读变体
|
||||
* 字段(`message` 之类不保证每个变体都有)、不注入上下文、载体自身 `message` 留空;分流只按
|
||||
* `error.error.type`。另外用编译期用例钉住"每个变体一个具名载荷类型":漏列任何变体,
|
||||
* `expectNever(error)` 都编译不过。
|
||||
* 字段(`serverMessage` 之类不保证每个变体都有)、不注入上下文、载体自身 `message` 留空;分流只按
|
||||
* `error.error.type`。另外用编译期用例钉住变体全集:无字段变体直接取固定文案,带载荷变体先
|
||||
* `as` 取具名类型;漏列任何变体,`expectNever(error)` 都编译不过。
|
||||
*/
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { loginClientWithPassword } from '../src/services/clientAuth';
|
||||
import { ClientAuthErrorWrapper } from '../src/services/clientAuthErrorWrapper';
|
||||
import type { AuthClientInitFailed } from '../src/services/generated/AuthClientInitFailed';
|
||||
import type { AuthNetworkUnavailable } from '../src/services/generated/AuthNetworkUnavailable';
|
||||
import type { AuthResponseMalformed } from '../src/services/generated/AuthResponseMalformed';
|
||||
import type { AuthResponseServerRejected } from '../src/services/generated/AuthResponseServerRejected';
|
||||
import type { AuthServiceUnavailable } from '../src/services/generated/AuthServiceUnavailable';
|
||||
import type { ClientAuthError } from '../src/services/generated/ClientAuthError';
|
||||
import type { ClientSessionPersistFailed } from '../src/services/generated/ClientSessionPersistFailed';
|
||||
import type { LoginCodeMissing } from '../src/services/generated/LoginCodeMissing';
|
||||
import type { PasswordEntryInputRejected } from '../src/services/generated/PasswordEntryInputRejected';
|
||||
import type { PasswordMissing } from '../src/services/generated/PasswordMissing';
|
||||
import type { PermissionDenied } from '../src/services/generated/PermissionDenied';
|
||||
import type { PhoneLoginInputRejected } from '../src/services/generated/PhoneLoginInputRejected';
|
||||
import type { PhoneNumberInvalid } from '../src/services/generated/PhoneNumberInvalid';
|
||||
import type { PhoneOrPasswordMismatch } from '../src/services/generated/PhoneOrPasswordMismatch';
|
||||
import type { PasswordLoginRejected } from '../src/services/generated/PasswordLoginRejected';
|
||||
import type { PhoneCodeLoginRejected } from '../src/services/generated/PhoneCodeLoginRejected';
|
||||
import type { RuntimeSessionInstallFailed } from '../src/services/generated/RuntimeSessionInstallFailed';
|
||||
import type { SendCodeInputRejected } from '../src/services/generated/SendCodeInputRejected';
|
||||
import type { ServerAddressRejected } from '../src/services/generated/ServerAddressRejected';
|
||||
import type { SessionAuthorityRejected } from '../src/services/generated/SessionAuthorityRejected';
|
||||
import type { SmsCodeInvalidOrExpired } from '../src/services/generated/SmsCodeInvalidOrExpired';
|
||||
import type { SmsCodeThrottled } from '../src/services/generated/SmsCodeThrottled';
|
||||
import type { SendCodeRejected } from '../src/services/generated/SendCodeRejected';
|
||||
import type { UnexpectedRejection } from '../src/services/generated/UnexpectedRejection';
|
||||
|
||||
function expectNever(value: never): void {
|
||||
void value;
|
||||
}
|
||||
|
||||
/** 编译期用例:19 个变体逐个列全,每个分支用 `as` 取具名载荷类型。 */
|
||||
/** 编译期用例:变体逐个列全;带载荷的分支先 `as` 再读自己的字段。 */
|
||||
function variantMessage(error: ClientAuthError): string {
|
||||
switch (error.type) {
|
||||
case 'serverAddressRejected':
|
||||
return (error as ServerAddressRejected).message;
|
||||
case 'serverAddressEmptyOrTooLong':
|
||||
return '不能为空或超长';
|
||||
case 'serverAddressNotAUrl':
|
||||
return '不是合法地址';
|
||||
case 'serverAddressHasCredentials':
|
||||
return '不能包含凭据';
|
||||
case 'serverAddressHasPathOrQueryOrFragment':
|
||||
return '不能带路径、查询或 fragment';
|
||||
case 'serverAddressNotHttps':
|
||||
return '远程地址不是 https';
|
||||
case 'serverAddressUnsupportedScheme':
|
||||
return '不是 http(s) 地址';
|
||||
case 'serverAddressOutsideChannel':
|
||||
return '不在当前构建渠道范围内';
|
||||
case 'phoneNumberInvalid':
|
||||
return (error as PhoneNumberInvalid).message;
|
||||
return '手机号格式不正确';
|
||||
case 'passwordMissing':
|
||||
return (error as PasswordMissing).message;
|
||||
return '密码为空';
|
||||
case 'loginCodeMissing':
|
||||
return (error as LoginCodeMissing).message;
|
||||
case 'passwordEntryInputRejected':
|
||||
return (error as PasswordEntryInputRejected).message;
|
||||
return '验证码为空';
|
||||
case 'passwordLoginRejected':
|
||||
return (error as PasswordLoginRejected).serverMessage ?? '登录输入被拒绝';
|
||||
case 'phoneOrPasswordMismatch':
|
||||
return (error as PhoneOrPasswordMismatch).message;
|
||||
case 'sendCodeInputRejected':
|
||||
return (error as SendCodeInputRejected).message;
|
||||
return '手机号或密码错误';
|
||||
case 'sendCodeRejected':
|
||||
return (error as SendCodeRejected).serverMessage ?? '发码被拒绝';
|
||||
case 'smsCodeThrottled':
|
||||
return (error as SmsCodeThrottled).message;
|
||||
case 'phoneLoginInputRejected':
|
||||
return (error as PhoneLoginInputRejected).message;
|
||||
case 'smsCodeInvalidOrExpired':
|
||||
return (error as SmsCodeInvalidOrExpired).message;
|
||||
case 'sessionAuthorityRejected':
|
||||
return (error as SessionAuthorityRejected).message;
|
||||
return '发码过于频繁';
|
||||
case 'phoneCodeLoginRejected':
|
||||
return (
|
||||
(error as PhoneCodeLoginRejected).serverMessage ?? '验证码登录被拒绝'
|
||||
);
|
||||
case 'smsCodeRejected':
|
||||
return '验证码错误或过期';
|
||||
case 'sessionInvalidated':
|
||||
return '登录态失效';
|
||||
case 'permissionDenied':
|
||||
return (error as PermissionDenied).message;
|
||||
case 'authNetworkUnavailable':
|
||||
return (error as AuthNetworkUnavailable).message;
|
||||
return '权限不足';
|
||||
case 'authNetworkTimeout':
|
||||
return '连接超时';
|
||||
case 'authNetworkUnreachable':
|
||||
return '连接失败';
|
||||
case 'authServiceUnavailable':
|
||||
return (error as AuthServiceUnavailable).message;
|
||||
return `HTTP ${(error as AuthServiceUnavailable).status}`;
|
||||
case 'unexpectedRejection':
|
||||
return (error as UnexpectedRejection).message;
|
||||
case 'authResponseMalformed':
|
||||
return (error as AuthResponseMalformed).message;
|
||||
return (error as UnexpectedRejection).serverMessage ?? '未识别拒绝';
|
||||
case 'authResponseNotJson':
|
||||
return '响应不是 JSON';
|
||||
case 'authResponseInvalidBody':
|
||||
return '响应结构不符';
|
||||
case 'authResponseMissingRefreshCookie':
|
||||
return '缺续期凭据';
|
||||
case 'authResponseMissingUserIdentity':
|
||||
return '缺用户主体';
|
||||
case 'authResponseServerRejected':
|
||||
return (
|
||||
(error as AuthResponseServerRejected).serverMessage ?? '服务端显式拒绝'
|
||||
);
|
||||
case 'clientSessionPersistFailed':
|
||||
return (error as ClientSessionPersistFailed).message;
|
||||
return (error as ClientSessionPersistFailed).detail;
|
||||
case 'runtimeSessionInstallFailed':
|
||||
return (error as RuntimeSessionInstallFailed).message;
|
||||
return (error as RuntimeSessionInstallFailed).detail;
|
||||
case 'authClientInitFailed':
|
||||
return (error as AuthClientInitFailed).message;
|
||||
return '网络客户端构建失败';
|
||||
default: {
|
||||
expectNever(error);
|
||||
return '';
|
||||
@@ -90,8 +105,8 @@ function installInvoke(handler: () => Promise<unknown>) {
|
||||
|
||||
describe('认证命令失败的 JS 侧载体', () => {
|
||||
it('结构化拒绝原样装进 error 字段,不读变体字段', async () => {
|
||||
// 故意不带 message:包装层绝不能假设每个变体都有这个字段。
|
||||
const rejection = { type: 'phoneNumberInvalid', status: 400 };
|
||||
// 故意不带 serverMessage:包装层绝不能假设每个变体都有这个字段。
|
||||
const rejection = { type: 'phoneNumberInvalid' };
|
||||
installInvoke(async () => {
|
||||
throw rejection;
|
||||
});
|
||||
@@ -138,16 +153,21 @@ describe('认证命令失败的 JS 侧载体', () => {
|
||||
});
|
||||
|
||||
describe('变体分流', () => {
|
||||
it('判别后按具名载荷类型取值', () => {
|
||||
expect(
|
||||
variantMessage({ type: 'phoneNumberInvalid', message: '格式不对' }),
|
||||
).toBe('格式不对');
|
||||
it('无字段变体直接取固定文案,带载荷变体按具名类型取值', () => {
|
||||
expect(variantMessage({ type: 'phoneNumberInvalid' })).toBe(
|
||||
'手机号格式不正确',
|
||||
);
|
||||
expect(
|
||||
variantMessage({
|
||||
type: 'authServiceUnavailable',
|
||||
status: 503,
|
||||
message: '服务暂不可用',
|
||||
type: 'passwordLoginRejected',
|
||||
serverMessage: '密码长度需要在 6 到 128 位之间',
|
||||
}),
|
||||
).toBe('服务暂不可用');
|
||||
).toBe('密码长度需要在 6 到 128 位之间');
|
||||
expect(
|
||||
variantMessage({ type: 'passwordLoginRejected', serverMessage: null }),
|
||||
).toBe('登录输入被拒绝');
|
||||
expect(
|
||||
variantMessage({ type: 'authServiceUnavailable', status: 503 }),
|
||||
).toBe('HTTP 503');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -159,10 +159,7 @@ test('没有原生宿主时登录能力明确失败关闭', async () => {
|
||||
});
|
||||
|
||||
test('结构化失败不降级成投影值,而是以携带原始拒绝值的 ClientAuthErrorWrapper 拒绝', async () => {
|
||||
const rejection = {
|
||||
type: 'authNetworkUnavailable',
|
||||
message: '无法连接登录服务',
|
||||
};
|
||||
const rejection = { type: 'authNetworkUnreachable' };
|
||||
invoke.mockRejectedValueOnce(rejection);
|
||||
await expect(refreshClientAuthSession('user-1')).rejects.toMatchObject({
|
||||
error: rejection,
|
||||
|
||||
@@ -210,10 +210,7 @@ describe('客户端错误报告池', () => {
|
||||
});
|
||||
|
||||
it('解包 ClientAuthErrorWrapper 里的原始拒绝值,上下文只认显式入参', async () => {
|
||||
const original = {
|
||||
type: 'authNetworkUnavailable',
|
||||
message: '无法连接登录服务,请确认网络后重试',
|
||||
};
|
||||
const original = { type: 'authNetworkUnreachable' };
|
||||
|
||||
await captureClientError(new ClientAuthErrorWrapper(original), {
|
||||
source: 'unhandledrejection',
|
||||
|
||||
Reference in New Issue
Block a user