mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
724c88c201
A mechanical prettier --write over server/src, server/tests, server/scripts and shared/src with the workspaces' .prettierrc; nothing but layout and import order changes, and every file keeps the same set of imports. shared is formatted throughout and its format baseline is empty. The server baseline keeps the 35 files that Prettier would grow past their lint:size limit (they need a split first, which is not a formatting change), and the generated entity files stay as their generator writes them.
440 lines
19 KiB
TypeScript
440 lines
19 KiB
TypeScript
import type { RuntimeEnvService } from '../../../src/nest/app-config/runtime-env.service';
|
|
import type { AuditService } from '../../../src/nest/audit/audit.service';
|
|
import { AdminGuard } from '../../../src/nest/auth-core/admin.guard';
|
|
import type { AutoBackupJob } from '../../../src/nest/backup/auto-backup.job';
|
|
import { BackupController } from '../../../src/nest/backup/backup.controller';
|
|
import * as backupSvc from '../../../src/nest/backup/backup.impl';
|
|
import { BackupService as RealBackupService } from '../../../src/nest/backup/backup.service';
|
|
import type { BackupService } from '../../../src/nest/backup/backup.service';
|
|
import { StorageNotFoundError } from '../../../src/nest/storage/storage.types';
|
|
import type { User } from '../../../src/types';
|
|
import { HttpException } from '@nestjs/common';
|
|
|
|
import type { Request, Response } from 'express';
|
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
|
|
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 thin BackupService wrapper forwards every call straight into this
|
|
// module, so the mock stubs the delegated functions for the wrapper tests
|
|
// below (the multer size cap is consumed by backup.module's upload factory).
|
|
vi.mock('../../../src/nest/backup/backup.impl', () => ({
|
|
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 }),
|
|
restoreBackup: vi.fn().mockResolvedValue({ success: true }),
|
|
deleteBackup: vi.fn(),
|
|
isValidBackupFilename: vi.fn().mockReturnValue(true),
|
|
backupFileExists: vi.fn().mockReturnValue(true),
|
|
sendBackupToResponse: vi.fn().mockResolvedValue(undefined),
|
|
checkRateLimit: vi.fn().mockReturnValue(true),
|
|
}));
|
|
|
|
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;
|
|
// The auto-settings routes live on AutoBackupJob since the cron moved into the
|
|
// backup domain; every other route still goes through BackupService.
|
|
function job(o: Partial<AutoBackupJob> = {}): AutoBackupJob {
|
|
return { getAutoSettings: vi.fn(), updateAutoSettings: vi.fn(), start: vi.fn(), ...o } as unknown as AutoBackupJob;
|
|
}
|
|
const bc = (s: BackupService, j: AutoBackupJob = job()) =>
|
|
new BackupController(s, audit, j, { isManaged: () => false } as unknown as RuntimeEnvService);
|
|
|
|
function svc(o: Partial<BackupService> = {}): BackupService {
|
|
return {
|
|
listBackups: vi.fn().mockReturnValue([]),
|
|
createBackup: vi.fn(),
|
|
restoreFromZip: vi.fn(),
|
|
restoreBackup: vi.fn(),
|
|
getAutoSettings: vi.fn(),
|
|
updateAutoSettings: vi.fn(),
|
|
deleteBackup: vi.fn(),
|
|
isValidBackupFilename: vi.fn().mockReturnValue(true),
|
|
backupFileExists: vi.fn().mockReturnValue(true),
|
|
sendBackupToResponse: vi.fn().mockResolvedValue(undefined),
|
|
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', async () => {
|
|
await expect(
|
|
bc(svc({ listBackups: vi.fn().mockResolvedValue([{ filename: 'a.zip' }]) } as Partial<BackupService>)).list(),
|
|
).resolves.toEqual({ backups: [{ filename: 'a.zip' }] });
|
|
expect(
|
|
await thrownAsync(() =>
|
|
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 serves through storage', async () => {
|
|
const res = {} as unknown as Response;
|
|
expect(
|
|
await thrownAsync(() => bc(svc({ isValidBackupFilename: vi.fn().mockReturnValue(false) })).download('x', res)),
|
|
).toEqual({ status: 400, body: { error: 'Invalid filename' } });
|
|
expect(
|
|
await thrownAsync(() => bc(svc({ backupFileExists: vi.fn().mockResolvedValue(false) })).download('x.zip', res)),
|
|
).toEqual({ status: 404, body: { error: 'Backup not found' } });
|
|
const sendBackupToResponse = vi.fn().mockResolvedValue(undefined);
|
|
await bc(svc({ sendBackupToResponse } as Partial<BackupService>)).download('x.zip', res);
|
|
expect(sendBackupToResponse).toHaveBeenCalledWith('x.zip', res);
|
|
});
|
|
|
|
it('GET /download maps a StorageNotFoundError raced past the pre-check to the same 404', async () => {
|
|
const res = {} as unknown as Response;
|
|
const sendBackupToResponse = vi.fn().mockRejectedValue(new StorageNotFoundError('x.zip'));
|
|
expect(
|
|
await thrownAsync(() => bc(svc({ sendBackupToResponse } as Partial<BackupService>)).download('x.zip', res)),
|
|
).toEqual({ status: 404, body: { error: 'Backup not found' } });
|
|
// A non-miss failure is NOT swallowed into the 404 envelope.
|
|
const boom = vi.fn().mockRejectedValue(new Error('io'));
|
|
await expect(
|
|
bc(svc({ sendBackupToResponse: boom } as Partial<BackupService>)).download('x.zip', res),
|
|
).rejects.toThrow('io');
|
|
});
|
|
|
|
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({
|
|
restoreBackup: 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 restoreBackup = vi.fn().mockResolvedValue({ success: true });
|
|
const res = await bc(svc({ restoreBackup } as Partial<BackupService>)).restore(user, 'x.zip', req);
|
|
expect(res).toEqual({ success: true });
|
|
expect(restoreBackup).toHaveBeenCalledWith('x.zip');
|
|
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({
|
|
restoreBackup: 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({ restoreBackup: 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(),
|
|
job({
|
|
getAutoSettings: vi.fn(() => {
|
|
throw new Error('io');
|
|
}),
|
|
}),
|
|
).autoSettings(),
|
|
),
|
|
).toEqual({ status: 500, body: { error: 'Could not load backup settings' } });
|
|
});
|
|
|
|
it('PUT /auto-settings maps errors to 500 (with a dev-only detail)', async () => {
|
|
vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
process.env.NODE_ENV = 'development';
|
|
const r = await thrownAsync(() =>
|
|
bc(
|
|
svc(),
|
|
job({
|
|
updateAutoSettings: vi.fn(() => {
|
|
throw new Error('parse fail');
|
|
}),
|
|
}),
|
|
).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', async () => {
|
|
vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
process.env.NODE_ENV = 'production';
|
|
const r = await thrownAsync(() =>
|
|
bc(
|
|
svc(),
|
|
job({
|
|
updateAutoSettings: vi.fn(() => {
|
|
throw 'plain string';
|
|
}),
|
|
}),
|
|
).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', async () => {
|
|
const updateAutoSettings = vi.fn().mockReturnValue({ enabled: false, interval: 'weekly', keep_days: 30 });
|
|
await bc(svc(), job({ updateAutoSettings })).updateAutoSettings(
|
|
user,
|
|
undefined as unknown as Record<string, unknown>,
|
|
req,
|
|
);
|
|
expect(updateAutoSettings).toHaveBeenCalledWith({});
|
|
});
|
|
|
|
it('GET/PUT /auto-settings', async () => {
|
|
expect(
|
|
bc(
|
|
svc(),
|
|
job({ getAutoSettings: vi.fn().mockReturnValue({ settings: { enabled: true }, timezone: 'UTC' }) as never }),
|
|
).autoSettings(),
|
|
).toEqual({ settings: { enabled: true }, timezone: 'UTC' });
|
|
const res = await bc(
|
|
svc(),
|
|
job({ updateAutoSettings: vi.fn().mockReturnValue({ enabled: true, interval: 'daily', keep_days: 7 }) as never }),
|
|
).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', async () => {
|
|
expect(
|
|
await thrownAsync(() =>
|
|
bc(svc({ isValidBackupFilename: vi.fn().mockReturnValue(false) })).remove(user, 'x', req),
|
|
),
|
|
).toEqual({ status: 400, body: { error: 'Invalid filename' } });
|
|
expect(
|
|
await thrownAsync(() =>
|
|
bc(svc({ backupFileExists: vi.fn().mockResolvedValue(false) })).remove(user, 'x.zip', req),
|
|
),
|
|
).toEqual({ status: 404, body: { error: 'Backup not found' } });
|
|
const deleteBackup = vi.fn();
|
|
await expect(bc(svc({ deleteBackup } as Partial<BackupService>)).remove(user, 'x.zip', req)).resolves.toEqual({
|
|
success: true,
|
|
});
|
|
expect(deleteBackup).toHaveBeenCalledWith('x.zip');
|
|
});
|
|
});
|
|
|
|
describe('BackupService (wrapper)', () => {
|
|
// The impl module is fully mocked above, so an empty sentinel stands in for
|
|
// the injected StorageService — the wrapper's job is passing it through.
|
|
const storage = {
|
|
__sentinel: 'storage',
|
|
} as unknown as import('../../../src/nest/storage/storage.service').StorageService;
|
|
// Same for the injected database port: a backup and a restore get it next to
|
|
// the storage, everything else does not need it.
|
|
const database = {
|
|
__sentinel: 'database',
|
|
} as unknown as import('../../../src/nest/database/database-backup.interface').DatabaseBackupStrategy;
|
|
const wrapper = new RealBackupService(storage, database);
|
|
|
|
it('forwards every call straight to the legacy backup service', async () => {
|
|
expect(await wrapper.listBackups()).toEqual([{ filename: 'svc.zip' }]);
|
|
expect(backupSvc.listBackups).toHaveBeenCalledWith(storage);
|
|
|
|
await expect(wrapper.createBackup('auto-backup')).resolves.toEqual({ filename: 'svc.zip', size: 5 });
|
|
expect(backupSvc.createBackup).toHaveBeenCalledWith({ storage, database }, 'auto-backup');
|
|
|
|
await expect(wrapper.restoreFromZip('/tmp/a.zip')).resolves.toEqual({ success: true });
|
|
expect(backupSvc.restoreFromZip).toHaveBeenCalledWith({ storage, database }, '/tmp/a.zip');
|
|
|
|
await expect(wrapper.restoreBackup('svc.zip')).resolves.toEqual({ success: true });
|
|
expect(backupSvc.restoreBackup).toHaveBeenCalledWith({ storage, database }, 'svc.zip');
|
|
|
|
await wrapper.deleteBackup('svc.zip');
|
|
expect(backupSvc.deleteBackup).toHaveBeenCalledWith(storage, 'svc.zip');
|
|
|
|
expect(wrapper.isValidBackupFilename('svc.zip')).toBe(true);
|
|
expect(backupSvc.isValidBackupFilename).toHaveBeenCalledWith('svc.zip');
|
|
|
|
expect(await wrapper.backupFileExists('svc.zip')).toBe(true);
|
|
expect(backupSvc.backupFileExists).toHaveBeenCalledWith(storage, 'svc.zip');
|
|
|
|
const fakeRes = {} as Response;
|
|
await wrapper.sendBackupToResponse('svc.zip', fakeRes);
|
|
expect(backupSvc.sendBackupToResponse).toHaveBeenCalledWith(storage, 'svc.zip', fakeRes);
|
|
|
|
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);
|
|
});
|
|
|
|
it('spools restore uploads to the storage tempDir with the legacy filter and cap', async () => {
|
|
// The restore upload is a restore INPUT, not a stored object — it gets no
|
|
// category and spools to the driver-agnostic scratch dir (spec Uploads #9).
|
|
const { buildBackupUploadOptions } = await import('../../../src/nest/backup/backup.module');
|
|
const storage = {
|
|
tempDir: vi.fn(() => '/data/tmp'),
|
|
} as unknown as import('../../../src/nest/storage/storage.service').StorageService;
|
|
|
|
const opts = buildBackupUploadOptions(storage);
|
|
|
|
expect(opts.dest).toBe('/data/tmp');
|
|
expect(opts.limits).toEqual({ fileSize: 1024 }); // MAX_BACKUP_UPLOAD_SIZE from the impl mock
|
|
const accept = vi.fn();
|
|
opts.fileFilter?.(undefined as never, { originalname: 'a.zip' } as Express.Multer.File, accept);
|
|
expect(accept).toHaveBeenCalledWith(null, true);
|
|
const reject = vi.fn();
|
|
opts.fileFilter?.(undefined as never, { originalname: 'a.tar.gz' } as Express.Multer.File, reject);
|
|
expect(reject).toHaveBeenCalledWith(expect.objectContaining({ message: 'Only ZIP files allowed' }), false);
|
|
});
|
|
});
|