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:
张翔
2026-04-21 07:53:56 +08:00
parent cd1d6aa28a
commit 6403489954
197 changed files with 654 additions and 24762 deletions
-95
View File
@@ -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');
});
});
});
-211
View File
@@ -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');
});
});
});
-221
View File
@@ -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 };
-426
View File
@@ -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([]);
});
});
});
-151
View File
@@ -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();
-66
View File
@@ -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>;
}
-106
View File
@@ -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');
});
});
});
-59
View File
@@ -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];
}
-209
View File
@@ -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({});
});
});
});
-74
View File
@@ -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',
},
});
-152
View File
@@ -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' });
});
});
});
-75
View File
@@ -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!,
};
}
-276
View File
@@ -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();
});
});
});
-42
View File
@@ -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;
}
-198
View File
@@ -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');
});
});
});
-41
View File
@@ -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,
};
}
-66
View File
@@ -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');
});
});
});
-29
View File
@@ -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);
}
-378
View File
@@ -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);
});
});
});
-150
View File
@@ -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();
}
-169
View File
@@ -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);
});
});
});
-200
View File
@@ -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);
});
});
});
-75
View File
@@ -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();
-42
View File
@@ -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> = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#x27;',
'/': '&#x2F;',
};
return str.replace(/[&<>"'/]/g, (char) => map[char] ?? char);
}
-46
View File
@@ -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);
});
});
-121
View File
@@ -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;
}
-23
View File
@@ -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);
});
});
-77
View File
@@ -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;
}
-49
View File
@@ -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);
});
});
-143
View File
@@ -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)),
};
}
}
-79
View File
@@ -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);
});
});
-129
View File
@@ -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);
}
}
-58
View File
@@ -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);
});
});
-119
View File
@@ -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();
}
}
-39
View File
@@ -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);
});
});
-105
View File
@@ -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);
}
-53
View File
@@ -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();
});
});
});
-15
View File
@@ -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);
});
}
}
-445
View File
@@ -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);
});
});
});
});
-190
View File
@@ -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;
}
}
-275
View File
@@ -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);
});
});
});
-80
View File
@@ -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);
}