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: { reload: 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 = { reload: jest.fn().mockResolvedValue(undefined) }; reauth = { verify: jest.fn().mockResolvedValue(undefined) }; controller = new BackupController( backupService as unknown as BackupService, scheduler as unknown as BackupSchedulerService, {} 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; } }); it('re-registers the backup schedule after an import, since it may restore other backup settings', async () => { await controller.import(file, { password: 'secret' }, actor); expect(backupService.importFile).toHaveBeenCalledTimes(1); expect(scheduler.reload).toHaveBeenCalledTimes(1); expect(scheduler.reload.mock.invocationCallOrder[0]).toBeGreaterThan( backupService.importFile.mock.invocationCallOrder[0], ); }); it('leaves the schedule alone when the import fails', async () => { backupService.importFile.mockRejectedValue(new Error('broken file')); await expect( controller.import(file, { password: 'secret' }, actor), ).rejects.toThrow('broken file'); expect(scheduler.reload).not.toHaveBeenCalled(); }); 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(); }); }); });