import { BadRequestException, Logger } from '@nestjs/common'; import * as path from 'node:path'; import { Prisma } from '../../generated/prisma'; import { BackupBundle, BackupService, LEGACY_UNSEALED_TABLES, MODELS_EXCLUDED_FROM_BACKUP, } from './backup.service'; import { PrismaService } from '../prisma/prisma.service'; import { AuditLogService } from '../audit/audit-log.service'; import { PermissionResolverService } from '../auth/permissions/permission-resolver.service'; jest.mock('node:fs/promises', () => ({ readFile: jest.fn(), writeFile: jest.fn(), mkdir: jest.fn(), unlink: jest.fn(), })); import * as fs from 'node:fs/promises'; const BUSINESS_ENTITY_KEYS = [ 'apiary', 'queen', 'colony', 'colonyApiaryHistory', 'colonyLineage', 'queenEvaluation', 'queenColonyHistory', 'inspection', 'taskTemplate', 'suggestion', 'task', 'varroaMeasurement', 'swarmControlCheck', 'winterCheck', 'phenologyReport', 'treatment', 'feeding', 'harvest', 'harvestColony', 'extraction', 'extractionHarvest', 'storageContainer', 'honeyBatch', 'honeyMeasurement', 'honeyBatchRelocation', 'honeyBatchContainerAllocation', 'stockItem', 'stockMovement', 'product', 'labelTemplate', 'bottlingBatch', 'rawMaterialLot', 'productionBatch', 'productionInput', 'shopStockRequest', 'batchMovement', 'equipment', 'webhookSubscription', 'automationRule', 'automationSequence', 'siteSettings', 'backupSettings', 'calendarSyncSettings', 'calendarSyncOutboxEntry', 'veterinaryMedicine', 'veterinaryMedicineLot', 'veterinaryMedicineMovement', 'complianceSettings', 'authorityNotice', 'apiaryRestriction', 'complianceAcknowledgement', ] as const; const T6_MODELS = [ 'VeterinaryMedicine', 'VeterinaryMedicineLot', 'VeterinaryMedicineMovement', 'ComplianceSettings', 'AuthorityNotice', 'ApiaryRestriction', 'ComplianceAcknowledgement', ] as const; const ALL_ENTITY_KEYS = [ 'role', 'user', 'rolePermission', ...BUSINESS_ENTITY_KEYS, 'document', 'notification', 'notificationPreference', 'auditLog', ] as const; // Prisma delegate name of a model: `productionBatch` for ProductionBatch. function modelKey(model: string): string { return model.charAt(0).toLowerCase() + model.slice(1); } // Every model the schema has - so a table added later is mocked too and the // coverage guard below reports it instead of crashing on a missing mock. const MODEL_KEYS = Object.values(Prisma.ModelName).map(modelKey); type Row = Record; describe('BackupService', () => { let service: BackupService; let prisma: Record & { user: { findMany: jest.Mock }; rolePermission: { findMany: jest.Mock }; document: { findMany: jest.Mock; count: jest.Mock }; $transaction: jest.Mock; }; let tx: Record< string, { upsert: jest.Mock; update?: jest.Mock; create?: jest.Mock; deleteMany?: jest.Mock; } > & { permission: { findUnique: jest.Mock }; user: { upsert: jest.Mock; findMany: jest.Mock }; queen: { upsert: jest.Mock; update: jest.Mock; findMany: jest.Mock }; document: { upsert: jest.Mock; findUnique: jest.Mock }; }; let auditLog: { record: jest.Mock }; let permissionResolver: { invalidateAll: jest.Mock }; const TRUSTED = { trusted: true }; beforeEach(() => { jest.clearAllMocks(); tx = {} as never; for (const key of MODEL_KEYS) { tx[key] = { upsert: jest.fn().mockResolvedValue({ id: `${key}-upserted` }), }; } tx.notification = { upsert: jest.fn().mockResolvedValue({ id: 'notification-upserted' }), findUnique: jest.fn().mockResolvedValue(null), } as never; (tx as unknown as { $queryRaw: jest.Mock }).$queryRaw = jest .fn() .mockResolvedValue([]); for (const key of ['hiveChange', 'hiveSetup']) { tx[key].create = jest.fn().mockResolvedValue({}); tx[key].deleteMany = jest.fn().mockResolvedValue({ count: 0 }); } tx.queen.update = jest.fn().mockResolvedValue({}); tx.queen.findMany = jest.fn().mockResolvedValue([]); tx.document.findUnique = jest.fn().mockResolvedValue(null); tx.permission = { findUnique: jest.fn().mockResolvedValue({ id: 'permission-1' }), }; tx.user.findMany = jest.fn().mockResolvedValue([{ id: 'known-user' }]); (tx.role as unknown as { findMany: jest.Mock }).findMany = jest .fn() .mockResolvedValue([]); prisma = { $transaction: jest.fn((callback: (txArg: typeof tx) => unknown) => callback(tx), ), } as never; for (const key of MODEL_KEYS) { prisma[key] = { findMany: jest.fn().mockResolvedValue([]) }; } prisma.document.count = jest.fn().mockResolvedValue(0); auditLog = { record: jest.fn().mockResolvedValue(undefined) }; permissionResolver = { invalidateAll: jest.fn() }; service = new BackupService( prisma as unknown as PrismaService, auditLog as unknown as AuditLogService, permissionResolver as unknown as PermissionResolverService, ); }); describe('exportAll', () => { it('returns a version 3 bundle with every entity key, including role/user/document', async () => { const bundle = await service.exportAll(); expect(bundle.version).toBe(3); expect(typeof bundle.exportedAt).toBe('string'); for (const key of ALL_ENTITY_KEYS) { expect(bundle.data).toHaveProperty(key); } }); it('denormalizes user.roleId into roleKey and strips roleId', async () => { prisma.user.findMany.mockResolvedValue([ { id: 'user-1', email: 'a@b.de', roleId: 'role-db-id', role: { key: 'ADMIN' }, }, ]); const bundle = await service.exportAll(); expect(bundle.data.user).toEqual([ { id: 'user-1', email: 'a@b.de', roleKey: 'ADMIN' }, ]); }); it('denormalizes rolePermission into roleKey/resource/action', async () => { prisma.rolePermission.findMany.mockResolvedValue([ { role: { key: 'ADMIN' }, permission: { resource: 'users', action: 'read' }, }, ]); const bundle = await service.exportAll(); expect(bundle.data.rolePermission).toEqual([ { roleKey: 'ADMIN', resource: 'users', action: 'read' }, ]); }); it('embeds document content as base64 and falls back to null when the file is missing', async () => { prisma.document.findMany.mockResolvedValue([ { id: 'doc-1', storagePath: '/uploads/ok.pdf', mimeType: 'application/pdf', }, { id: 'doc-2', storagePath: '/uploads/missing.pdf', mimeType: 'application/pdf', }, ]); (fs.readFile as jest.Mock).mockImplementation((filePath: string) => filePath === '/uploads/ok.pdf' ? Promise.resolve(Buffer.from('hello')) : Promise.reject(new Error('ENOENT')), ); const bundle = await service.exportAll(); const documents = bundle.data.document as { id: string; contentBase64: string | null; }[]; expect(documents.find((d) => d.id === 'doc-1')?.contentBase64).toBe( Buffer.from('hello').toString('base64'), ); expect(documents.find((d) => d.id === 'doc-2')?.contentBase64).toBeNull(); }); it('exports and restores Inspection.broodFrames', async () => { const row = { id: 'inspection-1', colonyId: 'colony-1', userId: 'known-user', broodFrames: 5, }; prisma.inspection.findMany.mockResolvedValue([row]); const bundle = await service.exportAll(); const exported = bundle.data.inspection as { broodFrames: number }[]; expect(exported[0]?.broodFrames).toBe(5); await service.importAll(bundle, 'admin-1', TRUSTED); const [[call]] = tx.inspection.upsert.mock.calls as [ [{ create: { broodFrames: number } }], ]; expect(call.create.broodFrames).toBe(5); }); it('maps a legacy free-text feedStatus of an old bundle on import', async () => { const base = { colonyId: 'colony-1', userId: 'known-user' }; const bundle = { version: 3, data: { inspection: [ { ...base, id: 'i-1', feedStatus: ' Niedrig ', notes: null }, { ...base, id: 'i-2', feedStatus: 'halb voll', notes: 'x' }, { ...base, id: 'i-3', feedStatus: 'CRITICAL', notes: null }, { ...base, id: 'i-4', feedStatus: '', notes: 'y' }, ], }, }; await service.importAll(bundle as never, 'admin-1', TRUSTED); const created = ( tx.inspection.upsert.mock.calls as [ [{ where: { id: string }; create: Record }], ] ).map(([call]) => call.create); expect(created).toEqual([ expect.objectContaining({ id: 'i-1', feedStatus: 'WEAK', notes: null }), expect.objectContaining({ id: 'i-2', feedStatus: null, notes: 'x\nFutter (alt): halb voll', }), expect.objectContaining({ id: 'i-3', feedStatus: 'CRITICAL', notes: null, }), expect.objectContaining({ id: 'i-4', feedStatus: null, notes: 'y' }), ]); }); }); describe('legacy feedStatus of unexpected type', () => { it('keeps a non-string feedStatus in the notes as JSON instead of dropping it', async () => { const base = { colonyId: 'colony-1', userId: 'known-user' }; const bundle = { version: 3, data: { inspection: [ { ...base, id: 'i-1', feedStatus: 3, notes: null }, { ...base, id: 'i-2', feedStatus: { level: 'low' }, notes: 'x' }, { ...base, id: 'i-3', feedStatus: null, notes: 'y' }, ], }, }; await service.importAll(bundle as never, 'admin-1', TRUSTED); const created = ( tx.inspection.upsert.mock.calls as [ [{ create: Record }], ] ).map(([call]) => call.create); expect(created).toEqual([ expect.objectContaining({ feedStatus: null, notes: 'Futter (alt): 3', }), expect.objectContaining({ feedStatus: null, notes: 'x\nFutter (alt): {"level":"low"}', }), expect.objectContaining({ feedStatus: null, notes: 'y' }), ]); }); }); describe('suggestions', () => { it('exports and restores suggestions with their decision before tasks', async () => { const suggestion = { id: 'suggestion-1', colonyId: 'colony-1', status: 'ACCEPTED', dedupeKey: 'INSPECTION_INTERVAL:colony-1:insp-9', decidedAt: '2026-10-14T08:00:00.000Z', decidedByUserId: 'known-user', decisionNote: 'Passt', }; const orphanDecision = { ...suggestion, id: 'suggestion-2', dedupeKey: null, decidedByUserId: 'deleted-user', }; const open = { ...suggestion, id: 'suggestion-3', status: 'OPEN', decidedAt: null, decidedByUserId: null, decisionNote: null, }; prisma.suggestion.findMany.mockResolvedValue([ suggestion, orphanDecision, open, ]); prisma.colony.findMany.mockResolvedValue([{ id: 'colony-1' }]); prisma.task.findMany.mockResolvedValue([ { id: 'task-1', sourceSuggestionId: 'suggestion-1' }, ]); const bundle = await service.exportAll(); expect(bundle.data.suggestion).toEqual([ suggestion, orphanDecision, open, ]); const summary = await service.importAll(bundle, 'admin-1', TRUSTED); expect(summary.imported.suggestion).toBe(3); const creates = ( tx.suggestion.upsert.mock.calls as [ { create: { id: string; decidedByUserId: string | null } }, ][] ).map(([call]) => call.create); // A decider that is gone resolves like any other user id; no decision // stays without one. expect(creates.map((row) => row.decidedByUserId)).toEqual([ 'known-user', 'admin-1', null, ]); const suggestionOrder = tx.suggestion.upsert.mock.invocationCallOrder[0]; const taskOrder = tx.task.upsert.mock.invocationCallOrder[0]; expect(suggestionOrder).toBeLessThan(taskOrder); const colonyOrder = tx.colony.upsert.mock.invocationCallOrder[0]; expect(colonyOrder).toBeLessThan(suggestionOrder); }); it('does not restore suggestions from an unsealed file', async () => { await service.importAll( { version: 3, exportedAt: '2026-10-14T00:00:00.000Z', data: { suggestion: [{ id: 'suggestion-1' }] }, }, 'admin-1', { trusted: false }, ); expect(tx.suggestion.upsert).not.toHaveBeenCalled(); }); }); describe('importAll', () => { it('rejects a bundle with no data object', async () => { await expect( service.importAll({ version: 2 } as never, 'admin-1', TRUSTED), ).rejects.toBeInstanceOf(BadRequestException); expect(prisma.$transaction).not.toHaveBeenCalled(); }); it('upserts a role by key and drops the source id on update', async () => { await service.importAll( { version: 2, exportedAt: '2026-01-01T00:00:00.000Z', data: { role: [{ id: 'source-id', key: 'ADMIN', name: 'Admin' }] }, }, 'admin-1', TRUSTED, ); const [[call]] = tx.role.upsert.mock.calls as [ [{ where: { key: string }; update: Record }], ]; expect(call.where).toEqual({ key: 'ADMIN' }); expect(call.update).not.toHaveProperty('id'); }); it('resolves a user roleKey to the freshly upserted role id', async () => { tx.role.upsert = jest.fn().mockResolvedValue({ id: 'role-new-id' }); await service.importAll( { version: 2, exportedAt: '2026-01-01T00:00:00.000Z', data: { role: [{ key: 'ADMIN', name: 'Admin' }], user: [{ id: 'user-1', email: 'a@b.de', roleKey: 'ADMIN' }], }, }, 'admin-1', TRUSTED, ); const [[call]] = tx.user.upsert.mock.calls as [ [{ create: { roleId: string } }], ]; expect(call.create.roleId).toBe('role-new-id'); }); it('throws when a user references a role key not present in the bundle', async () => { await expect( service.importAll( { version: 2, exportedAt: '2026-01-01T00:00:00.000Z', data: { user: [{ id: 'user-1', email: 'a@b.de', roleKey: 'UNKNOWN' }], }, }, 'admin-1', TRUSTED, ), ).rejects.toBeInstanceOf(BadRequestException); }); it('resolves rolePermission via permission.findUnique by resource/action and skips unknown permissions', async () => { tx.permission.findUnique = jest .fn() .mockResolvedValueOnce({ id: 'perm-1' }) .mockResolvedValueOnce(null); await service.importAll( { version: 2, exportedAt: '2026-01-01T00:00:00.000Z', data: { role: [{ key: 'ADMIN', name: 'Admin' }], rolePermission: [ { roleKey: 'ADMIN', resource: 'users', action: 'read' }, { roleKey: 'ADMIN', resource: 'ghost', action: 'read' }, ], }, }, 'admin-1', TRUSTED, ); expect(tx.rolePermission.upsert).toHaveBeenCalledTimes(1); }); it('links a self-referencing motherQueenId only after every queen exists', async () => { await service.importAll( { version: 2, exportedAt: '2026-01-01T00:00:00.000Z', data: { queen: [ { id: 'queen-daughter', motherQueenId: 'queen-mother' }, { id: 'queen-mother', motherQueenId: null }, ], }, }, 'admin-1', TRUSTED, ); expect(tx.queen.upsert).toHaveBeenCalledTimes(2); for (const call of tx.queen.upsert.mock.calls as { create: { motherQueenId: string | null }; }[][]) { expect(call[0].create.motherQueenId).toBeNull(); } expect(tx.queen.update).toHaveBeenCalledTimes(1); expect(tx.queen.update).toHaveBeenCalledWith({ where: { id: 'queen-daughter' }, data: { motherQueenId: 'queen-mother' }, }); }); it('remaps a required userId that does not exist in the target database to the importing admin', async () => { tx.user.findMany.mockResolvedValue([{ id: 'known-user' }]); await service.importAll( { version: 2, exportedAt: '2026-01-01T00:00:00.000Z', data: { inspection: [ { id: 'inspection-1', colonyId: 'colony-1', userId: 'ghost-user', }, ], }, }, 'admin-1', TRUSTED, ); const [[call]] = tx.inspection.upsert.mock.calls as [ [{ create: { userId: string } }], ]; expect(call.create.userId).toBe('admin-1'); }); it('keeps a required userId that does exist in the target database', async () => { tx.user.findMany.mockResolvedValue([{ id: 'known-user' }]); await service.importAll( { version: 2, exportedAt: '2026-01-01T00:00:00.000Z', data: { inspection: [ { id: 'inspection-1', colonyId: 'colony-1', userId: 'known-user', }, ], }, }, 'admin-1', TRUSTED, ); const [[call]] = tx.inspection.upsert.mock.calls as [ [{ create: { userId: string } }], ]; expect(call.create.userId).toBe('known-user'); }); it('leaves an optional userId as null rather than substituting the admin', async () => { await service.importAll( { version: 2, exportedAt: '2026-01-01T00:00:00.000Z', data: { stockMovement: [ { id: 'movement-1', stockItemId: 'stock-1', userId: null }, ], }, }, 'admin-1', TRUSTED, ); const [[call]] = tx.stockMovement.upsert.mock.calls as [ [{ create: { userId: string | null } }], ]; expect(call.create.userId).toBeNull(); }); it('upserts automationRule by triggerType, not id, and drops the source id', async () => { await service.importAll( { version: 2, exportedAt: '2026-01-01T00:00:00.000Z', data: { automationRule: [ { id: 'source-db-id', triggerType: 'VARROA_THRESHOLD', active: true, }, ], }, }, 'admin-1', TRUSTED, ); const [[call]] = tx.automationRule.upsert.mock.calls as [ [ { where: { triggerType: string }; create: Record; }, ], ]; expect(call.where).toEqual({ triggerType: 'VARROA_THRESHOLD' }); expect(call.create).not.toHaveProperty('id'); }); it('decodes document contentBase64, writes it to a fresh path, and skips rows with no content', async () => { (fs.mkdir as jest.Mock).mockResolvedValue(undefined); (fs.writeFile as jest.Mock).mockResolvedValue(undefined); await service.importAll( { version: 2, exportedAt: '2026-01-01T00:00:00.000Z', data: { document: [ { id: 'doc-1', entityType: 'COLONY', mimeType: 'application/pdf', uploadedByUserId: 'known-user', contentBase64: Buffer.from('%PDF-1.4 hello').toString('base64'), }, { id: 'doc-2', entityType: 'COLONY', mimeType: 'application/pdf', uploadedByUserId: null, contentBase64: null, }, ], }, }, 'admin-1', TRUSTED, ); expect(fs.writeFile).toHaveBeenCalledTimes(1); expect(tx.document.upsert).toHaveBeenCalledTimes(1); const [[call]] = tx.document.upsert.mock.calls as [ [{ create: { storagePath: string; contentBase64?: unknown } }], ]; expect(call.create.storagePath).toMatch(/colony/); expect(call.create).not.toHaveProperty('contentBase64'); }); it('records an audit log entry with the per-entity import counts', async () => { await service.importAll( { version: 2, exportedAt: '2026-01-01T00:00:00.000Z', data: { apiary: [{ id: 'apiary-1', name: 'Stand Wald' }] }, }, 'admin-1', TRUSTED, ); expect(auditLog.record).toHaveBeenCalledWith( expect.objectContaining({ entityType: 'Backup', action: 'CREATE', userId: 'admin-1', afterData: expect.objectContaining({ imported: expect.objectContaining({ apiary: 1 }) as unknown, }) as unknown, }), ); expect(permissionResolver.invalidateAll).toHaveBeenCalled(); }); it('refuses a document whose entityType would leave the storage root', async () => { await expect( service.importAll( { version: 2, exportedAt: '2026-01-01T00:00:00.000Z', data: { document: [ { id: 'doc-evil', entityType: '../../app/dist', mimeType: 'application/pdf', contentBase64: Buffer.from('%PDF-1.4').toString('base64'), }, ], }, }, 'admin-1', TRUSTED, ), ).rejects.toBeInstanceOf(BadRequestException); expect(fs.mkdir).not.toHaveBeenCalled(); expect(fs.writeFile).not.toHaveBeenCalled(); }); it('refuses a document whose bytes do not match its declared type', async () => { await expect( service.importAll( { version: 2, exportedAt: '2026-01-01T00:00:00.000Z', data: { document: [ { id: 'doc-html', entityType: 'COLONY', mimeType: 'application/pdf', contentBase64: Buffer.from('