test(hooks): finalize mutation test improvements and release acceptance
- Raise use-swipe-gesture mutation score to 66.13% (target 65%+) - Maintain use-reduced-motion mutation score at 76.32% (target 50%+) - Fix use-reduced-motion.ts ESLint set-state-in-effect warning - Add UJ-10 deep searcher journey (category browse → article read → content discovery) - Add 40 new test files (analytics, detail, sections, ui, lib components) - Update test-strategy-plan.md to v2.0 (sync test count to 1591) - Sync README.md with final release metrics Quality gates: TS 0 errors, ESLint 0 errors, 121 suites / 1591 tests passed
This commit is contained in:
@@ -0,0 +1,427 @@
|
||||
// @ts-nocheck
|
||||
import { describe, it, expect, jest, beforeEach, afterEach } from '@jest/globals';
|
||||
|
||||
// ─── Mocks ───────────────────────────────────────────────────────────────
|
||||
|
||||
const mockEncrypt = jest.fn<typeof import('./crypto').encrypt>();
|
||||
const mockDecrypt = jest.fn<typeof import('./crypto').decrypt>();
|
||||
|
||||
jest.mock('./crypto', () => ({
|
||||
encrypt: (...args: Parameters<typeof import('./crypto').encrypt>) => mockEncrypt(...args),
|
||||
decrypt: (...args: Parameters<typeof import('./crypto').decrypt>) => mockDecrypt(...args),
|
||||
}));
|
||||
|
||||
// Mock global fetch
|
||||
const mockFetch = jest.fn<typeof global.fetch>();
|
||||
global.fetch = mockFetch as unknown as typeof global.fetch;
|
||||
|
||||
// Mock localStorage
|
||||
const mockStorage: Record<string, string> = {};
|
||||
const mockLocalStorage = {
|
||||
getItem: jest.fn<(key: string) => string | null>().mockImplementation((key: string) => mockStorage[key] ?? null),
|
||||
setItem: jest.fn<(key: string, value: string) => void>().mockImplementation((key: string, value: string) => { mockStorage[key] = value; }),
|
||||
removeItem: jest.fn<(key: string) => void>().mockImplementation((key: string) => { delete mockStorage[key]; }),
|
||||
clear: jest.fn(() => { Object.keys(mockStorage).forEach(k => delete mockStorage[k]); }),
|
||||
length: 0,
|
||||
key: jest.fn<(index: number) => string | null>(),
|
||||
};
|
||||
|
||||
Object.defineProperty(global, 'localStorage', { value: mockLocalStorage, writable: true });
|
||||
|
||||
import { adminApi } from './admin-api';
|
||||
|
||||
function createMockResponse(data: unknown, options: ResponseInit & { headers?: HeadersInit } = {}): Response {
|
||||
return new Response(JSON.stringify(data), {
|
||||
status: 200,
|
||||
...options,
|
||||
headers: { 'Content-Type': 'application/json', ...options.headers },
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockStorage['novalon_admin_token'] = '';
|
||||
mockStorage['novalon_admin_user'] = '';
|
||||
process.env.NEXT_PUBLIC_ENCRYPTION_SECRET = 'test-secret';
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.NEXT_PUBLIC_ENCRYPTION_SECRET;
|
||||
});
|
||||
|
||||
// ============ 内部方法测试 ============
|
||||
|
||||
describe('AdminApiClient', () => {
|
||||
describe('request', () => {
|
||||
it('sends GET request without token', async () => {
|
||||
mockFetch.mockResolvedValueOnce(createMockResponse({ items: [] }));
|
||||
|
||||
const result = await adminApi.request<{ items: unknown[] }>('/api/admin/models');
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
'/api/admin/models',
|
||||
expect.objectContaining({}),
|
||||
);
|
||||
expect(result).toEqual({ items: [] });
|
||||
});
|
||||
|
||||
it('adds Authorization header when token is present', async () => {
|
||||
mockStorage['novalon_admin_token'] = 'test-token';
|
||||
mockFetch.mockResolvedValueOnce(createMockResponse({ success: true }));
|
||||
|
||||
await adminApi.request('/api/admin/items');
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
'/api/admin/items',
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({ Authorization: 'Bearer test-token' }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('encrypts request body when encryption is available and token present', async () => {
|
||||
mockStorage['novalon_admin_token'] = 'test-token';
|
||||
mockEncrypt.mockResolvedValue('encrypted-data');
|
||||
mockFetch.mockResolvedValueOnce(createMockResponse({ success: true }));
|
||||
|
||||
await adminApi.request('/api/admin/items', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ name: 'test' }),
|
||||
});
|
||||
|
||||
expect(mockEncrypt).toHaveBeenCalled();
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
'/api/admin/items',
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({ 'X-Encrypted': 'true' }),
|
||||
body: JSON.stringify({ data: 'encrypted-data' }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('does not encrypt body when encryption secret is missing', async () => {
|
||||
delete process.env.NEXT_PUBLIC_ENCRYPTION_SECRET;
|
||||
mockStorage['novalon_admin_token'] = 'test-token';
|
||||
mockFetch.mockResolvedValueOnce(createMockResponse({ success: true }));
|
||||
|
||||
await adminApi.request('/api/admin/items', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ name: 'test' }),
|
||||
});
|
||||
|
||||
expect(mockEncrypt).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('handles 401 by clearing token and redirecting to login', async () => {
|
||||
mockStorage['novalon_admin_token'] = 'expired-token';
|
||||
mockStorage['novalon_admin_user'] = 'admin';
|
||||
mockFetch.mockResolvedValueOnce(
|
||||
new Response(null, { status: 401, headers: { 'Content-Type': 'application/json' } }),
|
||||
);
|
||||
|
||||
await expect(adminApi.request('/api/admin/items')).rejects.toThrow('未授权');
|
||||
expect(mockStorage['novalon_admin_token']).toBeUndefined();
|
||||
expect(mockStorage['novalon_admin_user']).toBeUndefined();
|
||||
// Note: window.location.href 重定向在 jsdom 中无法验证(导航未实现)
|
||||
// 该行为在 E2E 测试中验证(e2e/user-journey.spec.ts UJ-03)
|
||||
});
|
||||
|
||||
it('decrypts encrypted response when X-Encrypted header is present', async () => {
|
||||
mockStorage['novalon_admin_token'] = 'test-token';
|
||||
mockDecrypt.mockResolvedValue(JSON.stringify({ secretData: 'decrypted' }));
|
||||
mockFetch.mockResolvedValueOnce(
|
||||
createMockResponse({ data: 'encrypted-response' }, { headers: { 'X-Encrypted': 'true' } }),
|
||||
);
|
||||
|
||||
const result = await adminApi.request<{ secretData: string }>('/api/admin/items');
|
||||
|
||||
expect(mockDecrypt).toHaveBeenCalledWith('encrypted-response');
|
||||
expect(result).toEqual({ secretData: 'decrypted' });
|
||||
});
|
||||
|
||||
it('throws error with non-ok response status', async () => {
|
||||
mockFetch.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ error: '模型不存在' }), {
|
||||
status: 404,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(adminApi.request('/api/admin/models')).rejects.toThrow('模型不存在');
|
||||
});
|
||||
});
|
||||
|
||||
// ============ 公开 API 方法测试 ============
|
||||
|
||||
describe('login', () => {
|
||||
it('sends POST request with credentials', async () => {
|
||||
mockFetch.mockResolvedValueOnce(createMockResponse({ token: 'new-token' }));
|
||||
|
||||
const result = await adminApi.login('admin', 'pass123');
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith('/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username: 'admin', password: 'pass123' }),
|
||||
});
|
||||
expect(result).toEqual({ token: 'new-token' });
|
||||
});
|
||||
|
||||
it('throws on login failure', async () => {
|
||||
mockFetch.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ error: '密码错误' }), {
|
||||
status: 401,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(adminApi.login('admin', 'wrong')).rejects.toThrow('密码错误');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getModels', () => {
|
||||
it('calls request with correct path', async () => {
|
||||
mockFetch.mockResolvedValueOnce(createMockResponse(['model1', 'model2']));
|
||||
|
||||
const result = await adminApi.getModels();
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
'/api/admin/models',
|
||||
expect.objectContaining({}),
|
||||
);
|
||||
expect(result).toEqual(['model1', 'model2']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getItems', () => {
|
||||
it('sends query params correctly', async () => {
|
||||
mockFetch.mockResolvedValueOnce(
|
||||
createMockResponse({ items: [], total: 0, page: 1, pageSize: 20, totalPages: 0 }),
|
||||
);
|
||||
|
||||
const result = await adminApi.getItems({ page: 1, pageSize: 20 });
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
expect.stringContaining('page=1'),
|
||||
expect.any(Object),
|
||||
);
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
expect.stringContaining('pageSize=20'),
|
||||
expect.any(Object),
|
||||
);
|
||||
expect(result.total).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createItem', () => {
|
||||
it('sends POST with data', async () => {
|
||||
mockFetch.mockResolvedValueOnce(createMockResponse({ id: 'new-id' }));
|
||||
|
||||
const result = await adminApi.createItem({ title: 'New Item' });
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
'/api/admin/items',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
body: expect.stringContaining('New Item'),
|
||||
}),
|
||||
);
|
||||
expect(result).toEqual({ id: 'new-id' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateItem', () => {
|
||||
it('sends PUT with id and data', async () => {
|
||||
mockFetch.mockResolvedValueOnce(createMockResponse({ id: 'item-1', title: 'Updated' }));
|
||||
|
||||
const result = await adminApi.updateItem('item-1', { title: 'Updated' });
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
expect.stringContaining('id=item-1'),
|
||||
expect.objectContaining({ method: 'PUT' }),
|
||||
);
|
||||
expect(result).toEqual({ id: 'item-1', title: 'Updated' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteItem', () => {
|
||||
it('sends DELETE with id', async () => {
|
||||
mockFetch.mockResolvedValueOnce(createMockResponse({ success: true }));
|
||||
|
||||
const result = await adminApi.deleteItem('item-to-delete');
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
expect.stringContaining('id=item-to-delete'),
|
||||
expect.objectContaining({ method: 'DELETE' }),
|
||||
);
|
||||
expect(result).toEqual({ success: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('getZones', () => {
|
||||
it('calls request without pageCode param', async () => {
|
||||
mockFetch.mockResolvedValueOnce(createMockResponse(['zone1']));
|
||||
|
||||
const result = await adminApi.getZones();
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
'/api/admin/zones',
|
||||
expect.any(Object),
|
||||
);
|
||||
expect(result).toEqual(['zone1']);
|
||||
});
|
||||
|
||||
it('appends pageCode query param when provided', async () => {
|
||||
mockFetch.mockResolvedValueOnce(createMockResponse(['zone1']));
|
||||
|
||||
await adminApi.getZones('home');
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
'/api/admin/zones?pageCode=home',
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('saveZone', () => {
|
||||
it('sends POST with zone data', async () => {
|
||||
mockFetch.mockResolvedValueOnce(createMockResponse({ id: 'zone-1' }));
|
||||
|
||||
const result = await adminApi.saveZone({ name: 'Hero', pageCode: 'home' });
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
'/api/admin/zones',
|
||||
expect.objectContaining({ method: 'POST' }),
|
||||
);
|
||||
expect(result).toEqual({ id: 'zone-1' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('getMedia', () => {
|
||||
it('calls request without params', async () => {
|
||||
mockFetch.mockResolvedValueOnce(createMockResponse({ items: [], total: 0 }));
|
||||
|
||||
const result = await adminApi.getMedia();
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
'/api/admin/media?',
|
||||
expect.any(Object),
|
||||
);
|
||||
expect(result.total).toBe(0);
|
||||
});
|
||||
|
||||
it('appends query params when provided', async () => {
|
||||
mockFetch.mockResolvedValueOnce(createMockResponse({ items: [], total: 0 }));
|
||||
|
||||
await adminApi.getMedia({ page: 1, type: 'image' });
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
expect.stringMatching(/page=1.*type=image|type=image.*page=1/),
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('uploadMedia', () => {
|
||||
it('sends FormData with Authorization header', async () => {
|
||||
mockStorage['novalon_admin_token'] = 'upload-token';
|
||||
mockFetch.mockResolvedValueOnce(createMockResponse({ url: 'https://cdn.example.com/file.jpg' }));
|
||||
|
||||
const file = new File(['content'], 'photo.jpg', { type: 'image/jpeg' });
|
||||
const result = await adminApi.uploadMedia(file);
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
'/api/admin/media',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
headers: { Authorization: 'Bearer upload-token' },
|
||||
}),
|
||||
);
|
||||
expect(result).toEqual({ url: 'https://cdn.example.com/file.jpg' });
|
||||
});
|
||||
|
||||
it('throws on upload failure', async () => {
|
||||
mockFetch.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ error: '文件过大' }), {
|
||||
status: 413,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
);
|
||||
|
||||
const file = new File(['content'], 'large.jpg', { type: 'image/jpeg' });
|
||||
await expect(adminApi.uploadMedia(file)).rejects.toThrow('文件过大');
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteMedia', () => {
|
||||
it('sends DELETE with media id', async () => {
|
||||
mockFetch.mockResolvedValueOnce(createMockResponse({ success: true }));
|
||||
|
||||
const result = await adminApi.deleteMedia('media-123');
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
expect.stringContaining('id=media-123'),
|
||||
expect.objectContaining({ method: 'DELETE' }),
|
||||
);
|
||||
expect(result).toEqual({ success: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('getStats', () => {
|
||||
it('aggregates stats from models, zones, and items', async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(createMockResponse(['m1', 'm2', 'm3'])) // getModels
|
||||
.mockResolvedValueOnce(createMockResponse(['z1'])) // getZones
|
||||
.mockResolvedValueOnce(createMockResponse({ items: [], total: 42, page: 1, pageSize: 1, totalPages: 42 })); // getItems
|
||||
|
||||
const stats = await adminApi.getStats();
|
||||
|
||||
expect(stats).toEqual({ models: 3, items: 42, zones: 1 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('getRoles', () => {
|
||||
it('calls request with correct path', async () => {
|
||||
mockFetch.mockResolvedValueOnce(
|
||||
createMockResponse({
|
||||
roles: [{ code: 'admin', name: '管理员', builtin: true }],
|
||||
permissions: [],
|
||||
models: [],
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await adminApi.getRoles();
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
'/api/admin/roles',
|
||||
expect.any(Object),
|
||||
);
|
||||
expect(result.roles).toHaveLength(1);
|
||||
expect(result.roles[0]).toEqual({ code: 'admin', name: '管理员', builtin: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateRolePermissions', () => {
|
||||
it('sends PUT with role code and permissions', async () => {
|
||||
mockFetch.mockResolvedValueOnce(
|
||||
createMockResponse({ roleCode: 'editor', permissions: ['model:read'] }),
|
||||
);
|
||||
|
||||
const result = await adminApi.updateRolePermissions('editor', [
|
||||
{ modelCode: 'model', action: 'read' },
|
||||
]);
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
'/api/admin/roles',
|
||||
expect.objectContaining({
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
roleCode: 'editor',
|
||||
permissions: [{ modelCode: 'model', action: 'read' }],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(result.roleCode).toBe('editor');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,697 @@
|
||||
/**
|
||||
* api-crypto.test.ts — API 路由加解密中间件单元测试
|
||||
*
|
||||
* 测试策略:
|
||||
* - 完全 mock crypto-server(encrypt / decrypt / isEncryptionAvailable)
|
||||
* - 覆盖 next/server 的 next/server mock,提供完整的方法模拟
|
||||
* - 覆盖所有 3 个导出函数:withCrypto / decryptRequest / encryptResponseData
|
||||
*/
|
||||
// @ts-nocheck
|
||||
|
||||
|
||||
import { describe, it, expect, jest, beforeEach } from '@jest/globals';
|
||||
|
||||
// ========== 全局 Mock 设置 ==========
|
||||
|
||||
// 覆盖 jest.setup.js 中的全局 Headers,补充 delete 方法
|
||||
// 使用直接属性存储,使 Object.entries() 能正确枚举 header 键值对
|
||||
global.Headers = class {
|
||||
[key: string]: unknown;
|
||||
|
||||
constructor(
|
||||
init?: Record<string, string> | globalThis.Headers | null,
|
||||
) {
|
||||
if (init) {
|
||||
for (const [k, v] of Object.entries(init)) {
|
||||
if (typeof v === 'string') {
|
||||
this[k.toLowerCase()] = v;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
get(name: string): string | undefined {
|
||||
return this[name.toLowerCase()] as string | undefined;
|
||||
}
|
||||
|
||||
set(name: string, value: string): void {
|
||||
this[name.toLowerCase()] = value;
|
||||
}
|
||||
|
||||
delete(name: string): void {
|
||||
delete this[name.toLowerCase()];
|
||||
}
|
||||
} as unknown as typeof globalThis.Headers;
|
||||
|
||||
// 同样覆盖 global.Response,使其 headers 使用新的 Headers 实现
|
||||
global.Response = class {
|
||||
public body: string | null;
|
||||
public status: number;
|
||||
public statusText: string;
|
||||
public headers: globalThis.Headers;
|
||||
public ok: boolean;
|
||||
|
||||
constructor(body?: BodyInit | null, init?: ResponseInit) {
|
||||
this.body = body?.toString() ?? null;
|
||||
this.status = init?.status ?? 200;
|
||||
this.statusText = init?.statusText ?? 'OK';
|
||||
this.ok = this.status >= 200 && this.status < 300;
|
||||
this.headers = new globalThis.Headers(
|
||||
init?.headers as Record<string, string> | undefined,
|
||||
);
|
||||
}
|
||||
|
||||
async json(): Promise<unknown> {
|
||||
return JSON.parse(this.body ?? 'null');
|
||||
}
|
||||
|
||||
async text(): Promise<string> {
|
||||
return this.body ?? '';
|
||||
}
|
||||
|
||||
clone(): globalThis.Response {
|
||||
return new globalThis.Response(this.body, {
|
||||
status: this.status,
|
||||
statusText: this.statusText,
|
||||
headers: this.headers as unknown as Record<string, string>,
|
||||
}) as unknown as globalThis.Response;
|
||||
}
|
||||
} as unknown as typeof globalThis.Response;
|
||||
|
||||
// ─── crypto-server mock ────────────────────────────────────────────────
|
||||
|
||||
const mockEncrypt = jest.fn<(plaintext: string) => string>();
|
||||
const mockDecrypt = jest.fn<(encryptedBase64: string) => string>();
|
||||
const mockIsEncryptionAvailable = jest.fn<() => boolean>();
|
||||
|
||||
jest.mock('@/lib/crypto-server', () => ({
|
||||
encrypt: (...args: unknown[]) => mockEncrypt(...(args as [string])),
|
||||
decrypt: (...args: unknown[]) => mockDecrypt(...(args as [string])),
|
||||
isEncryptionAvailable: (...args: unknown[]) =>
|
||||
mockIsEncryptionAvailable(...(args as [])),
|
||||
}));
|
||||
|
||||
// ─── next/server mock(覆盖 jest.setup.js 的简化版,提供完整方法) ────
|
||||
|
||||
jest.mock('next/server', () => {
|
||||
class MockHeaders {
|
||||
[key: string]: unknown;
|
||||
|
||||
constructor(
|
||||
init?: Record<string, string> | MockHeaders | globalThis.Headers | null,
|
||||
) {
|
||||
if (init) {
|
||||
for (const [k, v] of Object.entries(init)) {
|
||||
if (typeof v === 'string') {
|
||||
this[k.toLowerCase()] = v;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
get(name: string): string | undefined {
|
||||
return this[name.toLowerCase()] as string | undefined;
|
||||
}
|
||||
|
||||
set(name: string, value: string): void {
|
||||
this[name.toLowerCase()] = value;
|
||||
}
|
||||
|
||||
delete(name: string): void {
|
||||
delete this[name.toLowerCase()];
|
||||
}
|
||||
}
|
||||
|
||||
class MockNextRequest {
|
||||
public readonly url: string;
|
||||
public readonly method: string;
|
||||
public readonly headers: any;
|
||||
public readonly body: string | null;
|
||||
|
||||
constructor(
|
||||
input: string | URL | globalThis.Request,
|
||||
init?: RequestInit,
|
||||
) {
|
||||
this.url = typeof input === 'string' ? input : input.toString();
|
||||
this.method = init?.method?.toUpperCase() ?? 'GET';
|
||||
this.body = (init?.body as string | undefined) ?? null;
|
||||
this.headers = new MockHeaders(
|
||||
init?.headers as Record<string, string> | undefined,
|
||||
);
|
||||
}
|
||||
|
||||
clone(): MockNextRequest {
|
||||
return new MockNextRequest(this.url, {
|
||||
method: this.method,
|
||||
headers: this.headers,
|
||||
body: this.body ?? undefined,
|
||||
});
|
||||
}
|
||||
|
||||
async json(): Promise<unknown> {
|
||||
if (!this.body) throw new Error('Request has no body');
|
||||
return JSON.parse(this.body);
|
||||
}
|
||||
|
||||
async text(): Promise<string> {
|
||||
return this.body ?? '';
|
||||
}
|
||||
}
|
||||
|
||||
class MockNextResponse {
|
||||
public readonly body: string | null;
|
||||
public readonly status: number;
|
||||
public readonly statusText: string;
|
||||
public readonly headers: any;
|
||||
|
||||
constructor(body?: BodyInit | null, init?: ResponseInit) {
|
||||
this.body = body?.toString() ?? null;
|
||||
this.status = init?.status ?? 200;
|
||||
this.statusText = init?.statusText ?? 'OK';
|
||||
this.headers = new MockHeaders(
|
||||
init?.headers as Record<string, string> | undefined,
|
||||
);
|
||||
}
|
||||
|
||||
static json(
|
||||
body: unknown,
|
||||
init?: ResponseInit,
|
||||
): MockNextResponse {
|
||||
return new MockNextResponse(JSON.stringify(body), {
|
||||
...init,
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
...(init?.headers as Record<string, string> | undefined),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
clone(): MockNextResponse {
|
||||
return new MockNextResponse(this.body ?? undefined, {
|
||||
status: this.status,
|
||||
statusText: this.statusText,
|
||||
headers: this.headers,
|
||||
});
|
||||
}
|
||||
|
||||
async json(): Promise<unknown> {
|
||||
if (!this.body) throw new Error('Response has no body');
|
||||
return JSON.parse(this.body);
|
||||
}
|
||||
|
||||
async text(): Promise<string> {
|
||||
return this.body ?? '';
|
||||
}
|
||||
}
|
||||
|
||||
return { NextRequest: MockNextRequest, NextResponse: MockNextResponse };
|
||||
});
|
||||
|
||||
// ─── 导入被测试模块 ──────────────────────────────────────────────────
|
||||
|
||||
import { withCrypto, decryptRequest, encryptResponseData } from './api-crypto';
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
// ─── 测试辅助函数 ────────────────────────────────────────────────────
|
||||
|
||||
/** 创建测试用的 NextRequest */
|
||||
function createRequest(
|
||||
url: string,
|
||||
options?: {
|
||||
method?: string;
|
||||
headers?: Record<string, string>;
|
||||
body?: string;
|
||||
},
|
||||
): NextRequest {
|
||||
return new NextRequest(url, {
|
||||
method: options?.method ?? 'GET',
|
||||
headers: options?.headers,
|
||||
body: options?.body,
|
||||
}) as unknown as NextRequest;
|
||||
}
|
||||
|
||||
/** 测试 handler 类型 */
|
||||
type HandlerFn = (
|
||||
req: NextRequest,
|
||||
ctx: Record<string, unknown>,
|
||||
) => Promise<NextResponse>;
|
||||
|
||||
// ========== 测试套件 ==========
|
||||
|
||||
describe('withCrypto', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockIsEncryptionAvailable.mockReturnValue(true);
|
||||
mockEncrypt.mockImplementation((text: string) => `enc:${text}`);
|
||||
mockDecrypt.mockImplementation(
|
||||
(text: string) => text.replace(/^enc:/, ''),
|
||||
);
|
||||
});
|
||||
|
||||
// ── 未加密请求 ──────────────────────────────────────────────
|
||||
|
||||
it('未携带 X-Encrypted 头时直接透传,不调用加解密', async () => {
|
||||
const handler = jest.fn<HandlerFn>().mockImplementation(
|
||||
async () => NextResponse.json({ success: true }) as unknown as NextResponse,
|
||||
);
|
||||
const wrapped = withCrypto(handler);
|
||||
|
||||
const req = createRequest('http://localhost/api/test');
|
||||
const res = await wrapped(req);
|
||||
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
expect(mockEncrypt).not.toHaveBeenCalled();
|
||||
expect(mockDecrypt).not.toHaveBeenCalled();
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it('X-Encrypted 为 false 时直接透传', async () => {
|
||||
const handler = jest.fn<HandlerFn>().mockImplementation(
|
||||
async () => NextResponse.json({ success: true }) as unknown as NextResponse,
|
||||
);
|
||||
const wrapped = withCrypto(handler);
|
||||
|
||||
const req = createRequest('http://localhost/api/test', {
|
||||
headers: { 'x-encrypted': 'false' },
|
||||
});
|
||||
const res = await wrapped(req);
|
||||
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
expect(mockEncrypt).not.toHaveBeenCalled();
|
||||
expect(mockDecrypt).not.toHaveBeenCalled();
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
// ── 加密不可用 ──────────────────────────────────────────────
|
||||
|
||||
it('加密不可用时(isEncryptionAvailable=false)直接透传', async () => {
|
||||
mockIsEncryptionAvailable.mockReturnValue(false);
|
||||
const handler = jest.fn<HandlerFn>().mockImplementation(
|
||||
async () => NextResponse.json({ success: true }) as unknown as NextResponse,
|
||||
);
|
||||
const wrapped = withCrypto(handler);
|
||||
|
||||
const req = createRequest('http://localhost/api/test', {
|
||||
headers: { 'x-encrypted': 'true' },
|
||||
});
|
||||
const res = await wrapped(req);
|
||||
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
expect(mockEncrypt).not.toHaveBeenCalled();
|
||||
expect(mockDecrypt).not.toHaveBeenCalled();
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
// ── 加密请求(无 body) ─────────────────────────────────────
|
||||
|
||||
it('加密请求(GET / 无 body)时加密响应', async () => {
|
||||
const handler = jest.fn<HandlerFn>().mockImplementation(
|
||||
async () => NextResponse.json({ secret: 'data' }) as unknown as NextResponse,
|
||||
);
|
||||
const wrapped = withCrypto(handler);
|
||||
|
||||
const req = createRequest('http://localhost/api/test', {
|
||||
method: 'GET',
|
||||
headers: { 'x-encrypted': 'true' },
|
||||
});
|
||||
const res = await wrapped(req);
|
||||
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
// handler 接收到的 context 中 isEncrypted 为 true
|
||||
const ctx = handler.mock.calls[0]?.[1] as Record<string, unknown>;
|
||||
expect(ctx.isEncrypted).toBe(true);
|
||||
// 响应被加密
|
||||
expect(mockEncrypt).toHaveBeenCalledWith(JSON.stringify({ secret: 'data' }));
|
||||
const body = await (res as any).json();
|
||||
expect(body).toEqual({ data: 'enc:{"secret":"data"}' });
|
||||
expect((res as any).headers.get('x-encrypted')).toBe('true');
|
||||
});
|
||||
|
||||
// ── 加密请求(有 body) ─────────────────────────────────────
|
||||
|
||||
it('加密请求(POST / 有 body)时解密请求体、加密响应体', async () => {
|
||||
const handler = jest
|
||||
.fn<HandlerFn>()
|
||||
.mockImplementation(async (req: NextRequest) => {
|
||||
const body = await (req as any).json();
|
||||
return NextResponse.json({ received: body }) as unknown as NextResponse;
|
||||
});
|
||||
const wrapped = withCrypto(handler);
|
||||
|
||||
const req = createRequest('http://localhost/api/test', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'x-encrypted': 'true',
|
||||
'content-type': 'application/json',
|
||||
'content-length': '100',
|
||||
},
|
||||
body: JSON.stringify({ data: 'enc:{"name":"test"}' }),
|
||||
});
|
||||
const res = await wrapped(req);
|
||||
|
||||
// 请求体被解密
|
||||
expect(mockDecrypt).toHaveBeenCalledWith('enc:{"name":"test"}');
|
||||
// handler 收到解密后的 body
|
||||
const handlerReq = handler.mock.calls[0]?.[0] as any;
|
||||
const handlerBody = await handlerReq.json();
|
||||
expect(handlerBody).toEqual({ name: 'test' });
|
||||
// 响应被加密
|
||||
expect(mockEncrypt).toHaveBeenCalledWith(
|
||||
JSON.stringify({ received: { name: 'test' } }),
|
||||
);
|
||||
const body = await (res as any).json();
|
||||
expect(body).toEqual({
|
||||
data: 'enc:{"received":{"name":"test"}}',
|
||||
});
|
||||
expect((res as any).headers.get('x-encrypted')).toBe('true');
|
||||
});
|
||||
|
||||
it('加密请求(有 body)传递 routeContext 给 handler', async () => {
|
||||
const handler = jest
|
||||
.fn<HandlerFn>()
|
||||
.mockImplementation(
|
||||
async (_req: NextRequest, ctx: Record<string, unknown>) =>
|
||||
NextResponse.json({ params: ctx.params }) as unknown as NextResponse,
|
||||
);
|
||||
const wrapped = withCrypto(handler);
|
||||
|
||||
const req = createRequest('http://localhost/api/test', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'x-encrypted': 'true',
|
||||
'content-type': 'application/json',
|
||||
'content-length': '100',
|
||||
},
|
||||
body: JSON.stringify({ data: 'enc:{"x":1}' }),
|
||||
});
|
||||
const res = await wrapped(req, { params: { id: '123' } } as any);
|
||||
|
||||
// handler 收到 routeContext 和 isEncrypted
|
||||
const ctx = handler.mock.calls[0]?.[1] as Record<string, unknown>;
|
||||
expect(ctx.params).toEqual({ id: '123' });
|
||||
expect(ctx.isEncrypted).toBe(true);
|
||||
// 响应被加密,验证加密后的 body 包含原始响应数据
|
||||
const body = await (res as any).json();
|
||||
expect(body).toEqual({ data: 'enc:{"params":{"id":"123"}}' });
|
||||
});
|
||||
|
||||
// ── 加密请求(body 解密失败) ───────────────────────────────
|
||||
|
||||
it('加密请求 body 解密失败时返回 400 错误', async () => {
|
||||
mockDecrypt.mockImplementation(() => {
|
||||
throw new Error('解密失败: invalid ciphertext');
|
||||
});
|
||||
const handler = jest.fn<HandlerFn>();
|
||||
const wrapped = withCrypto(handler);
|
||||
|
||||
const req = createRequest('http://localhost/api/test', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'x-encrypted': 'true',
|
||||
'content-type': 'application/json',
|
||||
'content-length': '50',
|
||||
},
|
||||
body: JSON.stringify({ data: 'invalid-encrypted-data' }),
|
||||
});
|
||||
const res = await wrapped(req);
|
||||
|
||||
// handler 不应被调用
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
// 返回 400 错误
|
||||
expect(res.status).toBe(400);
|
||||
const body = await (res as any).json();
|
||||
expect(body.error).toBe('请求体解密失败');
|
||||
expect(body.message).toBe('解密失败: invalid ciphertext');
|
||||
});
|
||||
|
||||
it('加密请求 body 解密失败时记录 console.error', async () => {
|
||||
mockDecrypt.mockImplementation(() => {
|
||||
throw new Error('decrypt error');
|
||||
});
|
||||
const handler = jest.fn<HandlerFn>();
|
||||
const wrapped = withCrypto(handler);
|
||||
|
||||
const req = createRequest('http://localhost/api/test', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'x-encrypted': 'true',
|
||||
'content-type': 'application/json',
|
||||
'content-length': '50',
|
||||
},
|
||||
body: JSON.stringify({ data: 'bad' }),
|
||||
});
|
||||
await wrapped(req);
|
||||
|
||||
expect(console.error).toHaveBeenCalledWith(
|
||||
'[Crypto] Request body decrypt failed:',
|
||||
expect.any(Error),
|
||||
);
|
||||
});
|
||||
|
||||
it('加密请求 body 中无 data 字段时 handler 收到原始 JSON', async () => {
|
||||
const handler = jest
|
||||
.fn<HandlerFn>()
|
||||
.mockImplementation(async (req: NextRequest) => {
|
||||
const body = await (req as any).json();
|
||||
return NextResponse.json({ body }) as unknown as NextResponse;
|
||||
});
|
||||
const wrapped = withCrypto(handler);
|
||||
|
||||
const req = createRequest('http://localhost/api/test', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'x-encrypted': 'true',
|
||||
'content-type': 'application/json',
|
||||
'content-length': '30',
|
||||
},
|
||||
// body 有 JSON 但没有 data 字段
|
||||
body: JSON.stringify({ other: 'value' }),
|
||||
});
|
||||
const res = await wrapped(req);
|
||||
|
||||
// handler 仍然被调用,但解密未执行
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
expect(mockDecrypt).not.toHaveBeenCalled();
|
||||
// 响应被加密
|
||||
const body = await (res as any).json();
|
||||
expect(body).toEqual({
|
||||
data: 'enc:{"body":{"other":"value"}}',
|
||||
});
|
||||
expect((res as any).headers.get('x-encrypted')).toBe('true');
|
||||
});
|
||||
|
||||
// ── 加密响应失败 ────────────────────────────────────────────
|
||||
|
||||
it('加密响应失败时返回原始响应', async () => {
|
||||
mockEncrypt.mockImplementation(() => {
|
||||
throw new Error('encrypt error');
|
||||
});
|
||||
const handler = jest.fn<HandlerFn>().mockImplementation(
|
||||
async () => NextResponse.json({ secret: 'data' }) as unknown as NextResponse,
|
||||
);
|
||||
const wrapped = withCrypto(handler);
|
||||
|
||||
const req = createRequest('http://localhost/api/test', {
|
||||
headers: { 'x-encrypted': 'true' },
|
||||
});
|
||||
const res = await wrapped(req);
|
||||
|
||||
// 响应未被加密,返回原始响应
|
||||
const body = await (res as any).json();
|
||||
expect(body).toEqual({ secret: 'data' });
|
||||
// 但仍会记录错误
|
||||
expect(console.error).toHaveBeenCalledWith(
|
||||
'[Crypto] Response encrypt failed:',
|
||||
expect.any(Error),
|
||||
);
|
||||
});
|
||||
|
||||
// ── 非 JSON 响应不被加密 ────────────────────────────────────
|
||||
|
||||
it('非 JSON 响应不加密,直接透传', async () => {
|
||||
const handler = jest
|
||||
.fn<HandlerFn>()
|
||||
.mockImplementation(async () => {
|
||||
return new NextResponse('plain text', {
|
||||
headers: { 'content-type': 'text/plain' },
|
||||
}) as unknown as NextResponse;
|
||||
});
|
||||
const wrapped = withCrypto(handler);
|
||||
|
||||
const req = createRequest('http://localhost/api/test', {
|
||||
headers: { 'x-encrypted': 'true' },
|
||||
});
|
||||
const res = await wrapped(req);
|
||||
|
||||
expect(mockEncrypt).not.toHaveBeenCalled();
|
||||
expect((res as any).headers.get('x-encrypted')).toBeUndefined();
|
||||
const text = await (res as any).text();
|
||||
expect(text).toBe('plain text');
|
||||
});
|
||||
|
||||
// ── 空响应体不加密 ──────────────────────────────────────────
|
||||
|
||||
it('空响应体不加密,直接透传', async () => {
|
||||
const handler = jest
|
||||
.fn<HandlerFn>()
|
||||
.mockImplementation(async () => {
|
||||
return new NextResponse(null, {
|
||||
headers: { 'content-type': 'application/json' },
|
||||
}) as unknown as NextResponse;
|
||||
});
|
||||
const wrapped = withCrypto(handler);
|
||||
|
||||
const req = createRequest('http://localhost/api/test', {
|
||||
headers: { 'x-encrypted': 'true' },
|
||||
});
|
||||
const res = await wrapped(req);
|
||||
|
||||
expect(mockEncrypt).not.toHaveBeenCalled();
|
||||
const text = await (res as any).text();
|
||||
expect(text).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('decryptRequest', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockIsEncryptionAvailable.mockReturnValue(true);
|
||||
mockDecrypt.mockImplementation(
|
||||
(text: string) => text.replace(/^enc:/, ''),
|
||||
);
|
||||
});
|
||||
|
||||
it('加密不可用时返回 null', async () => {
|
||||
mockIsEncryptionAvailable.mockReturnValue(false);
|
||||
const req = createRequest('http://localhost/api/test', {
|
||||
headers: { 'x-encrypted': 'true' },
|
||||
body: JSON.stringify({ data: 'enc:{"x":1}' }),
|
||||
});
|
||||
const result = await decryptRequest(req);
|
||||
expect(result).toBeNull();
|
||||
expect(mockDecrypt).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('未携带 X-Encrypted 头时返回 null', async () => {
|
||||
const req = createRequest('http://localhost/api/test', {
|
||||
body: JSON.stringify({ data: 'enc:{"x":1}' }),
|
||||
});
|
||||
const result = await decryptRequest(req);
|
||||
expect(result).toBeNull();
|
||||
expect(mockDecrypt).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('X-Encrypted 为 false 时返回 null', async () => {
|
||||
const req = createRequest('http://localhost/api/test', {
|
||||
headers: { 'x-encrypted': 'false' },
|
||||
body: JSON.stringify({ data: 'enc:{"x":1}' }),
|
||||
});
|
||||
const result = await decryptRequest(req);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('成功解密并返回解析后的数据', async () => {
|
||||
const req = createRequest('http://localhost/api/test', {
|
||||
headers: { 'x-encrypted': 'true' },
|
||||
body: JSON.stringify({ data: 'enc:{"name":"test","value":42}' }),
|
||||
});
|
||||
const result = await decryptRequest<{ name: string; value: number }>(req);
|
||||
expect(result).toEqual({ name: 'test', value: 42 });
|
||||
expect(mockDecrypt).toHaveBeenCalledWith('enc:{"name":"test","value":42}');
|
||||
});
|
||||
|
||||
it('body 中无 data 字段时返回 null', async () => {
|
||||
const req = createRequest('http://localhost/api/test', {
|
||||
headers: { 'x-encrypted': 'true' },
|
||||
body: JSON.stringify({ other: 'value' }),
|
||||
});
|
||||
const result = await decryptRequest(req);
|
||||
expect(result).toBeNull();
|
||||
expect(mockDecrypt).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('解密失败时返回 null 并记录错误', async () => {
|
||||
mockDecrypt.mockImplementation(() => {
|
||||
throw new Error('decrypt failed');
|
||||
});
|
||||
const req = createRequest('http://localhost/api/test', {
|
||||
headers: { 'x-encrypted': 'true' },
|
||||
body: JSON.stringify({ data: 'enc:bad' }),
|
||||
});
|
||||
const result = await decryptRequest(req);
|
||||
expect(result).toBeNull();
|
||||
expect(console.error).toHaveBeenCalledWith(
|
||||
'[Crypto] decryptRequest failed:',
|
||||
expect.any(Error),
|
||||
);
|
||||
});
|
||||
|
||||
it('body JSON 解析失败时返回 null', async () => {
|
||||
const req = createRequest('http://localhost/api/test', {
|
||||
headers: { 'x-encrypted': 'true' },
|
||||
body: 'not-json',
|
||||
});
|
||||
const result = await decryptRequest(req);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('空 body 时返回 null', async () => {
|
||||
const req = createRequest('http://localhost/api/test', {
|
||||
headers: { 'x-encrypted': 'true' },
|
||||
});
|
||||
const result = await decryptRequest(req);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('encryptResponseData', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockEncrypt.mockImplementation((text: string) => `enc:${text}`);
|
||||
});
|
||||
|
||||
it('加密数据并返回带 X-Encrypted 头的 JSON 响应', () => {
|
||||
const data = { id: 1, name: 'secret' };
|
||||
const res = encryptResponseData(data);
|
||||
|
||||
expect(mockEncrypt).toHaveBeenCalledWith(JSON.stringify(data));
|
||||
expect((res as any).headers.get('x-encrypted')).toBe('true');
|
||||
expect((res as any).headers.get('content-type')).toBe('application/json');
|
||||
});
|
||||
|
||||
it('响应体格式为 { data: <加密字符串> }', async () => {
|
||||
const data = { key: 'value' };
|
||||
const res = encryptResponseData(data);
|
||||
|
||||
const body = await (res as any).json();
|
||||
expect(body).toEqual({ data: 'enc:{"key":"value"}' });
|
||||
});
|
||||
|
||||
it('返回 200 状态码', () => {
|
||||
const res = encryptResponseData({});
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it('处理数组数据', async () => {
|
||||
const arr = [1, 2, 3];
|
||||
const res = encryptResponseData(arr);
|
||||
|
||||
expect(mockEncrypt).toHaveBeenCalledWith(JSON.stringify(arr));
|
||||
const body = await (res as any).json();
|
||||
expect(body).toEqual({ data: 'enc:[1,2,3]' });
|
||||
});
|
||||
|
||||
it('处理字符串数据', async () => {
|
||||
const res = encryptResponseData('hello');
|
||||
expect(mockEncrypt).toHaveBeenCalledWith('"hello"');
|
||||
const body = await (res as any).json();
|
||||
expect(body).toEqual({ data: 'enc:"hello"' });
|
||||
});
|
||||
|
||||
it('处理 null 值', async () => {
|
||||
const res = encryptResponseData(null);
|
||||
expect(mockEncrypt).toHaveBeenCalledWith('null');
|
||||
const body = await (res as any).json();
|
||||
expect(body).toEqual({ data: 'enc:null' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,182 @@
|
||||
// @ts-nocheck
|
||||
import { describe, it, expect, jest, beforeEach } from '@jest/globals';
|
||||
import {
|
||||
unauthorized,
|
||||
forbidden,
|
||||
notFound,
|
||||
validationError,
|
||||
badRequest,
|
||||
internalError,
|
||||
success,
|
||||
handleApiError,
|
||||
} from './api-response';
|
||||
|
||||
describe('unauthorized', () => {
|
||||
it('返回默认错误消息和 401 状态码', async () => {
|
||||
const res = unauthorized();
|
||||
expect(res.status).toBe(401);
|
||||
const body = await res.json();
|
||||
expect(body).toEqual({ error: '未授权,请先登录', code: 'UNAUTHORIZED' });
|
||||
});
|
||||
|
||||
it('返回自定义错误消息和 401 状态码', async () => {
|
||||
const res = unauthorized('自定义未授权消息');
|
||||
expect(res.status).toBe(401);
|
||||
const body = await res.json();
|
||||
expect(body).toEqual({ error: '自定义未授权消息', code: 'UNAUTHORIZED' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('forbidden', () => {
|
||||
it('返回默认错误消息和 403 状态码', async () => {
|
||||
const res = forbidden();
|
||||
expect(res.status).toBe(403);
|
||||
const body = await res.json();
|
||||
expect(body).toEqual({ error: '无权限执行此操作', code: 'FORBIDDEN' });
|
||||
});
|
||||
|
||||
it('返回自定义错误消息和 403 状态码', async () => {
|
||||
const res = forbidden('自定义无权限消息');
|
||||
expect(res.status).toBe(403);
|
||||
const body = await res.json();
|
||||
expect(body).toEqual({ error: '自定义无权限消息', code: 'FORBIDDEN' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('notFound', () => {
|
||||
it('返回默认错误消息和 404 状态码', async () => {
|
||||
const res = notFound();
|
||||
expect(res.status).toBe(404);
|
||||
const body = await res.json();
|
||||
expect(body).toEqual({ error: '请求的资源不存在', code: 'NOT_FOUND' });
|
||||
});
|
||||
|
||||
it('返回自定义错误消息和 404 状态码', async () => {
|
||||
const res = notFound('自定义不存在消息');
|
||||
expect(res.status).toBe(404);
|
||||
const body = await res.json();
|
||||
expect(body).toEqual({ error: '自定义不存在消息', code: 'NOT_FOUND' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('validationError', () => {
|
||||
it('返回错误消息和 400 状态码', async () => {
|
||||
const res = validationError('数据验证失败');
|
||||
expect(res.status).toBe(400);
|
||||
const body = await res.json();
|
||||
expect(body).toEqual({ error: '数据验证失败', code: 'VALIDATION_ERROR' });
|
||||
});
|
||||
|
||||
it('返回包含 details 的错误响应', async () => {
|
||||
const details = { field: 'email', reason: '格式不正确' };
|
||||
const res = validationError('数据验证失败', details);
|
||||
expect(res.status).toBe(400);
|
||||
const body = await res.json();
|
||||
expect(body).toEqual({
|
||||
error: '数据验证失败',
|
||||
code: 'VALIDATION_ERROR',
|
||||
details,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('badRequest', () => {
|
||||
it('返回错误消息和 400 状态码', async () => {
|
||||
const res = badRequest('请求参数错误');
|
||||
expect(res.status).toBe(400);
|
||||
const body = await res.json();
|
||||
expect(body).toEqual({ error: '请求参数错误', code: 'BAD_REQUEST' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('internalError', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('返回默认错误消息和 500 状态码', async () => {
|
||||
const res = internalError();
|
||||
expect(res.status).toBe(500);
|
||||
const body = await res.json();
|
||||
expect(body).toEqual({ error: '服务器内部错误', code: 'INTERNAL_ERROR' });
|
||||
});
|
||||
|
||||
it('使用自定义消息调用 console.error', () => {
|
||||
internalError('自定义服务器错误');
|
||||
expect(console.error).toHaveBeenCalledWith('自定义服务器错误');
|
||||
});
|
||||
|
||||
it('当未传入消息时使用默认参数调用 console.error', () => {
|
||||
internalError();
|
||||
expect(console.error).toHaveBeenCalledWith('服务器错误');
|
||||
});
|
||||
});
|
||||
|
||||
describe('success', () => {
|
||||
it('返回数据和默认 200 状态码', async () => {
|
||||
const data = { id: 1, name: 'test' };
|
||||
const res = success(data);
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body).toEqual(data);
|
||||
});
|
||||
|
||||
it('返回数据和自定义状态码', async () => {
|
||||
const data = { message: 'created' };
|
||||
const res = success(data, 201);
|
||||
expect(res.status).toBe(201);
|
||||
const body = await res.json();
|
||||
expect(body).toEqual(data);
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleApiError', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('包含 "未授权" 的 Error 返回 unauthorized 响应', async () => {
|
||||
const error = new Error('未授权,请先登录');
|
||||
const res = handleApiError(error);
|
||||
expect(res.status).toBe(401);
|
||||
const body = await res.json();
|
||||
expect(body).toEqual({ error: '未授权,请先登录', code: 'UNAUTHORIZED' });
|
||||
});
|
||||
|
||||
it('包含 "无权限" 的 Error 返回 forbidden 响应', async () => {
|
||||
const error = new Error('无权限执行此操作');
|
||||
const res = handleApiError(error);
|
||||
expect(res.status).toBe(403);
|
||||
const body = await res.json();
|
||||
expect(body).toEqual({ error: '无权限执行此操作', code: 'FORBIDDEN' });
|
||||
});
|
||||
|
||||
it('包含 "不存在" 的 Error 返回 notFound 响应', async () => {
|
||||
const error = new Error('资源不存在');
|
||||
const res = handleApiError(error);
|
||||
expect(res.status).toBe(404);
|
||||
const body = await res.json();
|
||||
expect(body).toEqual({ error: '资源不存在', code: 'NOT_FOUND' });
|
||||
});
|
||||
|
||||
it('普通的 Error 返回 internalError 响应', async () => {
|
||||
const error = new Error('未知错误');
|
||||
const res = handleApiError(error);
|
||||
expect(res.status).toBe(500);
|
||||
const body = await res.json();
|
||||
expect(body).toEqual({ error: '服务器内部错误', code: 'INTERNAL_ERROR' });
|
||||
});
|
||||
|
||||
it('非 Error 类型返回 internalError 响应', async () => {
|
||||
const res = handleApiError('字符串错误');
|
||||
expect(res.status).toBe(500);
|
||||
const body = await res.json();
|
||||
expect(body).toEqual({ error: '服务器内部错误', code: 'INTERNAL_ERROR' });
|
||||
});
|
||||
|
||||
it('打印 API Error 日志', () => {
|
||||
const error = new Error('测试错误');
|
||||
handleApiError(error);
|
||||
expect(console.error).toHaveBeenCalledWith('API Error:', error);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,165 @@
|
||||
// @ts-nocheck
|
||||
import { describe, it, expect } from '@jest/globals';
|
||||
import { DESIGN_SYSTEM } from './design-system';
|
||||
|
||||
describe('DESIGN_SYSTEM', () => {
|
||||
describe('animation configuration', () => {
|
||||
it('has duration values', () => {
|
||||
expect(DESIGN_SYSTEM.animation.duration).toBeDefined();
|
||||
expect(DESIGN_SYSTEM.animation.duration.fast).toBe('0.2s');
|
||||
expect(DESIGN_SYSTEM.animation.duration.normal).toBe('0.4s');
|
||||
expect(DESIGN_SYSTEM.animation.duration.slow).toBe('0.6s');
|
||||
expect(DESIGN_SYSTEM.animation.duration.slower).toBe('0.8s');
|
||||
expect(DESIGN_SYSTEM.animation.duration.countUp).toBe('2s');
|
||||
});
|
||||
|
||||
it('has easing functions', () => {
|
||||
expect(DESIGN_SYSTEM.animation.easing).toBeDefined();
|
||||
expect(DESIGN_SYSTEM.animation.easing.smooth).toMatch(/^cubic-bezier\(/);
|
||||
expect(DESIGN_SYSTEM.animation.easing.bounce).toMatch(/^cubic-bezier\(/);
|
||||
expect(DESIGN_SYSTEM.animation.easing.easeOut).toMatch(/^cubic-bezier\(/);
|
||||
expect(DESIGN_SYSTEM.animation.easing.easeInOut).toMatch(/^cubic-bezier\(/);
|
||||
});
|
||||
|
||||
it('has delay values', () => {
|
||||
expect(DESIGN_SYSTEM.animation.delay).toBeDefined();
|
||||
expect(DESIGN_SYSTEM.animation.delay.stagger).toBe(0.08);
|
||||
expect(DESIGN_SYSTEM.animation.delay.section).toBe(0.15);
|
||||
});
|
||||
});
|
||||
|
||||
describe('spacing configuration', () => {
|
||||
it('has section spacing', () => {
|
||||
expect(DESIGN_SYSTEM.spacing.section).toBeDefined();
|
||||
expect(DESIGN_SYSTEM.spacing.section.py).toContain('py-20');
|
||||
expect(DESIGN_SYSTEM.spacing.section.pyCompact).toContain('py-16');
|
||||
});
|
||||
|
||||
it('has container spacing', () => {
|
||||
expect(DESIGN_SYSTEM.spacing.container).toBeDefined();
|
||||
expect(DESIGN_SYSTEM.spacing.container.default).toContain('max-w-7xl');
|
||||
expect(DESIGN_SYSTEM.spacing.container.narrow).toContain('max-w-5xl');
|
||||
expect(DESIGN_SYSTEM.spacing.container.wide).toContain('max-w-[1400px]');
|
||||
});
|
||||
|
||||
it('has grid spacing', () => {
|
||||
expect(DESIGN_SYSTEM.spacing.grid).toBeDefined();
|
||||
expect(DESIGN_SYSTEM.spacing.grid.gap).toContain('gap-6');
|
||||
expect(DESIGN_SYSTEM.spacing.grid.gapSmall).toContain('gap-4');
|
||||
});
|
||||
});
|
||||
|
||||
describe('typography configuration', () => {
|
||||
it('has hero typography', () => {
|
||||
expect(DESIGN_SYSTEM.typography.hero).toBeDefined();
|
||||
expect(DESIGN_SYSTEM.typography.hero.title).toContain('text-4xl');
|
||||
expect(DESIGN_SYSTEM.typography.hero.subtitle).toContain('text-lg');
|
||||
expect(DESIGN_SYSTEM.typography.hero.description).toContain('text-base');
|
||||
});
|
||||
|
||||
it('has section typography', () => {
|
||||
expect(DESIGN_SYSTEM.typography.section).toBeDefined();
|
||||
expect(DESIGN_SYSTEM.typography.section.title).toContain('text-3xl');
|
||||
expect(DESIGN_SYSTEM.typography.section.subtitle).toContain('text-lg');
|
||||
expect(DESIGN_SYSTEM.typography.section.body).toContain('text-base');
|
||||
});
|
||||
|
||||
it('has card typography', () => {
|
||||
expect(DESIGN_SYSTEM.typography.card).toBeDefined();
|
||||
expect(DESIGN_SYSTEM.typography.card.title).toContain('text-lg');
|
||||
expect(DESIGN_SYSTEM.typography.card.description).toContain('text-sm');
|
||||
});
|
||||
});
|
||||
|
||||
describe('effects configuration', () => {
|
||||
it('has inkGlow effect', () => {
|
||||
expect(DESIGN_SYSTEM.effects.inkGlow).toBeDefined();
|
||||
expect(DESIGN_SYSTEM.effects.inkGlow.border).toContain('conic-gradient');
|
||||
expect(DESIGN_SYSTEM.effects.inkGlow.glow).toContain('radial-gradient');
|
||||
expect(DESIGN_SYSTEM.effects.inkGlow.speed).toBe('3s');
|
||||
});
|
||||
|
||||
it('has hover effect', () => {
|
||||
expect(DESIGN_SYSTEM.effects.hover).toBeDefined();
|
||||
expect(DESIGN_SYSTEM.effects.hover.translateY).toBe('-4px');
|
||||
expect(DESIGN_SYSTEM.effects.hover.shadow).toBe('shadow-xl');
|
||||
expect(DESIGN_SYSTEM.effects.hover.transition).toContain('cubic-bezier');
|
||||
});
|
||||
|
||||
it('has scroll animation configuration', () => {
|
||||
expect(DESIGN_SYSTEM.effects.scroll).toBeDefined();
|
||||
expect(DESIGN_SYSTEM.effects.scroll.fadeInUp).toBeDefined();
|
||||
expect(DESIGN_SYSTEM.effects.scroll.fadeInUp.initial).toEqual({ opacity: 0, y: 24 });
|
||||
expect(DESIGN_SYSTEM.effects.scroll.fadeInUp.animate).toEqual({ opacity: 1, y: 0 });
|
||||
expect(DESIGN_SYSTEM.effects.scroll.fadeInUp.viewport).toEqual({ once: true, margin: '-80px' });
|
||||
expect(DESIGN_SYSTEM.effects.scroll.fadeInUp.transition.duration).toBe(0.6);
|
||||
expect(DESIGN_SYSTEM.effects.scroll.staggerChildren.delay).toBe(0.08);
|
||||
});
|
||||
});
|
||||
|
||||
describe('color references', () => {
|
||||
it('has brand colors', () => {
|
||||
expect(DESIGN_SYSTEM.colors.brand).toBeDefined();
|
||||
expect(DESIGN_SYSTEM.colors.brand.primary).toContain('var(--color-brand)');
|
||||
expect(DESIGN_SYSTEM.colors.brand.light).toContain('var(--color-brand-bg)');
|
||||
expect(DESIGN_SYSTEM.colors.brand.lighter).toContain('rgba');
|
||||
expect(DESIGN_SYSTEM.colors.brand.gradient).toContain('linear-gradient');
|
||||
});
|
||||
|
||||
it('has neutral color scale', () => {
|
||||
expect(DESIGN_SYSTEM.colors.neutral).toBeDefined();
|
||||
expect(DESIGN_SYSTEM.colors.neutral[50]).toContain('var(--color-bg-primary)');
|
||||
expect(DESIGN_SYSTEM.colors.neutral[100]).toContain('var(--color-bg-section)');
|
||||
expect(DESIGN_SYSTEM.colors.neutral[200]).toContain('var(--color-border-primary)');
|
||||
expect(DESIGN_SYSTEM.colors.neutral[700]).toContain('var(--color-text-primary)');
|
||||
expect(DESIGN_SYSTEM.colors.neutral[900]).toContain('var(--color-text-primary)');
|
||||
});
|
||||
|
||||
it('has ink colors', () => {
|
||||
expect(DESIGN_SYSTEM.colors.ink).toBeDefined();
|
||||
expect(DESIGN_SYSTEM.colors.ink.light).toContain('rgba');
|
||||
expect(DESIGN_SYSTEM.colors.ink.medium).toContain('rgba');
|
||||
expect(DESIGN_SYSTEM.colors.ink.texture).toContain('repeating-linear-gradient');
|
||||
});
|
||||
});
|
||||
|
||||
describe('component styles', () => {
|
||||
it('has card component styles', () => {
|
||||
expect(DESIGN_SYSTEM.components.card).toBeDefined();
|
||||
expect(DESIGN_SYSTEM.components.card.base).toContain('rounded-2xl');
|
||||
expect(DESIGN_SYSTEM.components.card.hover).toContain('hover:');
|
||||
expect(DESIGN_SYSTEM.components.card.padding).toContain('p-6');
|
||||
});
|
||||
|
||||
it('has badge component styles', () => {
|
||||
expect(DESIGN_SYSTEM.components.badge).toBeDefined();
|
||||
expect(DESIGN_SYSTEM.components.badge.base).toContain('rounded-full');
|
||||
expect(DESIGN_SYSTEM.components.badge.variants).toBeDefined();
|
||||
expect(DESIGN_SYSTEM.components.badge.variants.primary).toContain('var(--color-brand)');
|
||||
expect(DESIGN_SYSTEM.components.badge.variants.secondary).toContain('var(--color-text-secondary)');
|
||||
expect(DESIGN_SYSTEM.components.badge.variants.success).toContain('text-green-700');
|
||||
expect(DESIGN_SYSTEM.components.badge.variants.warning).toContain('text-yellow-700');
|
||||
});
|
||||
|
||||
it('has button component styles', () => {
|
||||
expect(DESIGN_SYSTEM.components.button).toBeDefined();
|
||||
expect(DESIGN_SYSTEM.components.button.primary).toContain('bg-[var(--color-brand)]');
|
||||
expect(DESIGN_SYSTEM.components.button.secondary).toContain('border-[var(--color-border-primary)]');
|
||||
expect(DESIGN_SYSTEM.components.button.ghost).toContain('text-[var(--color-brand)]');
|
||||
});
|
||||
|
||||
it('has metric component styles', () => {
|
||||
expect(DESIGN_SYSTEM.components.metric).toBeDefined();
|
||||
expect(DESIGN_SYSTEM.components.metric.container).toContain('rounded-xl');
|
||||
expect(DESIGN_SYSTEM.components.metric.value).toContain('text-3xl');
|
||||
expect(DESIGN_SYSTEM.components.metric.label).toContain('text-sm');
|
||||
expect(DESIGN_SYSTEM.components.metric.description).toContain('text-xs');
|
||||
});
|
||||
});
|
||||
|
||||
describe('immutability', () => {
|
||||
it('is frozen', () => {
|
||||
expect(Object.isFrozen(DESIGN_SYSTEM)).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,7 @@
|
||||
// Color tokens reference global CSS variables for dark-mode support.
|
||||
// Layout / spacing / animation / easing remain as JS constants.
|
||||
|
||||
export const DESIGN_SYSTEM = {
|
||||
export const DESIGN_SYSTEM = Object.freeze({
|
||||
animation: {
|
||||
duration: {
|
||||
fast: '0.2s',
|
||||
@@ -136,6 +136,6 @@ export const DESIGN_SYSTEM = {
|
||||
description: 'text-xs mt-1 text-[var(--color-text-hint)]',
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
export type DesignSystem = typeof DESIGN_SYSTEM;
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
// @ts-nocheck
|
||||
import { describe, it, expect, jest, beforeEach, afterEach } from '@jest/globals';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Instead of jest.mock('node:crypto', ...) — which Jest 30 does not reliably
|
||||
// apply to the source module's import — we use jest.spyOn on the real crypto
|
||||
// module. Since the crypto module is a Node.js singleton, spies placed on it
|
||||
// affect all consumers, including the module under test.
|
||||
// ---------------------------------------------------------------------------
|
||||
import crypto from 'node:crypto';
|
||||
import { encrypt, decrypt, isEncryptionAvailable } from './crypto-server';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Typed spy references (use ReturnType to avoid Jest 30 type changes)
|
||||
// ---------------------------------------------------------------------------
|
||||
let mockPbkdf2Sync: ReturnType<typeof jest.spyOn>;
|
||||
let mockRandomBytes: ReturnType<typeof jest.spyOn>;
|
||||
let mockCreateCipheriv: ReturnType<typeof jest.spyOn>;
|
||||
let mockCreateDecipheriv: ReturnType<typeof jest.spyOn>;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared mock values (fixed buffers for deterministic tests)
|
||||
// ---------------------------------------------------------------------------
|
||||
const mockKey = Buffer.alloc(32, 0x42);
|
||||
const mockIv = Buffer.alloc(12, 0x42);
|
||||
const mockAuthTag = Buffer.alloc(16, 0xab);
|
||||
const mockEncryptedData = Buffer.from('encrypted-content');
|
||||
|
||||
// Holds the plaintext captured during the mock cipher.update() call so the
|
||||
// mock decipher can "decrypt" it back (simulating a real round-trip).
|
||||
let storedPlaintext = '';
|
||||
|
||||
// Reusable mock cipher / decipher objects (implementations reset in beforeEach)
|
||||
const mockCipher = {
|
||||
update: jest.fn(),
|
||||
final: jest.fn(),
|
||||
getAuthTag: jest.fn(),
|
||||
};
|
||||
|
||||
const mockDecipher = {
|
||||
setAuthTag: jest.fn(),
|
||||
update: jest.fn(),
|
||||
final: jest.fn(),
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Setup / teardown
|
||||
// ---------------------------------------------------------------------------
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
process.env.ENCRYPTION_SECRET = 'test-secret-key';
|
||||
storedPlaintext = '';
|
||||
|
||||
// --- Spy on crypto.pbkdf2Sync ---
|
||||
mockPbkdf2Sync = jest.spyOn(crypto, 'pbkdf2Sync').mockReturnValue(mockKey);
|
||||
|
||||
// --- Spy on crypto.randomBytes ---
|
||||
mockRandomBytes = jest.spyOn(crypto, 'randomBytes').mockImplementation(() => mockIv as unknown as Buffer);
|
||||
|
||||
// --- Spy on crypto.createCipheriv → cipher ---
|
||||
mockCipher.update.mockImplementation((data: unknown) => {
|
||||
storedPlaintext = String(data);
|
||||
return mockEncryptedData;
|
||||
});
|
||||
mockCipher.final.mockReturnValue(Buffer.alloc(0));
|
||||
mockCipher.getAuthTag.mockReturnValue(mockAuthTag);
|
||||
mockCreateCipheriv = jest
|
||||
.spyOn(crypto, 'createCipheriv')
|
||||
.mockReturnValue(mockCipher as any);
|
||||
|
||||
// --- Spy on crypto.createDecipheriv → decipher ---
|
||||
mockDecipher.setAuthTag.mockReturnValue(undefined);
|
||||
mockDecipher.update.mockReturnValue(Buffer.alloc(0));
|
||||
mockDecipher.final.mockImplementation(() =>
|
||||
Buffer.from(storedPlaintext, 'utf8'),
|
||||
);
|
||||
mockCreateDecipheriv = jest
|
||||
.spyOn(crypto, 'createDecipheriv')
|
||||
.mockReturnValue(mockDecipher as any);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
delete process.env.ENCRYPTION_SECRET;
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('crypto-server', () => {
|
||||
describe('isEncryptionAvailable', () => {
|
||||
it('returns true when ENCRYPTION_SECRET is set', () => {
|
||||
process.env.ENCRYPTION_SECRET = 'some-secret';
|
||||
expect(isEncryptionAvailable()).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when ENCRYPTION_SECRET is not set', () => {
|
||||
delete process.env.ENCRYPTION_SECRET;
|
||||
expect(isEncryptionAvailable()).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when ENCRYPTION_SECRET is an empty string', () => {
|
||||
process.env.ENCRYPTION_SECRET = '';
|
||||
expect(isEncryptionAvailable()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('encrypt', () => {
|
||||
it('returns a base64-encoded string', () => {
|
||||
const result = encrypt('hello');
|
||||
expect(typeof result).toBe('string');
|
||||
// Base64 pattern: alphanumeric, +, /, and =
|
||||
expect(result).toMatch(/^[A-Za-z0-9+/=]+$/);
|
||||
});
|
||||
|
||||
it('calls crypto.pbkdf2Sync with the correct parameters', () => {
|
||||
jest.isolateModules(() => {
|
||||
const { encrypt: isolatedEncrypt } = require('./crypto-server');
|
||||
isolatedEncrypt('hello');
|
||||
expect(mockPbkdf2Sync).toHaveBeenCalledWith(
|
||||
'test-secret-key',
|
||||
'novalon-website-crypto-salt-v1',
|
||||
100_000,
|
||||
32,
|
||||
'sha256',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('calls crypto.randomBytes with IV_LENGTH (12)', () => {
|
||||
encrypt('hello');
|
||||
expect(mockRandomBytes).toHaveBeenCalledWith(12);
|
||||
});
|
||||
|
||||
it('creates a cipher with aes-256-gcm, the derived key, and the IV', () => {
|
||||
encrypt('hello');
|
||||
expect(mockCreateCipheriv).toHaveBeenCalledWith(
|
||||
'aes-256-gcm',
|
||||
mockKey,
|
||||
mockIv,
|
||||
);
|
||||
});
|
||||
|
||||
it('calls cipher.update with the plaintext in utf8', () => {
|
||||
encrypt('hello');
|
||||
expect(mockCipher.update).toHaveBeenCalledWith('hello', 'utf8');
|
||||
});
|
||||
|
||||
it('calls cipher.final and cipher.getAuthTag', () => {
|
||||
encrypt('hello');
|
||||
expect(mockCipher.final).toHaveBeenCalled();
|
||||
expect(mockCipher.getAuthTag).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws when ENCRYPTION_SECRET is not set', () => {
|
||||
jest.isolateModules(() => {
|
||||
delete process.env.ENCRYPTION_SECRET;
|
||||
const { encrypt: isolatedEncrypt } = require('./crypto-server');
|
||||
expect(() => isolatedEncrypt('test')).toThrow(
|
||||
'ENCRYPTION_SECRET 未配置,服务端加解密无法初始化',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('caches the derived key, calling pbkdf2Sync only once', () => {
|
||||
jest.isolateModules(() => {
|
||||
const { encrypt: isolatedEncrypt } = require('./crypto-server');
|
||||
|
||||
isolatedEncrypt('first call');
|
||||
expect(mockPbkdf2Sync).toHaveBeenCalledTimes(1);
|
||||
|
||||
isolatedEncrypt('second call');
|
||||
// cachedKey is reused — pbkdf2Sync should not be called again
|
||||
expect(mockPbkdf2Sync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('decrypt', () => {
|
||||
it('decrypts a base64-encoded string and returns the original plaintext', () => {
|
||||
storedPlaintext = 'original-message';
|
||||
mockDecipher.final.mockImplementation(() =>
|
||||
Buffer.from('original-message', 'utf8'),
|
||||
);
|
||||
|
||||
const result = decrypt('dGVzdA==');
|
||||
expect(result).toBe('original-message');
|
||||
});
|
||||
|
||||
it('creates a decipher with aes-256-gcm, the derived key, and the parsed IV', () => {
|
||||
decrypt('dGVzdA==');
|
||||
expect(mockCreateDecipheriv).toHaveBeenCalledWith(
|
||||
'aes-256-gcm',
|
||||
mockKey,
|
||||
expect.any(Buffer),
|
||||
);
|
||||
});
|
||||
|
||||
it('sets the auth tag on the decipher', () => {
|
||||
decrypt('dGVzdA==');
|
||||
expect(mockDecipher.setAuthTag).toHaveBeenCalledWith(
|
||||
expect.any(Buffer),
|
||||
);
|
||||
});
|
||||
|
||||
it('calls decipher.update and decipher.final', () => {
|
||||
decrypt('dGVzdA==');
|
||||
expect(mockDecipher.update).toHaveBeenCalled();
|
||||
expect(mockDecipher.final).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws when ENCRYPTION_SECRET is not set', () => {
|
||||
jest.isolateModules(() => {
|
||||
delete process.env.ENCRYPTION_SECRET;
|
||||
const { decrypt: isolatedDecrypt } = require('./crypto-server');
|
||||
expect(() => isolatedDecrypt('dGVzdA==')).toThrow(
|
||||
'ENCRYPTION_SECRET 未配置,服务端加解密无法初始化',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('throws when auth tag verification fails (decipher.final throws)', () => {
|
||||
mockDecipher.final.mockImplementation(() => {
|
||||
throw new Error('Unsupported state or unable to authenticate data');
|
||||
});
|
||||
|
||||
expect(() => decrypt('dGVzdA==')).toThrow(
|
||||
'Unsupported state or unable to authenticate data',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('encrypt/decrypt round-trip', () => {
|
||||
it('round-trips a simple ASCII string', () => {
|
||||
const original = 'Hello, Novalon!';
|
||||
expect(decrypt(encrypt(original))).toBe(original);
|
||||
});
|
||||
|
||||
it('round-trips an empty string', () => {
|
||||
const original = '';
|
||||
expect(decrypt(encrypt(original))).toBe(original);
|
||||
});
|
||||
|
||||
it('round-trips Chinese (UTF-8) characters', () => {
|
||||
const original = '你好,世界!';
|
||||
expect(decrypt(encrypt(original))).toBe(original);
|
||||
});
|
||||
|
||||
it('round-trips special characters', () => {
|
||||
const original = '!@#$%^&*()_+-=[]{}|;:\'",.<>?~`';
|
||||
expect(decrypt(encrypt(original))).toBe(original);
|
||||
});
|
||||
|
||||
it('round-trips a long string (1000 characters)', () => {
|
||||
const original = 'A'.repeat(1000);
|
||||
expect(decrypt(encrypt(original))).toBe(original);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,226 @@
|
||||
/**
|
||||
* crypto.ts — AES-256-GCM 加解密工具单元测试
|
||||
*
|
||||
* 测试策略:
|
||||
* - Web Crypto API (crypto.subtle) 在 jsdom 中不可用,完整 mock global.crypto
|
||||
* - 使用 jest.resetModules() + 动态 import 确保每个测试用例获得干净的模块实例
|
||||
* (cachedKey 是模块级变量,测试间需要隔离)
|
||||
* - 通过 mock 的 crypto.subtle 方法验证参数传递是否正确
|
||||
* - 覆盖正常路径、密钥缺失、加解密异常、key 缓存等场景
|
||||
*/
|
||||
// @ts-nocheck
|
||||
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, jest } from '@jest/globals';
|
||||
|
||||
// ─── Mock 定义 ───────────────────────────────────────────────────────────────
|
||||
|
||||
const mockImportKey = jest.fn<(...args: unknown[]) => Promise<CryptoKey>>();
|
||||
const mockDeriveKey = jest.fn<(...args: unknown[]) => Promise<CryptoKey>>();
|
||||
const mockEncrypt = jest.fn<(...args: unknown[]) => Promise<ArrayBuffer>>();
|
||||
const mockDecrypt = jest.fn<(...args: unknown[]) => Promise<ArrayBuffer>>();
|
||||
const mockGetRandomValues = jest.fn<(...args: unknown[]) => Uint8Array>();
|
||||
|
||||
const mockKeyMaterial = { type: 'secret' } as unknown as CryptoKey;
|
||||
const mockAesKey = { type: 'secret' } as unknown as CryptoKey;
|
||||
|
||||
const TEST_SECRET = 'test-secret-key-12345';
|
||||
const TEST_PLAINTEXT = 'Hello, Novalon!';
|
||||
const TEST_CIPHERTEXT = new Uint8Array([0x41, 0x42, 0x43, 0x44]); // 'ABCD'
|
||||
const TEST_PLAINTEXT_BYTES = new TextEncoder().encode(TEST_PLAINTEXT);
|
||||
|
||||
// 原始 crypto 引用,用于 afterEach 恢复
|
||||
const originalCrypto = global.crypto;
|
||||
|
||||
// ─── 测试变量 ────────────────────────────────────────────────────────────────
|
||||
|
||||
let encrypt: (plaintext: string) => Promise<string>;
|
||||
let decrypt: (encryptedBase64: string) => Promise<string>;
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
// 设置 global.crypto mock
|
||||
Object.defineProperty(global, 'crypto', {
|
||||
value: {
|
||||
subtle: {
|
||||
importKey: mockImportKey,
|
||||
deriveKey: mockDeriveKey,
|
||||
encrypt: mockEncrypt,
|
||||
decrypt: mockDecrypt,
|
||||
},
|
||||
getRandomValues: mockGetRandomValues,
|
||||
},
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
// 默认 mock 实现
|
||||
mockImportKey.mockResolvedValue(mockKeyMaterial);
|
||||
mockDeriveKey.mockResolvedValue(mockAesKey);
|
||||
mockGetRandomValues.mockImplementation((arr: unknown) => {
|
||||
const u8arr = arr as Uint8Array;
|
||||
// 固定填充 0xAB,保证输出可预测
|
||||
for (let i = 0; i < u8arr.length; i++) {
|
||||
u8arr[i] = 0xAB;
|
||||
}
|
||||
return u8arr;
|
||||
});
|
||||
mockEncrypt.mockResolvedValue(TEST_CIPHERTEXT.buffer as ArrayBuffer);
|
||||
mockDecrypt.mockResolvedValue(TEST_PLAINTEXT_BYTES.buffer as ArrayBuffer);
|
||||
|
||||
process.env.NEXT_PUBLIC_ENCRYPTION_SECRET = TEST_SECRET;
|
||||
|
||||
// 重置模块注册表 + 重新导入,确保 cachedKey 为 null
|
||||
jest.resetModules();
|
||||
const mod = await import('./crypto');
|
||||
encrypt = mod.encrypt;
|
||||
decrypt = mod.decrypt;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.NEXT_PUBLIC_ENCRYPTION_SECRET;
|
||||
global.crypto = originalCrypto;
|
||||
});
|
||||
|
||||
// ─── 辅助函数 ────────────────────────────────────────────────────────────────
|
||||
|
||||
/** 构造一个可以被 decrypt 正确解码的 base64 输入 */
|
||||
function buildEncryptedBase64(iv: Uint8Array, ciphertext: Uint8Array): string {
|
||||
const combined = new Uint8Array(iv.length + ciphertext.length);
|
||||
combined.set(iv, 0);
|
||||
combined.set(ciphertext, iv.length);
|
||||
return btoa(String.fromCodePoint(...combined));
|
||||
}
|
||||
|
||||
// ─── 测试套件 ────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('crypto', () => {
|
||||
describe('encrypt', () => {
|
||||
it('should encrypt plaintext and return base64-encoded string', async () => {
|
||||
const result = await encrypt(TEST_PLAINTEXT);
|
||||
|
||||
// 输出是 base64 字符串
|
||||
expect(typeof result).toBe('string');
|
||||
expect(result.length).toBeGreaterThan(0);
|
||||
|
||||
// 解码后验证结构:12 字节 IV + 4 字节密文
|
||||
const decoded = Uint8Array.from(atob(result), (c) => c.codePointAt(0)!);
|
||||
expect(decoded.length).toBe(12 + TEST_CIPHERTEXT.length);
|
||||
expect(decoded.slice(0, 12)).toEqual(new Uint8Array(12).fill(0xAB));
|
||||
expect(decoded.slice(12)).toEqual(TEST_CIPHERTEXT);
|
||||
|
||||
// crypto.subtle.importKey 被正确调用
|
||||
expect(mockImportKey).toHaveBeenCalledWith(
|
||||
'raw',
|
||||
expect.any(Object),
|
||||
'PBKDF2',
|
||||
false,
|
||||
['deriveBits', 'deriveKey'],
|
||||
);
|
||||
|
||||
// crypto.subtle.deriveKey 被正确调用
|
||||
expect(mockDeriveKey).toHaveBeenCalledWith(
|
||||
{ name: 'PBKDF2', salt: expect.any(Object), iterations: 100_000, hash: 'SHA-256' },
|
||||
mockKeyMaterial,
|
||||
{ name: 'AES-GCM', length: 256 },
|
||||
false,
|
||||
['encrypt', 'decrypt'],
|
||||
);
|
||||
|
||||
// crypto.subtle.encrypt 被正确调用
|
||||
expect(mockEncrypt).toHaveBeenCalledWith(
|
||||
{ name: 'AES-GCM', iv: new Uint8Array(12).fill(0xAB), tagLength: 128 },
|
||||
mockAesKey,
|
||||
expect.any(Object),
|
||||
);
|
||||
|
||||
// crypto.getRandomValues 被调用
|
||||
expect(mockGetRandomValues).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should encrypt empty string', async () => {
|
||||
const result = await encrypt('');
|
||||
|
||||
expect(typeof result).toBe('string');
|
||||
expect(result.length).toBeGreaterThan(0);
|
||||
|
||||
// 即使明文为空,加密仍被调用
|
||||
expect(mockEncrypt).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should propagate crypto.subtle.encrypt errors', async () => {
|
||||
mockEncrypt.mockRejectedValue(new Error('Encryption failed'));
|
||||
|
||||
await expect(encrypt(TEST_PLAINTEXT)).rejects.toThrow('Encryption failed');
|
||||
});
|
||||
|
||||
it('should propagate crypto.subtle.importKey errors', async () => {
|
||||
mockImportKey.mockRejectedValue(new Error('Import key failed'));
|
||||
|
||||
await expect(encrypt(TEST_PLAINTEXT)).rejects.toThrow('Import key failed');
|
||||
});
|
||||
|
||||
it('should propagate crypto.subtle.deriveKey errors', async () => {
|
||||
mockDeriveKey.mockRejectedValue(new Error('Derive key failed'));
|
||||
|
||||
await expect(encrypt(TEST_PLAINTEXT)).rejects.toThrow('Derive key failed');
|
||||
});
|
||||
});
|
||||
|
||||
describe('decrypt', () => {
|
||||
const validBase64Input = buildEncryptedBase64(
|
||||
new Uint8Array(12).fill(0xAB),
|
||||
TEST_CIPHERTEXT,
|
||||
);
|
||||
|
||||
it('should decrypt base64-encoded data and return plaintext', async () => {
|
||||
const result = await decrypt(validBase64Input);
|
||||
|
||||
expect(result).toBe(TEST_PLAINTEXT);
|
||||
|
||||
// crypto.subtle.decrypt 被正确调用
|
||||
expect(mockDecrypt).toHaveBeenCalledWith(
|
||||
{ name: 'AES-GCM', iv: new Uint8Array(12).fill(0xAB), tagLength: 128 },
|
||||
mockAesKey,
|
||||
TEST_CIPHERTEXT,
|
||||
);
|
||||
});
|
||||
|
||||
it('should propagate crypto.subtle.decrypt errors', async () => {
|
||||
mockDecrypt.mockRejectedValue(new Error('Decryption failed'));
|
||||
|
||||
await expect(decrypt(validBase64Input)).rejects.toThrow('Decryption failed');
|
||||
});
|
||||
|
||||
it('should throw on invalid base64 input', async () => {
|
||||
await expect(decrypt('not-valid-base64!!!')).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('error handling', () => {
|
||||
it('should throw when NEXT_PUBLIC_ENCRYPTION_SECRET is missing', async () => {
|
||||
// 清除密钥 → getKey() 内部 getSecret() 将抛出异常
|
||||
delete process.env.NEXT_PUBLIC_ENCRYPTION_SECRET;
|
||||
|
||||
// 需要重置模块以清除 cachedKey 缓存
|
||||
jest.resetModules();
|
||||
const mod = await import('./crypto');
|
||||
|
||||
await expect(mod.encrypt(TEST_PLAINTEXT)).rejects.toThrow(
|
||||
'NEXT_PUBLIC_ENCRYPTION_SECRET 未配置',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('key caching', () => {
|
||||
it('should cache derived key across multiple calls', async () => {
|
||||
await encrypt('first call');
|
||||
await encrypt('second call');
|
||||
|
||||
// importKey 和 deriveKey 应只被调用一次
|
||||
expect(mockImportKey).toHaveBeenCalledTimes(1);
|
||||
expect(mockDeriveKey).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
// @ts-nocheck
|
||||
import { describe, it, expect } from '@jest/globals';
|
||||
|
||||
// PrismaClient is already mocked in jest.setup.js
|
||||
describe('db', () => {
|
||||
it('should export prisma instance', async () => {
|
||||
const { prisma } = await import('./db');
|
||||
expect(prisma).toBeDefined();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user