mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2025-12-24 18:50:56 +08:00
* refactor: change qrcode landrop to lantransfer * chore: update docs and tests * fix: pr review * fix: pr review * chore: remove qrcode dependency * fix: pr review * fix: format * fix: test
275 lines
9.2 KiB
TypeScript
275 lines
9.2 KiB
TypeScript
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)
|
|
})
|
|
})
|
|
})
|