AGC认证错误用例对齐类别变体与类型化reason
- 载体用例:authNetworkFailure 与 authResponseInvalid 改为内层 reason switch,覆盖全部 reason 与 default expectNever - 网关、上报、页面用例改用 authNetworkFailure 等新变体名与类型化 reason 载荷 - 新增可枚举细分不读服务端文案的分流断言
This commit is contained in:
@@ -288,7 +288,7 @@ export function registerAuthTests() {
|
||||
return { status: 'unauthenticated' };
|
||||
}
|
||||
if (command === 'login_client_with_phone_code') {
|
||||
throw { type: 'authNetworkUnreachable' };
|
||||
throw { type: 'authNetworkFailure', reason: 'unreachable' };
|
||||
}
|
||||
return null;
|
||||
});
|
||||
@@ -300,7 +300,8 @@ export function registerAuthTests() {
|
||||
// 载体自身 message 留空;诊断信息在 error 字段里,由上报链路取。
|
||||
expect(reasons[0]).toBeInstanceOf(ClientAuthErrorWrapper);
|
||||
expect((reasons[0] as ClientAuthErrorWrapper).error).toMatchObject({
|
||||
type: 'authNetworkUnreachable',
|
||||
type: 'authNetworkFailure',
|
||||
reason: 'unreachable',
|
||||
});
|
||||
expect(screen.queryByText(/无法连接登录服务/u)).toBeNull();
|
||||
expect(document.body.textContent).not.toContain('ECONNREFUSED');
|
||||
@@ -318,7 +319,8 @@ export function registerAuthTests() {
|
||||
reads += 1;
|
||||
return reads === 1
|
||||
? Promise.reject({
|
||||
type: 'serverAddressNotHttps',
|
||||
type: 'serverAddressRejected',
|
||||
reason: 'notHttps',
|
||||
})
|
||||
: { status: 'unauthenticated' };
|
||||
}
|
||||
|
||||
@@ -136,7 +136,7 @@ describe('认证失败的上报判据', () => {
|
||||
return { status: 'unauthenticated' };
|
||||
}
|
||||
if (command === 'login_client_with_password') {
|
||||
throw { type: 'authNetworkUnreachable' };
|
||||
throw { type: 'authNetworkFailure', reason: 'unreachable' };
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
@@ -5,13 +5,15 @@
|
||||
* `invokeClientAuth` 是薄包装:Rust 的结构化拒绝原样装进 `ClientAuthErrorWrapper.error`,不读变体
|
||||
* 字段(`serverMessage` 之类不保证每个变体都有)、不注入上下文、载体自身 `message` 留空;分流只按
|
||||
* `error.error.type`。另外用编译期用例钉住变体全集:无字段变体直接取固定文案,带载荷变体先
|
||||
* `as` 取具名类型;漏列任何变体,`expectNever(error)` 都编译不过。
|
||||
* `as` 取具名类型,可枚举的细分再按类型化 reason 分流;漏列任何变体或 reason,
|
||||
* `expectNever(...)` 都编译不过。
|
||||
*/
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { loginClientWithPassword } from '../src/services/clientAuth';
|
||||
import { ClientAuthErrorWrapper } from '../src/services/clientAuthErrorWrapper';
|
||||
import type { AuthResponseServerRejected } from '../src/services/generated/AuthResponseServerRejected';
|
||||
import type { AuthNetworkFailure } from '../src/services/generated/AuthNetworkFailure';
|
||||
import type { AuthResponseInvalid } from '../src/services/generated/AuthResponseInvalid';
|
||||
import type { AuthServiceUnavailable } from '../src/services/generated/AuthServiceUnavailable';
|
||||
import type { ClientAuthError } from '../src/services/generated/ClientAuthError';
|
||||
import type { ClientSessionPersistFailed } from '../src/services/generated/ClientSessionPersistFailed';
|
||||
@@ -19,29 +21,39 @@ import type { PasswordLoginRejected } from '../src/services/generated/PasswordLo
|
||||
import type { PhoneCodeLoginRejected } from '../src/services/generated/PhoneCodeLoginRejected';
|
||||
import type { RuntimeSessionInstallFailed } from '../src/services/generated/RuntimeSessionInstallFailed';
|
||||
import type { SendCodeRejected } from '../src/services/generated/SendCodeRejected';
|
||||
import type { ServerAddressRejected } from '../src/services/generated/ServerAddressRejected';
|
||||
import type { UnexpectedRejection } from '../src/services/generated/UnexpectedRejection';
|
||||
|
||||
function expectNever(value: never): void {
|
||||
void value;
|
||||
}
|
||||
|
||||
/** 编译期用例:变体逐个列全;带载荷的分支先 `as` 再读自己的字段。 */
|
||||
/** 编译期用例:顶层变体逐个列全;带载荷的分支先 `as`,reason 再按类型化枚举分流。 */
|
||||
function variantMessage(error: ClientAuthError): string {
|
||||
switch (error.type) {
|
||||
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 'serverAddressRejected': {
|
||||
const payload = error as ServerAddressRejected;
|
||||
switch (payload.reason) {
|
||||
case 'emptyOrTooLong':
|
||||
return '不能为空或超长';
|
||||
case 'notAUrl':
|
||||
return '不是合法地址';
|
||||
case 'hasCredentials':
|
||||
return '不能包含凭据';
|
||||
case 'hasPathOrQueryOrFragment':
|
||||
return '不能带路径、查询或 fragment';
|
||||
case 'notHttps':
|
||||
return '远程地址不是 https';
|
||||
case 'unsupportedScheme':
|
||||
return '不是 http(s) 地址';
|
||||
case 'outsideChannel':
|
||||
return '不在当前构建渠道范围内';
|
||||
default: {
|
||||
expectNever(payload.reason);
|
||||
return '';
|
||||
}
|
||||
}
|
||||
}
|
||||
case 'phoneNumberInvalid':
|
||||
return '手机号格式不正确';
|
||||
case 'passwordMissing':
|
||||
@@ -66,26 +78,42 @@ function variantMessage(error: ClientAuthError): string {
|
||||
return '登录态失效';
|
||||
case 'permissionDenied':
|
||||
return '权限不足';
|
||||
case 'authNetworkTimeout':
|
||||
return '连接超时';
|
||||
case 'authNetworkUnreachable':
|
||||
return '连接失败';
|
||||
case 'authNetworkFailure': {
|
||||
const payload = error as AuthNetworkFailure;
|
||||
switch (payload.reason) {
|
||||
case 'timeout':
|
||||
return '连接超时';
|
||||
case 'unreachable':
|
||||
return '连接失败';
|
||||
default: {
|
||||
expectNever(payload.reason);
|
||||
return '';
|
||||
}
|
||||
}
|
||||
}
|
||||
case 'authServiceUnavailable':
|
||||
return `HTTP ${(error as AuthServiceUnavailable).status}`;
|
||||
case 'unexpectedRejection':
|
||||
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 'authResponseInvalid': {
|
||||
const payload = error as AuthResponseInvalid;
|
||||
switch (payload.reason) {
|
||||
case 'notJson':
|
||||
return '响应不是 JSON';
|
||||
case 'invalidBody':
|
||||
return '响应结构不符';
|
||||
case 'missingRefreshCookie':
|
||||
return '缺续期凭据';
|
||||
case 'missingUserIdentity':
|
||||
return '缺用户主体';
|
||||
case 'serverRejected':
|
||||
return payload.serverMessage ?? '服务端显式拒绝';
|
||||
default: {
|
||||
expectNever(payload.reason);
|
||||
return '';
|
||||
}
|
||||
}
|
||||
}
|
||||
case 'clientSessionPersistFailed':
|
||||
return (error as ClientSessionPersistFailed).detail;
|
||||
case 'runtimeSessionInstallFailed':
|
||||
@@ -170,4 +198,30 @@ describe('变体分流', () => {
|
||||
variantMessage({ type: 'authServiceUnavailable', status: 503 }),
|
||||
).toBe('HTTP 503');
|
||||
});
|
||||
|
||||
it('可枚举细分按类型化 reason 分流,不读服务端文案', () => {
|
||||
expect(
|
||||
variantMessage({
|
||||
type: 'serverAddressRejected',
|
||||
reason: 'notHttps',
|
||||
}),
|
||||
).toBe('远程地址不是 https');
|
||||
expect(
|
||||
variantMessage({ type: 'authNetworkFailure', reason: 'timeout' }),
|
||||
).toBe('连接超时');
|
||||
expect(
|
||||
variantMessage({
|
||||
type: 'authResponseInvalid',
|
||||
reason: 'serverRejected',
|
||||
serverMessage: '登录服务请求失败',
|
||||
}),
|
||||
).toBe('登录服务请求失败');
|
||||
expect(
|
||||
variantMessage({
|
||||
type: 'authResponseInvalid',
|
||||
reason: 'missingRefreshCookie',
|
||||
serverMessage: null,
|
||||
}),
|
||||
).toBe('缺续期凭据');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -159,7 +159,7 @@ test('没有原生宿主时登录能力明确失败关闭', async () => {
|
||||
});
|
||||
|
||||
test('结构化失败不降级成投影值,而是以携带原始拒绝值的 ClientAuthErrorWrapper 拒绝', async () => {
|
||||
const rejection = { type: 'authNetworkUnreachable' };
|
||||
const rejection = { type: 'authNetworkFailure', reason: 'unreachable' };
|
||||
invoke.mockRejectedValueOnce(rejection);
|
||||
await expect(refreshClientAuthSession('user-1')).rejects.toMatchObject({
|
||||
error: rejection,
|
||||
|
||||
@@ -210,7 +210,7 @@ describe('客户端错误报告池', () => {
|
||||
});
|
||||
|
||||
it('解包 ClientAuthErrorWrapper 里的原始拒绝值,上下文只认显式入参', async () => {
|
||||
const original = { type: 'authNetworkUnreachable' };
|
||||
const original = { type: 'authNetworkFailure', reason: 'unreachable' };
|
||||
|
||||
await captureClientError(new ClientAuthErrorWrapper(original), {
|
||||
source: 'unhandledrejection',
|
||||
|
||||
Reference in New Issue
Block a user