Spaces:
Sleeping
Sleeping
File size: 14,775 Bytes
1f5ea39 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 | import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { HttpException } from '@nestjs/common';
import type { Request, Response } from 'express';
vi.mock('../../../src/services/auditLog', () => ({ writeAudit: vi.fn(), getClientIp: vi.fn(() => '1.2.3.4') }));
// 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/services/backupService', () => ({
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 { writeAudit } from '../../../src/services/auditLog';
import * as backupSvc from '../../../src/services/backupService';
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;
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(new BackupController(svc({ listBackups: vi.fn().mockReturnValue([{ filename: 'a.zip' }]) } as Partial<BackupService>)).list()).toEqual({ backups: [{ filename: 'a.zip' }] });
expect(thrown(() => new BackupController(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(() => new BackupController(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 new BackupController(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(() => new BackupController(svc({ isValidBackupFilename: vi.fn().mockReturnValue(false) })).download('x', res))).toEqual({ status: 400, body: { error: 'Invalid filename' } });
expect(thrown(() => new BackupController(svc({ backupFileExists: vi.fn().mockReturnValue(false) })).download('x.zip', res))).toEqual({ status: 404, body: { error: 'Backup not found' } });
new BackupController(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(() => new BackupController(svc({ isValidBackupFilename: vi.fn().mockReturnValue(false) })).restore(user, 'x', req))).toEqual({ status: 400, body: { error: 'Invalid filename' } });
expect(await thrownAsync(() => new BackupController(svc({ backupFileExists: vi.fn().mockReturnValue(false) })).restore(user, 'x.zip', req))).toEqual({ status: 404, body: { error: 'Backup not found' } });
expect(await thrownAsync(() => new BackupController(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 new BackupController(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(() => new BackupController(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(() => new BackupController(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 new BackupController(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(() => new BackupController(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(() => new BackupController(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 new BackupController(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(() => new BackupController(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(() => new BackupController(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(() => new BackupController(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(() => new BackupController(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(() => new BackupController(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 });
new BackupController(svc({ updateAutoSettings } as Partial<BackupService>)).updateAutoSettings(user, undefined as unknown as Record<string, unknown>, req);
expect(updateAutoSettings).toHaveBeenCalledWith({});
});
it('GET/PUT /auto-settings', () => {
expect(new BackupController(svc({ getAutoSettings: vi.fn().mockReturnValue({ settings: { enabled: true }, timezone: 'UTC' }) } as Partial<BackupService>)).autoSettings()).toEqual({ settings: { enabled: true }, timezone: 'UTC' });
const res = new BackupController(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(() => new BackupController(svc({ isValidBackupFilename: vi.fn().mockReturnValue(false) })).remove(user, 'x', req))).toEqual({ status: 400, body: { error: 'Invalid filename' } });
expect(thrown(() => new BackupController(svc({ backupFileExists: vi.fn().mockReturnValue(false) })).remove(user, 'x.zip', req))).toEqual({ status: 404, body: { error: 'Backup not found' } });
const deleteBackup = vi.fn();
expect(new BackupController(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);
});
});
|