Files
TREK/server/tests/unit/nest/backup.controller.test.ts
T
Maurice fef338091c Fold backupService into the backup domain
src/services/backupService.ts moves to src/nest/backup/backup.impl.ts, imported
only by its own domain now that the controller reaches it through BackupService.
src/services/ is down to airtrail/.

It stays a module of free functions rather than becoming methods on the service.
The restore path closes and reinitializes the core DB handle, which is the single
most dangerous sequence in the server, and rewriting its shape in the same step
as the move would make a regression there impossible to bisect. The db-lifecycle
question is the last step of this fold, not part of it.

The move also pulled 512 lines out of ungated src/services/ into a gated domain,
which is how the per-domain ratchet caught that the per-entry extraction loop had
no tests at all: the zip-slip refusal, the running decompressed-byte cap (the
declared size in the central directory is attacker-controlled, so only the
running total catches a lying archive), a corrupt stream not being dressed up as
a size refusal, and the reopen failure that has to report restart-required rather
than success. Six cases now cover it, and backup goes from 84% to 96% statements.
2026-08-09 18:44:59 +02:00

240 lines
14 KiB
TypeScript

import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { HttpException } from '@nestjs/common';
import type { Request, Response } from 'express';
vi.mock('../../../src/nest/audit/client-ip', () => ({ getClientIp: vi.fn(() => '1.2.3.4') }));
vi.mock('../../../src/nest/audit/audit-log.logger', () => ({ LOG_LEVEL: 'error', logInfo: vi.fn(), logDebug: vi.fn(), logError: vi.fn(), logWarn: vi.fn() }));
// The controller imports the tmp-dir + size cap at module load. The thin
// BackupService wrapper forwards every call straight into this module, so the
// mock also stubs the delegated functions for the wrapper tests below.
vi.mock('../../../src/nest/backup/backup.impl', () => ({
getUploadTmpDir: () => '/tmp',
MAX_BACKUP_UPLOAD_SIZE: 1024,
BACKUP_RATE_WINDOW: 3600000,
listBackups: vi.fn().mockReturnValue([{ filename: 'svc.zip' }]),
createBackup: vi.fn().mockResolvedValue({ filename: 'svc.zip', size: 5 }),
restoreFromZip: vi.fn().mockResolvedValue({ success: true }),
getAutoSettings: vi.fn().mockReturnValue({ settings: { enabled: false }, timezone: 'UTC' }),
updateAutoSettings: vi.fn().mockReturnValue({ enabled: true, interval: 'daily', keep_days: 7 }),
deleteBackup: vi.fn(),
isValidBackupFilename: vi.fn().mockReturnValue(true),
backupFilePath: vi.fn().mockReturnValue('/data/backups/svc.zip'),
backupFileExists: vi.fn().mockReturnValue(true),
checkRateLimit: vi.fn().mockReturnValue(true),
}));
import { BackupController } from '../../../src/nest/backup/backup.controller';
import { BackupService as RealBackupService } from '../../../src/nest/backup/backup.service';
import { AdminGuard } from '../../../src/nest/auth/admin.guard';
import type { BackupService } from '../../../src/nest/backup/backup.service';
import type { AuditService } from '../../../src/nest/audit/audit.service';
import * as backupSvc from '../../../src/nest/backup/backup.impl';
import type { User } from '../../../src/types';
const user = { id: 1, role: 'admin', email: 'a@example.test' } as User;
const req = { ip: '1.2.3.4', headers: {} } as Request;
// AuditService is constructor-injected since the auditLog DI migration; the
// wrapper keeps the historical construction sites positional.
const writeAudit = vi.fn();
const audit = { writeAudit } as unknown as AuditService;
const bc = (s: BackupService) => new BackupController(s, audit);
function svc(o: Partial<BackupService> = {}): BackupService {
return {
listBackups: vi.fn().mockReturnValue([]),
createBackup: vi.fn(),
restoreFromZip: vi.fn(),
getAutoSettings: vi.fn(),
updateAutoSettings: vi.fn(),
deleteBackup: vi.fn(),
isValidBackupFilename: vi.fn().mockReturnValue(true),
backupFilePath: vi.fn().mockReturnValue('/b/x.zip'),
backupFileExists: vi.fn().mockReturnValue(true),
checkRateLimit: vi.fn().mockReturnValue(true),
rateWindow: 3600000,
...o,
} as unknown as BackupService;
}
function thrown(fn: () => unknown): { status: number; body: unknown } {
try { fn(); } catch (err) {
expect(err).toBeInstanceOf(HttpException);
const e = err as HttpException;
return { status: e.getStatus(), body: e.getResponse() };
}
throw new Error('expected throw');
}
async function thrownAsync(fn: () => Promise<unknown>): Promise<{ status: number; body: unknown }> {
try { await fn(); } catch (err) {
expect(err).toBeInstanceOf(HttpException);
const e = err as HttpException;
return { status: e.getStatus(), body: e.getResponse() };
}
throw new Error('expected throw');
}
beforeEach(() => vi.clearAllMocks());
afterEach(() => { delete process.env.NODE_ENV; });
describe('AdminGuard (used by BackupController)', () => {
function ctx(role?: string) {
return { switchToHttp: () => ({ getRequest: () => ({ user: role ? { role } : undefined }) }) } as never;
}
it('403 for a non-admin, passes for an admin', () => {
expect(thrown(() => new AdminGuard().canActivate(ctx('user')))).toEqual({ status: 403, body: { error: 'Admin access required' } });
expect(new AdminGuard().canActivate(ctx('admin'))).toBe(true);
});
});
describe('BackupController', () => {
it('GET /list returns backups, 500 on error', () => {
expect(bc(svc({ listBackups: vi.fn().mockReturnValue([{ filename: 'a.zip' }]) } as Partial<BackupService>)).list()).toEqual({ backups: [{ filename: 'a.zip' }] });
expect(thrown(() => bc(svc({ listBackups: vi.fn(() => { throw new Error('io'); }) } as Partial<BackupService>)).list())).toEqual({ status: 500, body: { error: 'Error loading backups' } });
});
it('POST /create 429 when rate-limited, else creates + audits', async () => {
expect(await thrownAsync(() => bc(svc({ checkRateLimit: vi.fn().mockReturnValue(false) })).create(user, req))).toEqual({ status: 429, body: { error: 'Too many backup requests. Please try again later.' } });
const createBackup = vi.fn().mockResolvedValue({ filename: 'b.zip', size: 10 });
const res = await bc(svc({ createBackup } as Partial<BackupService>)).create(user, req);
expect(res).toEqual({ success: true, backup: { filename: 'b.zip', size: 10 } });
expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'backup.create', resource: 'b.zip' }));
});
it('GET /download 400 invalid / 404 missing, else res.download', () => {
const res = { download: vi.fn() } as unknown as Response;
expect(thrown(() => bc(svc({ isValidBackupFilename: vi.fn().mockReturnValue(false) })).download('x', res))).toEqual({ status: 400, body: { error: 'Invalid filename' } });
expect(thrown(() => bc(svc({ backupFileExists: vi.fn().mockReturnValue(false) })).download('x.zip', res))).toEqual({ status: 404, body: { error: 'Backup not found' } });
bc(svc()).download('x.zip', res);
expect(res.download).toHaveBeenCalledWith('/b/x.zip', 'x.zip');
});
it('POST /restore maps the service status, else audits', async () => {
expect(await thrownAsync(() => bc(svc({ isValidBackupFilename: vi.fn().mockReturnValue(false) })).restore(user, 'x', req))).toEqual({ status: 400, body: { error: 'Invalid filename' } });
expect(await thrownAsync(() => bc(svc({ backupFileExists: vi.fn().mockReturnValue(false) })).restore(user, 'x.zip', req))).toEqual({ status: 404, body: { error: 'Backup not found' } });
expect(await thrownAsync(() => bc(svc({ restoreFromZip: vi.fn().mockResolvedValue({ success: false, status: 422, error: 'bad zip' }) } as Partial<BackupService>)).restore(user, 'x.zip', req))).toEqual({ status: 422, body: { error: 'bad zip' } });
const res = await bc(svc({ restoreFromZip: vi.fn().mockResolvedValue({ success: true }) } as Partial<BackupService>)).restore(user, 'x.zip', req);
expect(res).toEqual({ success: true });
expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'backup.restore', resource: 'x.zip' }));
});
it('POST /restore falls back to status 400 when the service omits one', async () => {
expect(await thrownAsync(() => bc(svc({ restoreFromZip: vi.fn().mockResolvedValue({ success: false, error: 'nope' }) } as Partial<BackupService>)).restore(user, 'x.zip', req))).toEqual({ status: 400, body: { error: 'nope' } });
});
it('POST /upload-restore 400 without a file, cleans up the tmp file', async () => {
expect(await thrownAsync(() => bc(svc()).uploadRestore(user, undefined, req))).toEqual({ status: 400, body: { error: 'No file uploaded' } });
});
it('POST /upload-restore success audits + reports', async () => {
const file = { path: '/tmp/does-not-exist-xyz.zip', originalname: 'up.zip' } as Express.Multer.File;
const res = await bc(svc({ restoreFromZip: vi.fn().mockResolvedValue({ success: true }) } as Partial<BackupService>)).uploadRestore(user, file, req);
expect(res).toEqual({ success: true });
expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'backup.upload_restore', resource: 'up.zip' }));
});
it('POST /upload-restore maps a failed restore status', async () => {
const file = { path: '/tmp/does-not-exist-xyz.zip', originalname: 'up.zip' } as Express.Multer.File;
expect(await thrownAsync(() => bc(svc({ restoreFromZip: vi.fn().mockResolvedValue({ success: false, status: 422, error: 'bad' }) } as Partial<BackupService>)).uploadRestore(user, file, req))).toEqual({ status: 422, body: { error: 'bad' } });
});
it('POST /upload-restore falls back to a default name and maps unexpected errors to 500', async () => {
const file = { path: '/tmp/does-not-exist-xyz.zip', originalname: '' } as Express.Multer.File;
expect(await thrownAsync(() => bc(svc({ restoreFromZip: vi.fn().mockRejectedValue(new Error('boom')) } as Partial<BackupService>)).uploadRestore(user, file, req))).toEqual({ status: 500, body: { error: 'Error restoring backup' } });
const ok = { path: '/tmp/does-not-exist-xyz.zip', originalname: '' } as Express.Multer.File;
await bc(svc({ restoreFromZip: vi.fn().mockResolvedValue({ success: true }) } as Partial<BackupService>)).uploadRestore(user, ok, req);
expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'backup.upload_restore', resource: 'upload.zip' }));
});
it('maps unexpected service errors to 500 (create, restore, auto-settings)', async () => {
vi.spyOn(console, 'error').mockImplementation(() => {});
expect(await thrownAsync(() => bc(svc({ createBackup: vi.fn().mockRejectedValue(new Error('disk')) } as Partial<BackupService>)).create(user, req))).toEqual({ status: 500, body: { error: 'Error creating backup' } });
expect(await thrownAsync(() => bc(svc({ restoreFromZip: vi.fn().mockRejectedValue(new Error('boom')) } as Partial<BackupService>)).restore(user, 'x.zip', req))).toEqual({ status: 500, body: { error: 'Error restoring backup' } });
expect(thrown(() => bc(svc({ getAutoSettings: vi.fn(() => { throw new Error('io'); }) } as Partial<BackupService>)).autoSettings())).toEqual({ status: 500, body: { error: 'Could not load backup settings' } });
});
it('PUT /auto-settings maps errors to 500 (with a dev-only detail)', () => {
vi.spyOn(console, 'error').mockImplementation(() => {});
process.env.NODE_ENV = 'development';
const r = thrown(() => bc(svc({ updateAutoSettings: vi.fn(() => { throw new Error('parse fail'); }) } as Partial<BackupService>)).updateAutoSettings(user, {}, req));
expect(r.status).toBe(500);
expect(r.body).toEqual({ error: 'Could not save auto-backup settings', detail: 'parse fail' });
});
it('PUT /auto-settings hides the detail in production and stringifies non-Error throws', () => {
vi.spyOn(console, 'error').mockImplementation(() => {});
process.env.NODE_ENV = 'production';
const r = thrown(() => bc(svc({ updateAutoSettings: vi.fn(() => { throw 'plain string'; }) } as Partial<BackupService>)).updateAutoSettings(user, {}, req));
expect(r.status).toBe(500);
expect(r.body).toEqual({ error: 'Could not save auto-backup settings', detail: undefined });
});
it('PUT /auto-settings tolerates a missing body', () => {
const updateAutoSettings = vi.fn().mockReturnValue({ enabled: false, interval: 'weekly', keep_days: 30 });
bc(svc({ updateAutoSettings } as Partial<BackupService>)).updateAutoSettings(user, undefined as unknown as Record<string, unknown>, req);
expect(updateAutoSettings).toHaveBeenCalledWith({});
});
it('GET/PUT /auto-settings', () => {
expect(bc(svc({ getAutoSettings: vi.fn().mockReturnValue({ settings: { enabled: true }, timezone: 'UTC' }) } as Partial<BackupService>)).autoSettings()).toEqual({ settings: { enabled: true }, timezone: 'UTC' });
const res = bc(svc({ updateAutoSettings: vi.fn().mockReturnValue({ enabled: true, interval: 'daily', keep_days: 7 }) } as Partial<BackupService>)).updateAutoSettings(user, { enabled: true }, req);
expect(res).toEqual({ settings: { enabled: true, interval: 'daily', keep_days: 7 } });
expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'backup.auto_settings' }));
});
it('DELETE /:filename 400/404, else deletes + audits', () => {
expect(thrown(() => bc(svc({ isValidBackupFilename: vi.fn().mockReturnValue(false) })).remove(user, 'x', req))).toEqual({ status: 400, body: { error: 'Invalid filename' } });
expect(thrown(() => bc(svc({ backupFileExists: vi.fn().mockReturnValue(false) })).remove(user, 'x.zip', req))).toEqual({ status: 404, body: { error: 'Backup not found' } });
const deleteBackup = vi.fn();
expect(bc(svc({ deleteBackup } as Partial<BackupService>)).remove(user, 'x.zip', req)).toEqual({ success: true });
expect(deleteBackup).toHaveBeenCalledWith('x.zip');
});
});
describe('BackupService (wrapper)', () => {
const wrapper = new RealBackupService();
it('forwards every call straight to the legacy backup service', async () => {
expect(wrapper.listBackups()).toEqual([{ filename: 'svc.zip' }]);
expect(backupSvc.listBackups).toHaveBeenCalled();
await expect(wrapper.createBackup()).resolves.toEqual({ filename: 'svc.zip', size: 5 });
expect(backupSvc.createBackup).toHaveBeenCalled();
await expect(wrapper.restoreFromZip('/tmp/a.zip')).resolves.toEqual({ success: true });
expect(backupSvc.restoreFromZip).toHaveBeenCalledWith('/tmp/a.zip');
expect(wrapper.getAutoSettings()).toEqual({ settings: { enabled: false }, timezone: 'UTC' });
expect(backupSvc.getAutoSettings).toHaveBeenCalled();
expect(wrapper.updateAutoSettings({ enabled: true })).toEqual({ enabled: true, interval: 'daily', keep_days: 7 });
expect(backupSvc.updateAutoSettings).toHaveBeenCalledWith({ enabled: true });
wrapper.deleteBackup('svc.zip');
expect(backupSvc.deleteBackup).toHaveBeenCalledWith('svc.zip');
expect(wrapper.isValidBackupFilename('svc.zip')).toBe(true);
expect(backupSvc.isValidBackupFilename).toHaveBeenCalledWith('svc.zip');
expect(wrapper.backupFilePath('svc.zip')).toBe('/data/backups/svc.zip');
expect(backupSvc.backupFilePath).toHaveBeenCalledWith('svc.zip');
expect(wrapper.backupFileExists('svc.zip')).toBe(true);
expect(backupSvc.backupFileExists).toHaveBeenCalledWith('svc.zip');
expect(wrapper.checkRateLimit('ip', 3, 1000)).toBe(true);
expect(backupSvc.checkRateLimit).toHaveBeenCalledWith('ip', 3, 1000);
});
it('exposes the legacy rate window', () => {
expect(wrapper.rateWindow).toBe(backupSvc.BACKUP_RATE_WINDOW);
});
});
describe('BackupModule', () => {
it('wires the controller and service together', async () => {
const { BackupModule } = await import('../../../src/nest/backup/backup.module');
expect(new BackupModule()).toBeInstanceOf(BackupModule);
});
});