修复客户端认证异常处理

登录态探活遇到服务不可达时保留本地 access token。

退出登录重试时 refresh 失败仍继续调用服务端 logout。

补充客户端认证异常处理回归测试。
This commit is contained in:
AIGameCreator App
2026-07-08 14:24:06 +08:00
parent 67a6cbb8d5
commit 6cb0b5d6a7
2 changed files with 163 additions and 6 deletions
+67 -6
View File
@@ -947,6 +947,36 @@ function resolveClientAuthApiUrl(url: string) {
let clientAuthRefreshPromise: Promise<string> | null = null;
class ClientAuthRequestError extends Error {
readonly status: number | null;
readonly networkError: boolean;
constructor(
message: string,
options: { status?: number | null; networkError?: boolean } = {},
) {
super(message);
this.name = 'ClientAuthRequestError';
this.status = options.status ?? null;
this.networkError = options.networkError ?? false;
}
}
function isClientAuthUnauthorizedError(error: unknown) {
return (
error instanceof ClientAuthRequestError &&
(error.status === 401 || error.status === 403)
);
}
function isClientAuthRecoverableCheckError(error: unknown) {
return !isClientAuthUnauthorizedError(error);
}
function getClientAuthErrorMessage(error: unknown, fallback: string) {
return error instanceof Error ? error.message : fallback;
}
async function readAuthErrorMessage(response: Response, fallback: string) {
const text = await response.text();
if (!text.trim()) {
@@ -983,10 +1013,16 @@ async function requestAuthJson<T>(
headers,
});
} catch {
throw new Error('无法连接登录服务,请确认配套后端或 API 代理已启动后重试');
throw new ClientAuthRequestError(
'无法连接登录服务,请确认配套后端或 API 代理已启动后重试',
{ networkError: true },
);
}
if (!response.ok) {
throw new Error(await readAuthErrorMessage(response, fallbackMessage));
throw new ClientAuthRequestError(
await readAuthErrorMessage(response, fallbackMessage),
{ status: response.status },
);
}
const text = await response.text();
return text ? unwrapApiResponse<T>(JSON.parse(text) as T) : (null as T);
@@ -1084,7 +1120,7 @@ async function logoutClientAuthSession() {
'退出登录失败',
);
} catch {
await refreshClientAuthAccessToken();
await refreshClientAuthAccessToken().catch(() => '');
await requestAuthJson<LogoutResponse>(
'/api/auth/logout',
{ method: 'POST' },
@@ -1140,10 +1176,23 @@ export function AuthenticatedClient({
}
clearStoredAuthAccessToken();
setAuthStatus('unauthenticated');
} catch {
} catch (error) {
if (disposed) {
return;
}
if (
getStoredAuthAccessToken() &&
isClientAuthRecoverableCheckError(error)
) {
setLoginStatus(
getClientAuthErrorMessage(
error,
'登录服务暂时不可用,请稍后重试',
),
);
setAuthStatus('unauthenticated');
return;
}
if (getStoredAuthAccessToken()) {
try {
await refreshClientAuthAccessToken();
@@ -1156,8 +1205,20 @@ export function AuthenticatedClient({
setAuthStatus('authenticated');
return;
}
} catch {
// fall through to local logout below
} catch (retryError) {
if (
getStoredAuthAccessToken() &&
isClientAuthRecoverableCheckError(retryError)
) {
setLoginStatus(
getClientAuthErrorMessage(
retryError,
'登录服务暂时不可用,请稍后重试',
),
);
setAuthStatus('unauthenticated');
return;
}
}
}
clearStoredAuthAccessToken();
@@ -269,6 +269,102 @@ describe('AI 游戏创作 App 界面边界', () => {
expect(screen.queryByLabelText('已登录')).toBeNull();
});
it('keeps the stored token when startup auth check cannot reach the service', async () => {
window.localStorage.setItem(
'genarrative.auth.access-token.v1',
'existing-token',
);
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation(
async (input: RequestInfo | URL) => {
const url = String(input);
if (url === '/api/auth/me') {
throw new TypeError('Load failed');
}
throw new Error(`unexpected fetch ${url}`);
},
);
render(
React.createElement(AuthenticatedClient, null, ({ user }) =>
React.createElement('main', { 'aria-label': '已登录' }, user.displayName),
),
);
expect(await screen.findByRole('main', { name: '登录' })).not.toBeNull();
expect(
screen.getByText(
'无法连接登录服务,请确认配套后端或 API 代理已启动后重试',
),
).not.toBeNull();
expect(window.localStorage.getItem('genarrative.auth.access-token.v1')).toBe(
'existing-token',
);
expect(screen.queryByLabelText('已登录')).toBeNull();
expect(
fetchSpy.mock.calls.filter(([input]) => String(input) === '/api/auth/me'),
).toHaveLength(1);
});
it('still calls logout when token refresh fails during logout retry', async () => {
window.localStorage.setItem(
'genarrative.auth.access-token.v1',
'existing-token',
);
let logoutCalls = 0;
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation(
async (input: RequestInfo | URL) => {
const url = String(input);
if (url === '/api/auth/me') {
return new Response(
JSON.stringify({
user: testAuthUser,
availableLoginMethods: ['password'],
}),
{ status: 200 },
);
}
if (url === '/api/auth/logout') {
logoutCalls += 1;
return new Response('', { status: logoutCalls === 1 ? 500 : 200 });
}
if (url === '/api/auth/refresh') {
return new Response('', { status: 401 });
}
throw new Error(`unexpected fetch ${url}`);
},
);
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 }, '退出'),
),
),
);
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(window.localStorage.getItem('genarrative.auth.access-token.v1')).toBe(
null,
);
expect(
fetchSpy.mock.calls.filter(
([input]) => String(input) === '/api/auth/logout',
),
).toHaveLength(2);
expect(
fetchSpy.mock.calls.filter(
([input]) => String(input) === '/api/auth/refresh',
),
).toHaveLength(1);
});
it('derives agent card status from the latest run trace step', () => {
const manifest = createGameCreationAppManifest(
'local-project-draft',