refactor: strengthen client boundaries and quality gates
This commit is contained in:
@@ -0,0 +1,221 @@
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { setupServer } from 'msw/node';
|
||||
import { fileDownloadUrl, readErrorBody, request, requestBlob, requestForm, withQuery } from './httpClient';
|
||||
import { setReauthenticationHandler, writeSession } from '../session';
|
||||
|
||||
let receivedTenantHeader: string | null = null;
|
||||
const server = setupServer(
|
||||
http.get('http://localhost/api/client/users', ({ request: incoming }) => {
|
||||
receivedTenantHeader = incoming.headers.get('x-tenant-id');
|
||||
return HttpResponse.json([]);
|
||||
}),
|
||||
http.get('http://localhost/api/admin/users', ({ request: incoming }) => {
|
||||
receivedTenantHeader = incoming.headers.get('x-tenant-id');
|
||||
return HttpResponse.json([]);
|
||||
}),
|
||||
http.get('http://localhost/api/client/failure', () =>
|
||||
HttpResponse.json({ message: '后端业务失败' }, { status: 500 }),
|
||||
),
|
||||
);
|
||||
|
||||
const nativeFetch = globalThis.fetch;
|
||||
|
||||
beforeAll(() => {
|
||||
server.listen({ onUnhandledRequest: 'error' });
|
||||
const interceptedFetch = globalThis.fetch;
|
||||
globalThis.fetch = ((input: RequestInfo | URL, init?: RequestInit) =>
|
||||
interceptedFetch(new URL(String(input), 'http://localhost'), init)) as typeof fetch;
|
||||
});
|
||||
afterEach(() => {
|
||||
receivedTenantHeader = null;
|
||||
server.resetHandlers();
|
||||
});
|
||||
afterAll(() => {
|
||||
server.close();
|
||||
globalThis.fetch = nativeFetch;
|
||||
setReauthenticationHandler();
|
||||
});
|
||||
|
||||
describe('request tenant and error boundaries', () => {
|
||||
it('never forwards an explicit tenant header to a client route', async () => {
|
||||
await expect(request('/client/users', { tenantId: 'tenant-attacker' })).resolves.toEqual([]);
|
||||
expect(receivedTenantHeader).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps explicit tenant selection for non-client administrative routes', async () => {
|
||||
await expect(request('/admin/users', { tenantId: 'tenant-admin-selected' })).resolves.toEqual([]);
|
||||
expect(receivedTenantHeader).toBe('tenant-admin-selected');
|
||||
});
|
||||
|
||||
it('surfaces a backend JSON error message instead of a generic status', async () => {
|
||||
await expect(request('/client/failure')).rejects.toThrow('后端业务失败');
|
||||
});
|
||||
|
||||
it.each([
|
||||
[HttpResponse.json({ message: ['字段一', '字段二'] }, { status: 422 }), '字段一;字段二'],
|
||||
[HttpResponse.json({ error: '网关错误' }, { status: 502 }), '网关错误'],
|
||||
[new HttpResponse(null, { status: 503 }), '请求失败(503)'],
|
||||
])('normalizes additional server error body shapes', async (response, expected) => {
|
||||
server.use(http.get('http://localhost/api/client/error-shape', () => response));
|
||||
await expect(request('/client/error-shape')).rejects.toThrow(expected);
|
||||
});
|
||||
|
||||
it('parses empty, JSON and plain-text error bodies safely', async () => {
|
||||
await expect(readErrorBody(new Response())).resolves.toEqual({});
|
||||
await expect(readErrorBody(HttpResponse.json({ code: 'BAD', message: ['字段一', '字段二'] }))).resolves.toEqual({
|
||||
code: 'BAD',
|
||||
message: ['字段一', '字段二'],
|
||||
});
|
||||
await expect(readErrorBody(new Response('代理错误'))).resolves.toEqual({ message: '代理错误' });
|
||||
});
|
||||
|
||||
it('does not redirect a rejected login attempt', async () => {
|
||||
server.use(
|
||||
http.post('http://localhost/api/client/auth/login', () =>
|
||||
HttpResponse.json({ message: 'Invalid login or password' }, { status: 401 }),
|
||||
),
|
||||
);
|
||||
await expect(request('/client/auth/login', { method: 'POST', body: '{}' })).rejects.toThrow(
|
||||
'Invalid login or password',
|
||||
);
|
||||
});
|
||||
|
||||
it('can suppress a session redirect for a background probe', async () => {
|
||||
server.use(
|
||||
http.get('http://localhost/api/client/probe', () => HttpResponse.json({ message: '未登录' }, { status: 401 })),
|
||||
);
|
||||
await expect(request('/client/probe', { suppressSessionRedirect: true })).rejects.toThrow('未登录');
|
||||
});
|
||||
|
||||
it('raises the server lock message for an authenticated session', async () => {
|
||||
writeSession({
|
||||
portal: 'client',
|
||||
user: { id: 'u', username: 'u', displayName: 'U', roles: [] },
|
||||
idleTimeoutSeconds: 1,
|
||||
lockRecoverySeconds: 1,
|
||||
absoluteExpiresAt: new Date(Date.now() + 1000).toISOString(),
|
||||
lastActivityAt: new Date().toISOString(),
|
||||
recentAuthenticationExpiresAt: new Date().toISOString(),
|
||||
});
|
||||
server.use(
|
||||
http.get('http://localhost/api/client/locked', () =>
|
||||
HttpResponse.json({ code: 'SESSION_LOCKED', message: '会话测试锁定' }, { status: 401 }),
|
||||
),
|
||||
);
|
||||
await expect(request('/client/locked')).rejects.toThrow('会话测试锁定');
|
||||
});
|
||||
|
||||
it('reauthenticates once and retries a protected request', async () => {
|
||||
writeSession({
|
||||
portal: 'client',
|
||||
user: { id: 'user-1', username: 'user', displayName: '用户', roles: ['enterprise_admin'] },
|
||||
idleTimeoutSeconds: 7200,
|
||||
lockRecoverySeconds: 14400,
|
||||
absoluteExpiresAt: new Date(Date.now() + 3600000).toISOString(),
|
||||
lastActivityAt: new Date().toISOString(),
|
||||
recentAuthenticationExpiresAt: new Date().toISOString(),
|
||||
});
|
||||
let attempts = 0;
|
||||
server.use(
|
||||
http.post('http://localhost/api/client/protected', () => {
|
||||
attempts += 1;
|
||||
return attempts === 1
|
||||
? HttpResponse.json({ code: 'RECENT_AUTHENTICATION_REQUIRED' }, { status: 403 })
|
||||
: HttpResponse.json({ success: true });
|
||||
}),
|
||||
);
|
||||
const reauthenticate = vi.fn().mockResolvedValue(undefined);
|
||||
setReauthenticationHandler(reauthenticate);
|
||||
await expect(request('/client/protected', { method: 'POST', body: '{}' })).resolves.toEqual({ success: true });
|
||||
expect(reauthenticate).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('downloads blobs and preserves server text errors', async () => {
|
||||
server.use(
|
||||
http.get('http://localhost/api/client/file-ok', () => new HttpResponse('file-data', { status: 200 })),
|
||||
http.get('http://localhost/api/client/file-fail', () => new HttpResponse('文件不存在', { status: 404 })),
|
||||
);
|
||||
await expect((await requestBlob('/client/file-ok')).text()).resolves.toBe('file-data');
|
||||
await expect(requestBlob('/client/file-fail')).rejects.toThrow('文件不存在');
|
||||
});
|
||||
|
||||
it('retries blob downloads after recent authentication and supports admin tenant selection', async () => {
|
||||
writeSession({
|
||||
portal: 'admin',
|
||||
user: { id: 'a', username: 'a', displayName: 'A', roles: ['platform_admin'] },
|
||||
idleTimeoutSeconds: 1,
|
||||
lockRecoverySeconds: 1,
|
||||
absoluteExpiresAt: new Date(Date.now() + 1000).toISOString(),
|
||||
lastActivityAt: new Date().toISOString(),
|
||||
recentAuthenticationExpiresAt: new Date().toISOString(),
|
||||
});
|
||||
let attempts = 0;
|
||||
server.use(
|
||||
http.get('http://localhost/api/admin/export', ({ request: incoming }) => {
|
||||
attempts += 1;
|
||||
receivedTenantHeader = incoming.headers.get('x-tenant-id');
|
||||
return attempts === 1
|
||||
? HttpResponse.json({ code: 'RECENT_AUTHENTICATION_REQUIRED' }, { status: 403 })
|
||||
: new HttpResponse('csv');
|
||||
}),
|
||||
);
|
||||
setReauthenticationHandler(vi.fn().mockResolvedValue(undefined));
|
||||
await expect((await requestBlob('/admin/export', { tenantId: 'tenant-1' })).text()).resolves.toBe('csv');
|
||||
expect(receivedTenantHeader).toBe('tenant-1');
|
||||
});
|
||||
|
||||
it('submits multipart forms without forcing a JSON content type', async () => {
|
||||
let contentType = '';
|
||||
server.use(
|
||||
http.post('http://localhost/api/client/upload', ({ request: incoming }) => {
|
||||
contentType = incoming.headers.get('content-type') ?? '';
|
||||
return HttpResponse.json({ id: 'file-1' });
|
||||
}),
|
||||
);
|
||||
const form = new FormData();
|
||||
form.set('file', new Blob(['data']), 'data.txt');
|
||||
await expect(requestForm('/client/upload', form)).resolves.toEqual({ id: 'file-1' });
|
||||
expect(contentType).toContain('multipart/form-data; boundary=');
|
||||
});
|
||||
|
||||
it('retries multipart forms after recent authentication and reports failures', async () => {
|
||||
writeSession({
|
||||
portal: 'client',
|
||||
user: { id: 'u', username: 'u', displayName: 'U', roles: [] },
|
||||
idleTimeoutSeconds: 1,
|
||||
lockRecoverySeconds: 1,
|
||||
absoluteExpiresAt: new Date(Date.now() + 1000).toISOString(),
|
||||
lastActivityAt: new Date().toISOString(),
|
||||
recentAuthenticationExpiresAt: new Date().toISOString(),
|
||||
});
|
||||
let attempts = 0;
|
||||
server.use(
|
||||
http.post('http://localhost/api/client/form-protected', () => {
|
||||
attempts += 1;
|
||||
return attempts === 1
|
||||
? HttpResponse.json({ code: 'RECENT_AUTHENTICATION_REQUIRED' }, { status: 403 })
|
||||
: HttpResponse.json({ ok: true });
|
||||
}),
|
||||
);
|
||||
setReauthenticationHandler(vi.fn().mockResolvedValue(undefined));
|
||||
await expect(requestForm('/client/form-protected', new FormData())).resolves.toEqual({ ok: true });
|
||||
server.use(
|
||||
http.post('http://localhost/api/client/form-fail', () =>
|
||||
HttpResponse.json({ error: '上传失败' }, { status: 400 }),
|
||||
),
|
||||
);
|
||||
await expect(requestForm('/client/form-fail', new FormData())).rejects.toThrow('上传失败');
|
||||
});
|
||||
|
||||
it('builds bounded queries and encoded download URLs', () => {
|
||||
expect(withQuery('/client/messages', { page: 2, status: 'all', keyword: '', level: 'warn' })).toBe(
|
||||
'/client/messages?page=2&level=warn',
|
||||
);
|
||||
expect(fileDownloadUrl('folder/file 1', 'inline', 'client')).toBe(
|
||||
'/api/client/files/folder%2Ffile%201/download?disposition=inline',
|
||||
);
|
||||
expect(withQuery('/client/messages', { status: undefined })).toBe('/client/messages');
|
||||
expect(fileDownloadUrl('file-1')).toBe('/api/admin/files/file-1/download?disposition=attachment');
|
||||
});
|
||||
});
|
||||
+17
-13
@@ -2,7 +2,6 @@ import {
|
||||
clearSession,
|
||||
currentRouteForPortal,
|
||||
dispatchSessionEvent,
|
||||
getSessionTenantId,
|
||||
hasRecentUserActivity,
|
||||
portalFromPath,
|
||||
readSession,
|
||||
@@ -19,7 +18,6 @@ type RequestOptions = RequestInit & {
|
||||
suppressSessionRedirect?: boolean;
|
||||
};
|
||||
|
||||
|
||||
type ApiErrorBody = { message?: string | string[]; error?: string; code?: string };
|
||||
|
||||
export async function readErrorBody(response: Response): Promise<ApiErrorBody> {
|
||||
@@ -32,8 +30,14 @@ export async function readErrorBody(response: Response): Promise<ApiErrorBody> {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export type SessionTiming = Pick<LoginSession, 'idleTimeoutSeconds' | 'lockRecoverySeconds' | 'absoluteExpiresAt' | 'lastActivityAt' | 'recentAuthenticationExpiresAt'>;
|
||||
export type SessionTiming = Pick<
|
||||
LoginSession,
|
||||
| 'idleTimeoutSeconds'
|
||||
| 'lockRecoverySeconds'
|
||||
| 'absoluteExpiresAt'
|
||||
| 'lastActivityAt'
|
||||
| 'recentAuthenticationExpiresAt'
|
||||
>;
|
||||
|
||||
// Authentication failures are handled centrally so every domain API keeps the
|
||||
// same lock, recovery and redirect behavior as the original adminApi facade.
|
||||
@@ -87,9 +91,8 @@ export async function request<T>(path: string, options: RequestOptions = {}): Pr
|
||||
const portal = requestPortal(path);
|
||||
const session = portal ? readSession(portal) : null;
|
||||
if (session && hasRecentUserActivity()) headers.set('x-session-activity', 'user');
|
||||
const tenantId = options.tenantId ?? (path.startsWith('/client') ? getSessionTenantId() : undefined);
|
||||
if (tenantId) {
|
||||
headers.set('x-tenant-id', tenantId);
|
||||
if (options.tenantId && !path.startsWith('/client')) {
|
||||
headers.set('x-tenant-id', options.tenantId);
|
||||
}
|
||||
const response = await fetch(`/api${path}`, { ...options, headers, credentials: 'same-origin' });
|
||||
const isLoginAttempt = path === '/admin/auth/login' || path === '/client/auth/login';
|
||||
@@ -114,9 +117,8 @@ export async function requestBlob(path: string, options: RequestOptions = {}): P
|
||||
const portal = requestPortal(path);
|
||||
const session = portal ? readSession(portal) : null;
|
||||
if (session && hasRecentUserActivity()) headers.set('x-session-activity', 'user');
|
||||
const tenantId = options.tenantId ?? (path.startsWith('/client') ? getSessionTenantId() : undefined);
|
||||
if (tenantId) {
|
||||
headers.set('x-tenant-id', tenantId);
|
||||
if (options.tenantId && !path.startsWith('/client')) {
|
||||
headers.set('x-tenant-id', options.tenantId);
|
||||
}
|
||||
const response = await fetch(`/api${path}`, { ...options, headers, credentials: 'same-origin' });
|
||||
if (response.status === 401) {
|
||||
@@ -156,7 +158,6 @@ export async function requestForm<T>(path: string, form: FormData, reauthenticat
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
|
||||
export function withQuery(path: string, query: Record<string, string | number | undefined>) {
|
||||
const params = new URLSearchParams();
|
||||
Object.entries(query).forEach(([key, value]) => {
|
||||
@@ -168,7 +169,10 @@ export function withQuery(path: string, query: Record<string, string | number |
|
||||
return `${path}${suffix}`;
|
||||
}
|
||||
|
||||
|
||||
export function fileDownloadUrl(fileObjectId: string, disposition: 'attachment' | 'inline' = 'attachment', portal: Portal = 'admin') {
|
||||
export function fileDownloadUrl(
|
||||
fileObjectId: string,
|
||||
disposition: 'attachment' | 'inline' = 'attachment',
|
||||
portal: Portal = 'admin',
|
||||
) {
|
||||
return `/api/${portal}/files/${encodeURIComponent(fileObjectId)}/download?disposition=${disposition}`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user