refactor: 完成静态网站转换,移除所有 CMS 和动态功能
- 删除数据库相关代码 (src/db/) - 删除 API 路由 (src/app/api/) - 删除认证相关代码 (src/lib/auth/, src/providers/) - 删除监控和安全中间件 (src/lib/security/, src/lib/monitoring/) - 删除 hooks (use-news, use-products, use-services) - 更新组件为静态数据源 - 添加 nginx 静态配置和部署脚本 - 添加 static-link 组件
This commit is contained in:
@@ -1,95 +0,0 @@
|
||||
jest.mock('./analytics', () => {
|
||||
const actual = jest.requireActual('./analytics');
|
||||
return {
|
||||
...actual,
|
||||
pageview: jest.fn(),
|
||||
event: jest.fn(),
|
||||
trackContactForm: jest.fn(),
|
||||
trackButtonClick: jest.fn(),
|
||||
trackPageView: jest.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
import {
|
||||
pageview,
|
||||
event,
|
||||
trackContactForm,
|
||||
trackButtonClick,
|
||||
trackPageView,
|
||||
} from './analytics';
|
||||
|
||||
describe('analytics', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('pageview', () => {
|
||||
it('should be defined', () => {
|
||||
expect(pageview).toBeDefined();
|
||||
expect(typeof pageview).toBe('function');
|
||||
});
|
||||
|
||||
it('should be callable', () => {
|
||||
pageview('/test-page');
|
||||
expect(pageview).toHaveBeenCalledWith('/test-page');
|
||||
});
|
||||
});
|
||||
|
||||
describe('event', () => {
|
||||
it('should be defined', () => {
|
||||
expect(event).toBeDefined();
|
||||
expect(typeof event).toBe('function');
|
||||
});
|
||||
|
||||
it('should be callable with all parameters', () => {
|
||||
event('click', 'button', 'submit', 1);
|
||||
expect(event).toHaveBeenCalledWith('click', 'button', 'submit', 1);
|
||||
});
|
||||
|
||||
it('should be callable with minimal parameters', () => {
|
||||
event('click', 'button');
|
||||
expect(event).toHaveBeenCalledWith('click', 'button');
|
||||
});
|
||||
});
|
||||
|
||||
describe('trackContactForm', () => {
|
||||
it('should be defined', () => {
|
||||
expect(trackContactForm).toBeDefined();
|
||||
expect(typeof trackContactForm).toBe('function');
|
||||
});
|
||||
|
||||
it('should be callable', () => {
|
||||
const formData = {
|
||||
name: 'John Doe',
|
||||
email: 'john@example.com',
|
||||
message: 'Test message',
|
||||
};
|
||||
trackContactForm(formData);
|
||||
expect(trackContactForm).toHaveBeenCalledWith(formData);
|
||||
});
|
||||
});
|
||||
|
||||
describe('trackButtonClick', () => {
|
||||
it('should be defined', () => {
|
||||
expect(trackButtonClick).toBeDefined();
|
||||
expect(typeof trackButtonClick).toBe('function');
|
||||
});
|
||||
|
||||
it('should be callable', () => {
|
||||
trackButtonClick('submit', 'header');
|
||||
expect(trackButtonClick).toHaveBeenCalledWith('submit', 'header');
|
||||
});
|
||||
});
|
||||
|
||||
describe('trackPageView', () => {
|
||||
it('should be defined', () => {
|
||||
expect(trackPageView).toBeDefined();
|
||||
expect(typeof trackPageView).toBe('function');
|
||||
});
|
||||
|
||||
it('should be callable', () => {
|
||||
trackPageView('Home Page', '/home');
|
||||
expect(trackPageView).toHaveBeenCalledWith('Home Page', '/home');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,211 +0,0 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, jest } from '@jest/globals';
|
||||
|
||||
describe('API Client', () => {
|
||||
let mockFetch: jest.Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
mockFetch = jest.fn();
|
||||
global.fetch = mockFetch;
|
||||
delete (global as any).window;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('GET requests', () => {
|
||||
it('should make GET request to correct endpoint', async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ success: true, data: { id: 1 } }),
|
||||
});
|
||||
|
||||
const { apiClient } = await import('./client');
|
||||
const result = await apiClient.get('/api/test');
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith('http://localhost/api/test', {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
signal: expect.any(AbortSignal),
|
||||
});
|
||||
expect(result).toEqual({ id: 1 });
|
||||
});
|
||||
|
||||
it('should handle successful response', async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ success: true, data: { name: 'test' } }),
|
||||
});
|
||||
|
||||
const { apiClient } = await import('./client');
|
||||
const result = await apiClient.get('/api/test');
|
||||
|
||||
expect(result).toEqual({ name: 'test' });
|
||||
});
|
||||
|
||||
it('should handle 404 error', async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 404,
|
||||
statusText: 'Not Found',
|
||||
json: async () => ({ success: false, error: 'Not found' }),
|
||||
});
|
||||
|
||||
const { apiClient } = await import('./client');
|
||||
await expect(apiClient.get('/api/test')).rejects.toThrow('Not found');
|
||||
});
|
||||
|
||||
it.skip('should handle 500 error', async () => {
|
||||
mockFetch.mockImplementationOnce(async () => {
|
||||
return {
|
||||
ok: false,
|
||||
status: 500,
|
||||
statusText: 'Internal Server Error',
|
||||
json: async () => ({ success: false, error: 'Internal server error' }),
|
||||
headers: new Headers(),
|
||||
url: '/api/test',
|
||||
redirected: false,
|
||||
type: 'basic' as ResponseType,
|
||||
clone: () => ({ ok: false, status: 500, statusText: 'Internal Server Error' } as any),
|
||||
body: null,
|
||||
bodyUsed: false,
|
||||
arrayBuffer: async () => new ArrayBuffer(0),
|
||||
blob: async () => new Blob(),
|
||||
formData: async () => new FormData(),
|
||||
text: async () => JSON.stringify({ success: false, error: 'Internal server error' }),
|
||||
useFinalURL: false,
|
||||
} as Response;
|
||||
});
|
||||
|
||||
const { apiClient } = await import('./client');
|
||||
await expect(apiClient.get('/api/test')).rejects.toThrow('Internal server error');
|
||||
});
|
||||
|
||||
it('should include query parameters', async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ success: true, data: [] }),
|
||||
});
|
||||
|
||||
const { apiClient } = await import('./client');
|
||||
await apiClient.get('/api/test', { page: 1, limit: 10 });
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
'http://localhost/api/test?page=1&limit=10',
|
||||
expect.objectContaining({
|
||||
method: 'GET',
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error handling', () => {
|
||||
it('should throw ApiError with status code', async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 403,
|
||||
statusText: 'Forbidden',
|
||||
json: async () => ({ success: false, error: 'Forbidden' }),
|
||||
});
|
||||
|
||||
const { apiClient } = await import('./client');
|
||||
|
||||
try {
|
||||
await apiClient.get('/api/test');
|
||||
fail('Should have thrown ApiError');
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(Error);
|
||||
expect((error as any).status).toBe(403);
|
||||
expect((error as any).message).toBe('Forbidden');
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle malformed JSON response', async () => {
|
||||
mockFetch.mockResolvedValueOnce(
|
||||
new Response('invalid json', {
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
);
|
||||
|
||||
const { apiClient } = await import('./client');
|
||||
await expect(apiClient.get('/api/test')).rejects.toThrow();
|
||||
});
|
||||
|
||||
it.skip('should handle timeout', async () => {
|
||||
const abortError = new Error('The operation was aborted');
|
||||
(abortError as any).name = 'AbortError';
|
||||
|
||||
mockFetch.mockImplementationOnce(() =>
|
||||
new Promise((_, reject) =>
|
||||
setTimeout(() => reject(abortError), 100)
|
||||
)
|
||||
);
|
||||
|
||||
const { apiClient } = await import('./client');
|
||||
await expect(apiClient.get('/api/test', undefined, { timeout: 50 })).rejects.toThrow('Request timeout');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Retry mechanism', () => {
|
||||
it('should retry failed requests', async () => {
|
||||
mockFetch
|
||||
.mockRejectedValueOnce(new Error('Network error'))
|
||||
.mockRejectedValueOnce(new Error('Network error'))
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ success: true, data: { id: 1 } }),
|
||||
});
|
||||
|
||||
const { apiClient } = await import('./client');
|
||||
const result = await apiClient.get('/api/test', undefined, { retries: 3 });
|
||||
|
||||
expect(result).toEqual({ id: 1 });
|
||||
expect(mockFetch).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('should give up after max retries', async () => {
|
||||
mockFetch.mockRejectedValue(new Error('Network error'));
|
||||
|
||||
const { apiClient } = await import('./client');
|
||||
await expect(apiClient.get('/api/test', undefined, { retries: 2 })).rejects.toThrow('Network error');
|
||||
expect(mockFetch).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('should not retry on 4xx errors', async () => {
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: false,
|
||||
status: 404,
|
||||
statusText: 'Not Found',
|
||||
json: async () => ({ success: false, error: 'Not found' }),
|
||||
});
|
||||
|
||||
const { apiClient } = await import('./client');
|
||||
await expect(apiClient.get('/api/test')).rejects.toThrow('Not found');
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Type safety', () => {
|
||||
it('should return typed data', async () => {
|
||||
interface TestData {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ success: true, data: { id: 1, name: 'test' } }),
|
||||
});
|
||||
|
||||
const { apiClient } = await import('./client');
|
||||
const result = await apiClient.get<TestData>('/api/test');
|
||||
|
||||
expect(result.id).toBe(1);
|
||||
expect(result.name).toBe('test');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,221 +0,0 @@
|
||||
import { ApiResponse, RequestConfig } from './types';
|
||||
|
||||
class ApiError extends Error {
|
||||
status: number;
|
||||
code?: string;
|
||||
|
||||
constructor(status: number, message: string) {
|
||||
super(message);
|
||||
this.name = 'ApiError';
|
||||
this.status = status;
|
||||
this.code = status.toString();
|
||||
}
|
||||
}
|
||||
|
||||
class ApiClient {
|
||||
private baseUrl: string = '';
|
||||
private defaultTimeout: number = 5000;
|
||||
private defaultRetries: number = 2;
|
||||
|
||||
constructor(baseUrl?: string) {
|
||||
if (typeof window !== 'undefined') {
|
||||
this.baseUrl = window.location.origin;
|
||||
}
|
||||
if (baseUrl) {
|
||||
this.baseUrl = baseUrl;
|
||||
}
|
||||
}
|
||||
|
||||
async get<T = any>(
|
||||
endpoint: string,
|
||||
params?: Record<string, any>,
|
||||
config?: RequestConfig
|
||||
): Promise<T> {
|
||||
const url = this.buildUrl(endpoint, params);
|
||||
return this.request<T>(url, {
|
||||
method: 'GET',
|
||||
...config,
|
||||
});
|
||||
}
|
||||
|
||||
async post<T = any>(
|
||||
endpoint: string,
|
||||
data?: any,
|
||||
config?: RequestConfig
|
||||
): Promise<T> {
|
||||
return this.request<T>(endpoint, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
...config,
|
||||
});
|
||||
}
|
||||
|
||||
async put<T = any>(
|
||||
endpoint: string,
|
||||
data?: any,
|
||||
config?: RequestConfig
|
||||
): Promise<T> {
|
||||
return this.request<T>(endpoint, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
...config,
|
||||
});
|
||||
}
|
||||
|
||||
async delete<T = any>(
|
||||
endpoint: string,
|
||||
config?: RequestConfig
|
||||
): Promise<T> {
|
||||
return this.request<T>(endpoint, {
|
||||
method: 'DELETE',
|
||||
...config,
|
||||
});
|
||||
}
|
||||
|
||||
private async request<T>(
|
||||
url: string,
|
||||
options: RequestInit & RequestConfig = {}
|
||||
): Promise<T> {
|
||||
const {
|
||||
retries = this.defaultRetries,
|
||||
timeout = this.defaultTimeout,
|
||||
headers = {},
|
||||
...fetchOptions
|
||||
} = options;
|
||||
|
||||
return this.executeWithRetry(
|
||||
async () => {
|
||||
const response = await this.fetchWithTimeout(url, {
|
||||
...fetchOptions,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...headers,
|
||||
},
|
||||
}, timeout);
|
||||
|
||||
if (!response) {
|
||||
throw new Error('No response received');
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await this.parseError(response);
|
||||
const error = this.createError(response.status, errorData.error || response.statusText);
|
||||
throw error;
|
||||
}
|
||||
|
||||
const data: ApiResponse<T> = await response.json();
|
||||
|
||||
if (!data.success) {
|
||||
const error = this.createError(response.status, data.error || 'Request failed');
|
||||
throw error;
|
||||
}
|
||||
|
||||
return data.data;
|
||||
},
|
||||
retries,
|
||||
(error) => {
|
||||
const status = error?.status;
|
||||
if (typeof status === 'number') {
|
||||
return status >= 500 || status === 0;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
private async executeWithRetry<T>(
|
||||
fn: () => Promise<T>,
|
||||
retries: number,
|
||||
shouldRetry: (error: any) => boolean
|
||||
): Promise<T> {
|
||||
let lastError: any;
|
||||
|
||||
for (let attempt = 0; attempt <= retries; attempt++) {
|
||||
try {
|
||||
return await fn();
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
|
||||
if (attempt === retries || !shouldRetry(error)) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
await this.delay(Math.pow(2, attempt) * 1000);
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
private async fetchWithTimeout(
|
||||
url: string,
|
||||
options: RequestInit,
|
||||
timeout: number
|
||||
): Promise<Response> {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
signal: controller.signal,
|
||||
});
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!response) {
|
||||
throw new Error('No response received');
|
||||
}
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
clearTimeout(timeoutId);
|
||||
if (error instanceof Error && error.name === 'AbortError') {
|
||||
const timeoutError = new Error('Request timeout') as any;
|
||||
timeoutError.status = 0;
|
||||
throw timeoutError;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async parseError(response: Response): Promise<{ error: string }> {
|
||||
try {
|
||||
const data = await response.json();
|
||||
return data;
|
||||
} catch {
|
||||
return { error: response.statusText };
|
||||
}
|
||||
}
|
||||
|
||||
private createError(status: number, message: string): ApiError {
|
||||
return new ApiError(status, message);
|
||||
}
|
||||
|
||||
private buildUrl(endpoint: string, params?: Record<string, any>): string {
|
||||
let url = endpoint;
|
||||
|
||||
if (this.baseUrl && !url.startsWith('http')) {
|
||||
url = `${this.baseUrl}${url.startsWith('/') ? '' : '/'}${url}`;
|
||||
}
|
||||
|
||||
if (!params || Object.keys(params).length === 0) {
|
||||
return url;
|
||||
}
|
||||
|
||||
const queryString = new URLSearchParams();
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
if (value !== undefined && value !== null) {
|
||||
queryString.append(key, String(value));
|
||||
}
|
||||
});
|
||||
|
||||
return `${url}?${queryString.toString()}`;
|
||||
}
|
||||
|
||||
private delay(ms: number): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
}
|
||||
|
||||
export const apiClient = new ApiClient();
|
||||
export { ApiClient, ApiError };
|
||||
@@ -1,426 +0,0 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, jest } from '@jest/globals';
|
||||
|
||||
describe('Content API Service', () => {
|
||||
let mockApiClientGet: jest.Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
mockApiClientGet = jest.fn();
|
||||
jest.doMock('./client', () => ({
|
||||
apiClient: {
|
||||
get: mockApiClientGet,
|
||||
},
|
||||
}));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
jest.resetModules();
|
||||
});
|
||||
|
||||
describe('getProducts', () => {
|
||||
it('should fetch products from API', async () => {
|
||||
const mockProducts = [
|
||||
{
|
||||
id: '1',
|
||||
title: 'ERP System',
|
||||
type: 'product',
|
||||
slug: 'erp-system',
|
||||
excerpt: 'Enterprise resource planning',
|
||||
content: 'Full description',
|
||||
category: '企业软件',
|
||||
metadata: {
|
||||
features: ['Feature 1', 'Feature 2'],
|
||||
benefits: ['Benefit 1', 'Benefit 2'],
|
||||
pricing: { base: '¥10,000' },
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
mockApiClientGet.mockResolvedValueOnce(mockProducts);
|
||||
|
||||
const { contentService } = await import('./services');
|
||||
const result = await contentService.getProducts();
|
||||
|
||||
expect(mockApiClientGet).toHaveBeenCalledWith('/api/content', {
|
||||
type: 'product',
|
||||
status: 'published',
|
||||
});
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toMatchObject({
|
||||
id: '1',
|
||||
title: 'ERP System',
|
||||
description: 'Enterprise resource planning',
|
||||
category: '企业软件',
|
||||
features: ['Feature 1', 'Feature 2'],
|
||||
benefits: ['Benefit 1', 'Benefit 2'],
|
||||
pricing: { base: '¥10,000' },
|
||||
});
|
||||
});
|
||||
|
||||
it('should return empty array on error', async () => {
|
||||
mockApiClientGet.mockRejectedValueOnce(new Error('API Error'));
|
||||
|
||||
const { contentService } = await import('./services');
|
||||
const result = await contentService.getProducts();
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should filter products by featured IDs', async () => {
|
||||
const mockProducts = [
|
||||
{ id: '1', title: 'Product 1', type: 'product', slug: 'p1' },
|
||||
{ id: '2', title: 'Product 2', type: 'product', slug: 'p2' },
|
||||
{ id: '3', title: 'Product 3', type: 'product', slug: 'p3' },
|
||||
];
|
||||
|
||||
mockApiClientGet.mockResolvedValueOnce(mockProducts);
|
||||
|
||||
const { contentService } = await import('./services');
|
||||
const result = await contentService.getProducts(['1', '3']);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0].id).toBe('1');
|
||||
expect(result[1].id).toBe('3');
|
||||
});
|
||||
|
||||
it('should transform API data to component format', async () => {
|
||||
const mockApiData = [
|
||||
{
|
||||
id: '1',
|
||||
title: 'ERP System',
|
||||
type: 'product',
|
||||
slug: 'erp-system',
|
||||
excerpt: 'Enterprise resource planning',
|
||||
content: 'Full description',
|
||||
category: '企业软件',
|
||||
metadata: {
|
||||
features: ['Feature 1', 'Feature 2'],
|
||||
benefits: ['Benefit 1', 'Benefit 2'],
|
||||
pricing: { base: '¥10,000' },
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
mockApiClientGet.mockResolvedValueOnce(mockApiData);
|
||||
|
||||
const { contentService } = await import('./services');
|
||||
const result = await contentService.getProducts();
|
||||
|
||||
expect(result[0]).toMatchObject({
|
||||
id: '1',
|
||||
title: 'ERP System',
|
||||
description: 'Enterprise resource planning',
|
||||
category: '企业软件',
|
||||
features: ['Feature 1', 'Feature 2'],
|
||||
benefits: ['Benefit 1', 'Benefit 2'],
|
||||
pricing: { base: '¥10,000' },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getNews', () => {
|
||||
it('should fetch news from API', async () => {
|
||||
const mockNews = [
|
||||
{
|
||||
id: '1',
|
||||
title: 'Company News',
|
||||
type: 'news',
|
||||
slug: 'company-news',
|
||||
excerpt: 'Latest update',
|
||||
content: 'Full content',
|
||||
category: '公司新闻',
|
||||
publishedAt: '2026-01-15T00:00:00Z',
|
||||
status: 'published',
|
||||
createdAt: '2026-01-15T00:00:00Z',
|
||||
updatedAt: '2026-01-15T00:00:00Z',
|
||||
},
|
||||
];
|
||||
|
||||
mockApiClientGet.mockResolvedValueOnce(mockNews);
|
||||
|
||||
const { contentService } = await import('./services');
|
||||
const result = await contentService.getNews();
|
||||
|
||||
expect(mockApiClientGet).toHaveBeenCalledWith('/api/content', {
|
||||
type: 'news',
|
||||
status: 'published',
|
||||
});
|
||||
expect(mockApiClientGet).toHaveBeenCalledTimes(1);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toMatchObject({
|
||||
id: '1',
|
||||
title: 'Company News',
|
||||
excerpt: 'Latest update',
|
||||
date: '2026-01-15',
|
||||
category: '公司新闻',
|
||||
});
|
||||
});
|
||||
|
||||
it('should limit news count', async () => {
|
||||
const mockNews = Array.from({ length: 10 }, (_, i) => ({
|
||||
id: `${i}`,
|
||||
title: `News ${i}`,
|
||||
type: 'news',
|
||||
slug: `news-${i}`,
|
||||
excerpt: `Excerpt ${i}`,
|
||||
content: `Content ${i}`,
|
||||
category: '公司新闻',
|
||||
publishedAt: new Date(Date.now() - i * 86400000).toISOString(),
|
||||
status: 'published',
|
||||
createdAt: new Date(Date.now() - i * 86400000).toISOString(),
|
||||
updatedAt: new Date(Date.now() - i * 86400000).toISOString(),
|
||||
}));
|
||||
|
||||
mockApiClientGet.mockResolvedValueOnce(mockNews);
|
||||
|
||||
const { contentService } = await import('./services');
|
||||
const result = await contentService.getNews(undefined, 4);
|
||||
|
||||
expect(result).toHaveLength(4);
|
||||
});
|
||||
|
||||
it('should filter by categories', async () => {
|
||||
const mockNews = [
|
||||
{
|
||||
id: '1',
|
||||
title: 'News 1',
|
||||
type: 'news',
|
||||
slug: 'news-1',
|
||||
excerpt: 'Excerpt 1',
|
||||
content: 'Content 1',
|
||||
category: '公司新闻',
|
||||
publishedAt: '2026-01-15T00:00:00Z',
|
||||
status: 'published',
|
||||
createdAt: '2026-01-15T00:00:00Z',
|
||||
updatedAt: '2026-01-15T00:00:00Z',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
title: 'News 2',
|
||||
type: 'news',
|
||||
slug: 'news-2',
|
||||
excerpt: 'Excerpt 2',
|
||||
content: 'Content 2',
|
||||
category: '产品发布',
|
||||
publishedAt: '2026-01-16T00:00:00Z',
|
||||
status: 'published',
|
||||
createdAt: '2026-01-16T00:00:00Z',
|
||||
updatedAt: '2026-01-16T00:00:00Z',
|
||||
},
|
||||
];
|
||||
|
||||
mockApiClientGet.mockResolvedValueOnce(mockNews);
|
||||
|
||||
const { contentService } = await import('./services');
|
||||
const result = await contentService.getNews(['公司新闻']);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].category).toBe('公司新闻');
|
||||
});
|
||||
|
||||
it('should sort by date descending by default', async () => {
|
||||
const mockNews = [
|
||||
{
|
||||
id: '1',
|
||||
title: 'News 1',
|
||||
type: 'news',
|
||||
slug: 'news-1',
|
||||
excerpt: 'Excerpt 1',
|
||||
content: 'Content 1',
|
||||
category: '公司新闻',
|
||||
publishedAt: '2026-01-15T00:00:00Z',
|
||||
status: 'published',
|
||||
createdAt: '2026-01-15T00:00:00Z',
|
||||
updatedAt: '2026-01-15T00:00:00Z',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
title: 'News 2',
|
||||
type: 'news',
|
||||
slug: 'news-2',
|
||||
excerpt: 'Excerpt 2',
|
||||
content: 'Content 2',
|
||||
category: '公司新闻',
|
||||
publishedAt: '2026-01-16T00:00:00Z',
|
||||
status: 'published',
|
||||
createdAt: '2026-01-16T00:00:00Z',
|
||||
updatedAt: '2026-01-16T00:00:00Z',
|
||||
},
|
||||
];
|
||||
|
||||
mockApiClientGet.mockResolvedValueOnce(mockNews);
|
||||
|
||||
const { contentService } = await import('./services');
|
||||
const result = await contentService.getNews();
|
||||
|
||||
expect(result[0].id).toBe('2');
|
||||
expect(result[1].id).toBe('1');
|
||||
});
|
||||
|
||||
it('should transform API data to component format', async () => {
|
||||
const mockApiData = [
|
||||
{
|
||||
id: '1',
|
||||
title: 'Company News',
|
||||
type: 'news',
|
||||
slug: 'company-news',
|
||||
excerpt: 'Latest update',
|
||||
content: 'Full content',
|
||||
category: '公司新闻',
|
||||
publishedAt: '2026-01-15T00:00:00Z',
|
||||
status: 'published',
|
||||
createdAt: '2026-01-15T00:00:00Z',
|
||||
updatedAt: '2026-01-15T00:00:00Z',
|
||||
},
|
||||
];
|
||||
|
||||
mockApiClientGet.mockResolvedValueOnce(mockApiData);
|
||||
|
||||
const { contentService } = await import('./services');
|
||||
const result = await contentService.getNews();
|
||||
|
||||
expect(result[0]).toMatchObject({
|
||||
id: '1',
|
||||
title: 'Company News',
|
||||
excerpt: 'Latest update',
|
||||
date: '2026-01-15',
|
||||
category: '公司新闻',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getServices', () => {
|
||||
it('should fetch services from API', async () => {
|
||||
const mockServices = [
|
||||
{
|
||||
id: 'software',
|
||||
title: 'Software Development',
|
||||
type: 'service',
|
||||
slug: 'software-development',
|
||||
excerpt: 'Custom software solutions',
|
||||
content: 'Full description',
|
||||
metadata: {
|
||||
icon: 'Code',
|
||||
features: ['Feature 1', 'Feature 2'],
|
||||
benefits: ['Benefit 1', 'Benefit 2'],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
mockApiClientGet.mockResolvedValueOnce(mockServices);
|
||||
|
||||
const { contentService } = await import('./services');
|
||||
const result = await contentService.getServices();
|
||||
|
||||
expect(mockApiClientGet).toHaveBeenCalledWith('/api/content', {
|
||||
type: 'service',
|
||||
status: 'published',
|
||||
});
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toMatchObject({
|
||||
id: 'software',
|
||||
title: 'Software Development',
|
||||
description: 'Custom software solutions',
|
||||
icon: 'Code',
|
||||
features: ['Feature 1', 'Feature 2'],
|
||||
benefits: ['Benefit 1', 'Benefit 2'],
|
||||
});
|
||||
});
|
||||
|
||||
it('should filter services by IDs', async () => {
|
||||
const mockServices = [
|
||||
{ id: 'software', title: 'Software', type: 'service', slug: 'software' },
|
||||
{ id: 'cloud', title: 'Cloud', type: 'service', slug: 'cloud' },
|
||||
{ id: 'data', title: 'Data', type: 'service', slug: 'data' },
|
||||
];
|
||||
|
||||
mockApiClientGet.mockResolvedValueOnce(mockServices);
|
||||
|
||||
const { contentService } = await import('./services');
|
||||
const result = await contentService.getServices(['software', 'data']);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0].id).toBe('software');
|
||||
expect(result[1].id).toBe('data');
|
||||
});
|
||||
|
||||
it('should transform API data to component format', async () => {
|
||||
const mockApiData = [
|
||||
{
|
||||
id: 'software',
|
||||
title: 'Software Development',
|
||||
type: 'service',
|
||||
slug: 'software-development',
|
||||
excerpt: 'Custom software solutions',
|
||||
content: 'Full description',
|
||||
metadata: {
|
||||
icon: 'Code',
|
||||
features: ['Feature 1', 'Feature 2'],
|
||||
benefits: ['Benefit 1', 'Benefit 2'],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
mockApiClientGet.mockResolvedValueOnce(mockApiData);
|
||||
|
||||
const { contentService } = await import('./services');
|
||||
const result = await contentService.getServices();
|
||||
|
||||
expect(result[0]).toMatchObject({
|
||||
id: 'software',
|
||||
title: 'Software Development',
|
||||
description: 'Custom software solutions',
|
||||
icon: 'Code',
|
||||
features: ['Feature 1', 'Feature 2'],
|
||||
benefits: ['Benefit 1', 'Benefit 2'],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error handling', () => {
|
||||
it('should return empty array when API fails', async () => {
|
||||
mockApiClientGet.mockRejectedValueOnce(new Error('Network error'));
|
||||
|
||||
const { contentService } = await import('./services');
|
||||
|
||||
const products = await contentService.getProducts();
|
||||
const news = await contentService.getNews();
|
||||
const services = await contentService.getServices();
|
||||
|
||||
expect(products).toEqual([]);
|
||||
expect(news).toEqual([]);
|
||||
expect(services).toEqual([]);
|
||||
});
|
||||
|
||||
it('should handle malformed API response', async () => {
|
||||
mockApiClientGet.mockResolvedValueOnce(null);
|
||||
|
||||
const { contentService } = await import('./services');
|
||||
const result = await contentService.getProducts();
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should handle missing metadata', async () => {
|
||||
const mockProducts = [
|
||||
{
|
||||
id: '1',
|
||||
title: 'Product',
|
||||
type: 'product',
|
||||
slug: 'product',
|
||||
excerpt: 'Description',
|
||||
content: 'Content',
|
||||
category: '企业软件',
|
||||
},
|
||||
];
|
||||
|
||||
mockApiClientGet.mockResolvedValueOnce(mockProducts);
|
||||
|
||||
const { contentService } = await import('./services');
|
||||
const result = await contentService.getProducts();
|
||||
|
||||
expect(result[0].features).toEqual([]);
|
||||
expect(result[0].benefits).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,151 +0,0 @@
|
||||
import { apiClient } from './client';
|
||||
import { Product, NewsItem, Service, ContentItem } from './types';
|
||||
|
||||
class ContentService {
|
||||
async getProducts(featuredIds?: string[]): Promise<Product[]> {
|
||||
try {
|
||||
const data = await apiClient.get<ContentItem[]>('/api/content', {
|
||||
type: 'product',
|
||||
status: 'published',
|
||||
});
|
||||
|
||||
let products = data.map(item => this.transformToProduct(item));
|
||||
|
||||
if (featuredIds && featuredIds.length > 0) {
|
||||
products = products.filter(p => featuredIds.includes(p.id));
|
||||
}
|
||||
|
||||
return products;
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch products:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async getNews(
|
||||
categories?: string[],
|
||||
limit?: number,
|
||||
sortOrder: 'asc' | 'desc' = 'desc'
|
||||
): Promise<NewsItem[]> {
|
||||
try {
|
||||
const data = await apiClient.get<ContentItem[]>('/api/content', {
|
||||
type: 'news',
|
||||
status: 'published',
|
||||
});
|
||||
|
||||
let news = data.map(item => this.transformToNews(item));
|
||||
|
||||
if (categories && categories.length > 0) {
|
||||
news = news.filter(n => categories.includes(n.category));
|
||||
}
|
||||
|
||||
if (sortOrder === 'desc') {
|
||||
news.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
|
||||
} else {
|
||||
news.sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime());
|
||||
}
|
||||
|
||||
if (limit && limit > 0) {
|
||||
news = news.slice(0, limit);
|
||||
}
|
||||
|
||||
return news;
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch news:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async getServices(ids?: string[]): Promise<Service[]> {
|
||||
try {
|
||||
const data = await apiClient.get<ContentItem[]>('/api/content', {
|
||||
type: 'service',
|
||||
status: 'published',
|
||||
});
|
||||
|
||||
let services = data.map(item => this.transformToService(item));
|
||||
|
||||
if (ids && ids.length > 0) {
|
||||
services = services.filter(s => ids.includes(s.id));
|
||||
}
|
||||
|
||||
return services;
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch services:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async getCases(limit?: number): Promise<NewsItem[]> {
|
||||
try {
|
||||
const data = await apiClient.get<ContentItem[]>('/api/content', {
|
||||
type: 'case',
|
||||
status: 'published',
|
||||
});
|
||||
|
||||
let cases = data.map(item => this.transformToNews(item));
|
||||
|
||||
if (limit && limit > 0) {
|
||||
cases = cases.slice(0, limit);
|
||||
}
|
||||
|
||||
return cases;
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch cases:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private transformToProduct(item: ContentItem): Product {
|
||||
const metadata = item.metadata || {};
|
||||
return {
|
||||
id: item.id,
|
||||
title: item.title,
|
||||
description: item.excerpt || '',
|
||||
category: item.category || '',
|
||||
features: metadata.features || [],
|
||||
benefits: metadata.benefits || [],
|
||||
pricing: metadata.pricing,
|
||||
image: item.coverImage,
|
||||
slug: item.slug,
|
||||
};
|
||||
}
|
||||
|
||||
private transformToNews(item: ContentItem): NewsItem {
|
||||
return {
|
||||
id: item.id,
|
||||
title: item.title,
|
||||
excerpt: item.excerpt || '',
|
||||
content: item.content,
|
||||
date: item.publishedAt ? this.formatDate(item.publishedAt) : this.formatDate(item.createdAt),
|
||||
category: item.category || '公司新闻',
|
||||
image: item.coverImage,
|
||||
slug: item.slug,
|
||||
};
|
||||
}
|
||||
|
||||
private transformToService(item: ContentItem): Service {
|
||||
const metadata = item.metadata || {};
|
||||
return {
|
||||
id: item.id,
|
||||
title: item.title,
|
||||
description: item.excerpt || '',
|
||||
icon: metadata.icon || 'Code',
|
||||
features: metadata.features || [],
|
||||
benefits: metadata.benefits || [],
|
||||
slug: item.slug,
|
||||
};
|
||||
}
|
||||
|
||||
private formatDate(dateString: string): string {
|
||||
try {
|
||||
const date = new Date(dateString);
|
||||
const isoString = date.toISOString();
|
||||
return isoString.split('T')[0] || dateString;
|
||||
} catch {
|
||||
return dateString;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const contentService = new ContentService();
|
||||
@@ -1,66 +0,0 @@
|
||||
export interface ApiResponse<T = any> {
|
||||
success: boolean;
|
||||
data: T;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface Product {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
category: string;
|
||||
features: string[];
|
||||
benefits: string[];
|
||||
pricing?: Record<string, string>;
|
||||
image?: string;
|
||||
slug: string;
|
||||
}
|
||||
|
||||
export interface NewsItem {
|
||||
id: string;
|
||||
title: string;
|
||||
excerpt: string;
|
||||
content: string;
|
||||
date: string;
|
||||
category: string;
|
||||
image?: string;
|
||||
slug: string;
|
||||
}
|
||||
|
||||
export interface Service {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
icon: string;
|
||||
features: string[];
|
||||
benefits: string[];
|
||||
slug: string;
|
||||
}
|
||||
|
||||
export interface ContentItem {
|
||||
id: string;
|
||||
type: 'news' | 'product' | 'service' | 'case';
|
||||
title: string;
|
||||
slug: string;
|
||||
excerpt?: string;
|
||||
content: string;
|
||||
coverImage?: string;
|
||||
category?: string;
|
||||
tags?: string[];
|
||||
status: 'draft' | 'published' | 'archived';
|
||||
publishedAt?: string;
|
||||
metadata?: Record<string, any>;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface ApiError extends Error {
|
||||
status: number;
|
||||
code?: string;
|
||||
}
|
||||
|
||||
export interface RequestConfig {
|
||||
retries?: number;
|
||||
timeout?: number;
|
||||
headers?: Record<string, string>;
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
const mockInsert = jest.fn().mockReturnValue({
|
||||
values: jest.fn().mockResolvedValue(undefined),
|
||||
});
|
||||
|
||||
jest.mock('@/db', () => ({
|
||||
db: {
|
||||
insert: mockInsert,
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('nanoid', () => ({
|
||||
nanoid: jest.fn(() => 'test-id'),
|
||||
}));
|
||||
|
||||
import { createAuditLog, getActionLabel, getActionColor } from './audit';
|
||||
|
||||
describe('audit', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('createAuditLog', () => {
|
||||
it('should create audit log successfully', async () => {
|
||||
const mockValues = jest.fn().mockResolvedValue(undefined);
|
||||
mockInsert.mockReturnValue({ values: mockValues });
|
||||
|
||||
const logData = {
|
||||
userId: 'user-123',
|
||||
action: 'LOGIN',
|
||||
details: { ip: '192.168.1.1' },
|
||||
};
|
||||
|
||||
await createAuditLog(logData);
|
||||
|
||||
expect(mockValues).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
id: 'test-id',
|
||||
userId: 'user-123',
|
||||
action: 'LOGIN',
|
||||
details: { ip: '192.168.1.1' },
|
||||
timestamp: expect.any(Date),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle missing optional fields', async () => {
|
||||
const mockValues = jest.fn().mockResolvedValue(undefined);
|
||||
mockInsert.mockReturnValue({ values: mockValues });
|
||||
|
||||
const logData = {
|
||||
userId: 'user-456',
|
||||
action: 'LOGOUT',
|
||||
};
|
||||
|
||||
await createAuditLog(logData);
|
||||
|
||||
expect(mockValues).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
id: 'test-id',
|
||||
userId: 'user-456',
|
||||
action: 'LOGOUT',
|
||||
timestamp: expect.any(Date),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle database errors gracefully', async () => {
|
||||
const mockValues = jest.fn().mockRejectedValue(new Error('Database error'));
|
||||
mockInsert.mockReturnValue({ values: mockValues });
|
||||
|
||||
const consoleSpy = jest.spyOn(console, 'error').mockImplementation();
|
||||
|
||||
await createAuditLog({
|
||||
userId: 'user-789',
|
||||
action: 'LOGIN',
|
||||
});
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalled();
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getActionLabel', () => {
|
||||
it('should return correct label for known actions', () => {
|
||||
expect(getActionLabel('login')).toBe('登录');
|
||||
expect(getActionLabel('logout')).toBe('登出');
|
||||
expect(getActionLabel('create')).toBe('创建');
|
||||
expect(getActionLabel('update')).toBe('更新');
|
||||
expect(getActionLabel('delete')).toBe('删除');
|
||||
expect(getActionLabel('publish')).toBe('发布');
|
||||
expect(getActionLabel('upload')).toBe('上传');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getActionColor', () => {
|
||||
it('should return correct color for known actions', () => {
|
||||
expect(getActionColor('login')).toBe('bg-cyan-100 text-cyan-800');
|
||||
expect(getActionColor('logout')).toBe('bg-gray-100 text-gray-800');
|
||||
expect(getActionColor('create')).toBe('bg-green-100 text-green-800');
|
||||
expect(getActionColor('update')).toBe('bg-blue-100 text-blue-800');
|
||||
expect(getActionColor('delete')).toBe('bg-red-100 text-red-800');
|
||||
expect(getActionColor('publish')).toBe('bg-purple-100 text-purple-800');
|
||||
expect(getActionColor('upload')).toBe('bg-yellow-100 text-yellow-800');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,59 +0,0 @@
|
||||
import { db } from '@/db';
|
||||
import { auditLogs } from '@/db/schema';
|
||||
import { nanoid } from 'nanoid';
|
||||
|
||||
export type AuditAction = 'create' | 'update' | 'delete' | 'publish' | 'login' | 'logout' | 'upload';
|
||||
|
||||
export interface AuditLogData {
|
||||
userId?: string;
|
||||
action: AuditAction;
|
||||
resourceType: string;
|
||||
resourceId?: string;
|
||||
details?: Record<string, any>;
|
||||
ipAddress?: string;
|
||||
userAgent?: string;
|
||||
}
|
||||
|
||||
export async function createAuditLog(data: AuditLogData) {
|
||||
try {
|
||||
await db.insert(auditLogs).values({
|
||||
id: nanoid(),
|
||||
userId: data.userId || null,
|
||||
action: data.action,
|
||||
resourceType: data.resourceType,
|
||||
resourceId: data.resourceId || null,
|
||||
details: data.details || null,
|
||||
ipAddress: data.ipAddress || null,
|
||||
userAgent: data.userAgent || null,
|
||||
timestamp: new Date(),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('创建审计日志失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
export function getActionLabel(action: AuditAction): string {
|
||||
const labels: Record<AuditAction, string> = {
|
||||
create: '创建',
|
||||
update: '更新',
|
||||
delete: '删除',
|
||||
publish: '发布',
|
||||
login: '登录',
|
||||
logout: '登出',
|
||||
upload: '上传',
|
||||
};
|
||||
return labels[action];
|
||||
}
|
||||
|
||||
export function getActionColor(action: AuditAction): string {
|
||||
const colors: Record<AuditAction, string> = {
|
||||
create: 'bg-green-100 text-green-800',
|
||||
update: 'bg-blue-100 text-blue-800',
|
||||
delete: 'bg-red-100 text-red-800',
|
||||
publish: 'bg-purple-100 text-purple-800',
|
||||
login: 'bg-cyan-100 text-cyan-800',
|
||||
logout: 'bg-gray-100 text-gray-800',
|
||||
upload: 'bg-yellow-100 text-yellow-800',
|
||||
};
|
||||
return colors[action];
|
||||
}
|
||||
@@ -1,209 +0,0 @@
|
||||
import { auth } from './auth';
|
||||
import { db } from '@/db';
|
||||
import { users } from '@/db/schema';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import bcrypt from 'bcryptjs';
|
||||
|
||||
jest.mock('@/db', () => ({
|
||||
db: {
|
||||
select: jest.fn(() => ({
|
||||
from: jest.fn(() => ({
|
||||
where: jest.fn(() => ({
|
||||
limit: jest.fn(),
|
||||
})),
|
||||
})),
|
||||
})),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('bcryptjs');
|
||||
|
||||
describe('auth', () => {
|
||||
const mockUser = {
|
||||
id: '1',
|
||||
email: 'test@example.com',
|
||||
name: 'Test User',
|
||||
passwordHash: 'hashedpassword',
|
||||
isAdmin: true,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('auth configuration', () => {
|
||||
it('应该导出auth对象', () => {
|
||||
expect(auth).toBeDefined();
|
||||
expect(typeof auth).toBe('function');
|
||||
});
|
||||
|
||||
it('应该支持signIn方法', () => {
|
||||
expect(typeof auth).toBe('function');
|
||||
});
|
||||
|
||||
it('应该支持signOut方法', () => {
|
||||
expect(typeof auth).toBe('function');
|
||||
});
|
||||
});
|
||||
|
||||
describe('CredentialsProvider 验证逻辑', () => {
|
||||
it('应该成功验证正确的邮箱和密码', async () => {
|
||||
const mockLimit = jest.fn().mockResolvedValue([mockUser]);
|
||||
const mockWhere = jest.fn().mockReturnValue({ limit: mockLimit });
|
||||
const mockFrom = jest.fn().mockReturnValue({ where: mockWhere });
|
||||
const mockSelect = jest.fn().mockReturnValue({ from: mockFrom });
|
||||
|
||||
(db.select as jest.Mock).mockImplementation(() => ({ from: mockFrom }));
|
||||
(bcrypt.compare as jest.Mock).mockResolvedValue(true);
|
||||
|
||||
const credentials = {
|
||||
email: 'test@example.com',
|
||||
password: 'password123',
|
||||
};
|
||||
|
||||
const userResult = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.email, credentials.email as string))
|
||||
.limit(1);
|
||||
|
||||
const user = userResult[0];
|
||||
const isValid = await bcrypt.compare(
|
||||
credentials.password as string,
|
||||
user.passwordHash || ''
|
||||
);
|
||||
|
||||
expect(user).toEqual(mockUser);
|
||||
expect(isValid).toBe(true);
|
||||
});
|
||||
|
||||
it('应该拒绝不存在的用户', async () => {
|
||||
const mockLimit = jest.fn().mockResolvedValue([]);
|
||||
const mockWhere = jest.fn().mockReturnValue({ limit: mockLimit });
|
||||
const mockFrom = jest.fn().mockReturnValue({ where: mockWhere });
|
||||
|
||||
(db.select as jest.Mock).mockImplementation(() => ({ from: mockFrom }));
|
||||
|
||||
const credentials = {
|
||||
email: 'nonexistent@example.com',
|
||||
password: 'password123',
|
||||
};
|
||||
|
||||
const userResult = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.email, credentials.email as string))
|
||||
.limit(1);
|
||||
|
||||
expect(userResult).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('应该拒绝错误的密码', async () => {
|
||||
const mockLimit = jest.fn().mockResolvedValue([mockUser]);
|
||||
const mockWhere = jest.fn().mockReturnValue({ limit: mockLimit });
|
||||
const mockFrom = jest.fn().mockReturnValue({ where: mockWhere });
|
||||
|
||||
(db.select as jest.Mock).mockImplementation(() => ({ from: mockFrom }));
|
||||
(bcrypt.compare as jest.Mock).mockResolvedValue(false);
|
||||
|
||||
const credentials = {
|
||||
email: 'test@example.com',
|
||||
password: 'wrongpassword',
|
||||
};
|
||||
|
||||
const userResult = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.email, credentials.email as string))
|
||||
.limit(1);
|
||||
|
||||
const user = userResult[0];
|
||||
const isValid = await bcrypt.compare(
|
||||
credentials.password as string,
|
||||
user.passwordHash || ''
|
||||
);
|
||||
|
||||
expect(isValid).toBe(false);
|
||||
});
|
||||
|
||||
it('应该拒绝缺少邮箱的凭证', async () => {
|
||||
const credentials = {
|
||||
password: 'password123',
|
||||
};
|
||||
|
||||
expect(credentials.email).toBeUndefined();
|
||||
});
|
||||
|
||||
it('应该拒绝缺少密码的凭证', async () => {
|
||||
const credentials = {
|
||||
email: 'test@example.com',
|
||||
};
|
||||
|
||||
expect(credentials.password).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('JWT callback 逻辑', () => {
|
||||
it('应该在用户登录时添加token信息', async () => {
|
||||
const token = {};
|
||||
const user = {
|
||||
id: '1',
|
||||
email: 'test@example.com',
|
||||
name: 'Test User',
|
||||
isAdmin: true,
|
||||
};
|
||||
|
||||
if (user) {
|
||||
token.id = user.id;
|
||||
token.isAdmin = user.isAdmin;
|
||||
}
|
||||
|
||||
expect(token).toEqual({
|
||||
id: user.id,
|
||||
isAdmin: user.isAdmin,
|
||||
});
|
||||
});
|
||||
|
||||
it('应该在用户不存在时保持token不变', async () => {
|
||||
const token = { id: '1', isAdmin: true };
|
||||
const user = undefined;
|
||||
|
||||
if (user) {
|
||||
token.id = user.id;
|
||||
token.isAdmin = user.isAdmin;
|
||||
}
|
||||
|
||||
expect(token).toEqual({ id: '1', isAdmin: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('Session callback 逻辑', () => {
|
||||
it('应该在会话中添加用户信息', async () => {
|
||||
const session = { user: { name: 'Test User' } };
|
||||
const token = { id: '1', isAdmin: true };
|
||||
|
||||
if (session.user) {
|
||||
session.user.id = token.id as string;
|
||||
session.user.isAdmin = token.isAdmin as boolean;
|
||||
}
|
||||
|
||||
expect(session.user).toEqual({
|
||||
...session.user,
|
||||
id: token.id,
|
||||
isAdmin: token.isAdmin,
|
||||
});
|
||||
});
|
||||
|
||||
it('应该处理没有user的session', async () => {
|
||||
const session = {};
|
||||
const token = { id: '1', isAdmin: true };
|
||||
|
||||
if (session.user) {
|
||||
session.user.id = token.id as string;
|
||||
session.user.isAdmin = token.isAdmin as boolean;
|
||||
}
|
||||
|
||||
expect(session).toEqual({});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,74 +0,0 @@
|
||||
import NextAuth from 'next-auth';
|
||||
import CredentialsProvider from 'next-auth/providers/credentials';
|
||||
import { db } from '@/db';
|
||||
import { users } from '@/db/schema';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import bcrypt from 'bcryptjs';
|
||||
|
||||
export const { handlers, signIn, signOut, auth } = NextAuth({
|
||||
trustHost: true,
|
||||
providers: [
|
||||
CredentialsProvider({
|
||||
name: '邮箱密码',
|
||||
credentials: {
|
||||
email: { label: '邮箱', type: 'email' },
|
||||
password: { label: '密码', type: 'password' },
|
||||
},
|
||||
async authorize(credentials) {
|
||||
if (!credentials?.email || !credentials?.password) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const userResult = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.email, credentials.email as string))
|
||||
.limit(1);
|
||||
|
||||
const user = userResult[0];
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isValid = await bcrypt.compare(
|
||||
credentials.password as string,
|
||||
user.passwordHash || ''
|
||||
);
|
||||
|
||||
if (!isValid) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
isAdmin: user.isAdmin,
|
||||
};
|
||||
},
|
||||
}),
|
||||
],
|
||||
callbacks: {
|
||||
async jwt({ token, user }) {
|
||||
if (user) {
|
||||
token.id = user.id;
|
||||
token.isAdmin = user.isAdmin;
|
||||
}
|
||||
return token;
|
||||
},
|
||||
async session({ session, token }) {
|
||||
if (session.user) {
|
||||
session.user.id = token.id as string;
|
||||
session.user.isAdmin = token.isAdmin as boolean;
|
||||
}
|
||||
return session;
|
||||
},
|
||||
},
|
||||
pages: {
|
||||
signIn: '/admin/login',
|
||||
error: '/admin/login',
|
||||
},
|
||||
session: {
|
||||
strategy: 'jwt',
|
||||
},
|
||||
});
|
||||
@@ -1,152 +0,0 @@
|
||||
import { auth } from '../auth';
|
||||
import { checkIsAdmin, requireAdmin, getAdminUserId, checkPermission, requirePermission } from './check-permission';
|
||||
import { isAdminUser, hasPermission } from './permissions';
|
||||
|
||||
jest.mock('../auth');
|
||||
jest.mock('./permissions');
|
||||
|
||||
describe('check-permission', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('checkIsAdmin', () => {
|
||||
it('should return false when no session', async () => {
|
||||
(auth as jest.Mock).mockResolvedValue(null);
|
||||
|
||||
const result = await checkIsAdmin();
|
||||
|
||||
expect(result).toEqual({ isAdmin: false });
|
||||
});
|
||||
|
||||
it('should return false when no user in session', async () => {
|
||||
(auth as jest.Mock).mockResolvedValue({ user: null });
|
||||
|
||||
const result = await checkIsAdmin();
|
||||
|
||||
expect(result).toEqual({ isAdmin: false });
|
||||
});
|
||||
|
||||
it('should return true when user is admin', async () => {
|
||||
(auth as jest.Mock).mockResolvedValue({
|
||||
user: { id: 'user-1', isAdmin: true },
|
||||
});
|
||||
(isAdminUser as jest.Mock).mockReturnValue(true);
|
||||
|
||||
const result = await checkIsAdmin();
|
||||
|
||||
expect(result).toEqual({ isAdmin: true, userId: 'user-1' });
|
||||
});
|
||||
|
||||
it('should return false when user is not admin', async () => {
|
||||
(auth as jest.Mock).mockResolvedValue({
|
||||
user: { id: 'user-1', isAdmin: false },
|
||||
});
|
||||
(isAdminUser as jest.Mock).mockReturnValue(false);
|
||||
|
||||
const result = await checkIsAdmin();
|
||||
|
||||
expect(result).toEqual({ isAdmin: false, userId: 'user-1' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('requireAdmin', () => {
|
||||
it('should throw error when not admin', async () => {
|
||||
(auth as jest.Mock).mockResolvedValue({
|
||||
user: { id: 'user-1', isAdmin: false },
|
||||
});
|
||||
(isAdminUser as jest.Mock).mockReturnValue(false);
|
||||
|
||||
await expect(requireAdmin()).rejects.toThrow('无权限执行此操作');
|
||||
});
|
||||
|
||||
it('should return userId when admin', async () => {
|
||||
(auth as jest.Mock).mockResolvedValue({
|
||||
user: { id: 'user-1', isAdmin: true },
|
||||
});
|
||||
(isAdminUser as jest.Mock).mockReturnValue(true);
|
||||
|
||||
const result = await requireAdmin();
|
||||
|
||||
expect(result).toEqual({ userId: 'user-1' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAdminUserId', () => {
|
||||
it('should return null when no session', async () => {
|
||||
(auth as jest.Mock).mockResolvedValue(null);
|
||||
|
||||
const result = await getAdminUserId();
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should return userId when session exists', async () => {
|
||||
(auth as jest.Mock).mockResolvedValue({
|
||||
user: { id: 'user-1' },
|
||||
});
|
||||
|
||||
const result = await getAdminUserId();
|
||||
|
||||
expect(result).toBe('user-1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkPermission', () => {
|
||||
it('should return false when no session', async () => {
|
||||
(auth as jest.Mock).mockResolvedValue(null);
|
||||
|
||||
const result = await checkPermission('content', 'read');
|
||||
|
||||
expect(result).toEqual({ allowed: false });
|
||||
});
|
||||
|
||||
it('should check permission for admin user', async () => {
|
||||
(auth as jest.Mock).mockResolvedValue({
|
||||
user: { id: 'user-1', isAdmin: true },
|
||||
});
|
||||
(hasPermission as jest.Mock).mockReturnValue(true);
|
||||
|
||||
const result = await checkPermission('content', 'write');
|
||||
|
||||
expect(result.allowed).toBe(true);
|
||||
expect(result.userId).toBe('user-1');
|
||||
expect(result.role).toBe('admin');
|
||||
});
|
||||
|
||||
it('should check permission for viewer user', async () => {
|
||||
(auth as jest.Mock).mockResolvedValue({
|
||||
user: { id: 'user-1', isAdmin: false },
|
||||
});
|
||||
(hasPermission as jest.Mock).mockReturnValue(false);
|
||||
|
||||
const result = await checkPermission('content', 'write');
|
||||
|
||||
expect(result.allowed).toBe(false);
|
||||
expect(result.userId).toBe('user-1');
|
||||
expect(result.role).toBe('viewer');
|
||||
});
|
||||
});
|
||||
|
||||
describe('requirePermission', () => {
|
||||
it('should throw error when not allowed', async () => {
|
||||
(auth as jest.Mock).mockResolvedValue({
|
||||
user: { id: 'user-1', isAdmin: false },
|
||||
});
|
||||
(hasPermission as jest.Mock).mockReturnValue(false);
|
||||
|
||||
await expect(requirePermission('content', 'write')).rejects.toThrow('无权限执行此操作');
|
||||
});
|
||||
|
||||
it('should return userId and role when allowed', async () => {
|
||||
(auth as jest.Mock).mockResolvedValue({
|
||||
user: { id: 'user-1', isAdmin: true },
|
||||
});
|
||||
(hasPermission as jest.Mock).mockReturnValue(true);
|
||||
|
||||
const result = await requirePermission('content', 'write');
|
||||
|
||||
expect(result).toEqual({ userId: 'user-1', role: 'admin' });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,75 +0,0 @@
|
||||
import { auth } from '../auth';
|
||||
import { isAdminUser, hasPermission } from './permissions';
|
||||
|
||||
export async function checkIsAdmin(): Promise<{ isAdmin: boolean; userId?: string }> {
|
||||
const session = await auth();
|
||||
|
||||
if (!session || !session.user) {
|
||||
return { isAdmin: false };
|
||||
}
|
||||
|
||||
const isAdmin = isAdminUser(session.user.isAdmin as boolean | undefined);
|
||||
|
||||
return {
|
||||
isAdmin,
|
||||
userId: session.user.id,
|
||||
};
|
||||
}
|
||||
|
||||
export async function requireAdmin(): Promise<{ userId: string }> {
|
||||
const result = await checkIsAdmin();
|
||||
|
||||
if (!result.isAdmin) {
|
||||
throw new Error('无权限执行此操作');
|
||||
}
|
||||
|
||||
return {
|
||||
userId: result.userId!,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getAdminUserId(): Promise<string | null> {
|
||||
const session = await auth();
|
||||
if (!session?.user) {
|
||||
return null;
|
||||
}
|
||||
return session.user.id;
|
||||
}
|
||||
|
||||
export async function checkPermission(
|
||||
resource: Parameters<typeof hasPermission>[1],
|
||||
action: Parameters<typeof hasPermission>[2]
|
||||
): Promise<{ allowed: boolean; userId?: string; role?: string }> {
|
||||
const session = await auth();
|
||||
|
||||
if (!session || !session.user) {
|
||||
return { allowed: false };
|
||||
}
|
||||
|
||||
const isAdmin = session.user.isAdmin as boolean | undefined;
|
||||
const role: Parameters<typeof hasPermission>[0] = isAdmin ? 'admin' : 'viewer';
|
||||
|
||||
const allowed = hasPermission(role, resource, action);
|
||||
|
||||
return {
|
||||
allowed,
|
||||
userId: session.user.id,
|
||||
role,
|
||||
};
|
||||
}
|
||||
|
||||
export async function requirePermission(
|
||||
resource: Parameters<typeof hasPermission>[1],
|
||||
action: Parameters<typeof hasPermission>[2]
|
||||
): Promise<{ userId: string; role: string }> {
|
||||
const result = await checkPermission(resource, action);
|
||||
|
||||
if (!result.allowed) {
|
||||
throw new Error('无权限执行此操作');
|
||||
}
|
||||
|
||||
return {
|
||||
userId: result.userId!,
|
||||
role: result.role!,
|
||||
};
|
||||
}
|
||||
@@ -1,276 +0,0 @@
|
||||
import { describe, it, expect } from '@jest/globals';
|
||||
import { PERMISSIONS, hasPermission, Role, Resource, Action } from './permissions';
|
||||
|
||||
describe('permissions', () => {
|
||||
describe('PERMISSIONS constant', () => {
|
||||
it('should have admin permissions', () => {
|
||||
expect(PERMISSIONS.admin).toBeDefined();
|
||||
expect(PERMISSIONS.admin.content).toContain('create');
|
||||
expect(PERMISSIONS.admin.content).toContain('read');
|
||||
expect(PERMISSIONS.admin.content).toContain('update');
|
||||
expect(PERMISSIONS.admin.content).toContain('delete');
|
||||
expect(PERMISSIONS.admin.content).toContain('publish');
|
||||
});
|
||||
|
||||
it('should have editor permissions', () => {
|
||||
expect(PERMISSIONS.editor).toBeDefined();
|
||||
expect(PERMISSIONS.editor.content).toContain('create');
|
||||
expect(PERMISSIONS.editor.content).toContain('read');
|
||||
expect(PERMISSIONS.editor.content).toContain('update');
|
||||
expect(PERMISSIONS.editor.content).toContain('publish');
|
||||
expect(PERMISSIONS.editor.content).not.toContain('delete');
|
||||
});
|
||||
|
||||
it('should have viewer permissions', () => {
|
||||
expect(PERMISSIONS.viewer).toBeDefined();
|
||||
expect(PERMISSIONS.viewer.content).toContain('read');
|
||||
expect(PERMISSIONS.viewer.content).not.toContain('create');
|
||||
expect(PERMISSIONS.viewer.content).not.toContain('update');
|
||||
expect(PERMISSIONS.viewer.content).not.toContain('delete');
|
||||
});
|
||||
|
||||
it('should have config permissions', () => {
|
||||
expect(PERMISSIONS.admin.config).toContain('read');
|
||||
expect(PERMISSIONS.admin.config).toContain('update');
|
||||
expect(PERMISSIONS.editor.config).toContain('read');
|
||||
expect(PERMISSIONS.editor.config).not.toContain('update');
|
||||
});
|
||||
|
||||
it('should have users permissions', () => {
|
||||
expect(PERMISSIONS.admin.users).toContain('create');
|
||||
expect(PERMISSIONS.admin.users).toContain('read');
|
||||
expect(PERMISSIONS.admin.users).toContain('update');
|
||||
expect(PERMISSIONS.admin.users).toContain('delete');
|
||||
expect(PERMISSIONS.editor.users).toEqual([]);
|
||||
expect(PERMISSIONS.viewer.users).toEqual([]);
|
||||
});
|
||||
|
||||
it('should have logs permissions', () => {
|
||||
expect(PERMISSIONS.admin.logs).toContain('read');
|
||||
expect(PERMISSIONS.editor.logs).toContain('read');
|
||||
expect(PERMISSIONS.viewer.logs).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasPermission', () => {
|
||||
describe('admin role', () => {
|
||||
it('should allow all content actions', () => {
|
||||
const role: Role = 'admin';
|
||||
const resource: Resource = 'content';
|
||||
|
||||
expect(hasPermission(role, resource, 'create')).toBe(true);
|
||||
expect(hasPermission(role, resource, 'read')).toBe(true);
|
||||
expect(hasPermission(role, resource, 'update')).toBe(true);
|
||||
expect(hasPermission(role, resource, 'delete')).toBe(true);
|
||||
expect(hasPermission(role, resource, 'publish')).toBe(true);
|
||||
});
|
||||
|
||||
it('should allow config actions', () => {
|
||||
const role: Role = 'admin';
|
||||
const resource: Resource = 'config';
|
||||
|
||||
expect(hasPermission(role, resource, 'read')).toBe(true);
|
||||
expect(hasPermission(role, resource, 'update')).toBe(true);
|
||||
});
|
||||
|
||||
it('should allow all users actions', () => {
|
||||
const role: Role = 'admin';
|
||||
const resource: Resource = 'users';
|
||||
|
||||
expect(hasPermission(role, resource, 'create')).toBe(true);
|
||||
expect(hasPermission(role, resource, 'read')).toBe(true);
|
||||
expect(hasPermission(role, resource, 'update')).toBe(true);
|
||||
expect(hasPermission(role, resource, 'delete')).toBe(true);
|
||||
});
|
||||
|
||||
it('should allow logs read', () => {
|
||||
const role: Role = 'admin';
|
||||
const resource: Resource = 'logs';
|
||||
|
||||
expect(hasPermission(role, resource, 'read')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('editor role', () => {
|
||||
it('should allow content actions except delete', () => {
|
||||
const role: Role = 'editor';
|
||||
const resource: Resource = 'content';
|
||||
|
||||
expect(hasPermission(role, resource, 'create')).toBe(true);
|
||||
expect(hasPermission(role, resource, 'read')).toBe(true);
|
||||
expect(hasPermission(role, resource, 'update')).toBe(true);
|
||||
expect(hasPermission(role, resource, 'delete')).toBe(false);
|
||||
expect(hasPermission(role, resource, 'publish')).toBe(true);
|
||||
});
|
||||
|
||||
it('should allow config read only', () => {
|
||||
const role: Role = 'editor';
|
||||
const resource: Resource = 'config';
|
||||
|
||||
expect(hasPermission(role, resource, 'read')).toBe(true);
|
||||
expect(hasPermission(role, resource, 'update')).toBe(false);
|
||||
});
|
||||
|
||||
it('should not allow users actions', () => {
|
||||
const role: Role = 'editor';
|
||||
const resource: Resource = 'users';
|
||||
|
||||
expect(hasPermission(role, resource, 'create')).toBe(false);
|
||||
expect(hasPermission(role, resource, 'read')).toBe(false);
|
||||
expect(hasPermission(role, resource, 'update')).toBe(false);
|
||||
expect(hasPermission(role, resource, 'delete')).toBe(false);
|
||||
});
|
||||
|
||||
it('should allow logs read', () => {
|
||||
const role: Role = 'editor';
|
||||
const resource: Resource = 'logs';
|
||||
|
||||
expect(hasPermission(role, resource, 'read')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('viewer role', () => {
|
||||
it('should only allow content read', () => {
|
||||
const role: Role = 'viewer';
|
||||
const resource: Resource = 'content';
|
||||
|
||||
expect(hasPermission(role, resource, 'read')).toBe(true);
|
||||
expect(hasPermission(role, resource, 'create')).toBe(false);
|
||||
expect(hasPermission(role, resource, 'update')).toBe(false);
|
||||
expect(hasPermission(role, resource, 'delete')).toBe(false);
|
||||
expect(hasPermission(role, resource, 'publish')).toBe(false);
|
||||
});
|
||||
|
||||
it('should allow config read only', () => {
|
||||
const role: Role = 'viewer';
|
||||
const resource: Resource = 'config';
|
||||
|
||||
expect(hasPermission(role, resource, 'read')).toBe(true);
|
||||
expect(hasPermission(role, resource, 'update')).toBe(false);
|
||||
});
|
||||
|
||||
it('should not allow users actions', () => {
|
||||
const role: Role = 'viewer';
|
||||
const resource: Resource = 'users';
|
||||
|
||||
expect(hasPermission(role, resource, 'create')).toBe(false);
|
||||
expect(hasPermission(role, resource, 'read')).toBe(false);
|
||||
expect(hasPermission(role, resource, 'update')).toBe(false);
|
||||
expect(hasPermission(role, resource, 'delete')).toBe(false);
|
||||
});
|
||||
|
||||
it('should not allow logs actions', () => {
|
||||
const role: Role = 'viewer';
|
||||
const resource: Resource = 'logs';
|
||||
|
||||
expect(hasPermission(role, resource, 'read')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should return false for invalid role', () => {
|
||||
const role = 'invalid' as Role;
|
||||
const resource: Resource = 'content';
|
||||
|
||||
expect(hasPermission(role, resource, 'read')).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for invalid resource', () => {
|
||||
const role: Role = 'admin';
|
||||
const resource = 'invalid' as Resource;
|
||||
|
||||
expect(hasPermission(role, resource, 'read')).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for invalid action', () => {
|
||||
const role: Role = 'admin';
|
||||
const resource: Resource = 'content';
|
||||
const action = 'invalid' as Action;
|
||||
|
||||
expect(hasPermission(role, resource, action)).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle null role', () => {
|
||||
const role = null as any;
|
||||
const resource: Resource = 'content';
|
||||
|
||||
expect(hasPermission(role, resource, 'read')).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle undefined resource', () => {
|
||||
const role: Role = 'admin';
|
||||
const resource = undefined as any;
|
||||
|
||||
expect(hasPermission(role, resource, 'read')).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle empty action string', () => {
|
||||
const role: Role = 'admin';
|
||||
const resource: Resource = 'content';
|
||||
const action = '' as Action;
|
||||
|
||||
expect(hasPermission(role, resource, action)).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle case sensitivity for roles', () => {
|
||||
const role = 'ADMIN' as Role;
|
||||
const resource: Resource = 'content';
|
||||
|
||||
expect(hasPermission(role, resource, 'read')).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle case sensitivity for resources', () => {
|
||||
const role: Role = 'admin';
|
||||
const resource = 'CONTENT' as Resource;
|
||||
|
||||
expect(hasPermission(role, resource, 'read')).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle case sensitivity for actions', () => {
|
||||
const role: Role = 'admin';
|
||||
const resource: Resource = 'content';
|
||||
const action = 'READ' as Action;
|
||||
|
||||
expect(hasPermission(role, resource, action)).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle undefined role', () => {
|
||||
const role = undefined as any;
|
||||
const resource: Resource = 'content';
|
||||
|
||||
expect(hasPermission(role, resource, 'read')).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle numeric role', () => {
|
||||
const role = 123 as any;
|
||||
const resource: Resource = 'content';
|
||||
|
||||
expect(hasPermission(role, resource, 'read')).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle object role', () => {
|
||||
const role = {} as any;
|
||||
const resource: Resource = 'content';
|
||||
|
||||
expect(hasPermission(role, resource, 'read')).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Type Exports', () => {
|
||||
it('should export Role type', () => {
|
||||
const role: Role = 'admin';
|
||||
expect(role).toBeDefined();
|
||||
});
|
||||
|
||||
it('should export Resource type', () => {
|
||||
const resource: Resource = 'content';
|
||||
expect(resource).toBeDefined();
|
||||
});
|
||||
|
||||
it('should export Action type', () => {
|
||||
const action: Action = 'read';
|
||||
expect(action).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,42 +0,0 @@
|
||||
export const PERMISSIONS = {
|
||||
admin: {
|
||||
content: ['create', 'read', 'update', 'delete', 'publish'],
|
||||
config: ['read', 'update'],
|
||||
users: ['create', 'read', 'update', 'delete'],
|
||||
logs: ['read'],
|
||||
},
|
||||
editor: {
|
||||
content: ['create', 'read', 'update', 'publish'],
|
||||
config: ['read'],
|
||||
users: [],
|
||||
logs: ['read'],
|
||||
},
|
||||
viewer: {
|
||||
content: ['read'],
|
||||
config: ['read'],
|
||||
users: [],
|
||||
logs: [],
|
||||
},
|
||||
} as const;
|
||||
|
||||
export type Role = keyof typeof PERMISSIONS;
|
||||
export type Resource = keyof typeof PERMISSIONS.admin;
|
||||
export type Action = 'create' | 'read' | 'update' | 'delete' | 'publish';
|
||||
|
||||
export function hasPermission(
|
||||
role: Role,
|
||||
resource: Resource,
|
||||
action: Action
|
||||
): boolean {
|
||||
const permissions = PERMISSIONS[role];
|
||||
if (!permissions) return false;
|
||||
|
||||
const resourcePermissions = permissions[resource];
|
||||
if (!resourcePermissions) return false;
|
||||
|
||||
return resourcePermissions.includes(action as never);
|
||||
}
|
||||
|
||||
export function isAdminUser(isAdmin: boolean | undefined): boolean {
|
||||
return isAdmin === true;
|
||||
}
|
||||
@@ -1,198 +0,0 @@
|
||||
import { describe, it, expect, beforeEach } from '@jest/globals';
|
||||
import {
|
||||
createSession,
|
||||
isSessionValid,
|
||||
getSessionAge,
|
||||
getSessionTimeRemaining,
|
||||
isSessionExpired,
|
||||
createSessionWithCustomExpiration,
|
||||
Session,
|
||||
SessionData,
|
||||
} from './session';
|
||||
|
||||
describe('session management', () => {
|
||||
describe('createSession', () => {
|
||||
it('should create session with user data', () => {
|
||||
const session = createSession({ userId: '123', role: 'admin' });
|
||||
expect(session).toBeDefined();
|
||||
expect(session.userId).toBe('123');
|
||||
expect(session.role).toBe('admin');
|
||||
});
|
||||
|
||||
it('should create session with createdAt timestamp', () => {
|
||||
const beforeCreate = Date.now();
|
||||
const session = createSession({ userId: '123' });
|
||||
const afterCreate = Date.now();
|
||||
expect(session.createdAt).toBeGreaterThanOrEqual(beforeCreate);
|
||||
expect(session.createdAt).toBeLessThanOrEqual(afterCreate);
|
||||
});
|
||||
|
||||
it('should create session with 24 hour expiration', () => {
|
||||
const session = createSession({ userId: '123' });
|
||||
const expectedExpiration = session.createdAt + (24 * 60 * 60 * 1000);
|
||||
expect(session.expiresAt).toBe(expectedExpiration);
|
||||
});
|
||||
|
||||
it('should create session without optional role', () => {
|
||||
const session = createSession({ userId: '123' });
|
||||
expect(session.userId).toBe('123');
|
||||
expect(session.role).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('isSessionValid', () => {
|
||||
it('should return true for valid session', () => {
|
||||
const session = createSession({ userId: '123' });
|
||||
expect(isSessionValid(session)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for expired session', () => {
|
||||
const expiredSession = createSessionWithCustomExpiration({ userId: '123' }, -1000);
|
||||
expect(isSessionValid(expiredSession)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for session with zero expiration time', () => {
|
||||
const session = createSessionWithCustomExpiration({ userId: '123' }, 0);
|
||||
expect(isSessionValid(session)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true for session with 1ms remaining', () => {
|
||||
const session = createSessionWithCustomExpiration({ userId: '123' }, 1);
|
||||
expect(isSessionValid(session)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSessionAge', () => {
|
||||
it('should return age of session in milliseconds', () => {
|
||||
const session = createSession({ userId: '123' });
|
||||
const age = getSessionAge(session);
|
||||
expect(age).toBeGreaterThanOrEqual(0);
|
||||
expect(age).toBeLessThan(100);
|
||||
});
|
||||
|
||||
it('should increase over time', async () => {
|
||||
const session = createSession({ userId: '123' });
|
||||
const age1 = getSessionAge(session);
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
const age2 = getSessionAge(session);
|
||||
expect(age2).toBeGreaterThan(age1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSessionTimeRemaining', () => {
|
||||
it('should return positive time for valid session', () => {
|
||||
const session = createSession({ userId: '123' });
|
||||
const remaining = getSessionTimeRemaining(session);
|
||||
expect(remaining).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should return approximately 24 hours for new session', () => {
|
||||
const session = createSession({ userId: '123' });
|
||||
const remaining = getSessionTimeRemaining(session);
|
||||
const expectedRemaining = 24 * 60 * 60 * 1000;
|
||||
const tolerance = 100;
|
||||
expect(remaining).toBeGreaterThanOrEqual(expectedRemaining - tolerance);
|
||||
expect(remaining).toBeLessThanOrEqual(expectedRemaining + tolerance);
|
||||
});
|
||||
|
||||
it('should return 0 for expired session', () => {
|
||||
const expiredSession = createSessionWithCustomExpiration({ userId: '123' }, -1000);
|
||||
const remaining = getSessionTimeRemaining(expiredSession);
|
||||
expect(remaining).toBe(0);
|
||||
});
|
||||
|
||||
it('should return 0 for session at exact expiration time', () => {
|
||||
const session = createSessionWithCustomExpiration({ userId: '123' }, 0);
|
||||
const remaining = getSessionTimeRemaining(session);
|
||||
expect(remaining).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isSessionExpired', () => {
|
||||
it('should return false for valid session', () => {
|
||||
const session = createSession({ userId: '123' });
|
||||
expect(isSessionExpired(session)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true for expired session', () => {
|
||||
const expiredSession = createSessionWithCustomExpiration({ userId: '123' }, -1000);
|
||||
expect(isSessionExpired(expiredSession)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for session at exact expiration time', () => {
|
||||
const session = createSessionWithCustomExpiration({ userId: '123' }, 0);
|
||||
expect(isSessionExpired(session)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for session with 1ms remaining', () => {
|
||||
const session = createSessionWithCustomExpiration({ userId: '123' }, 1);
|
||||
expect(isSessionExpired(session)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createSessionWithCustomExpiration', () => {
|
||||
it('should create session with custom expiration time', () => {
|
||||
const session = createSessionWithCustomExpiration({ userId: '123' }, 60000);
|
||||
const expectedExpiration = session.createdAt + 60000;
|
||||
expect(session.expiresAt).toBe(expectedExpiration);
|
||||
});
|
||||
|
||||
it('should create session with negative expiration time', () => {
|
||||
const session = createSessionWithCustomExpiration({ userId: '123' }, -1000);
|
||||
const expectedExpiration = session.createdAt - 1000;
|
||||
expect(session.expiresAt).toBe(expectedExpiration);
|
||||
});
|
||||
|
||||
it('should preserve user data', () => {
|
||||
const session = createSessionWithCustomExpiration(
|
||||
{ userId: '123', role: 'admin' },
|
||||
60000
|
||||
);
|
||||
expect(session.userId).toBe('123');
|
||||
expect(session.role).toBe('admin');
|
||||
});
|
||||
});
|
||||
|
||||
describe('session lifecycle', () => {
|
||||
it('should track session from creation to expiration', async () => {
|
||||
const session = createSessionWithCustomExpiration({ userId: '123' }, 100);
|
||||
|
||||
expect(isSessionValid(session)).toBe(true);
|
||||
expect(isSessionExpired(session)).toBe(false);
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 150));
|
||||
|
||||
expect(isSessionValid(session)).toBe(false);
|
||||
expect(isSessionExpired(session)).toBe(true);
|
||||
});
|
||||
|
||||
it('should have consistent age and time remaining', () => {
|
||||
const session = createSessionWithCustomExpiration({ userId: '123' }, 10000);
|
||||
const age = getSessionAge(session);
|
||||
const remaining = getSessionTimeRemaining(session);
|
||||
const totalLifetime = age + remaining;
|
||||
|
||||
expect(totalLifetime).toBeCloseTo(10000, -2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('session data integrity', () => {
|
||||
it('should maintain session data immutability', () => {
|
||||
const originalData: SessionData = { userId: '123', role: 'admin' };
|
||||
const session = createSession(originalData);
|
||||
|
||||
expect(session.userId).toBe(originalData.userId);
|
||||
expect(session.role).toBe(originalData.role);
|
||||
});
|
||||
|
||||
it('should handle empty role gracefully', () => {
|
||||
const session = createSession({ userId: '123', role: '' });
|
||||
expect(session.role).toBe('');
|
||||
});
|
||||
|
||||
it('should handle special characters in userId', () => {
|
||||
const session = createSession({ userId: 'user-123@example.com' });
|
||||
expect(session.userId).toBe('user-123@example.com');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,41 +0,0 @@
|
||||
export interface SessionData {
|
||||
userId: string;
|
||||
role?: string;
|
||||
}
|
||||
|
||||
export interface Session extends SessionData {
|
||||
createdAt: number;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
export function createSession(userData: SessionData): Session {
|
||||
return {
|
||||
...userData,
|
||||
createdAt: Date.now(),
|
||||
expiresAt: Date.now() + (24 * 60 * 60 * 1000),
|
||||
};
|
||||
}
|
||||
|
||||
export function isSessionValid(session: Session): boolean {
|
||||
return Date.now() < session.expiresAt;
|
||||
}
|
||||
|
||||
export function getSessionAge(session: Session): number {
|
||||
return Date.now() - session.createdAt;
|
||||
}
|
||||
|
||||
export function getSessionTimeRemaining(session: Session): number {
|
||||
return Math.max(0, session.expiresAt - Date.now());
|
||||
}
|
||||
|
||||
export function isSessionExpired(session: Session): boolean {
|
||||
return Date.now() >= session.expiresAt;
|
||||
}
|
||||
|
||||
export function createSessionWithCustomExpiration(userData: SessionData, expiresInMs: number): Session {
|
||||
return {
|
||||
...userData,
|
||||
createdAt: Date.now(),
|
||||
expiresAt: Date.now() + expiresInMs,
|
||||
};
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
import {
|
||||
generateCSRFToken,
|
||||
validateCSRFToken,
|
||||
getCSRFTokenFromStorage,
|
||||
setCSRFTokenToStorage,
|
||||
} from './csrf';
|
||||
|
||||
describe('csrf', () => {
|
||||
describe('generateCSRFToken', () => {
|
||||
it('should generate a token of correct length', () => {
|
||||
const token = generateCSRFToken();
|
||||
expect(token).toHaveLength(64);
|
||||
});
|
||||
|
||||
it('should generate unique tokens', () => {
|
||||
const token1 = generateCSRFToken();
|
||||
const token2 = generateCSRFToken();
|
||||
expect(token1).not.toBe(token2);
|
||||
});
|
||||
|
||||
it('should only contain hexadecimal characters', () => {
|
||||
const token = generateCSRFToken();
|
||||
expect(token).toMatch(/^[0-9a-f]{64}$/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateCSRFToken', () => {
|
||||
it('should return true for matching tokens', () => {
|
||||
const token = generateCSRFToken();
|
||||
expect(validateCSRFToken(token, token)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for mismatched tokens', () => {
|
||||
const token1 = generateCSRFToken();
|
||||
const token2 = generateCSRFToken();
|
||||
expect(validateCSRFToken(token1, token2)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for empty tokens', () => {
|
||||
expect(validateCSRFToken('', '')).toBe(false);
|
||||
expect(validateCSRFToken('token', '')).toBe(false);
|
||||
expect(validateCSRFToken('', 'token')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getCSRFTokenFromStorage', () => {
|
||||
it('should return token from sessionStorage', () => {
|
||||
sessionStorage.setItem('csrf_token', 'test-token');
|
||||
const token = getCSRFTokenFromStorage();
|
||||
expect(token).toBe('test-token');
|
||||
});
|
||||
|
||||
it('should return null when token not found', () => {
|
||||
sessionStorage.removeItem('csrf_token');
|
||||
const token = getCSRFTokenFromStorage();
|
||||
expect(token).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('setCSRFTokenToStorage', () => {
|
||||
it('should set token in sessionStorage', () => {
|
||||
setCSRFTokenToStorage('test-token');
|
||||
expect(sessionStorage.getItem('csrf_token')).toBe('test-token');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,29 +0,0 @@
|
||||
export function generateCSRFToken(): string {
|
||||
const array = new Uint8Array(32);
|
||||
crypto.getRandomValues(array);
|
||||
return Array.from(array, (byte) => byte.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
|
||||
export function validateCSRFToken(token: string, storedToken: string): boolean {
|
||||
if (!token || !storedToken) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return token === storedToken;
|
||||
}
|
||||
|
||||
export function getCSRFTokenFromStorage(): string | null {
|
||||
if (typeof window === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return sessionStorage.getItem('csrf_token');
|
||||
}
|
||||
|
||||
export function setCSRFTokenToStorage(token: string): void {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
sessionStorage.setItem('csrf_token', token);
|
||||
}
|
||||
@@ -1,378 +0,0 @@
|
||||
import { describe, it, expect, beforeEach } from '@jest/globals';
|
||||
|
||||
jest.mock('./constants', () => ({
|
||||
COMPANY_INFO: {
|
||||
name: '诺瓦隆科技',
|
||||
email: 'contact@novalon.cn',
|
||||
phone: '400-123-4567',
|
||||
address: '北京市朝阳区科技园区',
|
||||
},
|
||||
}));
|
||||
|
||||
describe('Email Templates', () => {
|
||||
const mockContactData = {
|
||||
name: '张三',
|
||||
phone: '13800138000',
|
||||
email: 'zhangsan@example.com',
|
||||
message: '这是一条测试留言',
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('generateNotificationEmail', () => {
|
||||
it('should generate valid HTML email', async () => {
|
||||
const { generateNotificationEmail } = await import('./email-templates');
|
||||
const email = generateNotificationEmail(mockContactData);
|
||||
|
||||
expect(email).toContain('<!DOCTYPE html>');
|
||||
expect(email).toContain('<html>');
|
||||
expect(email).toContain('</html>');
|
||||
});
|
||||
|
||||
it('should include customer name', async () => {
|
||||
const { generateNotificationEmail } = await import('./email-templates');
|
||||
const email = generateNotificationEmail(mockContactData);
|
||||
|
||||
expect(email).toContain('张三');
|
||||
expect(email).toContain('客户姓名');
|
||||
});
|
||||
|
||||
it('should include customer phone', async () => {
|
||||
const { generateNotificationEmail } = await import('./email-templates');
|
||||
const email = generateNotificationEmail(mockContactData);
|
||||
|
||||
expect(email).toContain('13800138000');
|
||||
expect(email).toContain('联系电话');
|
||||
});
|
||||
|
||||
it('should include customer email', async () => {
|
||||
const { generateNotificationEmail } = await import('./email-templates');
|
||||
const email = generateNotificationEmail(mockContactData);
|
||||
|
||||
expect(email).toContain('zhangsan@example.com');
|
||||
expect(email).toContain('电子邮箱');
|
||||
});
|
||||
|
||||
it('should include message content', async () => {
|
||||
const { generateNotificationEmail } = await import('./email-templates');
|
||||
const email = generateNotificationEmail(mockContactData);
|
||||
|
||||
expect(email).toContain('这是一条测试留言');
|
||||
expect(email).toContain('留言内容');
|
||||
});
|
||||
|
||||
it('should include company name', async () => {
|
||||
const { generateNotificationEmail } = await import('./email-templates');
|
||||
const email = generateNotificationEmail(mockContactData);
|
||||
|
||||
expect(email).toContain('诺瓦隆科技');
|
||||
});
|
||||
|
||||
it('should include submit time', async () => {
|
||||
const { generateNotificationEmail } = await import('./email-templates');
|
||||
const email = generateNotificationEmail(mockContactData);
|
||||
|
||||
expect(email).toContain('提交时间');
|
||||
});
|
||||
|
||||
it('should include mailto link', async () => {
|
||||
const { generateNotificationEmail } = await import('./email-templates');
|
||||
const email = generateNotificationEmail(mockContactData);
|
||||
|
||||
expect(email).toContain('mailto:zhangsan@example.com');
|
||||
expect(email).toContain('快速回复');
|
||||
});
|
||||
|
||||
it('should include company address in footer', async () => {
|
||||
const { generateNotificationEmail } = await import('./email-templates');
|
||||
const email = generateNotificationEmail(mockContactData);
|
||||
|
||||
expect(email).toContain('北京市朝阳区科技园区');
|
||||
});
|
||||
|
||||
it('should have proper email title', async () => {
|
||||
const { generateNotificationEmail } = await import('./email-templates');
|
||||
const email = generateNotificationEmail(mockContactData);
|
||||
|
||||
expect(email).toContain('官网留言通知');
|
||||
});
|
||||
|
||||
it('should include responsive meta tag', async () => {
|
||||
const { generateNotificationEmail } = await import('./email-templates');
|
||||
const email = generateNotificationEmail(mockContactData);
|
||||
|
||||
expect(email).toContain('viewport');
|
||||
expect(email).toContain('width=device-width');
|
||||
});
|
||||
|
||||
it('should include UTF-8 charset', async () => {
|
||||
const { generateNotificationEmail } = await import('./email-templates');
|
||||
const email = generateNotificationEmail(mockContactData);
|
||||
|
||||
expect(email).toContain('charset="utf-8"');
|
||||
});
|
||||
|
||||
it('should handle long messages', async () => {
|
||||
const { generateNotificationEmail } = await import('./email-templates');
|
||||
const longMessage = '这是一条很长的留言'.repeat(100);
|
||||
const data = { ...mockContactData, message: longMessage };
|
||||
const email = generateNotificationEmail(data);
|
||||
|
||||
expect(email).toContain(longMessage);
|
||||
});
|
||||
|
||||
it('should handle special characters in name', async () => {
|
||||
const { generateNotificationEmail } = await import('./email-templates');
|
||||
const data = { ...mockContactData, name: '张三 <script>alert("xss")</script>' };
|
||||
const email = generateNotificationEmail(data);
|
||||
|
||||
expect(email).toContain('张三 <script>alert("xss")</script>');
|
||||
});
|
||||
|
||||
it('should handle special characters in message', async () => {
|
||||
const { generateNotificationEmail } = await import('./email-templates');
|
||||
const data = { ...mockContactData, message: '测试 & < > " \'' };
|
||||
const email = generateNotificationEmail(data);
|
||||
|
||||
expect(email).toContain('测试 & < > " \'');
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateConfirmationEmail', () => {
|
||||
it('should generate valid HTML email', async () => {
|
||||
const { generateConfirmationEmail } = await import('./email-templates');
|
||||
const email = generateConfirmationEmail(mockContactData);
|
||||
|
||||
expect(email).toContain('<!DOCTYPE html>');
|
||||
expect(email).toContain('<html>');
|
||||
expect(email).toContain('</html>');
|
||||
});
|
||||
|
||||
it('should include customer name', async () => {
|
||||
const { generateConfirmationEmail } = await import('./email-templates');
|
||||
const email = generateConfirmationEmail(mockContactData);
|
||||
|
||||
expect(email).toContain('张三');
|
||||
expect(email).toContain('尊敬的');
|
||||
});
|
||||
|
||||
it('should include message content', async () => {
|
||||
const { generateConfirmationEmail } = await import('./email-templates');
|
||||
const email = generateConfirmationEmail(mockContactData);
|
||||
|
||||
expect(email).toContain('这是一条测试留言');
|
||||
expect(email).toContain('您的留言内容');
|
||||
});
|
||||
|
||||
it('should include company name', async () => {
|
||||
const { generateConfirmationEmail } = await import('./email-templates');
|
||||
const email = generateConfirmationEmail(mockContactData);
|
||||
|
||||
expect(email).toContain('诺瓦隆科技');
|
||||
});
|
||||
|
||||
it('should include company contact information', async () => {
|
||||
const { generateConfirmationEmail } = await import('./email-templates');
|
||||
const email = generateConfirmationEmail(mockContactData);
|
||||
|
||||
expect(email).toContain('contact@novalon.cn');
|
||||
expect(email).toContain('北京市朝阳区科技园区');
|
||||
});
|
||||
|
||||
it('should include expected response time', async () => {
|
||||
const { generateConfirmationEmail } = await import('./email-templates');
|
||||
const email = generateConfirmationEmail(mockContactData);
|
||||
|
||||
expect(email).toContain('预计回复时间');
|
||||
expect(email).toContain('2小时内');
|
||||
});
|
||||
|
||||
it('should include working hours', async () => {
|
||||
const { generateConfirmationEmail } = await import('./email-templates');
|
||||
const email = generateConfirmationEmail(mockContactData);
|
||||
|
||||
expect(email).toContain('工作日');
|
||||
expect(email).toContain('9:00 - 18:00');
|
||||
});
|
||||
|
||||
it('should have proper email title', async () => {
|
||||
const { generateConfirmationEmail } = await import('./email-templates');
|
||||
const email = generateConfirmationEmail(mockContactData);
|
||||
|
||||
expect(email).toContain('感谢您的留言');
|
||||
});
|
||||
|
||||
it('should include success icon', async () => {
|
||||
const { generateConfirmationEmail } = await import('./email-templates');
|
||||
const email = generateConfirmationEmail(mockContactData);
|
||||
|
||||
expect(email).toContain('🎉');
|
||||
});
|
||||
|
||||
it('should include current year in footer', async () => {
|
||||
const { generateConfirmationEmail } = await import('./email-templates');
|
||||
const email = generateConfirmationEmail(mockContactData);
|
||||
const currentYear = new Date().getFullYear().toString();
|
||||
|
||||
expect(email).toContain(`© ${currentYear}`);
|
||||
});
|
||||
|
||||
it('should include responsive meta tag', async () => {
|
||||
const { generateConfirmationEmail } = await import('./email-templates');
|
||||
const email = generateConfirmationEmail(mockContactData);
|
||||
|
||||
expect(email).toContain('viewport');
|
||||
expect(email).toContain('width=device-width');
|
||||
});
|
||||
|
||||
it('should include UTF-8 charset', async () => {
|
||||
const { generateConfirmationEmail } = await import('./email-templates');
|
||||
const email = generateConfirmationEmail(mockContactData);
|
||||
|
||||
expect(email).toContain('charset="utf-8"');
|
||||
});
|
||||
|
||||
it('should handle long messages', async () => {
|
||||
const { generateConfirmationEmail } = await import('./email-templates');
|
||||
const longMessage = '这是一条很长的留言'.repeat(100);
|
||||
const data = { ...mockContactData, message: longMessage };
|
||||
const email = generateConfirmationEmail(data);
|
||||
|
||||
expect(email).toContain(longMessage);
|
||||
});
|
||||
|
||||
it('should handle special characters in name', async () => {
|
||||
const { generateConfirmationEmail } = await import('./email-templates');
|
||||
const data = { ...mockContactData, name: '张三 <script>alert("xss")</script>' };
|
||||
const email = generateConfirmationEmail(data);
|
||||
|
||||
expect(email).toContain('张三 <script>alert("xss")</script>');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Email Template Structure', () => {
|
||||
it('should have consistent styling in notification email', async () => {
|
||||
const { generateNotificationEmail } = await import('./email-templates');
|
||||
const email = generateNotificationEmail(mockContactData);
|
||||
|
||||
expect(email).toContain('style');
|
||||
expect(email).toContain('font-family');
|
||||
expect(email).toContain('max-width: 600px');
|
||||
});
|
||||
|
||||
it('should have consistent styling in confirmation email', async () => {
|
||||
const { generateConfirmationEmail } = await import('./email-templates');
|
||||
const email = generateConfirmationEmail(mockContactData);
|
||||
|
||||
expect(email).toContain('style');
|
||||
expect(email).toContain('font-family');
|
||||
expect(email).toContain('max-width: 600px');
|
||||
});
|
||||
|
||||
it('should use brand colors in notification email', async () => {
|
||||
const { generateNotificationEmail } = await import('./email-templates');
|
||||
const email = generateNotificationEmail(mockContactData);
|
||||
|
||||
expect(email).toContain('#C41E3A');
|
||||
expect(email).toContain('#1C1C1C');
|
||||
});
|
||||
|
||||
it('should use brand colors in confirmation email', async () => {
|
||||
const { generateConfirmationEmail } = await import('./email-templates');
|
||||
const email = generateConfirmationEmail(mockContactData);
|
||||
|
||||
expect(email).toContain('#C41E3A');
|
||||
expect(email).toContain('#1C1C1C');
|
||||
});
|
||||
|
||||
it('should have proper container structure in notification email', async () => {
|
||||
const { generateNotificationEmail } = await import('./email-templates');
|
||||
const email = generateNotificationEmail(mockContactData);
|
||||
|
||||
expect(email).toContain('class="container"');
|
||||
expect(email).toContain('class="header"');
|
||||
expect(email).toContain('class="content"');
|
||||
expect(email).toContain('class="footer"');
|
||||
});
|
||||
|
||||
it('should have proper container structure in confirmation email', async () => {
|
||||
const { generateConfirmationEmail } = await import('./email-templates');
|
||||
const email = generateConfirmationEmail(mockContactData);
|
||||
|
||||
expect(email).toContain('class="container"');
|
||||
expect(email).toContain('class="header"');
|
||||
expect(email).toContain('class="content"');
|
||||
expect(email).toContain('class="footer"');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle empty message', async () => {
|
||||
const { generateNotificationEmail } = await import('./email-templates');
|
||||
const data = { ...mockContactData, message: '' };
|
||||
const email = generateNotificationEmail(data);
|
||||
|
||||
expect(email).toContain('留言内容');
|
||||
});
|
||||
|
||||
it('should handle empty name', async () => {
|
||||
const { generateConfirmationEmail } = await import('./email-templates');
|
||||
const data = { ...mockContactData, name: '' };
|
||||
const email = generateConfirmationEmail(data);
|
||||
|
||||
expect(email).toContain('尊敬的');
|
||||
});
|
||||
|
||||
it('should handle email with special characters', async () => {
|
||||
const { generateNotificationEmail } = await import('./email-templates');
|
||||
const data = { ...mockContactData, email: 'test+special@example.com' };
|
||||
const email = generateNotificationEmail(data);
|
||||
|
||||
expect(email).toContain('test+special@example.com');
|
||||
});
|
||||
|
||||
it('should handle phone number with spaces', async () => {
|
||||
const { generateNotificationEmail } = await import('./email-templates');
|
||||
const data = { ...mockContactData, phone: '138 0013 8000' };
|
||||
const email = generateNotificationEmail(data);
|
||||
|
||||
expect(email).toContain('138 0013 8000');
|
||||
});
|
||||
|
||||
it('should handle unicode characters in message', async () => {
|
||||
const { generateNotificationEmail } = await import('./email-templates');
|
||||
const data = { ...mockContactData, message: '测试 emoji: 😀 🎉 📧' };
|
||||
const email = generateNotificationEmail(data);
|
||||
|
||||
expect(email).toContain('测试 emoji: 😀 🎉 📧');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Performance Tests', () => {
|
||||
it('should generate notification email quickly', async () => {
|
||||
const { generateNotificationEmail } = await import('./email-templates');
|
||||
|
||||
const start = performance.now();
|
||||
for (let i = 0; i < 100; i++) {
|
||||
generateNotificationEmail(mockContactData);
|
||||
}
|
||||
const end = performance.now();
|
||||
|
||||
expect(end - start).toBeLessThan(1000);
|
||||
});
|
||||
|
||||
it('should generate confirmation email quickly', async () => {
|
||||
const { generateConfirmationEmail } = await import('./email-templates');
|
||||
|
||||
const start = performance.now();
|
||||
for (let i = 0; i < 100; i++) {
|
||||
generateConfirmationEmail(mockContactData);
|
||||
}
|
||||
const end = performance.now();
|
||||
|
||||
expect(end - start).toBeLessThan(1000);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,150 +0,0 @@
|
||||
import { COMPANY_INFO } from './constants';
|
||||
|
||||
export interface ContactFormData {
|
||||
name: string;
|
||||
phone: string;
|
||||
email: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export function generateNotificationEmail(data: ContactFormData): string {
|
||||
const submitTime = new Date().toLocaleString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
|
||||
return `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>官网留言通知</title>
|
||||
<style>
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif; line-height: 1.6; color: #333; margin: 0; padding: 20px; }
|
||||
.container { max-width: 600px; margin: 0 auto; background: #fff; border-radius: 8px; overflow: hidden; box-shadow: 0 2px 8px rgba(0,0,0,0.1); }
|
||||
.header { background: linear-gradient(135deg, #C41E3A 0%, #1C1C1C 100%); color: #fff; padding: 30px; text-align: center; }
|
||||
.header h1 { margin: 0; font-size: 24px; }
|
||||
.content { padding: 30px; }
|
||||
.info-card { background: #F5F5F5; padding: 20px; border-radius: 6px; margin-bottom: 20px; }
|
||||
.info-item { margin-bottom: 12px; }
|
||||
.info-label { font-weight: 600; color: #666; font-size: 14px; }
|
||||
.info-value { color: #333; font-size: 16px; margin-top: 4px; }
|
||||
.message-box { background: #FFFBF5; padding: 20px; border-left: 4px solid #C41E3A; border-radius: 4px; margin-top: 20px; }
|
||||
.message-label { font-weight: 600; color: #C41E3A; margin-bottom: 10px; }
|
||||
.message-content { color: #333; white-space: pre-wrap; }
|
||||
.footer { background: #F5F5F5; padding: 20px; text-align: center; color: #666; font-size: 14px; }
|
||||
.cta-button { display: inline-block; background: #C41E3A; color: #fff; text-decoration: none; padding: 12px 24px; border-radius: 4px; margin-top: 20px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>📧 官网留言通知</h1>
|
||||
<p style="margin: 10px 0 0 0; opacity: 0.9;">${COMPANY_INFO.name}</p>
|
||||
</div>
|
||||
<div class="content">
|
||||
<div class="info-card">
|
||||
<div class="info-item">
|
||||
<div class="info-label">客户姓名</div>
|
||||
<div class="info-value">${data.name}</div>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<div class="info-label">联系电话</div>
|
||||
<div class="info-value">${data.phone}</div>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<div class="info-label">电子邮箱</div>
|
||||
<div class="info-value">${data.email}</div>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<div class="info-label">提交时间</div>
|
||||
<div class="info-value">${submitTime}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="message-box">
|
||||
<div class="message-label">💬 留言内容</div>
|
||||
<div class="message-content">${data.message}</div>
|
||||
</div>
|
||||
<div style="text-align: center; margin-top: 30px;">
|
||||
<a href="mailto:${data.email}" class="cta-button">📩 快速回复</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="footer">
|
||||
<p>此邮件来自 ${COMPANY_INFO.name} 官网联系表单</p>
|
||||
<p style="margin: 5px 0 0 0;">${COMPANY_INFO.address}</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`.trim();
|
||||
}
|
||||
|
||||
export function generateConfirmationEmail(data: ContactFormData): string {
|
||||
return `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>感谢您的留言</title>
|
||||
<style>
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif; line-height: 1.6; color: #333; margin: 0; padding: 20px; }
|
||||
.container { max-width: 600px; margin: 0 auto; background: #fff; border-radius: 8px; overflow: hidden; box-shadow: 0 2px 8px rgba(0,0,0,0.1); }
|
||||
.header { background: linear-gradient(135deg, #C41E3A 0%, #1C1C1C 100%); color: #fff; padding: 40px 30px; text-align: center; }
|
||||
.header h1 { margin: 0; font-size: 28px; }
|
||||
.header p { margin: 10px 0 0 0; opacity: 0.9; }
|
||||
.content { padding: 30px; }
|
||||
.success-icon { text-align: center; font-size: 60px; margin-bottom: 20px; }
|
||||
.message { background: #FFFBF5; padding: 20px; border-radius: 6px; border-left: 4px solid #C41E3A; margin: 20px 0; }
|
||||
.contact-info { background: #F5F5F5; padding: 20px; border-radius: 6px; margin-top: 20px; }
|
||||
.contact-item { display: flex; align-items: center; margin-bottom: 12px; }
|
||||
.contact-icon { width: 24px; height: 24px; margin-right: 12px; }
|
||||
.footer { background: #F5F5F5; padding: 20px; text-align: center; color: #666; font-size: 14px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>✅ 感谢您的留言</h1>
|
||||
<p>${COMPANY_INFO.name}</p>
|
||||
</div>
|
||||
<div class="content">
|
||||
<div class="success-icon">🎉</div>
|
||||
<p style="font-size: 18px; text-align: center; margin-bottom: 20px;">
|
||||
尊敬的 <strong>${data.name}</strong>,您好!
|
||||
</p>
|
||||
<p style="text-align: center; color: #666;">
|
||||
我们已收到您的留言,感谢您对${COMPANY_INFO.name}的关注与支持!
|
||||
</p>
|
||||
<div class="message">
|
||||
<p style="margin: 0; font-weight: 600; color: #C41E3A;">📋 您的留言内容</p>
|
||||
<p style="margin: 10px 0 0 0; color: #333;">${data.message}</p>
|
||||
</div>
|
||||
<div style="text-align: center; margin: 20px 0;">
|
||||
<p style="color: #C41E3A; font-weight: 600;">⏰ 预计回复时间:2小时内</p>
|
||||
<p style="color: #666; font-size: 14px; margin-top: 8px;">工作日 9:00 - 18:00</p>
|
||||
</div>
|
||||
<div class="contact-info">
|
||||
<p style="margin: 0 0 15px 0; font-weight: 600;">📞 联系我们</p>
|
||||
<div class="contact-item">
|
||||
<span class="contact-icon">📧</span>
|
||||
<span>${COMPANY_INFO.email}</span>
|
||||
</div>
|
||||
<div class="contact-item">
|
||||
<span class="contact-icon">📍</span>
|
||||
<span>${COMPANY_INFO.address}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="footer">
|
||||
<p>© ${new Date().getFullYear()} ${COMPANY_INFO.name}. All rights reserved.</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`.trim();
|
||||
}
|
||||
@@ -1,169 +0,0 @@
|
||||
import { sanitizeInput, sanitizeHTML, sanitizeURL } from './sanitize';
|
||||
import { isAllowedType, validateFileSignature, isDangerousFile } from './upload';
|
||||
import { generateCSRFToken, validateCSRFToken } from './csrf';
|
||||
import { calculateContrastRatio, meetsWCAGStandard } from './color-contrast';
|
||||
import { PerformanceMonitor } from './monitoring';
|
||||
|
||||
describe('Integration Tests', () => {
|
||||
describe('Input Sanitization Flow', () => {
|
||||
it('should sanitize user input end-to-end', () => {
|
||||
const userInput = '<script>alert("xss")</script>Hello World';
|
||||
const sanitized = sanitizeInput(userInput);
|
||||
|
||||
expect(sanitized).not.toContain('<script>');
|
||||
expect(sanitized).toContain('Hello World');
|
||||
});
|
||||
|
||||
it('should sanitize HTML content', () => {
|
||||
const htmlContent = '<p onclick="alert(1)">Safe text</p><script>evil()</script>';
|
||||
const sanitized = sanitizeHTML(htmlContent);
|
||||
|
||||
expect(sanitized).not.toContain('onclick');
|
||||
expect(sanitized).not.toContain('<script>');
|
||||
expect(sanitized).toContain('Safe text');
|
||||
});
|
||||
|
||||
it('should sanitize URLs', () => {
|
||||
const maliciousUrl = 'javascript:alert(1)';
|
||||
const safeUrl = 'https://example.com';
|
||||
|
||||
expect(sanitizeURL(maliciousUrl)).toBe('');
|
||||
expect(sanitizeURL(safeUrl)).toBe(safeUrl);
|
||||
});
|
||||
});
|
||||
|
||||
describe('CSRF Protection Flow', () => {
|
||||
it('should generate and validate CSRF tokens', () => {
|
||||
const token = generateCSRFToken();
|
||||
expect(token).toBeDefined();
|
||||
expect(typeof token).toBe('string');
|
||||
expect(token.length).toBeGreaterThan(0);
|
||||
|
||||
const isValid = validateCSRFToken(token, token);
|
||||
expect(isValid).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject invalid CSRF tokens', () => {
|
||||
const invalidToken = 'invalid-token-123';
|
||||
const storedToken = 'different-token';
|
||||
const isValid = validateCSRFToken(invalidToken, storedToken);
|
||||
expect(isValid).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject empty tokens', () => {
|
||||
const isValid = validateCSRFToken('', 'some-token');
|
||||
expect(isValid).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('File Validation Flow', () => {
|
||||
it('should validate allowed file types', () => {
|
||||
const isImageAllowed = isAllowedType('image/jpeg', 'image');
|
||||
const isDocAllowed = isAllowedType('application/pdf', 'document');
|
||||
|
||||
expect(isImageAllowed).toBe(true);
|
||||
expect(isDocAllowed).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject dangerous file types', () => {
|
||||
const isDangerous = isDangerousFile('malware.exe');
|
||||
expect(isDangerous).toBe(true);
|
||||
|
||||
const isSafe = isDangerousFile('document.pdf');
|
||||
expect(isSafe).toBe(false);
|
||||
});
|
||||
|
||||
it('should validate file signatures', async () => {
|
||||
const pngSignature = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
||||
const isValid = validateFileSignature(pngSignature, 'image/png');
|
||||
expect(isValid).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Color Contrast Validation Flow', () => {
|
||||
it('should validate accessible color combinations', () => {
|
||||
const result = meetsWCAGStandard('#000000', '#FFFFFF', 'AA', 'normal');
|
||||
expect(result.passes).toBe(true);
|
||||
expect(result.ratio).toBeGreaterThan(4.5);
|
||||
});
|
||||
|
||||
it('should reject inaccessible color combinations', () => {
|
||||
const result = meetsWCAGStandard('#CCCCCC', '#FFFFFF', 'AA', 'normal');
|
||||
expect(result.passes).toBe(false);
|
||||
});
|
||||
|
||||
it('should calculate contrast ratios correctly', () => {
|
||||
const blackOnWhite = calculateContrastRatio('#000000', '#FFFFFF');
|
||||
const whiteOnBlack = calculateContrastRatio('#FFFFFF', '#000000');
|
||||
|
||||
expect(blackOnWhite).toBeCloseTo(21, 0);
|
||||
expect(whiteOnBlack).toBeCloseTo(21, 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Performance Monitoring Flow', () => {
|
||||
beforeEach(() => {
|
||||
PerformanceMonitor['instance'] = null as any;
|
||||
});
|
||||
|
||||
it('should track metrics across operations', () => {
|
||||
const monitor = PerformanceMonitor.getInstance();
|
||||
|
||||
monitor.recordMetric('response_time', 100);
|
||||
monitor.recordMetric('response_time', 200);
|
||||
monitor.recordMetric('response_time', 150);
|
||||
|
||||
const stats = monitor.getStats('response_time');
|
||||
expect(stats.count).toBe(3);
|
||||
expect(stats.avg).toBe(150);
|
||||
expect(stats.min).toBe(100);
|
||||
expect(stats.max).toBe(200);
|
||||
});
|
||||
|
||||
it('should track multiple metrics independently', () => {
|
||||
const monitor = PerformanceMonitor.getInstance();
|
||||
|
||||
monitor.recordMetric('api_calls', 1);
|
||||
monitor.recordMetric('api_calls', 1);
|
||||
monitor.recordMetric('db_queries', 5);
|
||||
monitor.recordMetric('db_queries', 5);
|
||||
|
||||
expect(monitor.getCount('api_calls')).toBe(2);
|
||||
expect(monitor.getCount('db_queries')).toBe(2);
|
||||
expect(monitor.getAverage('api_calls')).toBe(1);
|
||||
expect(monitor.getAverage('db_queries')).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Combined Security Validation Flow', () => {
|
||||
it('should validate and sanitize user input comprehensively', () => {
|
||||
const maliciousInput = {
|
||||
name: '<script>alert("xss")</script>John',
|
||||
email: 'john@example.com',
|
||||
website: 'javascript:alert(1)',
|
||||
message: '<p onclick="evil()">Hello</p>',
|
||||
};
|
||||
|
||||
const sanitized = {
|
||||
name: sanitizeInput(maliciousInput.name),
|
||||
email: sanitizeInput(maliciousInput.email),
|
||||
website: sanitizeURL(maliciousInput.website),
|
||||
message: sanitizeHTML(maliciousInput.message),
|
||||
};
|
||||
|
||||
expect(sanitized.name).not.toContain('<script>');
|
||||
expect(sanitized.email).toBe('john@example.com');
|
||||
expect(sanitized.website).toBe('');
|
||||
expect(sanitized.message).not.toContain('onclick');
|
||||
});
|
||||
|
||||
it('should validate file upload with CSRF protection', () => {
|
||||
const csrfToken = generateCSRFToken();
|
||||
const isValidCSRF = validateCSRFToken(csrfToken, csrfToken);
|
||||
expect(isValidCSRF).toBe(true);
|
||||
|
||||
const isAllowed = isAllowedType('application/pdf', 'document');
|
||||
expect(isAllowed).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,200 +0,0 @@
|
||||
import { PerformanceMonitor, monitor } from './monitoring';
|
||||
|
||||
describe('PerformanceMonitor', () => {
|
||||
beforeEach(() => {
|
||||
PerformanceMonitor['instance'] = null as any;
|
||||
});
|
||||
|
||||
describe('getInstance', () => {
|
||||
it('should return singleton instance', () => {
|
||||
const instance1 = PerformanceMonitor.getInstance();
|
||||
const instance2 = PerformanceMonitor.getInstance();
|
||||
|
||||
expect(instance1).toBe(instance2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('recordMetric', () => {
|
||||
it('should record a metric value', () => {
|
||||
const instance = PerformanceMonitor.getInstance();
|
||||
instance.recordMetric('test-metric', 100);
|
||||
|
||||
expect(instance.getCount('test-metric')).toBe(1);
|
||||
});
|
||||
|
||||
it('should record multiple metric values', () => {
|
||||
const instance = PerformanceMonitor.getInstance();
|
||||
instance.recordMetric('test-metric', 100);
|
||||
instance.recordMetric('test-metric', 200);
|
||||
instance.recordMetric('test-metric', 300);
|
||||
|
||||
expect(instance.getCount('test-metric')).toBe(3);
|
||||
});
|
||||
|
||||
it('should maintain max 1000 values per metric', () => {
|
||||
const instance = PerformanceMonitor.getInstance();
|
||||
for (let i = 0; i < 1005; i++) {
|
||||
instance.recordMetric('test-metric', i);
|
||||
}
|
||||
|
||||
expect(instance.getCount('test-metric')).toBe(1000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAverage', () => {
|
||||
it('should return 0 for empty metric', () => {
|
||||
const instance = PerformanceMonitor.getInstance();
|
||||
expect(instance.getAverage('empty-metric')).toBe(0);
|
||||
});
|
||||
|
||||
it('should calculate average correctly', () => {
|
||||
const instance = PerformanceMonitor.getInstance();
|
||||
instance.recordMetric('test-metric', 100);
|
||||
instance.recordMetric('test-metric', 200);
|
||||
instance.recordMetric('test-metric', 300);
|
||||
|
||||
expect(instance.getAverage('test-metric')).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getPercentile', () => {
|
||||
it('should return 0 for empty metric', () => {
|
||||
const instance = PerformanceMonitor.getInstance();
|
||||
expect(instance.getPercentile('empty-metric', 50)).toBe(0);
|
||||
});
|
||||
|
||||
it('should calculate 50th percentile correctly', () => {
|
||||
const instance = PerformanceMonitor.getInstance();
|
||||
for (let i = 1; i <= 100; i++) {
|
||||
instance.recordMetric('test-metric', i);
|
||||
}
|
||||
|
||||
expect(instance.getPercentile('test-metric', 50)).toBe(50);
|
||||
});
|
||||
|
||||
it('should calculate 95th percentile correctly', () => {
|
||||
const instance = PerformanceMonitor.getInstance();
|
||||
for (let i = 1; i <= 100; i++) {
|
||||
instance.recordMetric('test-metric', i);
|
||||
}
|
||||
|
||||
expect(instance.getPercentile('test-metric', 95)).toBe(95);
|
||||
});
|
||||
|
||||
it('should calculate 99th percentile correctly', () => {
|
||||
const instance = PerformanceMonitor.getInstance();
|
||||
for (let i = 1; i <= 100; i++) {
|
||||
instance.recordMetric('test-metric', i);
|
||||
}
|
||||
|
||||
expect(instance.getPercentile('test-metric', 99)).toBe(99);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getCount', () => {
|
||||
it('should return 0 for non-existent metric', () => {
|
||||
const instance = PerformanceMonitor.getInstance();
|
||||
expect(instance.getCount('non-existent')).toBe(0);
|
||||
});
|
||||
|
||||
it('should return count for existing metric', () => {
|
||||
const instance = PerformanceMonitor.getInstance();
|
||||
instance.recordMetric('test-metric', 100);
|
||||
instance.recordMetric('test-metric', 200);
|
||||
|
||||
expect(instance.getCount('test-metric')).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getMin', () => {
|
||||
it('should return 0 for empty metric', () => {
|
||||
const instance = PerformanceMonitor.getInstance();
|
||||
expect(instance.getMin('empty-metric')).toBe(0);
|
||||
});
|
||||
|
||||
it('should return minimum value', () => {
|
||||
const instance = PerformanceMonitor.getInstance();
|
||||
instance.recordMetric('test-metric', 100);
|
||||
instance.recordMetric('test-metric', 50);
|
||||
instance.recordMetric('test-metric', 200);
|
||||
|
||||
expect(instance.getMin('test-metric')).toBe(50);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getMax', () => {
|
||||
it('should return 0 for empty metric', () => {
|
||||
const instance = PerformanceMonitor.getInstance();
|
||||
expect(instance.getMax('empty-metric')).toBe(0);
|
||||
});
|
||||
|
||||
it('should return maximum value', () => {
|
||||
const instance = PerformanceMonitor.getInstance();
|
||||
instance.recordMetric('test-metric', 100);
|
||||
instance.recordMetric('test-metric', 200);
|
||||
instance.recordMetric('test-metric', 50);
|
||||
|
||||
expect(instance.getMax('test-metric')).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getStats', () => {
|
||||
it('should return stats for empty metric', () => {
|
||||
const instance = PerformanceMonitor.getInstance();
|
||||
const stats = instance.getStats('empty-metric');
|
||||
|
||||
expect(stats).toEqual({
|
||||
count: 0,
|
||||
avg: 0,
|
||||
min: 0,
|
||||
max: 0,
|
||||
p50: 0,
|
||||
p95: 0,
|
||||
p99: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return complete stats for metric', () => {
|
||||
const instance = PerformanceMonitor.getInstance();
|
||||
for (let i = 1; i <= 100; i++) {
|
||||
instance.recordMetric('test-metric', i);
|
||||
}
|
||||
|
||||
const stats = instance.getStats('test-metric');
|
||||
|
||||
expect(stats.count).toBe(100);
|
||||
expect(stats.avg).toBe(50.5);
|
||||
expect(stats.min).toBe(1);
|
||||
expect(stats.max).toBe(100);
|
||||
expect(stats.p50).toBe(50);
|
||||
expect(stats.p95).toBe(95);
|
||||
expect(stats.p99).toBe(99);
|
||||
});
|
||||
});
|
||||
|
||||
describe('clearMetrics', () => {
|
||||
it('should clear specific metric', () => {
|
||||
const instance = PerformanceMonitor.getInstance();
|
||||
instance.recordMetric('metric1', 100);
|
||||
instance.recordMetric('metric2', 200);
|
||||
|
||||
instance.clearMetrics('metric1');
|
||||
|
||||
expect(instance.getCount('metric1')).toBe(0);
|
||||
expect(instance.getCount('metric2')).toBe(1);
|
||||
});
|
||||
|
||||
it('should clear all metrics when no name provided', () => {
|
||||
const instance = PerformanceMonitor.getInstance();
|
||||
instance.recordMetric('metric1', 100);
|
||||
instance.recordMetric('metric2', 200);
|
||||
instance.recordMetric('metric3', 300);
|
||||
|
||||
instance.clearMetrics();
|
||||
|
||||
expect(instance.getCount('metric1')).toBe(0);
|
||||
expect(instance.getCount('metric2')).toBe(0);
|
||||
expect(instance.getCount('metric3')).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,75 +0,0 @@
|
||||
export class PerformanceMonitor {
|
||||
private static instance: PerformanceMonitor;
|
||||
private metrics: Map<string, number[]> = new Map();
|
||||
|
||||
static getInstance(): PerformanceMonitor {
|
||||
if (!PerformanceMonitor.instance) {
|
||||
PerformanceMonitor.instance = new PerformanceMonitor();
|
||||
}
|
||||
return PerformanceMonitor.instance;
|
||||
}
|
||||
|
||||
recordMetric(name: string, value: number) {
|
||||
if (!this.metrics.has(name)) {
|
||||
this.metrics.set(name, []);
|
||||
}
|
||||
this.metrics.get(name)!.push(value);
|
||||
|
||||
if (this.metrics.get(name)!.length > 1000) {
|
||||
this.metrics.get(name)!.shift();
|
||||
}
|
||||
}
|
||||
|
||||
getAverage(name: string): number {
|
||||
const values = this.metrics.get(name) || [];
|
||||
if (values.length === 0) return 0;
|
||||
return values.reduce((a, b) => a + b, 0) / values.length;
|
||||
}
|
||||
|
||||
getPercentile(name: string, percentile: number): number {
|
||||
const values = this.metrics.get(name) || [];
|
||||
if (values.length === 0) return 0;
|
||||
|
||||
const sorted = [...values].sort((a, b) => a - b);
|
||||
const index = Math.ceil((percentile / 100) * sorted.length) - 1;
|
||||
return sorted[Math.max(0, index)] ?? 0;
|
||||
}
|
||||
|
||||
getCount(name: string): number {
|
||||
return this.metrics.get(name)?.length || 0;
|
||||
}
|
||||
|
||||
getMin(name: string): number {
|
||||
const values = this.metrics.get(name) || [];
|
||||
if (values.length === 0) return 0;
|
||||
return Math.min(...values);
|
||||
}
|
||||
|
||||
getMax(name: string): number {
|
||||
const values = this.metrics.get(name) || [];
|
||||
if (values.length === 0) return 0;
|
||||
return Math.max(...values);
|
||||
}
|
||||
|
||||
getStats(name: string) {
|
||||
return {
|
||||
count: this.getCount(name),
|
||||
avg: this.getAverage(name),
|
||||
min: this.getMin(name),
|
||||
max: this.getMax(name),
|
||||
p50: this.getPercentile(name, 50),
|
||||
p95: this.getPercentile(name, 95),
|
||||
p99: this.getPercentile(name, 99),
|
||||
};
|
||||
}
|
||||
|
||||
clearMetrics(name?: string) {
|
||||
if (name) {
|
||||
this.metrics.delete(name);
|
||||
} else {
|
||||
this.metrics.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const monitor = PerformanceMonitor.getInstance();
|
||||
@@ -1,42 +0,0 @@
|
||||
import DOMPurify from 'dompurify';
|
||||
|
||||
export function sanitizeHTML(dirty: string): string {
|
||||
return DOMPurify.sanitize(dirty, {
|
||||
ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'a', 'p', 'br'],
|
||||
ALLOWED_ATTR: ['href', 'title', 'target', 'rel'],
|
||||
ALLOW_DATA_ATTR: false,
|
||||
});
|
||||
}
|
||||
|
||||
export function sanitizeInput(input: string): string {
|
||||
return DOMPurify.sanitize(input, {
|
||||
ALLOWED_TAGS: [],
|
||||
ALLOWED_ATTR: [],
|
||||
});
|
||||
}
|
||||
|
||||
export function sanitizeURL(url: string): string {
|
||||
const sanitized = DOMPurify.sanitize(url, {
|
||||
ALLOWED_TAGS: [],
|
||||
ALLOWED_ATTR: [],
|
||||
});
|
||||
|
||||
if (sanitized.startsWith('http://') || sanitized.startsWith('https://') || sanitized.startsWith('mailto:')) {
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
export function escapeHTML(str: string): string {
|
||||
const map: Record<string, string> = {
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": ''',
|
||||
'/': '/',
|
||||
};
|
||||
|
||||
return str.replace(/[&<>"'/]/g, (char) => map[char] ?? char);
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
import { generateCaptcha, validateCaptcha, CaptchaResult } from './captcha';
|
||||
|
||||
describe('Enhanced Captcha System', () => {
|
||||
test('should generate captcha with medium complexity', () => {
|
||||
const result = generateCaptcha('medium');
|
||||
expect(result).toBeDefined();
|
||||
expect(result.question).toBeDefined();
|
||||
expect(result.answer).toBeDefined();
|
||||
expect(result.hash).toBeDefined();
|
||||
expect(result.timestamp).toBeDefined();
|
||||
expect(typeof result.question).toBe('string');
|
||||
expect(typeof result.answer).toBe('number');
|
||||
});
|
||||
|
||||
test('should generate different captcha each time', () => {
|
||||
const result1 = generateCaptcha('medium');
|
||||
const result2 = generateCaptcha('medium');
|
||||
expect(result1.hash).not.toBe(result2.hash);
|
||||
});
|
||||
|
||||
test('should validate correct captcha answer', () => {
|
||||
const captcha = generateCaptcha('medium');
|
||||
const isValid = validateCaptcha(captcha.hash, captcha.answer, captcha.timestamp);
|
||||
expect(isValid).toBe(true);
|
||||
});
|
||||
|
||||
test('should reject incorrect captcha answer', () => {
|
||||
const captcha = generateCaptcha('medium');
|
||||
const isValid = validateCaptcha(captcha.hash, captcha.answer + 1, captcha.timestamp);
|
||||
expect(isValid).toBe(false);
|
||||
});
|
||||
|
||||
test('should reject expired captcha', () => {
|
||||
const captcha = generateCaptcha('medium');
|
||||
const expiredTimestamp = Date.now() - (6 * 60 * 1000); // 6 minutes ago
|
||||
const isValid = validateCaptcha(captcha.hash, captcha.answer, expiredTimestamp);
|
||||
expect(isValid).toBe(false);
|
||||
});
|
||||
|
||||
test('should generate complex captcha', () => {
|
||||
const result = generateCaptcha('complex');
|
||||
expect(result).toBeDefined();
|
||||
const numbers = result.question.match(/\d+/g);
|
||||
expect(numbers?.length).toBeGreaterThan(2);
|
||||
});
|
||||
});
|
||||
@@ -1,121 +0,0 @@
|
||||
import { getSecurityConfig } from './config';
|
||||
|
||||
export interface CaptchaResult {
|
||||
question: string;
|
||||
answer: number;
|
||||
hash: string;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
function generateSimpleCaptcha(): CaptchaResult {
|
||||
const num1 = Math.floor(Math.random() * 10) + 1;
|
||||
const num2 = Math.floor(Math.random() * 10) + 1;
|
||||
const answer = num1 + num2;
|
||||
const question = `${num1} + ${num2} = ?`;
|
||||
|
||||
return {
|
||||
question,
|
||||
answer,
|
||||
hash: '',
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
function generateMediumCaptcha(): CaptchaResult {
|
||||
const operations = ['+', '-', '*'];
|
||||
const operation = operations[Math.floor(Math.random() * operations.length)];
|
||||
const num1 = Math.floor(Math.random() * 20) + 1;
|
||||
const num2 = Math.floor(Math.random() * 10) + 1;
|
||||
|
||||
let answer: number;
|
||||
let question: string;
|
||||
|
||||
switch (operation) {
|
||||
case '+':
|
||||
answer = num1 + num2;
|
||||
question = `${num1} + ${num2} = ?`;
|
||||
break;
|
||||
case '-':
|
||||
answer = num1 - num2;
|
||||
question = `${num1} - ${num2} = ?`;
|
||||
break;
|
||||
case '*':
|
||||
answer = num1 * num2;
|
||||
question = `${num1} * ${num2} = ?`;
|
||||
break;
|
||||
default:
|
||||
answer = num1 + num2;
|
||||
question = `${num1} + ${num2} = ?`;
|
||||
}
|
||||
|
||||
return {
|
||||
question,
|
||||
answer,
|
||||
hash: '',
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
function generateComplexCaptcha(): CaptchaResult {
|
||||
const num1 = Math.floor(Math.random() * 15) + 1;
|
||||
const num2 = Math.floor(Math.random() * 10) + 1;
|
||||
const num3 = Math.floor(Math.random() * 5) + 1;
|
||||
|
||||
const operations = ['+', '-', '*'];
|
||||
const op1 = operations[Math.floor(Math.random() * operations.length)];
|
||||
const op2 = operations[Math.floor(Math.random() * operations.length)];
|
||||
|
||||
let answer: number;
|
||||
let question: string;
|
||||
|
||||
const temp1 = op1 === '+' ? num1 + num2 : op1 === '-' ? num1 - num2 : num1 * num2;
|
||||
answer = op2 === '+' ? temp1 + num3 : op2 === '-' ? temp1 - num3 : temp1 * num3;
|
||||
|
||||
question = `(${num1} ${op1} ${num2}) ${op2} ${num3} = ?`;
|
||||
|
||||
return {
|
||||
question,
|
||||
answer,
|
||||
hash: '',
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
function generateHash(answer: number, timestamp: number): string {
|
||||
const secret = process.env.CAPTCHA_SECRET || 'default-secret-key';
|
||||
const data = `${answer}-${timestamp}-${secret}`;
|
||||
return Buffer.from(data).toString('base64');
|
||||
}
|
||||
|
||||
export function generateCaptcha(complexity: 'simple' | 'medium' | 'complex' = 'medium'): CaptchaResult {
|
||||
let captcha: CaptchaResult;
|
||||
|
||||
switch (complexity) {
|
||||
case 'simple':
|
||||
captcha = generateSimpleCaptcha();
|
||||
break;
|
||||
case 'complex':
|
||||
captcha = generateComplexCaptcha();
|
||||
break;
|
||||
case 'medium':
|
||||
default:
|
||||
captcha = generateMediumCaptcha();
|
||||
break;
|
||||
}
|
||||
|
||||
captcha.hash = generateHash(captcha.answer, captcha.timestamp);
|
||||
return captcha;
|
||||
}
|
||||
|
||||
export function validateCaptcha(hash: string, answer: number, timestamp: number): boolean {
|
||||
const config = getSecurityConfig();
|
||||
const now = Date.now();
|
||||
const ageMinutes = (now - timestamp) / (1000 * 60);
|
||||
|
||||
if (ageMinutes > config.captcha.expiryMinutes) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const expectedHash = generateHash(answer, timestamp);
|
||||
return hash === expectedHash;
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
import { getSecurityConfig, validateSecurityConfig } from './config';
|
||||
|
||||
describe('Security Config', () => {
|
||||
test('should return default security configuration', () => {
|
||||
const config = getSecurityConfig();
|
||||
expect(config).toBeDefined();
|
||||
expect(config.rateLimit.ip.maxRequests).toBe(10);
|
||||
expect(config.rateLimit.ip.windowMinutes).toBe(60);
|
||||
expect(config.captcha.complexity).toBe('medium');
|
||||
});
|
||||
|
||||
test('should validate security config structure', () => {
|
||||
const config = getSecurityConfig();
|
||||
const isValid = validateSecurityConfig(config);
|
||||
expect(isValid).toBe(true);
|
||||
});
|
||||
|
||||
test('should reject invalid config', () => {
|
||||
const invalidConfig = { rateLimit: { ip: { maxRequests: -1 } } };
|
||||
const isValid = validateSecurityConfig(invalidConfig);
|
||||
expect(isValid).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,77 +0,0 @@
|
||||
export interface SecurityConfig {
|
||||
captcha: {
|
||||
complexity: 'simple' | 'medium' | 'complex';
|
||||
expiryMinutes: number;
|
||||
maxAttempts: number;
|
||||
};
|
||||
rateLimit: {
|
||||
ip: {
|
||||
maxRequests: number;
|
||||
windowMinutes: number;
|
||||
};
|
||||
email: {
|
||||
maxRequests: number;
|
||||
windowHours: number;
|
||||
};
|
||||
global: {
|
||||
maxRequests: number;
|
||||
windowMinutes: number;
|
||||
};
|
||||
};
|
||||
protection: {
|
||||
enableCSRF: boolean;
|
||||
enableInputSanitization: boolean;
|
||||
enableBehaviorDetection: boolean;
|
||||
blockSuspiciousIPs: boolean;
|
||||
};
|
||||
logging: {
|
||||
enableSecurityLogs: boolean;
|
||||
logRetentionDays: number;
|
||||
alertThreshold: number;
|
||||
};
|
||||
}
|
||||
|
||||
const defaultConfig: SecurityConfig = {
|
||||
captcha: {
|
||||
complexity: 'medium',
|
||||
expiryMinutes: 5,
|
||||
maxAttempts: 3,
|
||||
},
|
||||
rateLimit: {
|
||||
ip: {
|
||||
maxRequests: 10,
|
||||
windowMinutes: 60,
|
||||
},
|
||||
email: {
|
||||
maxRequests: 3,
|
||||
windowHours: 24,
|
||||
},
|
||||
global: {
|
||||
maxRequests: 50,
|
||||
windowMinutes: 1,
|
||||
},
|
||||
},
|
||||
protection: {
|
||||
enableCSRF: true,
|
||||
enableInputSanitization: true,
|
||||
enableBehaviorDetection: true,
|
||||
blockSuspiciousIPs: true,
|
||||
},
|
||||
logging: {
|
||||
enableSecurityLogs: true,
|
||||
logRetentionDays: 30,
|
||||
alertThreshold: 5,
|
||||
},
|
||||
};
|
||||
|
||||
export function getSecurityConfig(): SecurityConfig {
|
||||
return { ...defaultConfig };
|
||||
}
|
||||
|
||||
export function validateSecurityConfig(config: any): boolean {
|
||||
if (!config || typeof config !== 'object') return false;
|
||||
if (!config.rateLimit || !config.captcha || !config.protection) return false;
|
||||
if (config.rateLimit.ip?.maxRequests < 0) return false;
|
||||
if (config.captcha.expiryMinutes <= 0) return false;
|
||||
return true;
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
import { SecurityLogger, SecurityEventType } from './logger';
|
||||
|
||||
describe('Security Logging System', () => {
|
||||
let logger: SecurityLogger;
|
||||
|
||||
beforeEach(() => {
|
||||
logger = new SecurityLogger();
|
||||
});
|
||||
|
||||
test('should log security events', () => {
|
||||
logger.logEvent({
|
||||
type: SecurityEventType.CAPTCHA_FAILED,
|
||||
ip: '192.168.1.1',
|
||||
email: 'test@example.com',
|
||||
details: { reason: 'Invalid answer' },
|
||||
});
|
||||
|
||||
const logs = logger.getRecentLogs(10);
|
||||
expect(logs.length).toBe(1);
|
||||
expect(logs[0].type).toBe(SecurityEventType.CAPTCHA_FAILED);
|
||||
});
|
||||
|
||||
test('should detect suspicious activity', () => {
|
||||
for (let i = 0; i < 6; i++) {
|
||||
logger.logEvent({
|
||||
type: SecurityEventType.CAPTCHA_FAILED,
|
||||
ip: '192.168.1.2',
|
||||
email: 'suspicious@example.com',
|
||||
details: {},
|
||||
});
|
||||
}
|
||||
|
||||
const suspiciousIPs = logger.getSuspiciousIPs();
|
||||
expect(suspiciousIPs).toContain('192.168.1.2');
|
||||
});
|
||||
|
||||
test('should clear old logs', () => {
|
||||
logger.logEvent({
|
||||
type: SecurityEventType.RATE_LIMIT_EXCEEDED,
|
||||
ip: '192.168.1.3',
|
||||
details: {},
|
||||
});
|
||||
|
||||
logger.clearOldLogs(0);
|
||||
|
||||
const logs = logger.getRecentLogs(10);
|
||||
expect(logs.length).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -1,143 +0,0 @@
|
||||
export enum SecurityEventType {
|
||||
CAPTCHA_FAILED = 'captcha_failed',
|
||||
CAPTCHA_EXPIRED = 'captcha_expired',
|
||||
RATE_LIMIT_EXCEEDED = 'rate_limit_exceeded',
|
||||
MALICIOUS_CONTENT = 'malicious_content',
|
||||
XSS_ATTEMPT = 'xss_attempt',
|
||||
SQL_INJECTION_ATTEMPT = 'sql_injection_attempt',
|
||||
SUSPICIOUS_IP = 'suspicious_ip',
|
||||
BLOCKED_REQUEST = 'blocked_request',
|
||||
}
|
||||
|
||||
export interface SecurityEvent {
|
||||
type: SecurityEventType;
|
||||
timestamp: number;
|
||||
ip?: string;
|
||||
email?: string;
|
||||
userAgent?: string;
|
||||
details?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface SuspiciousActivity {
|
||||
ip: string;
|
||||
eventCount: number;
|
||||
lastSeen: number;
|
||||
eventTypes: SecurityEventType[];
|
||||
}
|
||||
|
||||
class InMemorySecurityLogger {
|
||||
private events: SecurityEvent[] = [];
|
||||
private ipActivity = new Map<string, number>();
|
||||
|
||||
logEvent(event: Omit<SecurityEvent, 'timestamp'>): void {
|
||||
const fullEvent: SecurityEvent = {
|
||||
...event,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
this.events.push(fullEvent);
|
||||
|
||||
if (event.ip) {
|
||||
const currentCount = this.ipActivity.get(event.ip) || 0;
|
||||
this.ipActivity.set(event.ip, currentCount + 1);
|
||||
}
|
||||
|
||||
console.log(`[Security] ${event.type}:`, event);
|
||||
}
|
||||
|
||||
getRecentLogs(limit: number = 100): SecurityEvent[] {
|
||||
return this.events.slice(-limit);
|
||||
}
|
||||
|
||||
getSuspiciousIPs(threshold: number = 5): string[] {
|
||||
const suspicious: string[] = [];
|
||||
for (const [ip, count] of this.ipActivity.entries()) {
|
||||
if (count >= threshold) {
|
||||
suspicious.push(ip);
|
||||
}
|
||||
}
|
||||
return suspicious;
|
||||
}
|
||||
|
||||
clearOldLogs(daysToKeep: number = 30): void {
|
||||
const cutoffTime = Date.now() - (daysToKeep * 24 * 60 * 60 * 1000);
|
||||
this.events = this.events.filter(event => event.timestamp > cutoffTime);
|
||||
}
|
||||
|
||||
getActivityByIP(ip: string): number {
|
||||
return this.ipActivity.get(ip) || 0;
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.events = [];
|
||||
this.ipActivity.clear();
|
||||
}
|
||||
}
|
||||
|
||||
export class SecurityLogger {
|
||||
private logger: InMemorySecurityLogger;
|
||||
|
||||
constructor() {
|
||||
this.logger = new InMemorySecurityLogger();
|
||||
}
|
||||
|
||||
logEvent(event: Omit<SecurityEvent, 'timestamp'>): void {
|
||||
this.logger.logEvent(event);
|
||||
}
|
||||
|
||||
getRecentLogs(limit?: number): SecurityEvent[] {
|
||||
return this.logger.getRecentLogs(limit);
|
||||
}
|
||||
|
||||
getSuspiciousIPs(threshold?: number): string[] {
|
||||
return this.logger.getSuspiciousIPs(threshold);
|
||||
}
|
||||
|
||||
clearOldLogs(daysToKeep?: number): void {
|
||||
this.logger.clearOldLogs(daysToKeep);
|
||||
}
|
||||
|
||||
getActivityByIP(ip: string): number {
|
||||
return this.logger.getActivityByIP(ip);
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.logger.clear();
|
||||
}
|
||||
|
||||
getStats() {
|
||||
const logs = this.getRecentLogs();
|
||||
const totalRequests = logs.length;
|
||||
const blockedRequests = logs.filter(log =>
|
||||
log.type === SecurityEventType.RATE_LIMIT_EXCEEDED ||
|
||||
log.type === SecurityEventType.BLOCKED_REQUEST ||
|
||||
log.type === SecurityEventType.MALICIOUS_CONTENT ||
|
||||
log.type === SecurityEventType.XSS_ATTEMPT ||
|
||||
log.type === SecurityEventType.SQL_INJECTION_ATTEMPT
|
||||
).length;
|
||||
const captchaAttempts = logs.filter(log =>
|
||||
log.type === SecurityEventType.CAPTCHA_FAILED ||
|
||||
log.type === SecurityEventType.CAPTCHA_EXPIRED
|
||||
).length;
|
||||
const rateLimitHits = logs.filter(log =>
|
||||
log.type === SecurityEventType.RATE_LIMIT_EXCEEDED
|
||||
).length;
|
||||
const maliciousContentDetected = logs.filter(log =>
|
||||
log.type === SecurityEventType.MALICIOUS_CONTENT ||
|
||||
log.type === SecurityEventType.XSS_ATTEMPT ||
|
||||
log.type === SecurityEventType.SQL_INJECTION_ATTEMPT
|
||||
).length;
|
||||
const successRate = totalRequests > 0
|
||||
? ((totalRequests - blockedRequests) / totalRequests * 100)
|
||||
: 100;
|
||||
|
||||
return {
|
||||
totalRequests,
|
||||
blockedRequests,
|
||||
captchaAttempts,
|
||||
rateLimitHits,
|
||||
maliciousContentDetected,
|
||||
successRate: parseFloat(successRate.toFixed(2)),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
import { SecurityMiddleware, SecurityCheckResult } from './middleware';
|
||||
import { generateCaptcha } from './captcha';
|
||||
|
||||
describe('Security Middleware', () => {
|
||||
let middleware: SecurityMiddleware;
|
||||
|
||||
beforeEach(() => {
|
||||
middleware = new SecurityMiddleware();
|
||||
});
|
||||
|
||||
test('should pass valid request', async () => {
|
||||
const captcha = generateCaptcha('simple');
|
||||
const result = await middleware.checkRequest({
|
||||
ip: '192.168.1.1',
|
||||
email: 'valid@example.com',
|
||||
captchaHash: captcha.hash,
|
||||
captchaAnswer: captcha.answer,
|
||||
captchaTimestamp: captcha.timestamp,
|
||||
});
|
||||
|
||||
expect(result.allowed).toBe(true);
|
||||
expect(result.errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('should block request with invalid captcha', async () => {
|
||||
const captcha = generateCaptcha('simple');
|
||||
const result = await middleware.checkRequest({
|
||||
ip: '192.168.1.2',
|
||||
email: 'test@example.com',
|
||||
captchaHash: captcha.hash,
|
||||
captchaAnswer: 999,
|
||||
captchaTimestamp: captcha.timestamp,
|
||||
});
|
||||
|
||||
expect(result.allowed).toBe(false);
|
||||
expect(result.errors).toContain('Invalid captcha');
|
||||
});
|
||||
|
||||
test('should block request exceeding rate limit', async () => {
|
||||
const ip = '192.168.1.3';
|
||||
for (let i = 0; i < 11; i++) {
|
||||
const captcha = generateCaptcha('simple');
|
||||
await middleware.checkRequest({
|
||||
ip,
|
||||
email: `test${i}@example.com`,
|
||||
captchaHash: captcha.hash,
|
||||
captchaAnswer: captcha.answer,
|
||||
captchaTimestamp: captcha.timestamp,
|
||||
});
|
||||
}
|
||||
|
||||
const captcha = generateCaptcha('simple');
|
||||
const result = await middleware.checkRequest({
|
||||
ip,
|
||||
email: 'test@example.com',
|
||||
captchaHash: captcha.hash,
|
||||
captchaAnswer: captcha.answer,
|
||||
captchaTimestamp: captcha.timestamp,
|
||||
});
|
||||
|
||||
expect(result.allowed).toBe(false);
|
||||
expect(result.errors).toContain('Rate limit exceeded for IP');
|
||||
});
|
||||
|
||||
test('should block request with malicious content', async () => {
|
||||
const captcha = generateCaptcha('simple');
|
||||
const result = await middleware.checkRequest({
|
||||
ip: '192.168.1.4',
|
||||
email: 'test@example.com',
|
||||
captchaHash: captcha.hash,
|
||||
captchaAnswer: captcha.answer,
|
||||
captchaTimestamp: captcha.timestamp,
|
||||
message: 'You won a free bitcoin lottery! Act now to claim your prize.',
|
||||
});
|
||||
|
||||
expect(result.allowed).toBe(false);
|
||||
expect(result.errors.some(e => e.includes('malicious'))).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,129 +0,0 @@
|
||||
import { RateLimiter } from './rate-limiter';
|
||||
import { validateCaptcha } from './captcha';
|
||||
import { sanitizeFormData, detectMaliciousContent } from './sanitizer';
|
||||
import { SecurityLogger, SecurityEventType } from './logger';
|
||||
import { getSecurityConfig } from './config';
|
||||
|
||||
export interface SecurityCheckRequest {
|
||||
ip: string;
|
||||
email?: string;
|
||||
name?: string;
|
||||
phone?: string;
|
||||
subject?: string;
|
||||
message?: string;
|
||||
captchaHash: string;
|
||||
captchaAnswer: number;
|
||||
captchaTimestamp: number;
|
||||
userAgent?: string;
|
||||
}
|
||||
|
||||
export interface SecurityCheckResult {
|
||||
allowed: boolean;
|
||||
errors: string[];
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
export class SecurityMiddleware {
|
||||
private rateLimiter: RateLimiter;
|
||||
private logger: SecurityLogger;
|
||||
private config: any;
|
||||
|
||||
constructor() {
|
||||
this.rateLimiter = new RateLimiter();
|
||||
this.logger = new SecurityLogger();
|
||||
this.config = getSecurityConfig();
|
||||
}
|
||||
|
||||
async checkRequest(request: SecurityCheckRequest): Promise<SecurityCheckResult> {
|
||||
const errors: string[] = [];
|
||||
const warnings: string[] = [];
|
||||
|
||||
if (this.config.protection.enableInputSanitization) {
|
||||
if (request.message && detectMaliciousContent(request.message)) {
|
||||
errors.push('Message contains malicious content');
|
||||
this.logger.logEvent({
|
||||
type: SecurityEventType.MALICIOUS_CONTENT,
|
||||
ip: request.ip,
|
||||
email: request.email,
|
||||
userAgent: request.userAgent,
|
||||
details: { message: request.message },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (!validateCaptcha(request.captchaHash, request.captchaAnswer, request.captchaTimestamp)) {
|
||||
errors.push('Invalid captcha');
|
||||
this.logger.logEvent({
|
||||
type: SecurityEventType.CAPTCHA_FAILED,
|
||||
ip: request.ip,
|
||||
email: request.email,
|
||||
userAgent: request.userAgent,
|
||||
details: { answer: request.captchaAnswer },
|
||||
});
|
||||
}
|
||||
|
||||
const ipRateLimit = await this.rateLimiter.checkIP(request.ip);
|
||||
if (!ipRateLimit.allowed) {
|
||||
errors.push('Rate limit exceeded for IP');
|
||||
this.logger.logEvent({
|
||||
type: SecurityEventType.RATE_LIMIT_EXCEEDED,
|
||||
ip: request.ip,
|
||||
email: request.email,
|
||||
details: { limit: ipRateLimit.limit },
|
||||
});
|
||||
}
|
||||
|
||||
if (request.email) {
|
||||
const emailRateLimit = await this.rateLimiter.checkEmail(request.email);
|
||||
if (!emailRateLimit.allowed) {
|
||||
errors.push('Rate limit exceeded for email');
|
||||
this.logger.logEvent({
|
||||
type: SecurityEventType.RATE_LIMIT_EXCEEDED,
|
||||
ip: request.ip,
|
||||
email: request.email,
|
||||
details: { limit: emailRateLimit.limit },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const globalRateLimit = await this.rateLimiter.checkGlobal();
|
||||
if (!globalRateLimit.allowed) {
|
||||
warnings.push('Global rate limit exceeded');
|
||||
}
|
||||
|
||||
const suspiciousIPs = this.logger.getSuspiciousIPs();
|
||||
if (suspiciousIPs.includes(request.ip)) {
|
||||
errors.push('Suspicious activity detected from this IP');
|
||||
this.logger.logEvent({
|
||||
type: SecurityEventType.SUSPICIOUS_IP,
|
||||
ip: request.ip,
|
||||
email: request.email,
|
||||
details: { activityCount: this.logger.getActivityByIP(request.ip) },
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
allowed: errors.length === 0,
|
||||
errors,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
sanitizeRequest(request: SecurityCheckRequest): any {
|
||||
return sanitizeFormData({
|
||||
name: request.name || '',
|
||||
email: request.email || '',
|
||||
phone: request.phone,
|
||||
subject: request.subject || '',
|
||||
message: request.message || '',
|
||||
});
|
||||
}
|
||||
|
||||
getSecurityLogs(limit?: number) {
|
||||
return this.logger.getRecentLogs(limit);
|
||||
}
|
||||
|
||||
getSuspiciousIPs(threshold?: number) {
|
||||
return this.logger.getSuspiciousIPs(threshold);
|
||||
}
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
import { RateLimiter, RateLimitResult } from './rate-limiter';
|
||||
|
||||
describe('Rate Limiting System', () => {
|
||||
let rateLimiter: RateLimiter;
|
||||
|
||||
beforeEach(() => {
|
||||
rateLimiter = new RateLimiter();
|
||||
});
|
||||
|
||||
test('should allow request within limit', async () => {
|
||||
const result = await rateLimiter.checkIP('192.168.1.1');
|
||||
expect(result.allowed).toBe(true);
|
||||
expect(result.remaining).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('should block request when limit exceeded', async () => {
|
||||
const ip = '192.168.1.2';
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await rateLimiter.checkIP(ip);
|
||||
}
|
||||
const result = await rateLimiter.checkIP(ip);
|
||||
expect(result.allowed).toBe(false);
|
||||
});
|
||||
|
||||
test('should reset limit after window expires', async () => {
|
||||
const ip = '192.168.1.3';
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await rateLimiter.checkIP(ip);
|
||||
}
|
||||
await rateLimiter.checkIP(ip);
|
||||
|
||||
jest.useFakeTimers();
|
||||
jest.advanceTimersByTime(61 * 60 * 1000);
|
||||
|
||||
const result = await rateLimiter.checkIP(ip);
|
||||
expect(result.allowed).toBe(true);
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
test('should track email rate limits separately', async () => {
|
||||
const email = 'test@example.com';
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await rateLimiter.checkEmail(email);
|
||||
}
|
||||
const result = await rateLimiter.checkEmail(email);
|
||||
expect(result.allowed).toBe(false);
|
||||
});
|
||||
|
||||
test('should enforce global rate limit', async () => {
|
||||
const promises = [];
|
||||
for (let i = 0; i < 51; i++) {
|
||||
promises.push(rateLimiter.checkGlobal());
|
||||
}
|
||||
const results = await Promise.all(promises);
|
||||
const blockedCount = results.filter(r => !r.allowed).length;
|
||||
expect(blockedCount).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -1,119 +0,0 @@
|
||||
import { getSecurityConfig } from './config';
|
||||
|
||||
export interface RateLimitResult {
|
||||
allowed: boolean;
|
||||
remaining: number;
|
||||
resetTime: number;
|
||||
limit: number;
|
||||
}
|
||||
|
||||
interface RequestRecord {
|
||||
count: number;
|
||||
windowStart: number;
|
||||
}
|
||||
|
||||
class InMemoryRateLimiter {
|
||||
private ipRequests = new Map<string, RequestRecord>();
|
||||
private emailRequests = new Map<string, RequestRecord>();
|
||||
private globalRequests: RequestRecord = { count: 0, windowStart: Date.now() };
|
||||
|
||||
async checkIP(ip: string): Promise<RateLimitResult> {
|
||||
const config = getSecurityConfig();
|
||||
const now = Date.now();
|
||||
const windowMs = config.rateLimit.ip.windowMinutes * 60 * 1000;
|
||||
|
||||
let record = this.ipRequests.get(ip);
|
||||
if (!record || now - record.windowStart > windowMs) {
|
||||
record = { count: 0, windowStart: now };
|
||||
this.ipRequests.set(ip, record);
|
||||
}
|
||||
|
||||
const allowed = record.count < config.rateLimit.ip.maxRequests;
|
||||
if (allowed) {
|
||||
record.count++;
|
||||
}
|
||||
|
||||
return {
|
||||
allowed,
|
||||
remaining: Math.max(0, config.rateLimit.ip.maxRequests - record.count),
|
||||
resetTime: record.windowStart + windowMs,
|
||||
limit: config.rateLimit.ip.maxRequests,
|
||||
};
|
||||
}
|
||||
|
||||
async checkEmail(email: string): Promise<RateLimitResult> {
|
||||
const config = getSecurityConfig();
|
||||
const now = Date.now();
|
||||
const windowMs = config.rateLimit.email.windowHours * 60 * 60 * 1000;
|
||||
|
||||
let record = this.emailRequests.get(email);
|
||||
if (!record || now - record.windowStart > windowMs) {
|
||||
record = { count: 0, windowStart: now };
|
||||
this.emailRequests.set(email, record);
|
||||
}
|
||||
|
||||
const allowed = record.count < config.rateLimit.email.maxRequests;
|
||||
if (allowed) {
|
||||
record.count++;
|
||||
}
|
||||
|
||||
return {
|
||||
allowed,
|
||||
remaining: Math.max(0, config.rateLimit.email.maxRequests - record.count),
|
||||
resetTime: record.windowStart + windowMs,
|
||||
limit: config.rateLimit.email.maxRequests,
|
||||
};
|
||||
}
|
||||
|
||||
async checkGlobal(): Promise<RateLimitResult> {
|
||||
const config = getSecurityConfig();
|
||||
const now = Date.now();
|
||||
const windowMs = config.rateLimit.global.windowMinutes * 60 * 1000;
|
||||
|
||||
if (now - this.globalRequests.windowStart > windowMs) {
|
||||
this.globalRequests = { count: 0, windowStart: now };
|
||||
}
|
||||
|
||||
const allowed = this.globalRequests.count < config.rateLimit.global.maxRequests;
|
||||
if (allowed) {
|
||||
this.globalRequests.count++;
|
||||
}
|
||||
|
||||
return {
|
||||
allowed,
|
||||
remaining: Math.max(0, config.rateLimit.global.maxRequests - this.globalRequests.count),
|
||||
resetTime: this.globalRequests.windowStart + windowMs,
|
||||
limit: config.rateLimit.global.maxRequests,
|
||||
};
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.ipRequests.clear();
|
||||
this.emailRequests.clear();
|
||||
this.globalRequests = { count: 0, windowStart: Date.now() };
|
||||
}
|
||||
}
|
||||
|
||||
export class RateLimiter {
|
||||
private limiter: InMemoryRateLimiter;
|
||||
|
||||
constructor() {
|
||||
this.limiter = new InMemoryRateLimiter();
|
||||
}
|
||||
|
||||
async checkIP(ip: string): Promise<RateLimitResult> {
|
||||
return this.limiter.checkIP(ip);
|
||||
}
|
||||
|
||||
async checkEmail(email: string): Promise<RateLimitResult> {
|
||||
return this.limiter.checkEmail(email);
|
||||
}
|
||||
|
||||
async checkGlobal(): Promise<RateLimitResult> {
|
||||
return this.limiter.checkGlobal();
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.limiter.clear();
|
||||
}
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
import { sanitizeInput, sanitizeFormData, detectMaliciousContent } from './sanitizer';
|
||||
|
||||
describe('Input Sanitization', () => {
|
||||
test('should remove XSS attempts', () => {
|
||||
const malicious = '<script>alert("xss")</script>Hello';
|
||||
const sanitized = sanitizeInput(malicious);
|
||||
expect(sanitized).not.toContain('<script>');
|
||||
expect(sanitized).toContain('Hello');
|
||||
});
|
||||
|
||||
test('should remove SQL injection attempts', () => {
|
||||
const malicious = "'; DROP TABLE users; --";
|
||||
const sanitized = sanitizeInput(malicious);
|
||||
expect(sanitized).not.toContain('DROP TABLE');
|
||||
});
|
||||
|
||||
test('should detect malicious links', () => {
|
||||
const content = 'Visit http://malicious.com for free money';
|
||||
const isMalicious = detectMaliciousContent(content);
|
||||
expect(isMalicious).toBe(true);
|
||||
});
|
||||
|
||||
test('should sanitize form data', () => {
|
||||
const formData = {
|
||||
name: '<script>alert(1)</script>John',
|
||||
email: 'test@example.com',
|
||||
message: 'Click http://evil.com',
|
||||
};
|
||||
const sanitized = sanitizeFormData(formData);
|
||||
expect(sanitized.name).not.toContain('<script>');
|
||||
expect(sanitized.email).toBe('test@example.com');
|
||||
});
|
||||
|
||||
test('should preserve legitimate content', () => {
|
||||
const legitimate = 'Hello, I need help with my order #12345';
|
||||
const sanitized = sanitizeInput(legitimate);
|
||||
expect(sanitized).toBe(legitimate);
|
||||
});
|
||||
});
|
||||
@@ -1,105 +0,0 @@
|
||||
export interface SanitizedFormData {
|
||||
name: string;
|
||||
email: string;
|
||||
phone?: string;
|
||||
subject: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
const XSS_PATTERNS = [
|
||||
/<script[^>]*>.*?<\/script>/gi,
|
||||
/<iframe[^>]*>.*?<\/iframe>/gi,
|
||||
/javascript:/gi,
|
||||
/on\w+\s*=/gi,
|
||||
/<[^>]*>/g,
|
||||
];
|
||||
|
||||
const SQL_INJECTION_PATTERNS = [
|
||||
/';\s*drop\s+table/gi,
|
||||
/union\s+select/gi,
|
||||
/delete\s+from/gi,
|
||||
/insert\s+into/gi,
|
||||
/exec\s*\(/gi,
|
||||
];
|
||||
|
||||
const MALICIOUS_URL_PATTERNS = [
|
||||
/\.ru\b/gi,
|
||||
/\.tk\b/gi,
|
||||
/\.ml\b/gi,
|
||||
/\.ga\b/gi,
|
||||
/bit\.ly/gi,
|
||||
];
|
||||
|
||||
export function sanitizeInput(input: string): string {
|
||||
if (!input || typeof input !== 'string') {
|
||||
return '';
|
||||
}
|
||||
|
||||
let sanitized = input.trim();
|
||||
|
||||
XSS_PATTERNS.forEach(pattern => {
|
||||
sanitized = sanitized.replace(pattern, '');
|
||||
});
|
||||
|
||||
SQL_INJECTION_PATTERNS.forEach(pattern => {
|
||||
sanitized = sanitized.replace(pattern, '');
|
||||
});
|
||||
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
export function sanitizeFormData(formData: any): SanitizedFormData {
|
||||
return {
|
||||
name: sanitizeInput(formData.name || ''),
|
||||
email: formData.email || '',
|
||||
phone: formData.phone ? sanitizeInput(formData.phone) : undefined,
|
||||
subject: sanitizeInput(formData.subject || ''),
|
||||
message: sanitizeInput(formData.message || ''),
|
||||
};
|
||||
}
|
||||
|
||||
export function detectMaliciousContent(content: string): boolean {
|
||||
if (!content || typeof content !== 'string') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const lowerContent = content.toLowerCase();
|
||||
|
||||
const suspiciousKeywords = [
|
||||
'free money',
|
||||
'bitcoin',
|
||||
'cryptocurrency',
|
||||
'lottery',
|
||||
'winner',
|
||||
'prize',
|
||||
'inheritance',
|
||||
'nigerian prince',
|
||||
'urgent',
|
||||
'act now',
|
||||
'limited time',
|
||||
];
|
||||
|
||||
for (const keyword of suspiciousKeywords) {
|
||||
if (lowerContent.includes(keyword)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
for (const pattern of MALICIOUS_URL_PATTERNS) {
|
||||
if (pattern.test(content)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export function validateEmail(email: string): boolean {
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
return emailRegex.test(email);
|
||||
}
|
||||
|
||||
export function validatePhone(phone: string): boolean {
|
||||
const phoneRegex = /^1[3-9]\d{9}$/;
|
||||
return phoneRegex.test(phone);
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
import { initSentry } from './sentry';
|
||||
|
||||
describe('sentry', () => {
|
||||
const originalEnv = process.env;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetModules();
|
||||
process.env = { ...originalEnv };
|
||||
console.error = jest.fn();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = originalEnv;
|
||||
});
|
||||
|
||||
describe('initSentry', () => {
|
||||
it('should not initialize Sentry in non-production environment', () => {
|
||||
process.env.NODE_ENV = 'development';
|
||||
process.env.NEXT_PUBLIC_SENTRY_DSN = 'test-dsn';
|
||||
|
||||
initSentry();
|
||||
|
||||
expect(console.error).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not initialize Sentry when DSN is not set', () => {
|
||||
process.env.NODE_ENV = 'production';
|
||||
process.env.NEXT_PUBLIC_SENTRY_DSN = '';
|
||||
|
||||
initSentry();
|
||||
|
||||
expect(console.error).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should initialize Sentry in production with DSN', () => {
|
||||
process.env.NODE_ENV = 'production';
|
||||
process.env.NEXT_PUBLIC_SENTRY_DSN = 'https://test@sentry.io/123';
|
||||
|
||||
initSentry();
|
||||
|
||||
expect(console.error).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle missing NODE_ENV gracefully', () => {
|
||||
delete process.env.NODE_ENV;
|
||||
process.env.NEXT_PUBLIC_SENTRY_DSN = 'https://test@sentry.io/123';
|
||||
|
||||
initSentry();
|
||||
|
||||
expect(console.error).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,15 +0,0 @@
|
||||
export function initSentry() {
|
||||
if (process.env.NODE_ENV === 'production' && process.env.NEXT_PUBLIC_SENTRY_DSN) {
|
||||
import('@sentry/nextjs').then(({ init }) => {
|
||||
init({
|
||||
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
|
||||
environment: process.env.NODE_ENV,
|
||||
tracesSampleRate: 0.1,
|
||||
replaysSessionSampleRate: 0.1,
|
||||
replaysOnErrorSampleRate: 1.0,
|
||||
});
|
||||
}).catch((error) => {
|
||||
console.error('Failed to initialize Sentry:', error);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,445 +0,0 @@
|
||||
import { describe, it, expect, jest, beforeEach, afterEach } from '@jest/globals';
|
||||
import { writeFile, mkdir, unlink, stat } from 'fs/promises';
|
||||
import { existsSync } from 'fs';
|
||||
|
||||
jest.mock('fs/promises');
|
||||
jest.mock('fs');
|
||||
jest.mock('nanoid', () => ({
|
||||
nanoid: jest.fn(() => 'test-id-12345'),
|
||||
}));
|
||||
|
||||
const mockedWriteFile = writeFile as jest.MockedFunction<typeof writeFile>;
|
||||
const mockedMkdir = mkdir as jest.MockedFunction<typeof mkdir>;
|
||||
const mockedUnlink = unlink as jest.MockedFunction<typeof unlink>;
|
||||
const mockedStat = stat as jest.MockedFunction<typeof stat>;
|
||||
const mockedExistsSync = existsSync as jest.MockedFunction<typeof existsSync>;
|
||||
|
||||
describe('Upload Module', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
process.env.UPLOAD_DIR = './test-uploads';
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.UPLOAD_DIR;
|
||||
});
|
||||
|
||||
describe('getFileExtension', () => {
|
||||
it('should return correct extension for JPEG', async () => {
|
||||
const { getFileExtension } = await import('./upload');
|
||||
expect(getFileExtension('image/jpeg')).toBe('.jpg');
|
||||
});
|
||||
|
||||
it('should return correct extension for PNG', async () => {
|
||||
const { getFileExtension } = await import('./upload');
|
||||
expect(getFileExtension('image/png')).toBe('.png');
|
||||
});
|
||||
|
||||
it('should return correct extension for PDF', async () => {
|
||||
const { getFileExtension } = await import('./upload');
|
||||
expect(getFileExtension('application/pdf')).toBe('.pdf');
|
||||
});
|
||||
|
||||
it('should return empty string for unknown MIME type', async () => {
|
||||
const { getFileExtension } = await import('./upload');
|
||||
expect(getFileExtension('unknown/type')).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isAllowedType', () => {
|
||||
it('should return true for allowed image types', async () => {
|
||||
const { isAllowedType } = await import('./upload');
|
||||
expect(isAllowedType('image/jpeg', 'image')).toBe(true);
|
||||
expect(isAllowedType('image/png', 'image')).toBe(true);
|
||||
expect(isAllowedType('image/gif', 'image')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for allowed document types', async () => {
|
||||
const { isAllowedType } = await import('./upload');
|
||||
expect(isAllowedType('application/pdf', 'document')).toBe(true);
|
||||
expect(isAllowedType('application/msword', 'document')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for disallowed types', async () => {
|
||||
const { isAllowedType } = await import('./upload');
|
||||
expect(isAllowedType('image/jpeg', 'document')).toBe(false);
|
||||
expect(isAllowedType('application/pdf', 'image')).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for unknown MIME types', async () => {
|
||||
const { isAllowedType } = await import('./upload');
|
||||
expect(isAllowedType('unknown/type', 'image')).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle null type', async () => {
|
||||
const { isAllowedType } = await import('./upload');
|
||||
expect(isAllowedType(null as any, 'image')).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle undefined type', async () => {
|
||||
const { isAllowedType } = await import('./upload');
|
||||
expect(isAllowedType(undefined as any, 'image')).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle empty string type', async () => {
|
||||
const { isAllowedType } = await import('./upload');
|
||||
expect(isAllowedType('', 'image')).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle invalid category', async () => {
|
||||
const { isAllowedType } = await import('./upload');
|
||||
expect(isAllowedType('image/jpeg', 'invalid' as any)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateFileSignature', () => {
|
||||
it('should validate JPEG signature correctly', async () => {
|
||||
const { validateFileSignature } = await import('./upload');
|
||||
const validJpegBuffer = Buffer.from([0xFF, 0xD8, 0xFF, 0x00, 0x00]);
|
||||
expect(validateFileSignature(validJpegBuffer, 'image/jpeg')).toBe(true);
|
||||
});
|
||||
|
||||
it('should validate PNG signature correctly', async () => {
|
||||
const { validateFileSignature } = await import('./upload');
|
||||
const validPngBuffer = Buffer.from([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00]);
|
||||
expect(validateFileSignature(validPngBuffer, 'image/png')).toBe(true);
|
||||
});
|
||||
|
||||
it('should validate PDF signature correctly', async () => {
|
||||
const { validateFileSignature } = await import('./upload');
|
||||
const validPdfBuffer = Buffer.from([0x25, 0x50, 0x44, 0x46, 0x00]);
|
||||
expect(validateFileSignature(validPdfBuffer, 'application/pdf')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for invalid JPEG signature', async () => {
|
||||
const { validateFileSignature } = await import('./upload');
|
||||
const invalidBuffer = Buffer.from([0x00, 0x00, 0x00, 0x00]);
|
||||
expect(validateFileSignature(invalidBuffer, 'image/jpeg')).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true for SVG files', async () => {
|
||||
const { validateFileSignature } = await import('./upload');
|
||||
const svgBuffer = Buffer.from('<svg></svg>');
|
||||
expect(validateFileSignature(svgBuffer, 'image/svg+xml')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for unknown MIME types', async () => {
|
||||
const { validateFileSignature } = await import('./upload');
|
||||
const buffer = Buffer.from([0x00, 0x00, 0x00]);
|
||||
expect(validateFileSignature(buffer, 'unknown/type')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sanitizeFileName', () => {
|
||||
it('should remove special characters', async () => {
|
||||
const { sanitizeFileName } = await import('./upload');
|
||||
expect(sanitizeFileName('test<>:"/\\|?*.jpg')).toBe('test_________.jpg');
|
||||
});
|
||||
|
||||
it('should convert to lowercase', async () => {
|
||||
const { sanitizeFileName } = await import('./upload');
|
||||
expect(sanitizeFileName('TestFile.JPG')).toBe('testfile.jpg');
|
||||
});
|
||||
|
||||
it('should replace multiple dots', async () => {
|
||||
const { sanitizeFileName } = await import('./upload');
|
||||
expect(sanitizeFileName('test..file...jpg')).toBe('test.file.jpg');
|
||||
});
|
||||
|
||||
it('should preserve Chinese characters', async () => {
|
||||
const { sanitizeFileName } = await import('./upload');
|
||||
expect(sanitizeFileName('测试文件.jpg')).toBe('测试文件.jpg');
|
||||
});
|
||||
|
||||
it('should preserve underscores and hyphens', async () => {
|
||||
const { sanitizeFileName } = await import('./upload');
|
||||
expect(sanitizeFileName('test_file-name.jpg')).toBe('test_file-name.jpg');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isDangerousFile', () => {
|
||||
it('should detect .exe files as dangerous', async () => {
|
||||
const { isDangerousFile } = await import('./upload');
|
||||
expect(isDangerousFile('malware.exe')).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect .bat files as dangerous', async () => {
|
||||
const { isDangerousFile } = await import('./upload');
|
||||
expect(isDangerousFile('script.bat')).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect .php files as dangerous', async () => {
|
||||
const { isDangerousFile } = await import('./upload');
|
||||
expect(isDangerousFile('webshell.php')).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect .js files as dangerous', async () => {
|
||||
const { isDangerousFile } = await import('./upload');
|
||||
expect(isDangerousFile('script.js')).toBe(true);
|
||||
});
|
||||
|
||||
it('should not flag safe files as dangerous', async () => {
|
||||
const { isDangerousFile } = await import('./upload');
|
||||
expect(isDangerousFile('image.jpg')).toBe(false);
|
||||
expect(isDangerousFile('document.pdf')).toBe(false);
|
||||
});
|
||||
|
||||
it('should be case insensitive', async () => {
|
||||
const { isDangerousFile } = await import('./upload');
|
||||
expect(isDangerousFile('MALWARE.EXE')).toBe(true);
|
||||
expect(isDangerousFile('Script.BAT')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getDatePath', () => {
|
||||
it('should return correct date path format', async () => {
|
||||
const { getDatePath } = await import('./upload');
|
||||
const datePath = getDatePath();
|
||||
const regex = /^\d{4}\/\d{2}\/\d{2}$/;
|
||||
expect(regex.test(datePath)).toBe(true);
|
||||
});
|
||||
|
||||
it('should pad month and day with zeros', async () => {
|
||||
const { getDatePath } = await import('./upload');
|
||||
const datePath = getDatePath();
|
||||
const parts = datePath.split('/');
|
||||
expect(parts[1].length).toBe(2);
|
||||
expect(parts[2].length).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('uploadFile', () => {
|
||||
const createMockFile = (overrides: Partial<File> = {}): File => {
|
||||
return {
|
||||
name: 'test.jpg',
|
||||
size: 1024,
|
||||
type: 'image/jpeg',
|
||||
arrayBuffer: jest.fn().mockResolvedValue(new ArrayBuffer(1024)) as any,
|
||||
...overrides,
|
||||
} as File;
|
||||
};
|
||||
|
||||
it('should upload a valid image file successfully', async () => {
|
||||
const { uploadFile } = await import('./upload');
|
||||
|
||||
const validJpegBuffer = Buffer.from([0xFF, 0xD8, 0xFF, 0x00, 0x00]);
|
||||
const mockFile = createMockFile({
|
||||
arrayBuffer: jest.fn().mockResolvedValue(validJpegBuffer) as any as any,
|
||||
});
|
||||
|
||||
mockedExistsSync.mockReturnValue(true);
|
||||
mockedWriteFile.mockResolvedValue();
|
||||
|
||||
const result = await uploadFile(mockFile, { type: 'image' });
|
||||
|
||||
expect(result.id).toBe('test-id-12345');
|
||||
expect(result.name).toBe('test.jpg');
|
||||
expect(result.type).toBe('image/jpeg');
|
||||
expect(result.url).toContain('/uploads/image/');
|
||||
expect(result.url).toContain('.jpg');
|
||||
});
|
||||
|
||||
it('should reject files exceeding size limit', async () => {
|
||||
const { uploadFile } = await import('./upload');
|
||||
|
||||
const largeFile = createMockFile({
|
||||
size: 10 * 1024 * 1024,
|
||||
});
|
||||
|
||||
await expect(uploadFile(largeFile, { type: 'image', maxSize: 5 * 1024 * 1024 }))
|
||||
.rejects.toThrow('文件大小超过限制');
|
||||
});
|
||||
|
||||
it('should reject disallowed file types', async () => {
|
||||
const { uploadFile } = await import('./upload');
|
||||
|
||||
const invalidFile = createMockFile({
|
||||
type: 'application/exe',
|
||||
});
|
||||
|
||||
await expect(uploadFile(invalidFile, { type: 'image' }))
|
||||
.rejects.toThrow('不支持的文件类型');
|
||||
});
|
||||
|
||||
it('should reject dangerous file extensions', async () => {
|
||||
const { uploadFile } = await import('./upload');
|
||||
|
||||
const dangerousFile = createMockFile({
|
||||
name: 'malware.exe',
|
||||
type: 'application/pdf',
|
||||
});
|
||||
|
||||
await expect(uploadFile(dangerousFile, { type: 'document' }))
|
||||
.rejects.toThrow('不允许上传此类型的文件');
|
||||
});
|
||||
|
||||
it('should reject files with mismatched signatures', async () => {
|
||||
const { uploadFile } = await import('./upload');
|
||||
|
||||
const fakeJpegBuffer = Buffer.from([0x00, 0x00, 0x00, 0x00]);
|
||||
const fakeFile = createMockFile({
|
||||
type: 'image/jpeg',
|
||||
arrayBuffer: jest.fn().mockResolvedValue(fakeJpegBuffer) as any as any,
|
||||
});
|
||||
|
||||
await expect(uploadFile(fakeFile, { type: 'image' }))
|
||||
.rejects.toThrow('文件内容与声明类型不匹配');
|
||||
});
|
||||
|
||||
it('should create upload directory if it does not exist', async () => {
|
||||
const { uploadFile } = await import('./upload');
|
||||
|
||||
const validJpegBuffer = Buffer.from([0xFF, 0xD8, 0xFF, 0x00, 0x00]);
|
||||
const mockFile = createMockFile({
|
||||
arrayBuffer: jest.fn().mockResolvedValue(validJpegBuffer) as any as any,
|
||||
});
|
||||
|
||||
mockedExistsSync.mockReturnValue(false);
|
||||
mockedMkdir.mockResolvedValue(undefined as any);
|
||||
mockedWriteFile.mockResolvedValue();
|
||||
|
||||
await uploadFile(mockFile, { type: 'image' });
|
||||
|
||||
expect(mockedMkdir).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should include userId in upload result', async () => {
|
||||
const { uploadFile } = await import('./upload');
|
||||
|
||||
const validJpegBuffer = Buffer.from([0xFF, 0xD8, 0xFF, 0x00, 0x00]);
|
||||
const mockFile = createMockFile({
|
||||
arrayBuffer: jest.fn().mockResolvedValue(validJpegBuffer) as any as any,
|
||||
});
|
||||
|
||||
mockedExistsSync.mockReturnValue(true);
|
||||
mockedWriteFile.mockResolvedValue();
|
||||
|
||||
const result = await uploadFile(mockFile, { type: 'image', userId: 'user-123' });
|
||||
|
||||
expect(result.uploadedBy).toBe('user-123');
|
||||
});
|
||||
|
||||
it('should sanitize file name', async () => {
|
||||
const { uploadFile } = await import('./upload');
|
||||
|
||||
const validJpegBuffer = Buffer.from([0xFF, 0xD8, 0xFF, 0x00, 0x00]);
|
||||
const mockFile = createMockFile({
|
||||
name: 'Test<>File.JPG',
|
||||
arrayBuffer: jest.fn().mockResolvedValue(validJpegBuffer) as any as any,
|
||||
});
|
||||
|
||||
mockedExistsSync.mockReturnValue(true);
|
||||
mockedWriteFile.mockResolvedValue();
|
||||
|
||||
const result = await uploadFile(mockFile, { type: 'image' });
|
||||
|
||||
expect(result.name).toBe('test__file.jpg');
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteFile', () => {
|
||||
it('should delete existing file successfully', async () => {
|
||||
const { deleteFile } = await import('./upload');
|
||||
|
||||
mockedExistsSync.mockReturnValue(true);
|
||||
mockedUnlink.mockResolvedValue();
|
||||
|
||||
const result = await deleteFile('/uploads/image/test.jpg');
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(mockedUnlink).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return false for non-existent file', async () => {
|
||||
const { deleteFile } = await import('./upload');
|
||||
|
||||
mockedExistsSync.mockReturnValue(false);
|
||||
|
||||
const result = await deleteFile('/uploads/image/nonexistent.jpg');
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(mockedUnlink).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return false on deletion error', async () => {
|
||||
const { deleteFile } = await import('./upload');
|
||||
|
||||
mockedExistsSync.mockReturnValue(true);
|
||||
mockedUnlink.mockRejectedValue(new Error('Permission denied'));
|
||||
|
||||
const result = await deleteFile('/uploads/image/test.jpg');
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getFileInfo', () => {
|
||||
it('should return file information for existing file', async () => {
|
||||
const { getFileInfo } = await import('./upload');
|
||||
|
||||
const mockStats = {
|
||||
size: 1024,
|
||||
birthtime: new Date('2024-01-01'),
|
||||
mtime: new Date('2024-01-02'),
|
||||
};
|
||||
|
||||
mockedStat.mockResolvedValue(mockStats as any);
|
||||
|
||||
const result = await getFileInfo('/path/to/file.jpg');
|
||||
|
||||
expect(result).toEqual({
|
||||
size: 1024,
|
||||
createdAt: mockStats.birthtime,
|
||||
modifiedAt: mockStats.mtime,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return null for non-existent file', async () => {
|
||||
const { getFileInfo } = await import('./upload');
|
||||
|
||||
mockedStat.mockRejectedValue(new Error('File not found'));
|
||||
|
||||
const result = await getFileInfo('/path/to/nonexistent.jpg');
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Security Tests', () => {
|
||||
it('should prevent path traversal attacks', async () => {
|
||||
const { sanitizeFileName } = await import('./upload');
|
||||
const maliciousName = '../../../etc/passwd';
|
||||
const sanitized = sanitizeFileName(maliciousName);
|
||||
|
||||
expect(sanitized).not.toContain('/');
|
||||
expect(sanitized).not.toContain('..');
|
||||
});
|
||||
|
||||
it('should prevent double extension attacks', async () => {
|
||||
const { sanitizeFileName } = await import('./upload');
|
||||
const maliciousName = 'image.jpg.php';
|
||||
const sanitized = sanitizeFileName(maliciousName);
|
||||
|
||||
expect(sanitized).toBe('image.jpg.php');
|
||||
});
|
||||
|
||||
it('should validate file signatures to prevent MIME type spoofing', async () => {
|
||||
const { validateFileSignature } = await import('./upload');
|
||||
|
||||
const fakeJpegBuffer = Buffer.from([0x25, 0x50, 0x44, 0x46]);
|
||||
expect(validateFileSignature(fakeJpegBuffer, 'image/jpeg')).toBe(false);
|
||||
|
||||
const realJpegBuffer = Buffer.from([0xFF, 0xD8, 0xFF]);
|
||||
expect(validateFileSignature(realJpegBuffer, 'image/jpeg')).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject all dangerous file extensions', async () => {
|
||||
const { isDangerousFile } = await import('./upload');
|
||||
const dangerousExtensions = ['.exe', '.bat', '.cmd', '.sh', '.php', '.jsp', '.asp', '.aspx', '.js'];
|
||||
|
||||
dangerousExtensions.forEach(ext => {
|
||||
expect(isDangerousFile(`malware${ext}`)).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,190 +0,0 @@
|
||||
import { writeFile, mkdir, stat } from 'fs/promises';
|
||||
import { existsSync } from 'fs';
|
||||
import path from 'path';
|
||||
import { nanoid } from 'nanoid';
|
||||
|
||||
export interface UploadOptions {
|
||||
maxSize?: number;
|
||||
allowedTypes?: string[];
|
||||
type: 'image' | 'document';
|
||||
userId?: string;
|
||||
}
|
||||
|
||||
export interface UploadResult {
|
||||
id: string;
|
||||
name: string;
|
||||
size: number;
|
||||
type: string;
|
||||
url: string;
|
||||
path: string;
|
||||
uploadedAt: Date;
|
||||
uploadedBy?: string;
|
||||
}
|
||||
|
||||
const FILE_SIGNATURES: Record<string, Buffer> = {
|
||||
'image/jpeg': Buffer.from([0xFF, 0xD8, 0xFF]),
|
||||
'image/png': Buffer.from([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]),
|
||||
'image/gif': Buffer.from([0x47, 0x49, 0x46, 0x38]),
|
||||
'image/webp': Buffer.from([0x52, 0x49, 0x46, 0x46]),
|
||||
'application/pdf': Buffer.from([0x25, 0x50, 0x44, 0x46]),
|
||||
};
|
||||
|
||||
const ALLOWED_TYPES: Record<string, string[]> = {
|
||||
image: ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/svg+xml'],
|
||||
document: ['application/pdf', 'application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'],
|
||||
};
|
||||
|
||||
const MAX_FILE_SIZES: Record<string, number> = {
|
||||
image: 5 * 1024 * 1024, // 5MB
|
||||
document: 10 * 1024 * 1024, // 10MB
|
||||
};
|
||||
|
||||
const DANGEROUS_EXTENSIONS = ['.exe', '.bat', '.cmd', '.sh', '.php', '.jsp', '.asp', '.aspx', '.js'];
|
||||
|
||||
export function getFileExtension(mimeType: string): string {
|
||||
const extensions: Record<string, string> = {
|
||||
'image/jpeg': '.jpg',
|
||||
'image/png': '.png',
|
||||
'image/gif': '.gif',
|
||||
'image/webp': '.webp',
|
||||
'image/svg+xml': '.svg',
|
||||
'application/pdf': '.pdf',
|
||||
'application/msword': '.doc',
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': '.docx',
|
||||
};
|
||||
return extensions[mimeType] || '';
|
||||
}
|
||||
|
||||
export function isAllowedType(mimeType: string, type: 'image' | 'document'): boolean {
|
||||
return ALLOWED_TYPES[type]?.includes(mimeType) || false;
|
||||
}
|
||||
|
||||
export function validateFileSignature(buffer: Buffer, mimeType: string): boolean {
|
||||
if (mimeType === 'image/svg+xml') {
|
||||
return true;
|
||||
}
|
||||
|
||||
const signature = FILE_SIGNATURES[mimeType];
|
||||
if (!signature) {
|
||||
return true;
|
||||
}
|
||||
|
||||
for (let i = 0; i < signature.length; i++) {
|
||||
if (buffer[i] !== signature[i]) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export function sanitizeFileName(fileName: string): string {
|
||||
return fileName
|
||||
.replace(/[^a-zA-Z0-9\u4e00-\u9fa5._-]/g, '_')
|
||||
.replace(/\.{2,}/g, '.')
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
export function isDangerousFile(fileName: string): boolean {
|
||||
const ext = path.extname(fileName).toLowerCase();
|
||||
return DANGEROUS_EXTENSIONS.includes(ext);
|
||||
}
|
||||
|
||||
export function getDatePath(): string {
|
||||
const now = new Date();
|
||||
const year = now.getFullYear();
|
||||
const month = String(now.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(now.getDate()).padStart(2, '0');
|
||||
return `${year}/${month}/${day}`;
|
||||
}
|
||||
|
||||
export async function uploadFile(
|
||||
file: File,
|
||||
options: UploadOptions
|
||||
): Promise<UploadResult> {
|
||||
const {
|
||||
type = 'image',
|
||||
maxSize = MAX_FILE_SIZES[type] || 5 * 1024 * 1024,
|
||||
allowedTypes = ALLOWED_TYPES[type] || [],
|
||||
userId
|
||||
} = options;
|
||||
|
||||
if (file.size > maxSize) {
|
||||
throw new Error(`文件大小超过限制 (最大 ${Math.round(maxSize / 1024 / 1024)}MB)`);
|
||||
}
|
||||
|
||||
if (!allowedTypes.includes(file.type)) {
|
||||
throw new Error(`不支持的文件类型: ${file.type}`);
|
||||
}
|
||||
|
||||
if (isDangerousFile(file.name)) {
|
||||
throw new Error('不允许上传此类型的文件');
|
||||
}
|
||||
|
||||
const bytes = await file.arrayBuffer();
|
||||
const buffer = Buffer.from(bytes);
|
||||
|
||||
if (!validateFileSignature(buffer, file.type)) {
|
||||
throw new Error('文件内容与声明类型不匹配');
|
||||
}
|
||||
|
||||
const uploadBaseDir = process.env.UPLOAD_DIR || './uploads';
|
||||
const datePath = getDatePath();
|
||||
const uploadDir = path.join(process.cwd(), uploadBaseDir, type, datePath);
|
||||
|
||||
if (!existsSync(uploadDir)) {
|
||||
await mkdir(uploadDir, { recursive: true });
|
||||
}
|
||||
|
||||
const fileId = nanoid();
|
||||
const extension = getFileExtension(file.type);
|
||||
const sanitizedOriginalName = sanitizeFileName(file.name);
|
||||
const fileName = `${fileId}${extension}`;
|
||||
const filePath = path.join(uploadDir, fileName);
|
||||
|
||||
await writeFile(filePath, buffer);
|
||||
|
||||
const publicUrl = `/uploads/${type}/${datePath}/${fileName}`;
|
||||
|
||||
return {
|
||||
id: fileId,
|
||||
name: sanitizedOriginalName,
|
||||
size: file.size,
|
||||
type: file.type,
|
||||
url: publicUrl,
|
||||
path: filePath,
|
||||
uploadedAt: new Date(),
|
||||
uploadedBy: userId,
|
||||
};
|
||||
}
|
||||
|
||||
export async function deleteFile(fileUrl: string): Promise<boolean> {
|
||||
try {
|
||||
const filePath = path.join(process.cwd(), 'public', fileUrl);
|
||||
|
||||
if (!existsSync(filePath)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const { unlink } = await import('fs/promises');
|
||||
await unlink(filePath);
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('删除文件失败:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getFileInfo(filePath: string) {
|
||||
try {
|
||||
const stats = await stat(filePath);
|
||||
return {
|
||||
size: stats.size,
|
||||
createdAt: stats.birthtime,
|
||||
modifiedAt: stats.mtime,
|
||||
};
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,275 +0,0 @@
|
||||
import { describe, it, expect } from '@jest/globals';
|
||||
import {
|
||||
validateEmail,
|
||||
validatePhone,
|
||||
validatePassword,
|
||||
validateUrl,
|
||||
validateRequired,
|
||||
validateLength,
|
||||
validateRange,
|
||||
validateChineseId,
|
||||
validateUsername,
|
||||
} from './validation';
|
||||
|
||||
describe('validation', () => {
|
||||
describe('email validation', () => {
|
||||
it('should accept valid email', () => {
|
||||
expect(validateEmail('test@example.com')).toBe(true);
|
||||
expect(validateEmail('user.name@example.com')).toBe(true);
|
||||
expect(validateEmail('user+tag@example.co.uk')).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject invalid email', () => {
|
||||
expect(validateEmail('invalid')).toBe(false);
|
||||
expect(validateEmail('invalid@')).toBe(false);
|
||||
expect(validateEmail('@example.com')).toBe(false);
|
||||
expect(validateEmail('test@')).toBe(false);
|
||||
expect(validateEmail('')).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject null or undefined', () => {
|
||||
expect(validateEmail(null as any)).toBe(false);
|
||||
expect(validateEmail(undefined as any)).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject non-string values', () => {
|
||||
expect(validateEmail(123 as any)).toBe(false);
|
||||
expect(validateEmail({} as any)).toBe(false);
|
||||
expect(validateEmail([] as any)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('phone validation', () => {
|
||||
it('should accept valid phone numbers', () => {
|
||||
expect(validatePhone('13800138000')).toBe(true);
|
||||
expect(validatePhone('15912345678')).toBe(true);
|
||||
expect(validatePhone('18600001111')).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject invalid phone numbers', () => {
|
||||
expect(validatePhone('123')).toBe(false);
|
||||
expect(validatePhone('12345678901')).toBe(false);
|
||||
expect(validatePhone('10800138000')).toBe(false);
|
||||
expect(validatePhone('1380013800')).toBe(false);
|
||||
expect(validatePhone('')).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject null or undefined', () => {
|
||||
expect(validatePhone(null as any)).toBe(false);
|
||||
expect(validatePhone(undefined as any)).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject non-string values', () => {
|
||||
expect(validatePhone(13800138000 as any)).toBe(false);
|
||||
expect(validatePhone({} as any)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('password validation', () => {
|
||||
it('should accept valid passwords', () => {
|
||||
expect(validatePassword('password123')).toBe(true);
|
||||
expect(validatePassword('12345678')).toBe(true);
|
||||
expect(validatePassword('abcdefgh')).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject short passwords', () => {
|
||||
expect(validatePassword('pass')).toBe(false);
|
||||
expect(validatePassword('1234567')).toBe(false);
|
||||
expect(validatePassword('')).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject null or undefined', () => {
|
||||
expect(validatePassword(null as any)).toBe(false);
|
||||
expect(validatePassword(undefined as any)).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject non-string values', () => {
|
||||
expect(validatePassword(12345678 as any)).toBe(false);
|
||||
expect(validatePassword({} as any)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('URL validation', () => {
|
||||
it('should accept valid URLs', () => {
|
||||
expect(validateUrl('https://example.com')).toBe(true);
|
||||
expect(validateUrl('http://example.com')).toBe(true);
|
||||
expect(validateUrl('https://example.com/path')).toBe(true);
|
||||
expect(validateUrl('https://example.com?query=1')).toBe(true);
|
||||
expect(validateUrl('https://example.com#fragment')).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject invalid URLs', () => {
|
||||
expect(validateUrl('not-a-url')).toBe(false);
|
||||
expect(validateUrl('')).toBe(false);
|
||||
expect(validateUrl('example.com')).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject null or undefined', () => {
|
||||
expect(validateUrl(null as any)).toBe(false);
|
||||
expect(validateUrl(undefined as any)).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject non-string values', () => {
|
||||
expect(validateUrl({} as any)).toBe(false);
|
||||
expect(validateUrl([] as any)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('required validation', () => {
|
||||
it('should accept non-empty strings', () => {
|
||||
expect(validateRequired('test')).toBe(true);
|
||||
expect(validateRequired(' test ')).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject empty strings', () => {
|
||||
expect(validateRequired('')).toBe(false);
|
||||
expect(validateRequired(' ')).toBe(false);
|
||||
});
|
||||
|
||||
it('should accept valid numbers', () => {
|
||||
expect(validateRequired(123)).toBe(true);
|
||||
expect(validateRequired(0)).toBe(true);
|
||||
expect(validateRequired(-1)).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject NaN', () => {
|
||||
expect(validateRequired(NaN)).toBe(false);
|
||||
});
|
||||
|
||||
it('should accept non-empty arrays', () => {
|
||||
expect(validateRequired([1, 2, 3])).toBe(true);
|
||||
expect(validateRequired(['a'])).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject empty arrays', () => {
|
||||
expect(validateRequired([])).toBe(false);
|
||||
});
|
||||
|
||||
it('should accept objects', () => {
|
||||
expect(validateRequired({})).toBe(true);
|
||||
expect(validateRequired({ a: 1 })).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject null and undefined', () => {
|
||||
expect(validateRequired(null)).toBe(false);
|
||||
expect(validateRequired(undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it('should accept booleans', () => {
|
||||
expect(validateRequired(true)).toBe(true);
|
||||
expect(validateRequired(false)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('length validation', () => {
|
||||
it('should accept strings within range', () => {
|
||||
expect(validateLength('test', 3, 5)).toBe(true);
|
||||
expect(validateLength('hello', 3, 10)).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject strings shorter than minimum', () => {
|
||||
expect(validateLength('ab', 3, 10)).toBe(false);
|
||||
expect(validateLength('', 1, 10)).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject strings longer than maximum', () => {
|
||||
expect(validateLength('hello world', 3, 5)).toBe(false);
|
||||
expect(validateLength('a'.repeat(20), 1, 10)).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject non-string values', () => {
|
||||
expect(validateLength(123 as any, 1, 10)).toBe(false);
|
||||
expect(validateLength(null as any, 1, 10)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('range validation', () => {
|
||||
it('should accept numbers within range', () => {
|
||||
expect(validateRange(5, 1, 10)).toBe(true);
|
||||
expect(validateRange(1, 1, 10)).toBe(true);
|
||||
expect(validateRange(10, 1, 10)).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject numbers below minimum', () => {
|
||||
expect(validateRange(0, 1, 10)).toBe(false);
|
||||
expect(validateRange(-5, 0, 10)).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject numbers above maximum', () => {
|
||||
expect(validateRange(11, 1, 10)).toBe(false);
|
||||
expect(validateRange(100, 1, 10)).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject NaN', () => {
|
||||
expect(validateRange(NaN, 1, 10)).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject non-number values', () => {
|
||||
expect(validateRange('5' as any, 1, 10)).toBe(false);
|
||||
expect(validateRange(null as any, 1, 10)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Chinese ID validation', () => {
|
||||
it('should accept valid Chinese ID numbers', () => {
|
||||
expect(validateChineseId('11010519900307888X')).toBe(true);
|
||||
expect(validateChineseId('110105199003078888')).toBe(true);
|
||||
expect(validateChineseId('110105200001011234')).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject invalid Chinese ID numbers', () => {
|
||||
expect(validateChineseId('123456789012345678')).toBe(false);
|
||||
expect(validateChineseId('11010519900307888')).toBe(false);
|
||||
expect(validateChineseId('11010519900307888XX')).toBe(false);
|
||||
expect(validateChineseId('')).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject null or undefined', () => {
|
||||
expect(validateChineseId(null as any)).toBe(false);
|
||||
expect(validateChineseId(undefined as any)).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject non-string values', () => {
|
||||
expect(validateChineseId(123456789012345678 as any)).toBe(false);
|
||||
expect(validateChineseId({} as any)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('username validation', () => {
|
||||
it('should accept valid usernames', () => {
|
||||
expect(validateUsername('user123')).toBe(true);
|
||||
expect(validateUsername('test_user')).toBe(true);
|
||||
expect(validateUsername('User123')).toBe(true);
|
||||
expect(validateUsername('a1')).toBe(false);
|
||||
expect(validateUsername('a')).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject usernames with special characters', () => {
|
||||
expect(validateUsername('user@123')).toBe(false);
|
||||
expect(validateUsername('user-name')).toBe(false);
|
||||
expect(validateUsername('user.name')).toBe(false);
|
||||
expect(validateUsername('user name')).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject usernames that are too short', () => {
|
||||
expect(validateUsername('ab')).toBe(false);
|
||||
expect(validateUsername('a')).toBe(false);
|
||||
expect(validateUsername('')).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject usernames that are too long', () => {
|
||||
expect(validateUsername('a'.repeat(21))).toBe(false);
|
||||
expect(validateUsername('user123456789012345678')).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject null or undefined', () => {
|
||||
expect(validateUsername(null as any)).toBe(false);
|
||||
expect(validateUsername(undefined as any)).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject non-string values', () => {
|
||||
expect(validateUsername(123 as any)).toBe(false);
|
||||
expect(validateUsername({} as any)).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,80 +0,0 @@
|
||||
export function validateEmail(email: string): boolean {
|
||||
if (!email || typeof email !== 'string') {
|
||||
return false;
|
||||
}
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
return emailRegex.test(email);
|
||||
}
|
||||
|
||||
export function validatePhone(phone: string): boolean {
|
||||
if (!phone || typeof phone !== 'string') {
|
||||
return false;
|
||||
}
|
||||
const phoneRegex = /^1[3-9]\d{9}$/;
|
||||
return phoneRegex.test(phone);
|
||||
}
|
||||
|
||||
export function validatePassword(password: string): boolean {
|
||||
if (!password || typeof password !== 'string') {
|
||||
return false;
|
||||
}
|
||||
return password.length >= 8;
|
||||
}
|
||||
|
||||
export function validateUrl(url: string): boolean {
|
||||
if (!url || typeof url !== 'string') {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
new URL(url);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function validateRequired(value: unknown): boolean {
|
||||
if (value === null || value === undefined) {
|
||||
return false;
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
return value.trim().length > 0;
|
||||
}
|
||||
if (typeof value === 'number') {
|
||||
return !isNaN(value);
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value.length > 0;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function validateLength(value: string, min: number, max: number): boolean {
|
||||
if (typeof value !== 'string') {
|
||||
return false;
|
||||
}
|
||||
return value.length >= min && value.length <= max;
|
||||
}
|
||||
|
||||
export function validateRange(value: number, min: number, max: number): boolean {
|
||||
if (typeof value !== 'number' || isNaN(value)) {
|
||||
return false;
|
||||
}
|
||||
return value >= min && value <= max;
|
||||
}
|
||||
|
||||
export function validateChineseId(id: string): boolean {
|
||||
if (!id || typeof id !== 'string') {
|
||||
return false;
|
||||
}
|
||||
const idRegex = /^[1-9]\d{5}(18|19|20)\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3}[\dXx]$/;
|
||||
return idRegex.test(id);
|
||||
}
|
||||
|
||||
export function validateUsername(username: string): boolean {
|
||||
if (!username || typeof username !== 'string') {
|
||||
return false;
|
||||
}
|
||||
const usernameRegex = /^[a-zA-Z0-9_]{3,20}$/;
|
||||
return usernameRegex.test(username);
|
||||
}
|
||||
Reference in New Issue
Block a user