修复客户端认证异常处理
登录态探活遇到服务不可达时保留本地 access token。 退出登录重试时 refresh 失败仍继续调用服务端 logout。 补充客户端认证异常处理回归测试。
This commit is contained in:
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user