feat(frontend): T1-03 inject admin bearer token
Some checks failed
CI / pytest (Python 3.10) (push) Has been cancelled
CI / pytest (Python 3.11) (push) Has been cancelled
CI / pytest (Python 3.12) (push) Has been cancelled

This commit is contained in:
Hermes Agent
2026-07-05 22:00:34 +08:00
parent f634b47cd7
commit 0d9e1ddb6e
2 changed files with 61 additions and 0 deletions

View File

@@ -1,5 +1,6 @@
import { z } from 'zod'; import { z } from 'zod';
import { afterEach, describe, expect, it, vi } from 'vitest'; import { afterEach, describe, expect, it, vi } from 'vitest';
import { useUserStore } from '@/stores/user';
import { apiClient, HttpError } from './api-client'; import { apiClient, HttpError } from './api-client';
function setNavigatorOnline(value: boolean): void { function setNavigatorOnline(value: boolean): void {
@@ -103,4 +104,52 @@ describe('apiClient error handling', () => {
await expect(request).resolves.toEqual({ ok: true }); await expect(request).resolves.toEqual({ ok: true });
expect(fetchMock).toHaveBeenCalledWith('/api/queued-write', expect.objectContaining({ method: 'POST' })); expect(fetchMock).toHaveBeenCalledWith('/api/queued-write', expect.objectContaining({ method: 'POST' }));
}); });
it('injects Authorization Bearer from the admin session token', async () => {
useUserStore.getState().setAdminSession({
username: 'admin',
accessToken: 'jwt-token',
tokenType: 'bearer',
expiresIn: 3600,
});
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ ok: true }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
}),
);
vi.stubGlobal('fetch', fetchMock);
await expect(apiClient.get('/admin/orders', z.object({ ok: z.boolean() }))).resolves.toEqual({ ok: true });
expect(fetchMock).toHaveBeenCalledOnce();
const calls = fetchMock.mock.calls as Array<[string, RequestInit]>;
expect(calls[0][0]).toBe('/api/admin/orders');
const headers = calls[0][1].headers as Record<string, string>;
expect(headers.Authorization).toBe('Bearer jwt-token');
});
it('does not inject Authorization after the admin token has expired', async () => {
useUserStore.getState().setAdminSession({
username: 'admin',
accessToken: 'expired-token',
tokenType: 'bearer',
expiresIn: -1,
});
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ ok: true }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
}),
);
vi.stubGlobal('fetch', fetchMock);
await expect(apiClient.get('/admin/orders', z.object({ ok: z.boolean() }))).resolves.toEqual({ ok: true });
expect(fetchMock).toHaveBeenCalledOnce();
const calls = fetchMock.mock.calls as Array<[string, RequestInit]>;
expect(calls[0][0]).toBe('/api/admin/orders');
const headers = calls[0][1].headers as Record<string, string>;
expect(headers.Authorization).toBeUndefined();
});
}); });

View File

@@ -7,6 +7,7 @@
* - Error codes map to i18n copy. * - Error codes map to i18n copy.
*/ */
import { ZodError, type ZodType, type ZodTypeDef } from 'zod'; import { ZodError, type ZodType, type ZodTypeDef } from 'zod';
import { useUserStore } from '@/stores/user';
import { getLocalizedApiErrorMessage, type ApiErrorSeverity } from './error-messages'; import { getLocalizedApiErrorMessage, type ApiErrorSeverity } from './error-messages';
export interface ApiError extends Error { export interface ApiError extends Error {
@@ -61,6 +62,15 @@ function isWriteMethod(method: RequestOptions<unknown>['method']): boolean {
return method !== undefined && method !== 'GET'; return method !== undefined && method !== 'GET';
} }
function getActiveAuthorizationHeader(): string | undefined {
const { token, tokenType, tokenExpiresAt } = useUserStore.getState();
if (!token || !tokenExpiresAt || tokenExpiresAt <= Date.now()) {
return undefined;
}
const scheme = (tokenType ?? 'bearer').toLowerCase() === 'bearer' ? 'Bearer' : tokenType;
return `${scheme} ${token}`;
}
function waitUntilOnline(signal: AbortSignal | undefined): Promise<void> { function waitUntilOnline(signal: AbortSignal | undefined): Promise<void> {
if (!isBrowserOffline() || typeof window === 'undefined') { if (!isBrowserOffline() || typeof window === 'undefined') {
return Promise.resolve(); return Promise.resolve();
@@ -98,11 +108,13 @@ async function request<TResponse, TBody = unknown>(
): Promise<TResponse> { ): Promise<TResponse> {
const { method = 'GET', body, signal, headers = {} } = options; const { method = 'GET', body, signal, headers = {} } = options;
const authHeader = getActiveAuthorizationHeader();
const init: RequestInit = { const init: RequestInit = {
method, method,
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
Accept: 'application/json', Accept: 'application/json',
...(authHeader ? { Authorization: authHeader } : {}),
...headers, ...headers,
}, },
signal, signal,