test: improve branch coverage with edge cases

This commit is contained in:
张翔
2026-03-10 12:44:17 +08:00
parent 4141843b9d
commit 29ec90d2cc
2 changed files with 722 additions and 0 deletions
+276
View File
@@ -0,0 +1,276 @@
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();
});
});
});
+446
View File
@@ -0,0 +1,446 @@
import { describe, it, expect, jest, beforeEach, afterEach } from '@jest/globals';
import path from 'path';
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)),
...overrides,
} as any;
};
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),
});
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),
});
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),
});
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),
});
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),
});
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);
});
});
});
});