import { Logger } from '@nestjs/common'; import type { Response } from 'express'; import { BackupController } from './backup.controller'; import { BackupService } from './backup.service'; import { BackupSchedulerService } from './backup-scheduler.service'; import { BackupSettingsService } from './backup-settings.service'; import { ReauthService } from '../auth/reauth/reauth.service'; import type { AuthenticatedUser } from '../auth/types/authenticated-user'; describe('BackupController', () => { const originalLimit = process.env.BACKUP_MAX_IMPORT_BYTES; let controller: BackupController; let backupService: { importFile: jest.Mock; exportSealed: jest.Mock }; let scheduler: Record; let settings: { update: jest.Mock }; let reauth: { verify: jest.Mock }; const actor = { id: 'admin-1' } as AuthenticatedUser; const file = { buffer: Buffer.from(JSON.stringify({ format: 'apicore-backup' })), } as Express.Multer.File; beforeEach(() => { delete process.env.BACKUP_MAX_IMPORT_BYTES; backupService = { importFile: jest.fn().mockResolvedValue({ imported: {}, skipped: [] }), exportSealed: jest.fn().mockResolvedValue({ format: 'apicore-backup', exportedAt: '2026-10-12T03:00:00.000Z', ciphertext: 'x'.repeat(2000), }), }; scheduler = {}; settings = { update: jest.fn() }; reauth = { verify: jest.fn().mockResolvedValue(undefined) }; controller = new BackupController( backupService as unknown as BackupService, scheduler as unknown as BackupSchedulerService, settings as unknown as BackupSettingsService, reauth as unknown as ReauthService, ); }); afterEach(() => { if (originalLimit === undefined) { delete process.env.BACKUP_MAX_IMPORT_BYTES; } else { process.env.BACKUP_MAX_IMPORT_BYTES = originalLimit; } }); // The app-backup job reads the settings on every run, so nothing in the // API has to be told about a restore or a settings change. The scheduler // double has no methods: any call on it fails these tests. it('imports without touching the schedule', async () => { await expect( controller.import(file, { password: 'secret' }, actor), ).resolves.toEqual({ imported: {}, skipped: [] }); expect(backupService.importFile).toHaveBeenCalledTimes(1); }); it('saves settings without touching the schedule', async () => { const saved = { id: 'singleton', cronSchedule: '0 4 * * *' }; settings.update.mockResolvedValue(saved); await expect( controller.updateSettings({ cronSchedule: '0 4 * * *' }, actor), ).resolves.toBe(saved); expect(settings.update).toHaveBeenCalledWith( { cronSchedule: '0 4 * * *' }, 'admin-1', ); }); describe('export', () => { let warn: jest.SpyInstance; let res: { setHeader: jest.Mock; send: jest.Mock }; beforeEach(() => { warn = jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => {}); res = { setHeader: jest.fn(), send: jest.fn() }; }); afterEach(() => { warn.mockRestore(); }); it('still delivers a backup larger than the import limit, but logs a warning', async () => { process.env.BACKUP_MAX_IMPORT_BYTES = '1000'; await controller.export(res as unknown as Response); expect(res.send).toHaveBeenCalledTimes(1); expect(warn).toHaveBeenCalledTimes(1); expect(warn).toHaveBeenCalledWith( expect.stringContaining('Import-Limit'), ); }); it('logs nothing for a backup within the import limit', async () => { await controller.export(res as unknown as Response); expect(res.send).toHaveBeenCalledTimes(1); expect(warn).not.toHaveBeenCalled(); }); }); });