import { beforeEach, describe, expect, it, vi } from 'vitest' // Use vi.hoisted to define mocks that are available during hoisting const { mockLogger } = vi.hoisted(() => ({ mockLogger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() } })) vi.mock('@logger', () => ({ loggerService: { withContext: () => mockLogger } })) vi.mock('electron', () => ({ app: { getPath: vi.fn((key: string) => { if (key === 'temp') return '/tmp' if (key === 'userData') return '/mock/userData' return '/mock/unknown' }) } })) vi.mock('fs-extra', () => ({ default: { pathExists: vi.fn(), remove: vi.fn(), ensureDir: vi.fn(), copy: vi.fn(), readdir: vi.fn(), stat: vi.fn(), readFile: vi.fn(), writeFile: vi.fn(), createWriteStream: vi.fn(), createReadStream: vi.fn() }, pathExists: vi.fn(), remove: vi.fn(), ensureDir: vi.fn(), copy: vi.fn(), readdir: vi.fn(), stat: vi.fn(), readFile: vi.fn(), writeFile: vi.fn(), createWriteStream: vi.fn(), createReadStream: vi.fn() })) vi.mock('../WindowService', () => ({ windowService: { getMainWindow: vi.fn() } })) vi.mock('../WebDav', () => ({ default: vi.fn() })) vi.mock('../S3Storage', () => ({ default: vi.fn() })) vi.mock('../../utils', () => ({ getDataPath: vi.fn(() => '/mock/data') })) vi.mock('archiver', () => ({ default: vi.fn() })) vi.mock('node-stream-zip', () => ({ default: vi.fn() })) // Import after mocks import * as fs from 'fs-extra' import BackupManager from '../BackupManager' describe('BackupManager.deleteTempBackup - Security Tests', () => { let backupManager: BackupManager beforeEach(() => { vi.clearAllMocks() backupManager = new BackupManager() }) describe('Normal Operations', () => { it('should delete valid file in allowed directory', async () => { vi.mocked(fs.pathExists).mockResolvedValue(true as never) vi.mocked(fs.remove).mockResolvedValue(undefined as never) const validPath = '/tmp/cherry-studio/lan-transfer/backup.zip' const result = await backupManager.deleteTempBackup({} as Electron.IpcMainInvokeEvent, validPath) expect(result).toBe(true) expect(fs.remove).toHaveBeenCalledWith(validPath) expect(mockLogger.info).toHaveBeenCalledWith(expect.stringContaining('Deleted temp backup')) }) it('should delete file in nested subdirectory', async () => { vi.mocked(fs.pathExists).mockResolvedValue(true as never) vi.mocked(fs.remove).mockResolvedValue(undefined as never) const nestedPath = '/tmp/cherry-studio/lan-transfer/sub/dir/file.zip' const result = await backupManager.deleteTempBackup({} as Electron.IpcMainInvokeEvent, nestedPath) expect(result).toBe(true) expect(fs.remove).toHaveBeenCalledWith(nestedPath) }) it('should return false when file does not exist', async () => { vi.mocked(fs.pathExists).mockResolvedValue(false as never) const missingPath = '/tmp/cherry-studio/lan-transfer/missing.zip' const result = await backupManager.deleteTempBackup({} as Electron.IpcMainInvokeEvent, missingPath) expect(result).toBe(false) expect(fs.remove).not.toHaveBeenCalled() }) }) describe('Path Traversal Attacks', () => { it('should block basic directory traversal attack (../../../../etc/passwd)', async () => { const attackPath = '/tmp/cherry-studio/lan-transfer/../../../../etc/passwd' const result = await backupManager.deleteTempBackup({} as Electron.IpcMainInvokeEvent, attackPath) expect(result).toBe(false) expect(fs.pathExists).not.toHaveBeenCalled() expect(fs.remove).not.toHaveBeenCalled() expect(mockLogger.warn).toHaveBeenCalledWith(expect.stringContaining('outside temp directory')) }) it('should block absolute path escape (/etc/passwd)', async () => { const attackPath = '/etc/passwd' const result = await backupManager.deleteTempBackup({} as Electron.IpcMainInvokeEvent, attackPath) expect(result).toBe(false) expect(fs.remove).not.toHaveBeenCalled() expect(mockLogger.warn).toHaveBeenCalled() }) it('should block traversal with multiple slashes', async () => { const attackPath = '/tmp/cherry-studio/lan-transfer/../../../etc/passwd' const result = await backupManager.deleteTempBackup({} as Electron.IpcMainInvokeEvent, attackPath) expect(result).toBe(false) expect(fs.remove).not.toHaveBeenCalled() }) it('should block relative path traversal from current directory', async () => { const attackPath = '../../../etc/passwd' const result = await backupManager.deleteTempBackup({} as Electron.IpcMainInvokeEvent, attackPath) expect(result).toBe(false) expect(fs.remove).not.toHaveBeenCalled() }) it('should block traversal to parent directory', async () => { const attackPath = '/tmp/cherry-studio/lan-transfer/../backup/secret.zip' const result = await backupManager.deleteTempBackup({} as Electron.IpcMainInvokeEvent, attackPath) expect(result).toBe(false) expect(fs.remove).not.toHaveBeenCalled() }) }) describe('Prefix Attacks', () => { it('should block similar prefix attack (lan-transfer-evil)', async () => { const attackPath = '/tmp/cherry-studio/lan-transfer-evil/file.zip' const result = await backupManager.deleteTempBackup({} as Electron.IpcMainInvokeEvent, attackPath) expect(result).toBe(false) expect(fs.remove).not.toHaveBeenCalled() expect(mockLogger.warn).toHaveBeenCalled() }) it('should block path without separator (lan-transferx)', async () => { const attackPath = '/tmp/cherry-studio/lan-transferx' const result = await backupManager.deleteTempBackup({} as Electron.IpcMainInvokeEvent, attackPath) expect(result).toBe(false) expect(fs.remove).not.toHaveBeenCalled() }) it('should block different temp directory prefix', async () => { const attackPath = '/tmp-evil/cherry-studio/lan-transfer/file.zip' const result = await backupManager.deleteTempBackup({} as Electron.IpcMainInvokeEvent, attackPath) expect(result).toBe(false) expect(fs.remove).not.toHaveBeenCalled() }) }) describe('Error Handling', () => { it('should return false and log error on permission denied', async () => { vi.mocked(fs.pathExists).mockResolvedValue(true as never) vi.mocked(fs.remove).mockRejectedValue(new Error('EACCES: permission denied') as never) const validPath = '/tmp/cherry-studio/lan-transfer/file.zip' const result = await backupManager.deleteTempBackup({} as Electron.IpcMainInvokeEvent, validPath) expect(result).toBe(false) expect(mockLogger.error).toHaveBeenCalledWith(expect.stringContaining('Failed to delete'), expect.any(Error)) }) it('should return false on fs.pathExists error', async () => { vi.mocked(fs.pathExists).mockRejectedValue(new Error('ENOENT') as never) const validPath = '/tmp/cherry-studio/lan-transfer/file.zip' const result = await backupManager.deleteTempBackup({} as Electron.IpcMainInvokeEvent, validPath) expect(result).toBe(false) expect(mockLogger.error).toHaveBeenCalled() }) it('should handle empty path string', async () => { const result = await backupManager.deleteTempBackup({} as Electron.IpcMainInvokeEvent, '') expect(result).toBe(false) expect(fs.remove).not.toHaveBeenCalled() }) }) describe('Edge Cases', () => { it('should allow deletion of the temp directory itself', async () => { vi.mocked(fs.pathExists).mockResolvedValue(true as never) vi.mocked(fs.remove).mockResolvedValue(undefined as never) const tempDir = '/tmp/cherry-studio/lan-transfer' const result = await backupManager.deleteTempBackup({} as Electron.IpcMainInvokeEvent, tempDir) expect(result).toBe(true) expect(fs.remove).toHaveBeenCalledWith(tempDir) }) it('should handle path with trailing slash', async () => { vi.mocked(fs.pathExists).mockResolvedValue(true as never) vi.mocked(fs.remove).mockResolvedValue(undefined as never) const pathWithSlash = '/tmp/cherry-studio/lan-transfer/sub/' const result = await backupManager.deleteTempBackup({} as Electron.IpcMainInvokeEvent, pathWithSlash) // path.normalize removes trailing slash expect(result).toBe(true) }) it('should handle file with special characters in name', async () => { vi.mocked(fs.pathExists).mockResolvedValue(true as never) vi.mocked(fs.remove).mockResolvedValue(undefined as never) const specialPath = '/tmp/cherry-studio/lan-transfer/file with spaces & (special).zip' const result = await backupManager.deleteTempBackup({} as Electron.IpcMainInvokeEvent, specialPath) expect(result).toBe(true) expect(fs.remove).toHaveBeenCalled() }) it('should handle path with double slashes', async () => { vi.mocked(fs.pathExists).mockResolvedValue(true as never) vi.mocked(fs.remove).mockResolvedValue(undefined as never) const doubleSlashPath = '/tmp/cherry-studio//lan-transfer//file.zip' const result = await backupManager.deleteTempBackup({} as Electron.IpcMainInvokeEvent, doubleSlashPath) // path.normalize handles double slashes expect(result).toBe(true) }) }) })