AGC 认证命令失败改为 JS 侧载体并只按变体分流
- 新增 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
This commit is contained in:
@@ -432,6 +432,7 @@ const APP_INVOKE_BARE_CALL_NAMES = new Set([
|
||||
'directInvoke',
|
||||
'invokeInput',
|
||||
'invokeAuthenticatedInput',
|
||||
'invokeClientAuth',
|
||||
'invokeDiagnostic',
|
||||
]);
|
||||
|
||||
|
||||
@@ -11,9 +11,7 @@ import {
|
||||
import type { AuthUser } from '../../../../packages/shared/src/contracts/auth';
|
||||
import brandIcon from '../../../../packages/shared/src/icons/taonier-product-ip.png';
|
||||
import { ErrorReportNotice } from '../components/error-report/ErrorReportNotice';
|
||||
import { ClientActionError } from '../services/clientActionError';
|
||||
import {
|
||||
getClientAuthErrorMessage,
|
||||
loginClientWithPassword,
|
||||
loginClientWithPhoneCode,
|
||||
logoutClientAuthSession,
|
||||
@@ -22,7 +20,7 @@ import {
|
||||
sendClientPhoneLoginCode,
|
||||
subscribeClientAuthState,
|
||||
} from '../services/clientAuth';
|
||||
import { isClientAuthError } from '../services/clientAuthError';
|
||||
import { ClientAuthFailure } from '../services/clientAuthFailure';
|
||||
import {
|
||||
type ClientServerPreset,
|
||||
type ClientServerSelection,
|
||||
@@ -37,6 +35,18 @@ import {
|
||||
captureClientError,
|
||||
installWebviewLogBridge,
|
||||
} from '../services/errorReporting';
|
||||
import type { LoginCodeMissing } from '../services/generated/LoginCodeMissing';
|
||||
import type { PasswordEntryInputRejected } from '../services/generated/PasswordEntryInputRejected';
|
||||
import type { PasswordMissing } from '../services/generated/PasswordMissing';
|
||||
import type { PermissionDenied } from '../services/generated/PermissionDenied';
|
||||
import type { PhoneLoginInputRejected } from '../services/generated/PhoneLoginInputRejected';
|
||||
import type { PhoneNumberInvalid } from '../services/generated/PhoneNumberInvalid';
|
||||
import type { PhoneOrPasswordMismatch } from '../services/generated/PhoneOrPasswordMismatch';
|
||||
import type { SendCodeInputRejected } from '../services/generated/SendCodeInputRejected';
|
||||
import type { ServerAddressRejected } from '../services/generated/ServerAddressRejected';
|
||||
import type { SessionAuthorityRejected } from '../services/generated/SessionAuthorityRejected';
|
||||
import type { SmsCodeInvalidOrExpired } from '../services/generated/SmsCodeInvalidOrExpired';
|
||||
import type { SmsCodeThrottled } from '../services/generated/SmsCodeThrottled';
|
||||
import {
|
||||
beginPlatformSessionClearTransition,
|
||||
beginPlatformSessionTransition,
|
||||
@@ -67,6 +77,14 @@ const AUTH_CHECK_REQUEST_TIMEOUT_MS = 15_000;
|
||||
// 30s startup deadline. Keep the UI fence slightly above that worst case.
|
||||
const AUTH_CHECK_RUNNER_TIMEOUT_MS = 45_000;
|
||||
|
||||
/**
|
||||
* 编译期穷尽检查:参数只能是 `never`,switch 漏掉任何一个变体这一行就编译不过。
|
||||
* 运行时不做事(`void`),所以 `default` 分支还要自己 `throw`。
|
||||
*/
|
||||
function expectNever(value: never): void {
|
||||
void value;
|
||||
}
|
||||
|
||||
function withAuthCheckTimeout<T>(
|
||||
promise: Promise<T>,
|
||||
timeoutMs: number,
|
||||
@@ -237,12 +255,6 @@ export function AuthenticatedClient({
|
||||
'检查登录状态超时,请检查服务器地址和网络后重试',
|
||||
);
|
||||
if (!isActiveRun()) return;
|
||||
if (state.status === 'unavailable') {
|
||||
setAuthCheckError(state.message);
|
||||
setLoginStatus(state.message);
|
||||
setAuthStatus('unauthenticated');
|
||||
return;
|
||||
}
|
||||
if (state.status !== 'authenticated') {
|
||||
setAuthStatus('unauthenticated');
|
||||
return;
|
||||
@@ -263,13 +275,89 @@ export function AuthenticatedClient({
|
||||
setAuthStatus('authenticated');
|
||||
} catch (error) {
|
||||
if (!isActiveRun()) return;
|
||||
const message = getClientAuthErrorMessage(
|
||||
error,
|
||||
'登录服务暂时不可用,请稍后重试',
|
||||
);
|
||||
setAuthCheckError(message);
|
||||
setLoginStatus(message);
|
||||
// 失败一律先离开检查态:系统变体虽然要原样抛出上报,界面也不能卡在 loading。
|
||||
setAuthStatus('unauthenticated');
|
||||
if (!(error instanceof ClientAuthFailure)) throw error;
|
||||
switch (error.payload.type) {
|
||||
// 用户自己能改的输入 / 前置条件:展示原因并停在登录页,不进错误池。
|
||||
case 'serverAddressRejected': {
|
||||
const payload = error.payload as ServerAddressRejected;
|
||||
setAuthCheckError(payload.message);
|
||||
setLoginStatus(payload.message);
|
||||
break;
|
||||
}
|
||||
case 'phoneNumberInvalid': {
|
||||
const payload = error.payload as PhoneNumberInvalid;
|
||||
setAuthCheckError(payload.message);
|
||||
setLoginStatus(payload.message);
|
||||
break;
|
||||
}
|
||||
case 'passwordMissing': {
|
||||
const payload = error.payload as PasswordMissing;
|
||||
setAuthCheckError(payload.message);
|
||||
setLoginStatus(payload.message);
|
||||
break;
|
||||
}
|
||||
case 'loginCodeMissing': {
|
||||
const payload = error.payload as LoginCodeMissing;
|
||||
setAuthCheckError(payload.message);
|
||||
setLoginStatus(payload.message);
|
||||
break;
|
||||
}
|
||||
case 'passwordEntryInputRejected': {
|
||||
const payload = error.payload as PasswordEntryInputRejected;
|
||||
setAuthCheckError(payload.message);
|
||||
setLoginStatus(payload.message);
|
||||
break;
|
||||
}
|
||||
case 'phoneOrPasswordMismatch': {
|
||||
const payload = error.payload as PhoneOrPasswordMismatch;
|
||||
setAuthCheckError(payload.message);
|
||||
setLoginStatus(payload.message);
|
||||
break;
|
||||
}
|
||||
case 'sendCodeInputRejected': {
|
||||
const payload = error.payload as SendCodeInputRejected;
|
||||
setAuthCheckError(payload.message);
|
||||
setLoginStatus(payload.message);
|
||||
break;
|
||||
}
|
||||
case 'smsCodeThrottled': {
|
||||
const payload = error.payload as SmsCodeThrottled;
|
||||
setAuthCheckError(payload.message);
|
||||
setLoginStatus(payload.message);
|
||||
break;
|
||||
}
|
||||
case 'phoneLoginInputRejected': {
|
||||
const payload = error.payload as PhoneLoginInputRejected;
|
||||
setAuthCheckError(payload.message);
|
||||
setLoginStatus(payload.message);
|
||||
break;
|
||||
}
|
||||
case 'smsCodeInvalidOrExpired': {
|
||||
const payload = error.payload as SmsCodeInvalidOrExpired;
|
||||
setAuthCheckError(payload.message);
|
||||
setLoginStatus(payload.message);
|
||||
break;
|
||||
}
|
||||
// 会话权威失效:Rust 已按未登录处理;走到这里也只按未登录呈现。
|
||||
case 'sessionAuthorityRejected':
|
||||
case 'permissionDenied':
|
||||
break;
|
||||
// 系统失败:调用方处理不了,原样抛出 → 全局 unhandledrejection 交给错误池。
|
||||
case 'authNetworkUnavailable':
|
||||
case 'authServiceUnavailable':
|
||||
case 'unexpectedRejection':
|
||||
case 'authResponseMalformed':
|
||||
case 'clientSessionPersistFailed':
|
||||
case 'runtimeSessionInstallFailed':
|
||||
case 'authClientInitFailed':
|
||||
throw error;
|
||||
default: {
|
||||
expectNever(error.payload);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
void hydrateAuth();
|
||||
@@ -328,47 +416,15 @@ export function AuthenticatedClient({
|
||||
}, [codeCooldownSeconds]);
|
||||
|
||||
/**
|
||||
* 认证动作失败的统一收口:**要不要上报由这里按具体变体判**,不做任何文案匹配。
|
||||
* 认证失败的分流判据直接写在每个 catch 里:
|
||||
*
|
||||
* 名单里的是"用户自己能改的输入 / 前置条件"与"会话路由 401/403":只给提示。其余(网络、
|
||||
* 5xx、写盘、运行时、响应不合法,以及 Rust 新增而这里没接的变体)带上文交给错误池——
|
||||
* `default` 指向上报,新变体不会被静默吞掉。`action` 决定错误池指纹,新增认证动作必须走这里。
|
||||
* - 承载:命令失败由 `invokeClientAuth` 转成 `ClientAuthFailure`,`payload.type` 是唯一分流键;
|
||||
* - 业务 / 会话变体:把载荷自带的 `message` 原样给用户,永不进错误池;
|
||||
* - 系统变体:原样 `throw`,经全局 `unhandledrejection` 交给错误池;
|
||||
* - `default: expectNever` 保证 Rust 新增变体时这里编译失败。
|
||||
*
|
||||
* 不把判据抽成函数:判定必须发生在 catch 里。
|
||||
*/
|
||||
function presentAuthFailure(
|
||||
error: unknown,
|
||||
fallback: string,
|
||||
action: string,
|
||||
) {
|
||||
const structured = isClientAuthError(error);
|
||||
if (structured) {
|
||||
switch (structured.type) {
|
||||
// 用户自己能改的输入 / 前置条件,以及会话路由 401/403(按“未登录”处理):
|
||||
// 这些都给用户提示,不进错误池。
|
||||
case 'serverAddressRejected':
|
||||
case 'phoneNumberInvalid':
|
||||
case 'passwordMissing':
|
||||
case 'loginCodeMissing':
|
||||
case 'passwordEntryInputRejected':
|
||||
case 'phoneOrPasswordMismatch':
|
||||
case 'sendCodeInputRejected':
|
||||
case 'smsCodeThrottled':
|
||||
case 'phoneLoginInputRejected':
|
||||
case 'smsCodeInvalidOrExpired':
|
||||
case 'sessionAuthorityRejected':
|
||||
case 'permissionDenied': {
|
||||
setLoginStatus(structured.message.trim() || fallback);
|
||||
return;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
const message = getClientAuthErrorMessage(error, fallback);
|
||||
setLoginStatus(message);
|
||||
void captureClientError(
|
||||
new ClientActionError(message, { source: 'auth', action }, error),
|
||||
);
|
||||
}
|
||||
|
||||
async function handleSendCode() {
|
||||
if (codeBusy || codeCooldownSeconds > 0) {
|
||||
@@ -391,7 +447,82 @@ export function AuthenticatedClient({
|
||||
setCodeCooldownSeconds(Math.max(0, Math.floor(response.cooldownSeconds)));
|
||||
setLoginStatus(`验证码已发送,${response.expiresInSeconds} 秒内有效`);
|
||||
} catch (error) {
|
||||
presentAuthFailure(error, '发送验证码失败', 'send-login-code');
|
||||
if (!(error instanceof ClientAuthFailure)) throw error;
|
||||
switch (error.payload.type) {
|
||||
case 'serverAddressRejected': {
|
||||
const payload = error.payload as ServerAddressRejected;
|
||||
setLoginStatus(payload.message);
|
||||
break;
|
||||
}
|
||||
case 'phoneNumberInvalid': {
|
||||
const payload = error.payload as PhoneNumberInvalid;
|
||||
setLoginStatus(payload.message);
|
||||
break;
|
||||
}
|
||||
case 'passwordMissing': {
|
||||
const payload = error.payload as PasswordMissing;
|
||||
setLoginStatus(payload.message);
|
||||
break;
|
||||
}
|
||||
case 'loginCodeMissing': {
|
||||
const payload = error.payload as LoginCodeMissing;
|
||||
setLoginStatus(payload.message);
|
||||
break;
|
||||
}
|
||||
case 'passwordEntryInputRejected': {
|
||||
const payload = error.payload as PasswordEntryInputRejected;
|
||||
setLoginStatus(payload.message);
|
||||
break;
|
||||
}
|
||||
case 'phoneOrPasswordMismatch': {
|
||||
const payload = error.payload as PhoneOrPasswordMismatch;
|
||||
setLoginStatus(payload.message);
|
||||
break;
|
||||
}
|
||||
case 'sendCodeInputRejected': {
|
||||
const payload = error.payload as SendCodeInputRejected;
|
||||
setLoginStatus(payload.message);
|
||||
break;
|
||||
}
|
||||
case 'smsCodeThrottled': {
|
||||
const payload = error.payload as SmsCodeThrottled;
|
||||
setLoginStatus(payload.message);
|
||||
break;
|
||||
}
|
||||
case 'phoneLoginInputRejected': {
|
||||
const payload = error.payload as PhoneLoginInputRejected;
|
||||
setLoginStatus(payload.message);
|
||||
break;
|
||||
}
|
||||
case 'smsCodeInvalidOrExpired': {
|
||||
const payload = error.payload as SmsCodeInvalidOrExpired;
|
||||
setLoginStatus(payload.message);
|
||||
break;
|
||||
}
|
||||
case 'sessionAuthorityRejected': {
|
||||
const payload = error.payload as SessionAuthorityRejected;
|
||||
setLoginStatus(payload.message);
|
||||
break;
|
||||
}
|
||||
case 'permissionDenied': {
|
||||
const payload = error.payload as PermissionDenied;
|
||||
setLoginStatus(payload.message);
|
||||
break;
|
||||
}
|
||||
// 系统失败:调用方处理不了,原样抛出 → 全局 unhandledrejection 交给错误池。
|
||||
case 'authNetworkUnavailable':
|
||||
case 'authServiceUnavailable':
|
||||
case 'unexpectedRejection':
|
||||
case 'authResponseMalformed':
|
||||
case 'clientSessionPersistFailed':
|
||||
case 'runtimeSessionInstallFailed':
|
||||
case 'authClientInitFailed':
|
||||
throw error;
|
||||
default: {
|
||||
expectNever(error.payload);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
setCodeBusy(false);
|
||||
}
|
||||
@@ -456,7 +587,82 @@ export function AuthenticatedClient({
|
||||
setCode('');
|
||||
setPassword('');
|
||||
} catch (error) {
|
||||
presentAuthFailure(error, '登录失败', 'login');
|
||||
if (!(error instanceof ClientAuthFailure)) throw error;
|
||||
switch (error.payload.type) {
|
||||
case 'serverAddressRejected': {
|
||||
const payload = error.payload as ServerAddressRejected;
|
||||
setLoginStatus(payload.message);
|
||||
break;
|
||||
}
|
||||
case 'phoneNumberInvalid': {
|
||||
const payload = error.payload as PhoneNumberInvalid;
|
||||
setLoginStatus(payload.message);
|
||||
break;
|
||||
}
|
||||
case 'passwordMissing': {
|
||||
const payload = error.payload as PasswordMissing;
|
||||
setLoginStatus(payload.message);
|
||||
break;
|
||||
}
|
||||
case 'loginCodeMissing': {
|
||||
const payload = error.payload as LoginCodeMissing;
|
||||
setLoginStatus(payload.message);
|
||||
break;
|
||||
}
|
||||
case 'passwordEntryInputRejected': {
|
||||
const payload = error.payload as PasswordEntryInputRejected;
|
||||
setLoginStatus(payload.message);
|
||||
break;
|
||||
}
|
||||
case 'phoneOrPasswordMismatch': {
|
||||
const payload = error.payload as PhoneOrPasswordMismatch;
|
||||
setLoginStatus(payload.message);
|
||||
break;
|
||||
}
|
||||
case 'sendCodeInputRejected': {
|
||||
const payload = error.payload as SendCodeInputRejected;
|
||||
setLoginStatus(payload.message);
|
||||
break;
|
||||
}
|
||||
case 'smsCodeThrottled': {
|
||||
const payload = error.payload as SmsCodeThrottled;
|
||||
setLoginStatus(payload.message);
|
||||
break;
|
||||
}
|
||||
case 'phoneLoginInputRejected': {
|
||||
const payload = error.payload as PhoneLoginInputRejected;
|
||||
setLoginStatus(payload.message);
|
||||
break;
|
||||
}
|
||||
case 'smsCodeInvalidOrExpired': {
|
||||
const payload = error.payload as SmsCodeInvalidOrExpired;
|
||||
setLoginStatus(payload.message);
|
||||
break;
|
||||
}
|
||||
case 'sessionAuthorityRejected': {
|
||||
const payload = error.payload as SessionAuthorityRejected;
|
||||
setLoginStatus(payload.message);
|
||||
break;
|
||||
}
|
||||
case 'permissionDenied': {
|
||||
const payload = error.payload as PermissionDenied;
|
||||
setLoginStatus(payload.message);
|
||||
break;
|
||||
}
|
||||
// 系统失败:调用方处理不了,原样抛出 → 全局 unhandledrejection 交给错误池。
|
||||
case 'authNetworkUnavailable':
|
||||
case 'authServiceUnavailable':
|
||||
case 'unexpectedRejection':
|
||||
case 'authResponseMalformed':
|
||||
case 'clientSessionPersistFailed':
|
||||
case 'runtimeSessionInstallFailed':
|
||||
case 'authClientInitFailed':
|
||||
throw error;
|
||||
default: {
|
||||
expectNever(error.payload);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
setLoginBusy(false);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { AuthUser } from '../../../../packages/shared/src/contracts/auth';
|
||||
import { resolveTauriInvoke } from '../app/tauri';
|
||||
import { isClientAuthError } from './clientAuthError';
|
||||
import { ClientAuthFailure } from './clientAuthFailure';
|
||||
import type { ClientAuthError } from './generated/ClientAuthError';
|
||||
import { subscribeTauriEvent } from './tauriEventSubscription';
|
||||
|
||||
/** Rust 认证态事件:只承载状态投影,不含 token 或 refresh 凭据。 */
|
||||
@@ -8,14 +9,12 @@ export const CLIENT_AUTH_STATE_CHANGED_EVENT = 'agc-client-auth-state-changed';
|
||||
|
||||
export type ClientAuthState =
|
||||
| { status: 'authenticated'; user: AuthUser; apiBaseUrl: string }
|
||||
| { status: 'unauthenticated' }
|
||||
| { status: 'unavailable'; message: string };
|
||||
| { status: 'unauthenticated' };
|
||||
|
||||
export type ClientAuthRefreshResult =
|
||||
| { status: 'refreshed'; user: AuthUser }
|
||||
| { status: 'unauthenticated' }
|
||||
| { status: 'stale' }
|
||||
| { status: 'failed'; message: string; authoritative: boolean };
|
||||
| { status: 'stale' };
|
||||
|
||||
export type ClientLoginCodeResult = {
|
||||
cooldownSeconds: number;
|
||||
@@ -38,55 +37,67 @@ function requireInvoke() {
|
||||
}
|
||||
|
||||
/**
|
||||
* 从任意拒绝值里取一条可显示文案。
|
||||
* 认证命令的统一入口:把 Rust 结构化拒绝转成 JS 侧的 `ClientAuthFailure`,
|
||||
* 非结构化拒绝(Tauri / JS 运行时自己的错误)原样抛出。
|
||||
*
|
||||
* 命令失败现在是结构化的(`ClientAuthError`),这里必须按形状取 Rust 那一份文案;裸字符串
|
||||
* 是旧的 `Err(String)` 残留与浏览器环境的形态。**非 Error 的其它形状不再做字符串化**——
|
||||
* `String({type,message})` 只会得到 `[object Object]`,把它当文案显示比回落更糟。
|
||||
*
|
||||
* 它只取文案,**不判要不要上报**:那由调用方在 catch 里按具体变体决定。
|
||||
* 只判 `type` 是不是字符串(这是 ts-rs 判别联合的稳定键),**不校验字段名、不读文案判断、
|
||||
* 不兜底文案**。要不要上报、给不给用户提示,由调用方在 catch 里按 `payload.type` 决定。
|
||||
*/
|
||||
export function getClientAuthErrorMessage(error: unknown, fallback: string) {
|
||||
const structured = isClientAuthError(error);
|
||||
if (structured) return structured.message.trim() || fallback;
|
||||
if (error instanceof Error && error.message.trim()) return error.message;
|
||||
if (typeof error === 'string') return error.trim() || fallback;
|
||||
return fallback;
|
||||
async function invokeClientAuth<T>(
|
||||
command: string,
|
||||
args?: Record<string, unknown>,
|
||||
): Promise<T> {
|
||||
try {
|
||||
const invoke = requireInvoke();
|
||||
// 不带参数时保持 `invoke(command)` 的单参调用形态,别给命令多塞一个 undefined。
|
||||
return args === undefined
|
||||
? await invoke<T>(command)
|
||||
: await invoke<T>(command, args);
|
||||
} catch (error) {
|
||||
const rejection = error as { type?: unknown; message?: unknown };
|
||||
if (typeof rejection?.type !== 'string') throw error;
|
||||
throw new ClientAuthFailure(
|
||||
typeof rejection.message === 'string' ? rejection.message : '',
|
||||
error as ClientAuthError,
|
||||
command,
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
type RustAuthStateView = {
|
||||
status?: string;
|
||||
user?: AuthUser | null;
|
||||
apiBaseUrl?: string | null;
|
||||
errorMessage?: string | null;
|
||||
};
|
||||
/** Rust 认证态投影,与 `ClientAuthStateView` 一一对应。 */
|
||||
type RustAuthStateView =
|
||||
| { status: 'authenticated'; user: AuthUser; apiBaseUrl: string }
|
||||
| { status: 'unauthenticated' };
|
||||
|
||||
/** Rust 续期结果投影,与 `ClientAuthRefreshView` 一一对应。 */
|
||||
type RustAuthRefreshView =
|
||||
| { status: 'refreshed'; user: AuthUser }
|
||||
| { status: 'unauthenticated' }
|
||||
| { status: 'stale' };
|
||||
|
||||
/**
|
||||
* 恢复登录态。
|
||||
*
|
||||
* 凭据续期、当前用户复核与本机运行时会话安装都在 Rust 内完成;这里只把结果投影成
|
||||
* `authenticated` / `unauthenticated` / `unavailable` 三态,供登录页决定展示分支。
|
||||
* 凭据续期、当前用户复核与本机运行时会话安装都在 Rust 内完成;读状态失败就是命令失败,
|
||||
* 由 `invokeClientAuth` 转成 `ClientAuthFailure`,不再有第三态投影。
|
||||
*/
|
||||
export async function readClientAuthState(
|
||||
expectedApiBaseUrl?: string,
|
||||
): Promise<ClientAuthState> {
|
||||
const invoke = requireInvoke();
|
||||
const view = await invoke<RustAuthStateView>('read_client_auth_state', {
|
||||
expectedApiBaseUrl: expectedApiBaseUrl?.trim() || null,
|
||||
});
|
||||
if (view?.status === 'authenticated' && view.user && view.apiBaseUrl) {
|
||||
const view = await invokeClientAuth<RustAuthStateView>(
|
||||
'read_client_auth_state',
|
||||
{
|
||||
expectedApiBaseUrl: expectedApiBaseUrl?.trim() || null,
|
||||
},
|
||||
);
|
||||
if (view.status === 'authenticated') {
|
||||
return {
|
||||
status: 'authenticated',
|
||||
user: view.user,
|
||||
apiBaseUrl: view.apiBaseUrl,
|
||||
};
|
||||
}
|
||||
if (view?.status === 'unavailable') {
|
||||
return {
|
||||
status: 'unavailable',
|
||||
message: view.errorMessage?.trim() || '登录服务暂时不可用,请稍后重试',
|
||||
};
|
||||
}
|
||||
return { status: 'unauthenticated' };
|
||||
}
|
||||
|
||||
@@ -94,8 +105,7 @@ export async function sendClientPhoneLoginCode(
|
||||
phone: string,
|
||||
apiBaseUrl: string,
|
||||
): Promise<ClientLoginCodeResult> {
|
||||
const invoke = requireInvoke();
|
||||
const result = await invoke<{
|
||||
const result = await invokeClientAuth<{
|
||||
cooldownSeconds?: number;
|
||||
expiresInSeconds?: number;
|
||||
}>('send_client_phone_login_code', {
|
||||
@@ -113,8 +123,7 @@ export async function loginClientWithPassword(
|
||||
password: string,
|
||||
apiBaseUrl: string,
|
||||
): Promise<AuthUser> {
|
||||
const invoke = requireInvoke();
|
||||
return invoke<AuthUser>('login_client_with_password', {
|
||||
return invokeClientAuth<AuthUser>('login_client_with_password', {
|
||||
apiBaseUrl,
|
||||
phone: normalizeAuthPhoneInput(phone),
|
||||
password: password.trim(),
|
||||
@@ -126,8 +135,7 @@ export async function loginClientWithPhoneCode(
|
||||
code: string,
|
||||
apiBaseUrl: string,
|
||||
): Promise<AuthUser> {
|
||||
const invoke = requireInvoke();
|
||||
return invoke<AuthUser>('login_client_with_phone_code', {
|
||||
return invokeClientAuth<AuthUser>('login_client_with_phone_code', {
|
||||
apiBaseUrl,
|
||||
phone: normalizeAuthPhoneInput(phone),
|
||||
code: code.trim(),
|
||||
@@ -136,43 +144,23 @@ export async function loginClientWithPhoneCode(
|
||||
|
||||
/** 登出:Rust 负责服务端撤销、凭据清除与本机运行时会话清理。 */
|
||||
export async function logoutClientAuthSession(): Promise<void> {
|
||||
const invoke = requireInvoke();
|
||||
await invoke('logout_client_session');
|
||||
await invokeClientAuth('logout_client_session');
|
||||
}
|
||||
|
||||
export async function refreshClientAuthSession(
|
||||
expectedUserId?: string,
|
||||
): Promise<ClientAuthRefreshResult> {
|
||||
const invoke = requireInvoke();
|
||||
const view = await invoke<{
|
||||
status?: string;
|
||||
user?: AuthUser | null;
|
||||
authoritative?: boolean;
|
||||
errorMessage?: string | null;
|
||||
}>('refresh_client_auth_session', {
|
||||
expectedUserId: expectedUserId?.trim() || null,
|
||||
});
|
||||
switch (view?.status) {
|
||||
case 'refreshed':
|
||||
if (!view.user) {
|
||||
return {
|
||||
status: 'failed',
|
||||
message: '刷新登录状态失败',
|
||||
authoritative: false,
|
||||
};
|
||||
}
|
||||
return { status: 'refreshed', user: view.user };
|
||||
case 'unauthenticated':
|
||||
return { status: 'unauthenticated' };
|
||||
case 'stale':
|
||||
return { status: 'stale' };
|
||||
default:
|
||||
return {
|
||||
status: 'failed',
|
||||
message: view?.errorMessage?.trim() || '刷新登录状态失败',
|
||||
authoritative: view?.authoritative === true,
|
||||
};
|
||||
const view = await invokeClientAuth<RustAuthRefreshView>(
|
||||
'refresh_client_auth_session',
|
||||
{ expectedUserId: expectedUserId?.trim() || null },
|
||||
);
|
||||
if (view.status === 'refreshed') {
|
||||
return { status: 'refreshed', user: view.user };
|
||||
}
|
||||
if (view.status === 'unauthenticated') {
|
||||
return { status: 'unauthenticated' };
|
||||
}
|
||||
return { status: 'stale' };
|
||||
}
|
||||
|
||||
/** 订阅 Rust 认证态事件,返回幂等释放函数。 */
|
||||
@@ -183,7 +171,7 @@ export function subscribeClientAuthState(
|
||||
CLIENT_AUTH_STATE_CHANGED_EVENT,
|
||||
(event) => {
|
||||
const view = event.payload;
|
||||
if (view?.status === 'authenticated' && view.user && view.apiBaseUrl) {
|
||||
if (view.status === 'authenticated') {
|
||||
listener({
|
||||
status: 'authenticated',
|
||||
user: view.user,
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
import type { ClientAuthError } from './generated/ClientAuthError';
|
||||
|
||||
export type { ClientAuthError };
|
||||
|
||||
/**
|
||||
* `invoke` 拒绝时拿到的是 Rust 序列化出来的普通对象(不是 `Error`)。这里只做形状读取:
|
||||
* `type` 是稳定判别键,`message` 是 Rust 生成的可展示文案,**文案不参与任何判断**。
|
||||
*
|
||||
* 这里**不做任何分类**:要不要上报由调用方在 catch 里按具体变体判(`switch (error.type)`),
|
||||
* 不在名单里的新变体由调用方的 `default` 分支交给错误池——见 ADR 的"未识别变体上调"。
|
||||
*
|
||||
* **`status` 故意不校验**:`authServiceUnavailable` / `unexpectedRejection` 在生成类型里带必填
|
||||
* `status`,但全仓没有调用方读它(这两个变体只显示 `message`)。把 `status` 纳入校验只会让
|
||||
* "缺 `status` 但 `message` 可用"的拒绝落回非结构化分支,把 Rust 的文案换成调用方兜底文案
|
||||
* ——诊断信息更少、行为更差。将来真要用 `status` 做判断时,先补校验再用。
|
||||
*/
|
||||
export function isClientAuthError(value: unknown): ClientAuthError | null {
|
||||
if (!value || typeof value !== 'object') return null;
|
||||
const candidate = value as { type?: unknown; message?: unknown };
|
||||
if (typeof candidate.type !== 'string' || !candidate.type) return null;
|
||||
if (typeof candidate.message !== 'string') return null;
|
||||
return value as ClientAuthError;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { ClientActionError } from './clientActionError';
|
||||
import type { ClientAuthError } from './generated/ClientAuthError';
|
||||
|
||||
/**
|
||||
* 认证命令失败在 JS 侧的载体。
|
||||
*
|
||||
* Tauri 的 `invoke` 拒绝值是普通对象(Rust 序列化的结构化错误),不是 `Error`;直接抛出会被
|
||||
* 上报链路降级成 `String(obj)`,类型与文案一起丢掉。`invokeClientAuth` 统一把它转成本类:
|
||||
*
|
||||
* - `payload` 是 ts-rs 生成的判别联合,`payload.type` 是唯一的稳定分流键;
|
||||
* - `context` 固定为 `{ source: 'auth', action: 命令名 }`,给错误池定指纹;
|
||||
* - `cause` 是原始拒绝值,保留给上报链路取栈与排障。
|
||||
*
|
||||
* 本类只承载事实,**不做分类、不产派生值、不兜底文案**:要不要上报、给不给用户提示,由调用方
|
||||
* 在 catch 里按 `payload.type` 判。
|
||||
*/
|
||||
export class ClientAuthFailure extends ClientActionError {
|
||||
readonly payload: ClientAuthError;
|
||||
|
||||
constructor(
|
||||
message: string,
|
||||
payload: ClientAuthError,
|
||||
command: string,
|
||||
cause: unknown,
|
||||
) {
|
||||
super(message, { source: 'auth', action: command }, cause);
|
||||
this.name = 'ClientAuthFailure';
|
||||
this.payload = payload;
|
||||
}
|
||||
}
|
||||
@@ -168,14 +168,8 @@ export function requestPlatformSessionRefresh(expectedUserId?: string) {
|
||||
authoritative: true,
|
||||
};
|
||||
}
|
||||
if (result.status === 'stale') {
|
||||
return { status: 'stale' };
|
||||
}
|
||||
return {
|
||||
status: 'failed',
|
||||
error: new Error(result.message),
|
||||
authoritative: result.authoritative,
|
||||
};
|
||||
// `stale`:续期期间身份已变化,调用方不得重放旧身份请求。
|
||||
return { status: 'stale' };
|
||||
})().then((result) => {
|
||||
notifyPlatformSessionRefresh(result);
|
||||
return result;
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
currentPlatformSessionGeneration,
|
||||
resetPlatformSessionForTests,
|
||||
} from '../../src/services/platformSession';
|
||||
import { installUnhandledRejectionBridge } from '../unhandledRejectionBridge';
|
||||
import {
|
||||
act,
|
||||
AuthenticatedClient,
|
||||
@@ -99,26 +100,40 @@ export function registerAuthTests() {
|
||||
delete window.__TAURI__;
|
||||
});
|
||||
|
||||
it('leaves startup loading with an actionable retry after auth check timeout', async () => {
|
||||
renderAuthSurface(async (command: string) => {
|
||||
if (command === 'read_client_auth_state') {
|
||||
return await new Promise(() => {});
|
||||
}
|
||||
return null;
|
||||
});
|
||||
it('throws a startup timeout through to unhandledrejection and leaves the loading state', async () => {
|
||||
const uninstall = installUnhandledRejectionBridge();
|
||||
const reasons: unknown[] = [];
|
||||
const onRejection = (event: Event) => {
|
||||
reasons.push((event as Event & { reason?: unknown }).reason);
|
||||
};
|
||||
window.addEventListener('unhandledrejection', onRejection);
|
||||
try {
|
||||
renderAuthSurface(async (command: string) => {
|
||||
if (command === 'read_client_auth_state') {
|
||||
return await new Promise(() => {});
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
expect(
|
||||
await screen.findByText(
|
||||
'检查登录状态超时,请检查服务器地址和网络后重试',
|
||||
undefined,
|
||||
{
|
||||
timeout: 20_000,
|
||||
},
|
||||
),
|
||||
).not.toBeNull();
|
||||
expect(
|
||||
screen.getByRole('button', { name: '重试登录状态检查' }),
|
||||
).toHaveProperty('disabled', false);
|
||||
await waitFor(
|
||||
() =>
|
||||
expect(
|
||||
reasons.some(
|
||||
(reason) =>
|
||||
reason instanceof Error &&
|
||||
reason.message.includes('检查登录状态超时'),
|
||||
),
|
||||
).toBe(true),
|
||||
{ timeout: 20_000 },
|
||||
);
|
||||
expect(
|
||||
screen.queryByRole('button', { name: '重试登录状态检查' }),
|
||||
).toBeNull();
|
||||
await screen.findByRole('main', { name: '登录' });
|
||||
} finally {
|
||||
window.removeEventListener('unhandledrejection', onRejection);
|
||||
uninstall();
|
||||
}
|
||||
}, 30_000);
|
||||
|
||||
it('renders the unauthenticated client with the shared light platform theme and product image', async () => {
|
||||
@@ -254,47 +269,51 @@ export function registerAuthTests() {
|
||||
expect(screen.queryByLabelText('已登录')).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps transport details out of the login failure copy', async () => {
|
||||
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: 'authNetworkUnavailable',
|
||||
message: '无法连接登录服务,请确认配套后端或 API 代理已启动后重试',
|
||||
};
|
||||
}
|
||||
return null;
|
||||
});
|
||||
renderAuthSurface(invoke);
|
||||
await loginWithCode();
|
||||
it('keeps transport details out of the login copy by throwing system variants through', async () => {
|
||||
const uninstall = installUnhandledRejectionBridge();
|
||||
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: 'authNetworkUnavailable',
|
||||
message: '无法连接登录服务,请确认配套后端或 API 代理已启动后重试',
|
||||
};
|
||||
}
|
||||
return null;
|
||||
});
|
||||
renderAuthSurface(invoke);
|
||||
await loginWithCode();
|
||||
|
||||
expect(
|
||||
await screen.findByText(
|
||||
'无法连接登录服务,请确认配套后端或 API 代理已启动后重试',
|
||||
),
|
||||
).not.toBeNull();
|
||||
expect(document.body.textContent).not.toContain('ECONNREFUSED');
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByText(/无法连接登录服务/u)).toBeNull(),
|
||||
);
|
||||
expect(document.body.textContent).not.toContain('ECONNREFUSED');
|
||||
await screen.findByRole('main', { name: '登录' });
|
||||
} finally {
|
||||
uninstall();
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps a retry when the startup check reports the service unavailable', async () => {
|
||||
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;
|
||||
return reads === 1
|
||||
? {
|
||||
status: 'unavailable',
|
||||
errorMessage: '无法连接登录服务',
|
||||
}
|
||||
? Promise.reject({
|
||||
type: 'serverAddressRejected',
|
||||
message: '服务器地址无效',
|
||||
})
|
||||
: { status: 'unauthenticated' };
|
||||
}
|
||||
return null;
|
||||
});
|
||||
renderAuthSurface(invoke);
|
||||
|
||||
expect(await screen.findByText('无法连接登录服务')).not.toBeNull();
|
||||
expect(await screen.findByText('服务器地址无效')).not.toBeNull();
|
||||
expect(screen.queryByLabelText('已登录')).toBeNull();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '重试登录状态检查' }));
|
||||
|
||||
@@ -28,6 +28,7 @@ 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,
|
||||
@@ -105,24 +106,52 @@ describe('认证失败的上报判据', () => {
|
||||
expect(reportCalls()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('系统变体(网络不可达)带 auth/login 上下文上报一次', async () => {
|
||||
it('系统变体(网络不可达)原样抛出,经 unhandledrejection 上报一次', async () => {
|
||||
const message = '无法连接登录服务,请确认配套后端或 API 代理已启动后重试';
|
||||
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;
|
||||
});
|
||||
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;
|
||||
});
|
||||
|
||||
expect(await screen.findByText(message)).not.toBeNull();
|
||||
await waitFor(() => expect(reportCalls()).toHaveLength(1));
|
||||
expect(reportCalls()[0]?.[1]).toMatchObject({
|
||||
source: 'auth',
|
||||
action: 'login',
|
||||
message,
|
||||
});
|
||||
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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { isClientAuthError } from '../src/services/clientAuthError';
|
||||
import type { AuthServiceUnavailable } from '../src/services/generated/AuthServiceUnavailable';
|
||||
import type { ClientAuthError } from '../src/services/generated/ClientAuthError';
|
||||
import type { PhoneNumberInvalid } from '../src/services/generated/PhoneNumberInvalid';
|
||||
|
||||
/**
|
||||
* 编译期用例:每个变体在 `type` 上判别后都能落到一个**具名载荷类型**,
|
||||
* 等价于 Java 的 `catch (PhoneNumberInvalid e)`;类型不匹配时 `tsc` 会直接报错。
|
||||
*/
|
||||
function handleByNamedType(error: ClientAuthError) {
|
||||
switch (error.type) {
|
||||
case 'phoneNumberInvalid': {
|
||||
const payload: PhoneNumberInvalid = error;
|
||||
return payload.message;
|
||||
}
|
||||
case 'authServiceUnavailable': {
|
||||
const payload: AuthServiceUnavailable = error;
|
||||
return `${payload.status}:${payload.message}`;
|
||||
}
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
describe('AGC 认证命令错误的形状读取', () => {
|
||||
it('判别后按具名载荷类型分支处理', () => {
|
||||
expect(
|
||||
handleByNamedType({
|
||||
type: 'phoneNumberInvalid',
|
||||
message: '请输入正确的手机号',
|
||||
}),
|
||||
).toBe('请输入正确的手机号');
|
||||
expect(
|
||||
handleByNamedType({
|
||||
type: 'authServiceUnavailable',
|
||||
status: 503,
|
||||
message: '登录服务暂时不可用',
|
||||
}),
|
||||
).toBe('503:登录服务暂时不可用');
|
||||
});
|
||||
|
||||
it('只按形状读取 Rust 的结构化拒绝,裸字符串与 Error 都不算', () => {
|
||||
expect(
|
||||
isClientAuthError({
|
||||
type: 'phoneOrPasswordMismatch',
|
||||
message: '手机号或密码错误',
|
||||
}),
|
||||
).toEqual({ type: 'phoneOrPasswordMismatch', message: '手机号或密码错误' });
|
||||
expect(isClientAuthError('手机号或密码错误')).toBeNull();
|
||||
expect(isClientAuthError(new Error('手机号或密码错误'))).toBeNull();
|
||||
expect(
|
||||
isClientAuthError({ type: '', message: '手机号或密码错误' }),
|
||||
).toBeNull();
|
||||
expect(isClientAuthError({ type: 'phoneNumberInvalid' })).toBeNull();
|
||||
expect(isClientAuthError(null)).toBeNull();
|
||||
});
|
||||
|
||||
it('未知变体也算形状合法,由调用方按 default 交给错误池', () => {
|
||||
expect(
|
||||
isClientAuthError({ type: 'brandNewRejection', message: '新变体' }),
|
||||
).toEqual({ type: 'brandNewRejection', message: '新变体' });
|
||||
});
|
||||
|
||||
it('status 故意不校验:缺 status 但 message 可用的拒绝仍可读', () => {
|
||||
expect(
|
||||
isClientAuthError({
|
||||
type: 'authServiceUnavailable',
|
||||
message: '登录服务暂时不可用',
|
||||
}),
|
||||
).toEqual({
|
||||
type: 'authServiceUnavailable',
|
||||
message: '登录服务暂时不可用',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,164 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* 认证命令失败的 JS 侧载体。
|
||||
*
|
||||
* Rust 结构化拒绝(普通对象)必须被 `invokeClientAuth` 转成 `ClientAuthFailure`,非结构化拒绝
|
||||
* 原样抛出;分流只按 `payload.type`,不读文案。另外用编译期用例钉住"每个变体一个具名载荷
|
||||
* 类型":漏列任何变体,`expectNever(error)` 都编译不过。
|
||||
*/
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { ClientActionError } from '../src/services/clientActionError';
|
||||
import { loginClientWithPassword } from '../src/services/clientAuth';
|
||||
import { ClientAuthFailure } from '../src/services/clientAuthFailure';
|
||||
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 { 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 { 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 { UnexpectedRejection } from '../src/services/generated/UnexpectedRejection';
|
||||
|
||||
function expectNever(value: never): void {
|
||||
void value;
|
||||
}
|
||||
|
||||
/** 编译期用例:19 个变体逐个列全,每个分支用 `as` 取具名载荷类型。 */
|
||||
function variantMessage(error: ClientAuthError): string {
|
||||
switch (error.type) {
|
||||
case 'serverAddressRejected':
|
||||
return (error as ServerAddressRejected).message;
|
||||
case 'phoneNumberInvalid':
|
||||
return (error as PhoneNumberInvalid).message;
|
||||
case 'passwordMissing':
|
||||
return (error as PasswordMissing).message;
|
||||
case 'loginCodeMissing':
|
||||
return (error as LoginCodeMissing).message;
|
||||
case 'passwordEntryInputRejected':
|
||||
return (error as PasswordEntryInputRejected).message;
|
||||
case 'phoneOrPasswordMismatch':
|
||||
return (error as PhoneOrPasswordMismatch).message;
|
||||
case 'sendCodeInputRejected':
|
||||
return (error as SendCodeInputRejected).message;
|
||||
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;
|
||||
case 'permissionDenied':
|
||||
return (error as PermissionDenied).message;
|
||||
case 'authNetworkUnavailable':
|
||||
return (error as AuthNetworkUnavailable).message;
|
||||
case 'authServiceUnavailable':
|
||||
return (error as AuthServiceUnavailable).message;
|
||||
case 'unexpectedRejection':
|
||||
return (error as UnexpectedRejection).message;
|
||||
case 'authResponseMalformed':
|
||||
return (error as AuthResponseMalformed).message;
|
||||
case 'clientSessionPersistFailed':
|
||||
return (error as ClientSessionPersistFailed).message;
|
||||
case 'runtimeSessionInstallFailed':
|
||||
return (error as RuntimeSessionInstallFailed).message;
|
||||
case 'authClientInitFailed':
|
||||
return (error as AuthClientInitFailed).message;
|
||||
default: {
|
||||
expectNever(error);
|
||||
return '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function installInvoke(handler: () => Promise<unknown>) {
|
||||
window.__TAURI__ = { core: { invoke: vi.fn(handler) } } as never;
|
||||
}
|
||||
|
||||
describe('ClientAuthFailure', () => {
|
||||
it('结构化拒绝转成带 payload / context / cause 的 JS 错误', async () => {
|
||||
const rejection = {
|
||||
type: 'phoneNumberInvalid',
|
||||
message: '请输入正确的手机号',
|
||||
};
|
||||
installInvoke(async () => {
|
||||
throw rejection;
|
||||
});
|
||||
|
||||
const failure = await loginClientWithPassword(
|
||||
'13800000000',
|
||||
'secret',
|
||||
'https://dev.genarrative.world',
|
||||
).catch((error: unknown) => error);
|
||||
|
||||
expect(failure).toBeInstanceOf(ClientAuthFailure);
|
||||
expect(failure).toBeInstanceOf(ClientActionError);
|
||||
expect((failure as ClientAuthFailure).payload).toEqual(rejection);
|
||||
expect((failure as ClientAuthFailure).context).toEqual({
|
||||
source: 'auth',
|
||||
action: 'login_client_with_password',
|
||||
});
|
||||
expect((failure as ClientAuthFailure).cause).toBe(rejection);
|
||||
expect((failure as Error).message).toBe('请输入正确的手机号');
|
||||
});
|
||||
|
||||
it('非结构化拒绝原样抛出,不包装也不改写', async () => {
|
||||
const rejection = new Error('需要在 Tauri App 内登录');
|
||||
installInvoke(async () => {
|
||||
throw rejection;
|
||||
});
|
||||
|
||||
await expect(
|
||||
loginClientWithPassword(
|
||||
'13800000000',
|
||||
'secret',
|
||||
'https://dev.genarrative.world',
|
||||
),
|
||||
).rejects.toBe(rejection);
|
||||
});
|
||||
|
||||
it('结构化拒绝缺 message 时 Error.message 为空,不造兜底文案', async () => {
|
||||
installInvoke(async () => {
|
||||
throw { type: 'unexpectedRejection', status: 409 };
|
||||
});
|
||||
|
||||
const failure = await loginClientWithPassword(
|
||||
'13800000000',
|
||||
'secret',
|
||||
'https://dev.genarrative.world',
|
||||
).catch((error: unknown) => error);
|
||||
|
||||
expect((failure as Error).message).toBe('');
|
||||
expect((failure as ClientAuthFailure).payload.type).toBe(
|
||||
'unexpectedRejection',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('变体分流', () => {
|
||||
it('判别后按具名载荷类型取值', () => {
|
||||
expect(
|
||||
variantMessage({ type: 'phoneNumberInvalid', message: '格式不对' }),
|
||||
).toBe('格式不对');
|
||||
expect(
|
||||
variantMessage({
|
||||
type: 'authServiceUnavailable',
|
||||
status: 503,
|
||||
message: '服务暂不可用',
|
||||
}),
|
||||
).toBe('服务暂不可用');
|
||||
});
|
||||
});
|
||||
@@ -10,7 +10,6 @@ import { beforeEach, expect, test, vi } from 'vitest';
|
||||
const invoke = vi.fn();
|
||||
|
||||
import {
|
||||
getClientAuthErrorMessage,
|
||||
loginClientWithPassword,
|
||||
loginClientWithPhoneCode,
|
||||
logoutClientAuthSession,
|
||||
@@ -47,7 +46,7 @@ test('手机号归一化只保留可提交的纯号码', () => {
|
||||
expect(normalizeAuthPhoneInput('0086-138-0000-0000')).toBe('008613800000000');
|
||||
});
|
||||
|
||||
test('恢复登录态映射为 authenticated / unauthenticated / unavailable', async () => {
|
||||
test('恢复登录态映射为 authenticated / unauthenticated', async () => {
|
||||
invoke.mockResolvedValueOnce({
|
||||
status: 'authenticated',
|
||||
user: testUser,
|
||||
@@ -71,15 +70,6 @@ test('恢复登录态映射为 authenticated / unauthenticated / unavailable', a
|
||||
expect(invoke).toHaveBeenCalledWith('read_client_auth_state', {
|
||||
expectedApiBaseUrl: null,
|
||||
});
|
||||
|
||||
invoke.mockResolvedValueOnce({
|
||||
status: 'unavailable',
|
||||
errorMessage: '无法连接登录服务',
|
||||
});
|
||||
await expect(readClientAuthState()).resolves.toEqual({
|
||||
status: 'unavailable',
|
||||
message: '无法连接登录服务',
|
||||
});
|
||||
});
|
||||
|
||||
test('登录只提交结构化参数,凭据由 Rust 持有', async () => {
|
||||
@@ -135,7 +125,7 @@ test('登录只提交结构化参数,凭据由 Rust 持有', async () => {
|
||||
expect(serialized).not.toContain('cookie');
|
||||
});
|
||||
|
||||
test('续期结果区分成功、失效、身份变化与暂时失败', async () => {
|
||||
test('续期结果区分成功、失效与身份变化', async () => {
|
||||
invoke.mockResolvedValueOnce({ status: 'refreshed', user: testUser });
|
||||
await expect(refreshClientAuthSession('user-1')).resolves.toEqual({
|
||||
status: 'refreshed',
|
||||
@@ -151,17 +141,6 @@ test('续期结果区分成功、失效、身份变化与暂时失败', async ()
|
||||
await expect(refreshClientAuthSession()).resolves.toEqual({
|
||||
status: 'stale',
|
||||
});
|
||||
|
||||
invoke.mockResolvedValueOnce({
|
||||
status: 'failed',
|
||||
authoritative: false,
|
||||
errorMessage: '无法连接登录服务',
|
||||
});
|
||||
await expect(refreshClientAuthSession()).resolves.toEqual({
|
||||
status: 'failed',
|
||||
message: '无法连接登录服务',
|
||||
authoritative: false,
|
||||
});
|
||||
});
|
||||
|
||||
test('没有原生宿主时登录能力明确失败关闭', async () => {
|
||||
@@ -179,9 +158,14 @@ test('没有原生宿主时登录能力明确失败关闭', async () => {
|
||||
expect(invoke).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('错误文案优先使用服务端原因,缺失时回落到调用方文案', () => {
|
||||
expect(
|
||||
getClientAuthErrorMessage(new Error('手机号或密码错误'), '登录失败'),
|
||||
).toBe('手机号或密码错误');
|
||||
expect(getClientAuthErrorMessage('', '登录失败')).toBe('登录失败');
|
||||
test('结构化失败不降级成投影值,而是以 ClientAuthFailure 拒绝', async () => {
|
||||
const rejection = {
|
||||
type: 'authNetworkUnavailable',
|
||||
message: '无法连接登录服务',
|
||||
};
|
||||
invoke.mockRejectedValueOnce(rejection);
|
||||
await expect(refreshClientAuthSession('user-1')).rejects.toMatchObject({
|
||||
payload: rejection,
|
||||
context: { source: 'auth', action: 'refresh_client_auth_session' },
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* 把 Node 的 `unhandledRejection` 桥接成 jsdom `window` 的 `unhandledrejection` 事件。
|
||||
*
|
||||
* 生产 WebView 里,`void` 掉的 async handler(`onSubmit` / `onClick`)抛出的拒绝会派发
|
||||
* `unhandledrejection`,被 `AuthenticatedClient` 的全局 handler 交给错误池。jsdom 既不派发
|
||||
* 该事件,vitest 又会把未处理的拒绝当成用例失败;需要观察这条链路的用例用它接管 Node
|
||||
* 监听器,再合成一个带 `reason` 的 window 事件。
|
||||
*
|
||||
* 用法:用例内 `const uninstall = installUnhandledRejectionBridge()`,在 `finally` 或
|
||||
* `afterEach` 里 `uninstall()`。
|
||||
*/
|
||||
export function installUnhandledRejectionBridge() {
|
||||
const originalListeners = process.listeners('unhandledRejection');
|
||||
process.removeAllListeners('unhandledRejection');
|
||||
const handleRejection = (reason: unknown) => {
|
||||
const event = new Event('unhandledrejection');
|
||||
Object.defineProperty(event, 'reason', { value: reason });
|
||||
window.dispatchEvent(event);
|
||||
};
|
||||
process.on('unhandledRejection', handleRejection);
|
||||
return () => {
|
||||
process.removeListener('unhandledRejection', handleRejection);
|
||||
for (const listener of originalListeners) {
|
||||
process.on('unhandledRejection', listener);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -8106,7 +8106,7 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在
|
||||
|
||||
## 2026-08-22 AGC Tauri 命令调用可达性失败关闭
|
||||
|
||||
- 决策:`check-config.mjs` 的 App 调用扫描必须识别现役精确形态:裸 `invoke`、`directInvoke`、素材画布的 `invokeInput` / `invokeAuthenticatedInput` wrapper,以及对象字段 `.invoke`;不以包含 `invoke` 的任意名称、动态命令变量、注释、字符串、模板或正则文本作为可达证据。
|
||||
- 决策:`check-config.mjs` 的 App 调用扫描必须识别现役精确形态:裸 `invoke`、`directInvoke`、素材画布的 `invokeInput` / `invokeAuthenticatedInput`、认证命令的 `invokeClientAuth` wrapper,以及对象字段 `.invoke`;不以包含 `invoke` 的任意名称、动态命令变量、注释、字符串、模板或正则文本作为可达证据。
|
||||
- allowlist 边界:前端源码已调用的命令不得继续保留在 explicit native-only allowlist。allowlist 只承载确实由原生窗口或原生侧流程触发、App 源码不直接调用的 handler;源码调用与 allowlist 必须互斥。
|
||||
- 门禁:逐文件使用仓库锁定的 TypeScript AST 解析,设置文件数量、单文件 / 总源码长度、命令长度和调用数量上限。回归测试同时锁定直接、wrapper、对象字段的正例与诱饵 / 动态 / 畸形输入的反例,并证明删除真实 wrapper 调用后 handler 可达性检查失败,不能由错误 allowlist 继续误绿。
|
||||
|
||||
|
||||
Reference in New Issue
Block a user