fix: Paket 0 – data integrity, security hardening and field usability
Backend Image / build-and-push (push) Successful in 3m15s
Frontend Image / build-and-push (push) Successful in 6m2s

Backend
- Backup covers every Prisma model (bundle v3); a guard test fails for any
  new model that is neither backed up nor explicitly excluded. Unsealed
  bundles may only add rows to 27 legacy master/field tables and never
  overwrite; counters, ledgers, settings, roles etc. need a sealed file.
  Shared import limit BACKUP_MAX_IMPORT_BYTES (default 50 MB, max 256 MB)
  with warnings in log and UI; replaced document files are removed after
  commit.
- Post-commit side effects (automation, calendar sync, webhooks) run via
  runAfterCommit and never turn a committed write into a 500, so retries
  with the same Idempotency-Key no longer create duplicates.
- Calendar outbox is multi-replica safe: per-entry claims with stale
  takeover, revision-based settle, new Graph event only on 404/410,
  transactionId on create, 30 s Graph timeout, URL-encoded ids, GUID
  validation for groupId, "run now" in background and resets FAILED.
- Automation tasks are deduplicated under a row lock on the rule.
- Update DTOs follow one null rule: omitted = unchanged, null clears a
  nullable field, null on a required field is a 400.
- Varroa mites/day is recomputed on correction; withdrawal status is
  checked against the harvest date (?at=), bounded colonyIds; inspection
  create is audited; updates re-enqueue calendar entries.
- Dashboard, statistics and apiary/colony guards treat WEAK, CONSPICUOUS
  and QUEENLESS colonies as living.
- Prisma errors and body-parser errors are logged without query values or
  request bodies.
- Migration 20261012100000_calendar_sync_claims; schema index name aligned
  with the existing database object.

Frontend
- Sticky save bar sticks again (no scroll container ancestors, card uses
  overflow-clip), safe-area insets, scroll-padding, FAB hidden on forms,
  sticky desktop sidebar.
- Inspections record yes / no / not checked instead of silent defaults.
- All living colonies are selectable; shared RecordSelect, EnumSelect,
  ChoiceGroup and TriStateField replace per-page copies; selects show
  names instead of ids; labels point at real controls.
- Emptied optional fields are sent as null (emptyToNull) so every field
  can be cleared; dates default to the local day and are sent via
  toApiDate.
- Harvest withdrawal warning uses the harvest date; backup page warns when
  a backup outgrows the import limit and lists skipped tables.

Build and docs
- Docker builds fail unless lint, typecheck and unit tests pass.
- ARCHITECTURE.md updated (assumptions 9-12, 19-21, section 8) and spec
  docs/superpowers/specs/2026-10-12-plattform-rueckgrat-und-fahrplan-design.md
  for the backbone (api/worker/job roles, domain event outbox) and the
  feature roadmap T3-T7.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-12 16:38:55 +02:00
1 parent f5b1964223
commit 10cbe9bf2d
310 files changed
+12177 -3746

No files matched your search

@@ -0,0 +1,75 @@
import { Logger } from '@nestjs/common';
import {
DEFAULT_BACKUP_MAX_IMPORT_BYTES,
backupMaxImportBytes,
exceedsBackupImportLimit,
} from './backup-import-limit';
describe('backup-import-limit', () => {
const original = process.env.BACKUP_MAX_IMPORT_BYTES;
let warn: jest.SpyInstance;
beforeEach(() => {
warn = jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => {});
});
afterEach(() => {
warn.mockRestore();
if (original === undefined) {
delete process.env.BACKUP_MAX_IMPORT_BYTES;
} else {
process.env.BACKUP_MAX_IMPORT_BYTES = original;
}
});
it('allows 50 MB when BACKUP_MAX_IMPORT_BYTES is not set', () => {
delete process.env.BACKUP_MAX_IMPORT_BYTES;
expect(DEFAULT_BACKUP_MAX_IMPORT_BYTES).toBe(50 * 1024 * 1024);
expect(backupMaxImportBytes()).toBe(DEFAULT_BACKUP_MAX_IMPORT_BYTES);
expect(warn).not.toHaveBeenCalled();
});
it('takes a positive byte count from BACKUP_MAX_IMPORT_BYTES', () => {
process.env.BACKUP_MAX_IMPORT_BYTES = '104857600';
expect(backupMaxImportBytes()).toBe(104857600);
expect(warn).not.toHaveBeenCalled();
});
it.each(['abc', '0', '-5', '1.5', '50MB'])(
'falls back to the default with a logged warning for "%s"',
(value) => {
process.env.BACKUP_MAX_IMPORT_BYTES = value;
expect(backupMaxImportBytes()).toBe(DEFAULT_BACKUP_MAX_IMPORT_BYTES);
expect(warn).toHaveBeenCalledTimes(1);
expect(warn).toHaveBeenCalledWith(
expect.stringContaining('BACKUP_MAX_IMPORT_BYTES'),
);
},
);
it('caps a value beyond 256 MB, since the whole upload is held in memory', () => {
process.env.BACKUP_MAX_IMPORT_BYTES = String(8 * 1024 * 1024 * 1024);
expect(backupMaxImportBytes()).toBe(256 * 1024 * 1024);
expect(warn).toHaveBeenCalledTimes(1);
});
it('warns only once for the same invalid value', () => {
process.env.BACKUP_MAX_IMPORT_BYTES = 'not-a-number';
backupMaxImportBytes();
backupMaxImportBytes();
expect(warn).toHaveBeenCalledTimes(1);
});
it('flags only a file larger than the limit', () => {
process.env.BACKUP_MAX_IMPORT_BYTES = '1000';
expect(exceedsBackupImportLimit(1000)).toBe(false);
expect(exceedsBackupImportLimit(1001)).toBe(true);
});
});
@@ -0,0 +1,59 @@
import { Logger } from '@nestjs/common';
/**
* Largest backup file POST /backup/import accepts - and the size every
* export is checked against, so a backup that could no longer be restored
* is noticed when it is written, not on the day it is needed. A full bundle
* is far larger than the 5mb CSV import cap elsewhere (dozens of tables,
* years of records, document contents as base64), hence its own limit.
*
* BACKUP_MAX_IMPORT_BYTES overrides the default. Like the other BACKUP_*
* variables, an unusable value falls back instead of stopping the server,
* here with a logged warning.
*/
export const DEFAULT_BACKUP_MAX_IMPORT_BYTES = 50 * 1024 * 1024;
// The whole upload is held in memory, and parsed and decoded from there
// (a string near V8's ~512 MB limit, a 512Mi pod) - no setting goes beyond.
const BACKUP_MAX_IMPORT_CEILING_BYTES = 256 * 1024 * 1024;
const logger = new Logger('BackupImportLimit');
// Parsed once per distinct env value, so an invalid one warns only once.
let resolved: { raw: string | undefined; bytes: number } | undefined;
function parseLimit(raw: string | undefined): number {
if (raw === undefined || raw.trim() === '') {
return DEFAULT_BACKUP_MAX_IMPORT_BYTES;
}
const bytes = Number(raw);
if (!Number.isInteger(bytes) || bytes <= 0) {
logger.warn(
`BACKUP_MAX_IMPORT_BYTES="${raw}" ist keine positive ganze Zahl (Bytes) - es gilt der Standard von ${DEFAULT_BACKUP_MAX_IMPORT_BYTES} Bytes`,
);
return DEFAULT_BACKUP_MAX_IMPORT_BYTES;
}
if (bytes > BACKUP_MAX_IMPORT_CEILING_BYTES) {
logger.warn(
`BACKUP_MAX_IMPORT_BYTES=${bytes} liegt über der Obergrenze - es gelten ${BACKUP_MAX_IMPORT_CEILING_BYTES} Bytes`,
);
return BACKUP_MAX_IMPORT_CEILING_BYTES;
}
return bytes;
}
export function backupMaxImportBytes(): number {
const raw = process.env.BACKUP_MAX_IMPORT_BYTES;
if (!resolved || resolved.raw !== raw) {
resolved = { raw, bytes: parseLimit(raw) };
}
return resolved.bytes;
}
/** True when a backup file of this size would be refused on import. */
export function exceedsBackupImportLimit(sizeBytes: number): boolean {
return sizeBytes > backupMaxImportBytes();
}
/** A byte count as "52.4 MB" for log lines. */
export function formatMegabytes(bytes: number): string {
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
@@ -0,0 +1,198 @@
import { Logger } from '@nestjs/common';
import { SchedulerRegistry } from '@nestjs/schedule';
import { CronJob } from 'cron';
import * as path from 'node:path';
import { Prisma } from '../../generated/prisma';
import { BackupSchedulerService } from './backup-scheduler.service';
import { BackupService } from './backup.service';
import { BackupSettingsService } from './backup-settings.service';
jest.mock('node:fs/promises', () => ({
mkdir: jest.fn(),
writeFile: jest.fn(),
readdir: jest.fn(),
stat: jest.fn(),
unlink: jest.fn(),
}));
import * as fs from 'node:fs/promises';
describe('BackupSchedulerService', () => {
const originalEnv = { ...process.env };
const directory = path.resolve('/data/backups');
let service: BackupSchedulerService;
let backupService: { exportSealed: jest.Mock };
let error: jest.SpyInstance;
beforeEach(() => {
jest.clearAllMocks();
process.env.BACKUP_STORAGE_PATH = directory;
delete process.env.BACKUP_MAX_IMPORT_BYTES;
delete process.env.BACKUP_RETENTION_COUNT;
delete process.env.BACKUP_CRON_SCHEDULE;
error = jest.spyOn(Logger.prototype, 'error').mockImplementation(() => {});
backupService = {
exportSealed: jest.fn().mockResolvedValue({
format: 'apicore-backup',
exportedAt: '2026-10-12T03:00:00.000Z',
ciphertext: 'x'.repeat(2000),
}),
};
const settings = {
get: jest.fn().mockResolvedValue({
storagePath: null,
cronSchedule: null,
retentionCount: null,
}),
};
service = new BackupSchedulerService(
backupService as unknown as BackupService,
settings as unknown as BackupSettingsService,
{} as SchedulerRegistry,
);
(fs.mkdir as jest.Mock).mockResolvedValue(undefined);
(fs.writeFile as jest.Mock).mockResolvedValue(undefined);
(fs.readdir as jest.Mock).mockResolvedValue([]);
});
afterEach(() => {
error.mockRestore();
process.env = { ...originalEnv };
});
function onDisk(files: { name: string; size: number; mtime: string }[]) {
(fs.readdir as jest.Mock).mockResolvedValue(files.map((f) => f.name));
(fs.stat as jest.Mock).mockImplementation((filePath: string) => {
const file = files.find((f) => filePath.endsWith(f.name));
return Promise.resolve({
size: file?.size,
mtime: new Date(file!.mtime),
});
});
}
describe('getStatus', () => {
it('reports the import limit and the size of the newest backup', async () => {
process.env.BACKUP_MAX_IMPORT_BYTES = '1000';
onDisk([
{
name: 'apicore-backup-old.json',
size: 5000,
mtime: '2026-10-10T03:00:00.000Z',
},
{
name: 'apicore-backup-new.json',
size: 900,
mtime: '2026-10-11T03:00:00.000Z',
},
]);
const status = await service.getStatus();
expect(status).toEqual(
expect.objectContaining({
importLimitBytes: 1000,
lastBackupSizeBytes: 900,
lastBackupExceedsImportLimit: false,
}),
);
});
it('warns when the newest backup could no longer be imported', async () => {
process.env.BACKUP_MAX_IMPORT_BYTES = '1000';
onDisk([
{
name: 'apicore-backup-new.json',
size: 1001,
mtime: '2026-10-11T03:00:00.000Z',
},
]);
const status = await service.getStatus();
expect(status.lastBackupSizeBytes).toBe(1001);
expect(status.lastBackupExceedsImportLimit).toBe(true);
});
it('still reports the import limit while no schedule is configured', async () => {
delete process.env.BACKUP_STORAGE_PATH;
const status = await service.getStatus();
expect(status).toEqual(
expect.objectContaining({
enabled: false,
importLimitBytes: 50 * 1024 * 1024,
lastBackupSizeBytes: null,
lastBackupExceedsImportLimit: false,
}),
);
});
});
describe('runNow', () => {
it('keeps a backup larger than the import limit but logs an error', async () => {
process.env.BACKUP_MAX_IMPORT_BYTES = '1000';
const result = await service.runNow();
expect(fs.writeFile).toHaveBeenCalledTimes(1);
expect(result.fileName).toMatch(/^apicore-backup-.*\.json$/);
expect(error).toHaveBeenCalledTimes(1);
expect(error).toHaveBeenCalledWith(
expect.stringContaining('Import-Limit'),
);
});
it('logs nothing for a backup within the import limit', async () => {
await service.runNow();
expect(fs.writeFile).toHaveBeenCalledTimes(1);
expect(error).not.toHaveBeenCalled();
});
});
// The export reads every table through Prisma; a Prisma message quotes
// query values, so a failed scheduled run logs name, code and model only.
describe('scheduled run', () => {
it('logs a failed scheduled backup without the Prisma message', async () => {
const registry = {
doesExist: jest.fn().mockReturnValue(false),
addCronJob: jest.fn(),
deleteCronJob: jest.fn(),
};
const scheduled = new BackupSchedulerService(
backupService as unknown as BackupService,
{
get: jest.fn().mockResolvedValue({
storagePath: null,
cronSchedule: null,
retentionCount: null,
}),
} as unknown as BackupSettingsService,
registry as unknown as SchedulerRegistry,
);
backupService.exportSealed.mockRejectedValue(
new Prisma.PrismaClientKnownRequestError('title "Max Mustermann"', {
code: 'P2010',
clientVersion: '7.9.1',
meta: { modelName: 'Task' },
}),
);
await scheduled.reload();
const [[, job]] = registry.addCronJob.mock.calls as [[string, CronJob]];
await job.stop();
await job.fireOnTick();
await new Promise((resolve) => setImmediate(resolve));
const failure = error.mock.calls.find(([message]) =>
String(message).startsWith('Scheduled backup failed'),
) as [string, unknown];
expect(failure[0]).toContain('P2010');
expect(failure[0]).not.toContain('Mustermann');
expect(failure[1]).toBeUndefined();
});
});
});
@@ -5,10 +5,16 @@ import * as fs from 'node:fs/promises';
import * as path from 'node:path';
import { BackupService } from './backup.service';
import { BackupSettingsService } from './backup-settings.service';
import { loggableError } from '../common/loggable-error';
import {
assertBackupCron,
resolveGuiBackupPath,
} from './backup-schedule-rules';
import {
backupMaxImportBytes,
exceedsBackupImportLimit,
formatMegabytes,
} from './backup-import-limit';
const SCHEDULED_JOB_NAME = 'automatic-backup';
const DEFAULT_CRON_SCHEDULE = '0 3 * * *';
@@ -28,6 +34,13 @@ export interface BackupScheduleStatus {
cronSchedule: string | null;
retentionCount: number | null;
lastRunAt: string | null;
// Largest file POST /backup/import accepts (BACKUP_MAX_IMPORT_BYTES).
importLimitBytes: number;
// Size of the newest backup file - taken from the file itself, so the
// warning survives restarts and is the same on every replica.
lastBackupSizeBytes: number | null;
// True when that file could not be restored without a raised limit.
lastBackupExceedsImportLimit: boolean;
// Which fields are currently pinned by an env var (and therefore not
// editable via the GUI form) - env always wins over the GUI-stored value.
envOverrides: {
@@ -125,9 +138,11 @@ export class BackupSchedulerService implements OnModuleInit {
const job = CronJob.from({
cronTime: settings.cronSchedule,
onTick: () => {
this.runScheduled(directory).catch((error) =>
this.logger.error('Scheduled backup failed', error),
);
// Prisma errors by name/code/model only (see loggableError).
this.runScheduled(directory).catch((error: unknown) => {
const { message, stack } = loggableError(error);
this.logger.error(`Scheduled backup failed: ${message}`, stack);
});
},
start: true,
});
@@ -139,6 +154,7 @@ export class BackupSchedulerService implements OnModuleInit {
async getStatus(): Promise<BackupScheduleStatus> {
const settings = await this.effectiveSettings();
const importLimitBytes = backupMaxImportBytes();
if (!settings.path) {
return {
enabled: false,
@@ -146,12 +162,16 @@ export class BackupSchedulerService implements OnModuleInit {
cronSchedule: null,
retentionCount: null,
lastRunAt: null,
importLimitBytes,
lastBackupSizeBytes: null,
lastBackupExceedsImportLimit: false,
envOverrides: settings.envOverrides,
};
}
const files = await this.listBackupFiles(settings.path);
const lastRunAt = files[0]?.mtime.toISOString() ?? null;
const lastBackupSizeBytes = files[0]?.size ?? null;
return {
enabled: true,
@@ -159,6 +179,11 @@ export class BackupSchedulerService implements OnModuleInit {
cronSchedule: settings.cronSchedule,
retentionCount: settings.retentionCount,
lastRunAt,
importLimitBytes,
lastBackupSizeBytes,
lastBackupExceedsImportLimit:
lastBackupSizeBytes !== null &&
exceedsBackupImportLimit(lastBackupSizeBytes),
envOverrides: settings.envOverrides,
};
}
@@ -205,8 +230,17 @@ export class BackupSchedulerService implements OnModuleInit {
directory,
`${BACKUP_FILE_PREFIX}${timestamp}${BACKUP_FILE_SUFFIX}`,
);
const content = JSON.stringify(sealed);
// Owner-only: the file holds every account's credentials (encrypted).
await fs.writeFile(filePath, JSON.stringify(sealed), { mode: 0o600 });
await fs.writeFile(filePath, content, { mode: 0o600 });
const sizeBytes = Buffer.byteLength(content);
if (exceedsBackupImportLimit(sizeBytes)) {
// Kept anyway (a raised BACKUP_MAX_IMPORT_BYTES restores it); the
// status reads the size from the file and warns in the UI.
this.logger.error(
`Backup ${path.basename(filePath)} ist ${formatMegabytes(sizeBytes)} groß und überschreitet das Import-Limit von ${formatMegabytes(backupMaxImportBytes())} - eine Wiederherstellung würde scheitern (BACKUP_MAX_IMPORT_BYTES erhöhen)`,
);
}
return filePath;
}
@@ -231,7 +265,7 @@ export class BackupSchedulerService implements OnModuleInit {
private async listBackupFiles(
directory: string,
): Promise<{ filePath: string; mtime: Date }[]> {
): Promise<{ filePath: string; mtime: Date; size: number }[]> {
let entries: string[];
try {
entries = await fs.readdir(directory);
@@ -249,7 +283,7 @@ export class BackupSchedulerService implements OnModuleInit {
backupFiles.map(async (name) => {
const filePath = path.join(directory, name);
const stat = await fs.stat(filePath);
return { filePath, mtime: stat.mtime };
return { filePath, mtime: stat.mtime, size: stat.size };
}),
);
@@ -0,0 +1,100 @@
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();
});
});
});
@@ -3,6 +3,7 @@ import {
Body,
Controller,
Get,
Logger,
Patch,
Post,
Res,
@@ -25,17 +26,18 @@ import {
} from './backup-scheduler.service';
import { BackupSettingsService } from './backup-settings.service';
import { UpdateBackupSettingsDto } from './dto/update-backup-settings.dto';
// A full backup bundle can be much larger than the 5mb CSV import cap
// elsewhere (dozens of tables, potentially years of records, plus embedded
// label-template images) - 50mb comfortably covers a real dataset without
// raising the app-wide JSON body limit for every other route.
const MAX_BACKUP_FILE_SIZE_BYTES = 50 * 1024 * 1024;
import {
backupMaxImportBytes,
exceedsBackupImportLimit,
formatMegabytes,
} from './backup-import-limit';
@ApiTags('backup')
@ApiBearerAuth()
@Controller('backup')
export class BackupController {
private readonly logger = new Logger(BackupController.name);
constructor(
private readonly backupService: BackupService,
private readonly backupSchedulerService: BackupSchedulerService,
@@ -76,19 +78,29 @@ export class BackupController {
@Get('export')
async export(@Res() res: Response): Promise<void> {
const sealed = await this.backupService.exportSealed();
const body = JSON.stringify(sealed);
const sizeBytes = Buffer.byteLength(body);
if (exceedsBackupImportLimit(sizeBytes)) {
// Still delivered: losing the data is worse than needing a raised
// BACKUP_MAX_IMPORT_BYTES to restore it. The UI compares the file
// against the limit from GET /backup/schedule and warns there.
this.logger.warn(
`Exportiertes Backup ist ${formatMegabytes(sizeBytes)} groß und überschreitet das Import-Limit von ${formatMegabytes(backupMaxImportBytes())} - eine Wiederherstellung würde scheitern`,
);
}
const filename = `apicore-backup-${new Date().toISOString().slice(0, 10)}.json`;
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
res.setHeader('Cache-Control', 'no-store');
res.send(JSON.stringify(sealed));
res.send(body);
}
@RequirePermission('backup:import')
@Post('import')
@ApiConsumes('multipart/form-data')
@UseInterceptors(
// Size limit from MulterModule in BackupModule (BACKUP_MAX_IMPORT_BYTES).
FileInterceptor('file', {
limits: { fileSize: MAX_BACKUP_FILE_SIZE_BYTES },
fileFilter: (_req, file, callback) => {
if (!file.originalname.toLowerCase().endsWith('.json')) {
callback(
@@ -117,6 +129,9 @@ export class BackupController {
} catch {
throw new BadRequestException('Datei ist kein gültiges JSON');
}
return this.backupService.importFile(parsed, actor.id);
const summary = await this.backupService.importFile(parsed, actor.id);
// The backup may have restored the schedule's settings too.
await this.backupSchedulerService.reload();
return summary;
}
}
+11 -1
View File
@@ -1,12 +1,22 @@
import { Module } from '@nestjs/common';
import { MulterModule } from '@nestjs/platform-express';
import { ReauthModule } from '../auth/reauth/reauth.module';
import { BackupController } from './backup.controller';
import { BackupService } from './backup.service';
import { BackupSchedulerService } from './backup-scheduler.service';
import { BackupSettingsService } from './backup-settings.service';
import { backupMaxImportBytes } from './backup-import-limit';
@Module({
imports: [ReauthModule],
imports: [
ReauthModule,
// Upload limit of POST /backup/import. A factory rather than a constant
// in the decorator: it runs once the app starts, after ConfigModule has
// loaded .env, so the limit is the same one exports are checked against.
MulterModule.registerAsync({
useFactory: () => ({ limits: { fileSize: backupMaxImportBytes() } }),
}),
],
controllers: [BackupController],
providers: [BackupService, BackupSchedulerService, BackupSettingsService],
})
+602 -25
View File
@@ -1,5 +1,11 @@
import { BadRequestException } from '@nestjs/common';
import { BackupService } from './backup.service';
import { BadRequestException, Logger } from '@nestjs/common';
import * as path from 'node:path';
import { Prisma } from '../../generated/prisma';
import {
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';
@@ -8,6 +14,7 @@ jest.mock('node:fs/promises', () => ({
readFile: jest.fn(),
writeFile: jest.fn(),
mkdir: jest.fn(),
unlink: jest.fn(),
}));
import * as fs from 'node:fs/promises';
@@ -36,15 +43,25 @@ const BUSINESS_ENTITY_KEYS = [
'honeyBatch',
'honeyMeasurement',
'honeyBatchRelocation',
'honeyBatchContainerAllocation',
'stockItem',
'stockMovement',
'product',
'labelTemplate',
'bottlingBatch',
'rawMaterialLot',
'productionBatch',
'productionInput',
'shopStockRequest',
'batchMovement',
'equipment',
'webhookSubscription',
'automationRule',
'automationSequence',
'siteSettings',
'backupSettings',
'calendarSyncSettings',
'calendarSyncOutboxEntry',
] as const;
const ALL_ENTITY_KEYS = [
@@ -57,17 +74,30 @@ const ALL_ENTITY_KEYS = [
'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<string, unknown>;
describe('BackupService', () => {
let service: BackupService;
let prisma: Record<string, { findMany: jest.Mock }> & {
user: { findMany: jest.Mock };
rolePermission: { findMany: jest.Mock };
document: { findMany: jest.Mock };
document: { findMany: jest.Mock; count: jest.Mock };
$transaction: jest.Mock;
};
let tx: Record<string, { upsert: jest.Mock; update?: 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 };
@@ -77,20 +107,14 @@ describe('BackupService', () => {
jest.clearAllMocks();
tx = {} as never;
for (const key of [
...BUSINESS_ENTITY_KEYS,
'role',
'user',
'rolePermission',
'document',
'notification',
'auditLog',
]) {
for (const key of MODEL_KEYS) {
tx[key] = {
upsert: jest.fn().mockResolvedValue({ id: `${key}-upserted` }),
};
}
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' }),
};
@@ -100,23 +124,14 @@ describe('BackupService', () => {
.mockResolvedValue([]);
prisma = {
user: {
findMany: jest.fn().mockResolvedValue([]),
},
rolePermission: { findMany: jest.fn().mockResolvedValue([]) },
document: { findMany: jest.fn().mockResolvedValue([]) },
$transaction: jest.fn((callback: (txArg: typeof tx) => unknown) =>
callback(tx),
),
} as never;
for (const key of [
'role',
...BUSINESS_ENTITY_KEYS,
'notification',
'auditLog',
]) {
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) };
@@ -129,10 +144,10 @@ describe('BackupService', () => {
});
describe('exportAll', () => {
it('returns a version 2 bundle with every entity key, including role/user/document', async () => {
it('returns a version 3 bundle with every entity key, including role/user/document', async () => {
const bundle = await service.exportAll();
expect(bundle.version).toBe(2);
expect(bundle.version).toBe(3);
expect(typeof bundle.exportedAt).toBe('string');
for (const key of ALL_ENTITY_KEYS) {
expect(bundle.data).toHaveProperty(key);
@@ -589,5 +604,567 @@ describe('BackupService', () => {
expect.arrayContaining(['role', 'user', 'webhookSubscription']),
);
});
it('restores production, raw material and stock ledger rows by id, remapping their optional userId', async () => {
await service.importAll(
{
version: 3,
exportedAt: '2026-01-01T00:00:00.000Z',
data: {
rawMaterialLot: [{ id: 'lot-1', userId: 'ghost-user' }],
productionBatch: [
{ id: 'batch-1', productId: 'product-1', userId: 'known-user' },
],
productionInput: [
{
id: 'input-1',
productionBatchId: 'batch-1',
rawMaterialLotId: 'lot-1',
},
],
shopStockRequest: [{ id: 'request-1', externalId: 'order-1' }],
batchMovement: [
{
id: 'movement-1',
productionBatchId: 'batch-1',
shopRequestId: 'request-1',
userId: null,
},
],
honeyBatchContainerAllocation: [
{
id: 'allocation-1',
honeyBatchId: 'honey-1',
storageContainerId: 'container-1',
},
],
},
},
'admin-1',
TRUSTED,
);
const firstCall = (key: string) =>
(
tx[key].upsert.mock.calls as [
{
where: Record<string, unknown>;
create: Record<string, unknown>;
},
][]
)[0][0];
expect(firstCall('rawMaterialLot').where).toEqual({ id: 'lot-1' });
expect(firstCall('rawMaterialLot').create.userId).toBe('admin-1');
expect(firstCall('productionBatch').create.userId).toBe('known-user');
expect(firstCall('batchMovement').create.userId).toBeNull();
expect(firstCall('productionInput').where).toEqual({ id: 'input-1' });
expect(firstCall('shopStockRequest').where).toEqual({ id: 'request-1' });
expect(firstCall('honeyBatchContainerAllocation').where).toEqual({
id: 'allocation-1',
});
});
it('writes every parent row before the rows that reference it', async () => {
const one = (id: string) => [{ id }];
await service.importAll(
{
version: 3,
exportedAt: '2026-01-01T00:00:00.000Z',
data: {
apiary: one('apiary-1'),
harvest: one('harvest-1'),
storageContainer: one('container-1'),
honeyBatch: one('honey-1'),
honeyBatchContainerAllocation: one('allocation-1'),
product: one('product-1'),
bottlingBatch: one('bottling-1'),
rawMaterialLot: one('lot-1'),
productionBatch: one('batch-1'),
productionInput: one('input-1'),
shopStockRequest: one('request-1'),
batchMovement: one('movement-1'),
automationRule: [{ triggerType: 'RAW_MATERIAL_LOT_NUMBER_FORMAT' }],
automationSequence: [
{
triggerType: 'RAW_MATERIAL_LOT_NUMBER_FORMAT',
scope: 'BEESWAX',
value: 7,
},
],
},
},
'admin-1',
TRUSTED,
);
const order = (key: string) => tx[key].upsert.mock.invocationCallOrder[0];
const dependencies: [parent: string, child: string][] = [
['storageContainer', 'honeyBatchContainerAllocation'],
['honeyBatch', 'honeyBatchContainerAllocation'],
['apiary', 'rawMaterialLot'],
['harvest', 'rawMaterialLot'],
['product', 'productionBatch'],
['productionBatch', 'productionInput'],
['rawMaterialLot', 'productionInput'],
['bottlingBatch', 'batchMovement'],
['productionBatch', 'batchMovement'],
['rawMaterialLot', 'batchMovement'],
['shopStockRequest', 'batchMovement'],
['automationRule', 'automationSequence'],
];
for (const [parent, child] of dependencies) {
expect({ parent, child, before: order(parent) < order(child) }).toEqual(
{ parent, child, before: true },
);
}
});
it('upserts automationSequence by triggerType and scope', async () => {
await service.importAll(
{
version: 3,
exportedAt: '2026-01-01T00:00:00.000Z',
data: {
automationSequence: [
{
triggerType: 'RAW_MATERIAL_LOT_NUMBER_FORMAT',
scope: 'BEESWAX',
value: 7,
},
],
},
},
'admin-1',
TRUSTED,
);
const [[call]] = tx.automationSequence.upsert.mock.calls as [
[{ where: unknown; create: Record<string, unknown> }],
];
expect(call.where).toEqual({
triggerType_scope: {
triggerType: 'RAW_MATERIAL_LOT_NUMBER_FORMAT',
scope: 'BEESWAX',
},
});
expect(call.create.value).toBe(7);
});
it('upserts a calendar outbox entry by its source and keeps the target row id', async () => {
await service.importAll(
{
version: 3,
exportedAt: '2026-01-01T00:00:00.000Z',
data: {
calendarSyncOutboxEntry: [
{
id: 'entry-source-id',
sourceType: 'TASK',
sourceId: 'task-1',
graphEventId: 'graph-event-1',
},
],
},
},
'admin-1',
TRUSTED,
);
const [[call]] = tx.calendarSyncOutboxEntry.upsert.mock.calls as [
[{ where: unknown; update: Record<string, unknown> }],
];
expect(call.where).toEqual({
sourceType_sourceId: { sourceType: 'TASK', sourceId: 'task-1' },
});
expect(call.update).not.toHaveProperty('id');
expect(call.update.graphEventId).toBe('graph-event-1');
});
it('upserts the backup and calendar settings singletons by id without rewriting it', async () => {
await service.importAll(
{
version: 3,
exportedAt: '2026-01-01T00:00:00.000Z',
data: {
backupSettings: [{ id: 'singleton', retentionCount: 30 }],
calendarSyncSettings: [{ id: 'singleton', enabled: true }],
},
},
'admin-1',
TRUSTED,
);
for (const key of ['backupSettings', 'calendarSyncSettings']) {
const [[call]] = tx[key].upsert.mock.calls as [
[{ where: unknown; update: Record<string, unknown> }],
];
expect(call.where).toEqual({ id: 'singleton' });
expect(call.update).not.toHaveProperty('id');
}
});
it('takes no calendar sync or backup schedule from an unsealed file', async () => {
const summary = await service.importAll(
{
version: 2,
exportedAt: '2026-01-01T00:00:00.000Z',
data: {
backupSettings: [{ id: 'singleton', retentionCount: 1 }],
calendarSyncSettings: [{ id: 'singleton', groupId: 'evil' }],
calendarSyncOutboxEntry: [
{ id: 'entry-1', sourceType: 'TASK', sourceId: 'task-1' },
],
},
},
'admin-1',
{ trusted: false },
);
expect(tx.backupSettings.upsert).not.toHaveBeenCalled();
expect(tx.calendarSyncSettings.upsert).not.toHaveBeenCalled();
expect(tx.calendarSyncOutboxEntry.upsert).not.toHaveBeenCalled();
expect(summary.skipped).toEqual(
expect.arrayContaining([
'backupSettings',
'calendarSyncSettings',
'calendarSyncOutboxEntry',
]),
);
});
it('restores an older bundle that lacks the newer tables as if they were empty', async () => {
const summary = await service.importAll(
{
version: 2,
exportedAt: '2026-01-01T00:00:00.000Z',
data: { apiary: [{ id: 'apiary-1', name: 'Stand Wald' }] },
},
'admin-1',
TRUSTED,
);
expect(summary.imported).toEqual(
expect.objectContaining({
apiary: 1,
productionBatch: 0,
rawMaterialLot: 0,
batchMovement: 0,
automationSequence: 0,
}),
);
});
});
// A file without the seal cannot be proven to come from ApiCore: it may
// only add plain master and field records, nothing else.
describe('unsealed file', () => {
const UNSEALED = { trusted: false };
const SEALED_ONLY_SAMPLE: Record<string, Row[]> = {
automationRule: [{ triggerType: 'HONEY_BATCH_NUMBER_FORMAT' }],
automationSequence: [
{ triggerType: 'HONEY_BATCH_NUMBER_FORMAT', scope: 'x', value: 0 },
],
stockItem: [{ id: 'stock-1' }],
stockMovement: [{ id: 'movement-1' }],
batchMovement: [{ id: 'ledger-1' }],
shopStockRequest: [{ id: 'request-1', externalId: 'order-1' }],
rawMaterialLot: [{ id: 'lot-1' }],
productionBatch: [{ id: 'batch-1' }],
productionInput: [{ id: 'input-1' }],
honeyBatchContainerAllocation: [{ id: 'allocation-1' }],
siteSettings: [{ id: 'singleton', holderName: 'Fake' }],
document: [
{
id: 'doc-1',
entityType: 'COLONY',
mimeType: 'application/pdf',
contentBase64: Buffer.from('%PDF-1.4').toString('base64'),
},
],
notification: [{ id: 'note-1', message: 'Bitte hier anmelden' }],
};
it('takes no counters, ledgers, shop requests, settings, documents or notifications from it', async () => {
const summary = await service.importAll(
{
version: 2,
exportedAt: '2026-01-01T00:00:00.000Z',
data: {
...SEALED_ONLY_SAMPLE,
apiary: [{ id: 'apiary-1', name: 'Stand Wald' }],
},
},
'admin-1',
UNSEALED,
);
for (const key of Object.keys(SEALED_ONLY_SAMPLE)) {
expect({ key, calls: tx[key].upsert.mock.calls.length }).toEqual({
key,
calls: 0,
});
}
expect(fs.writeFile).not.toHaveBeenCalled();
expect(tx.apiary.upsert).toHaveBeenCalledTimes(1);
expect([...summary.skipped].sort()).toEqual(
Object.keys(SEALED_ONLY_SAMPLE).sort(),
);
});
it('allows only plain master and field records - no table added later', () => {
expect([...LEGACY_UNSEALED_TABLES].sort()).toEqual(
[
'apiary',
'queen',
'colony',
'colonyApiaryHistory',
'colonyLineage',
'queenEvaluation',
'queenColonyHistory',
'inspection',
'taskTemplate',
'task',
'varroaMeasurement',
'swarmControlCheck',
'winterCheck',
'treatment',
'feeding',
'harvest',
'harvestColony',
'extraction',
'extractionHarvest',
'storageContainer',
'honeyBatch',
'honeyMeasurement',
'honeyBatchRelocation',
'product',
'labelTemplate',
'bottlingBatch',
'equipment',
].sort(),
);
});
it('adds the records it carries but never rewrites ones that already exist', async () => {
const data = Object.fromEntries(
LEGACY_UNSEALED_TABLES.map((key) => [
key,
[{ id: `${key}-1`, notes: 'aus der Datei' }],
]),
);
await service.importAll(
{ version: 2, exportedAt: '2026-01-01T00:00:00.000Z', data },
'admin-1',
UNSEALED,
);
for (const key of LEGACY_UNSEALED_TABLES) {
const [[call]] = tx[key].upsert.mock.calls as [
[{ create: Row; update: Row }],
];
expect({ key, update: call.update }).toEqual({ key, update: {} });
expect(call.create.notes).toBe('aus der Datei');
}
});
it('does not re-link the mother line of a queen that was already there', async () => {
tx.queen.findMany.mockResolvedValue([{ id: 'queen-existing' }]);
await service.importAll(
{
version: 2,
exportedAt: '2026-01-01T00:00:00.000Z',
data: {
queen: [
{ id: 'queen-existing', motherQueenId: 'queen-mother' },
{ id: 'queen-new', motherQueenId: 'queen-mother' },
{ id: 'queen-mother', motherQueenId: null },
],
},
},
'admin-1',
UNSEALED,
);
expect(tx.queen.update).toHaveBeenCalledTimes(1);
expect(tx.queen.update).toHaveBeenCalledWith({
where: { id: 'queen-new' },
data: { motherQueenId: 'queen-mother' },
});
});
it('reports the import as unsealed and lists no unknown sections as skipped', async () => {
const summary = await service.importAll(
{
version: 2,
exportedAt: '2026-01-01T00:00:00.000Z',
data: { notATable: [{ id: 'x' }], apiary: [{ id: 'apiary-1' }] },
},
'admin-1',
UNSEALED,
);
expect(summary.sealed).toBe(false);
expect(summary.skipped).toEqual([]);
});
it('still restores everything, overwriting existing rows, from a sealed file', async () => {
const summary = await service.importAll(
{
version: 3,
exportedAt: '2026-01-01T00:00:00.000Z',
data: {
apiary: [{ id: 'apiary-1', name: 'Stand Wald' }],
stockMovement: [{ id: 'movement-1', userId: null }],
},
},
'admin-1',
TRUSTED,
);
expect(summary.sealed).toBe(true);
expect(summary.skipped).toEqual([]);
const [[call]] = tx.apiary.upsert.mock.calls as [[{ update: Row }]];
expect(call.update).toEqual({ id: 'apiary-1', name: 'Stand Wald' });
expect(tx.stockMovement.upsert).toHaveBeenCalledTimes(1);
});
});
// Restoring a document writes its content to a new file; the file the row
// pointed at before must not be left behind on disk.
describe('replaced document files', () => {
const storageRoot = path.resolve(
process.env.DOCUMENTS_STORAGE_PATH ?? './uploads',
);
const oldFile = path.join(storageRoot, 'colony', 'old.pdf');
const bundle = {
version: 3,
exportedAt: '2026-01-01T00:00:00.000Z',
data: {
document: [
{
id: 'doc-1',
entityType: 'COLONY',
mimeType: 'application/pdf',
contentBase64: Buffer.from('%PDF-1.4 new').toString('base64'),
},
],
},
};
let warn: jest.SpyInstance;
beforeEach(() => {
warn = jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => {});
(fs.mkdir as jest.Mock).mockResolvedValue(undefined);
(fs.writeFile as jest.Mock).mockResolvedValue(undefined);
(fs.unlink as jest.Mock).mockResolvedValue(undefined);
});
afterEach(() => {
warn.mockRestore();
});
it('deletes the file a restored row replaced, only after the import committed', async () => {
tx.document.findUnique.mockResolvedValue({ storagePath: oldFile });
await service.importAll(bundle, 'admin-1', TRUSTED);
expect(fs.unlink).toHaveBeenCalledWith(oldFile);
expect(
(fs.unlink as jest.Mock).mock.invocationCallOrder[0],
).toBeGreaterThan(tx.document.upsert.mock.invocationCallOrder[0]);
expect(prisma.document.count).toHaveBeenCalledWith({
where: { storagePath: oldFile },
});
});
it('deletes nothing when the import fails', async () => {
tx.document.findUnique.mockResolvedValue({ storagePath: oldFile });
tx.notification.upsert.mockRejectedValue(new Error('db down'));
await expect(
service.importAll(
{
...bundle,
data: { ...bundle.data, notification: [{ id: 'note-1' }] },
},
'admin-1',
TRUSTED,
),
).rejects.toThrow('db down');
expect(fs.unlink).not.toHaveBeenCalled();
});
it('keeps a replaced file outside the documents root and logs it', async () => {
tx.document.findUnique.mockResolvedValue({ storagePath: '/etc/passwd' });
await service.importAll(bundle, 'admin-1', TRUSTED);
expect(fs.unlink).not.toHaveBeenCalled();
expect(warn).toHaveBeenCalledTimes(1);
});
it('keeps a replaced file that another document row still uses', async () => {
tx.document.findUnique.mockResolvedValue({ storagePath: oldFile });
prisma.document.count.mockResolvedValue(1);
await service.importAll(bundle, 'admin-1', TRUSTED);
expect(fs.unlink).not.toHaveBeenCalled();
});
it('completes the import when a replaced file cannot be deleted, and logs it', async () => {
tx.document.findUnique.mockResolvedValue({ storagePath: oldFile });
(fs.unlink as jest.Mock).mockRejectedValue(new Error('EBUSY'));
await expect(
service.importAll(bundle, 'admin-1', TRUSTED),
).resolves.toEqual(expect.objectContaining({ sealed: true }));
expect(warn).toHaveBeenCalledTimes(1);
});
it('deletes nothing for a document that is new to this database', async () => {
await service.importAll(bundle, 'admin-1', TRUSTED);
expect(fs.unlink).not.toHaveBeenCalled();
});
});
// Drift guard: a model added to the schema has to be backed up or listed
// in MODELS_EXCLUDED_FROM_BACKUP with its reason - never silently missing.
describe('model coverage', () => {
it('backs up every Prisma model or names why it is left out', async () => {
const bundle = await service.exportAll();
const uncovered = Object.values(Prisma.ModelName).filter(
(model) =>
!(modelKey(model) in bundle.data) &&
!(model in MODELS_EXCLUDED_FROM_BACKUP),
);
expect(uncovered).toEqual([]);
});
it('gives every excluded model a reason and never exports it anyway', async () => {
const bundle = await service.exportAll();
for (const [model, reason] of Object.entries(
MODELS_EXCLUDED_FROM_BACKUP,
)) {
expect(bundle.data).not.toHaveProperty(modelKey(model));
expect(reason?.trim().length).toBeGreaterThan(0);
}
});
it('restores every table it exports', async () => {
const bundle = await service.exportAll();
const summary = await service.importAll(bundle, 'admin-1', TRUSTED);
expect(Object.keys(summary.imported).sort()).toEqual(
Object.keys(bundle.data).sort(),
);
});
});
});
+361 -70
View File
@@ -1,4 +1,4 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
import * as crypto from 'node:crypto';
import * as fs from 'node:fs/promises';
import * as path from 'node:path';
@@ -10,7 +10,7 @@ import {
} from '../documents/document-mime-types';
import { matchesFileSignature } from '../common/file-signature';
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
import { DocumentEntityType } from '../../generated/prisma';
import { DocumentEntityType, Prisma } from '../../generated/prisma';
import {
SealedBackup,
isSealedBackup,
@@ -18,7 +18,10 @@ import {
sealBackup,
} from './backup-crypto';
const BACKUP_VERSION = 2;
// 3: production, raw material, stock ledger, number sequence, calendar sync
// and backup schedule tables. Older bundles simply lack those sections and
// restore them as empty.
const BACKUP_VERSION = 3;
const IMPORT_TRANSACTION_TIMEOUT_MS = 10 * 60 * 1000;
export interface BackupBundle {
@@ -30,34 +33,107 @@ export interface BackupBundle {
export interface BackupImportSummary {
imported: Record<string, number>;
/**
* Tables left out because the file was an old, unsealed (v2) backup: its
* contents cannot be proven to come from ApiCore, so nothing that grants
* access (users, roles, webhooks) or rewrites history (audit log) is
* taken from it.
* Tables the file carried but an unsealed backup may not write - every
* table outside LEGACY_UNSEALED_TABLES.
*/
skipped: string[];
/** False for an old, unsealed file: restricted and add-only. */
sealed: boolean;
}
// What an unsealed file may never write - see BackupImportSummary.skipped.
const TRUSTED_ONLY_TABLES = [
'role',
'user',
'rolePermission',
'webhookSubscription',
'auditLog',
/**
* All an unsealed file may write. Its contents cannot be proven to come from
* ApiCore (it may be hand-built), so it is limited to the plain beekeeping
* master and field records of the old unsealed (v2) format - records an
* admin could just as well type in again - and it may only add missing
* rows, never rewrite existing ones.
*
* Sealed-only, and why (any table not listed here is sealed-only too, so
* tables added later are safe by default):
* - role, user, rolePermission: grant access.
* - webhookSubscription, calendarSyncSettings, calendarSyncOutboxEntry:
* send data out, or update/delete events in an M365 calendar.
* - backupSettings: retention 1 would prune every older backup.
* - auditLog: history must not be rewritten.
* - automationRule, automationSequence: hold the lot-number counters (a
* reset hands out duplicate batch numbers) and the rule thresholds.
* - stockItem, stockMovement, batchMovement: running stock and the stock
* ledgers - never forged, only booked.
* - shopStockRequest: the shop's idempotency keys - a planted one would
* swallow a real sale.
* - rawMaterialLot, productionBatch, productionInput,
* honeyBatchContainerAllocation: newer than the unsealed format, so a
* genuine old file never carries them.
* - siteSettings: keeper name/address printed on the Bestandsbuch and the
* public links.
* - document: writes files to the server's disk.
* - notification: messages shown to users as coming from ApiCore.
*/
export const LEGACY_UNSEALED_TABLES = [
'apiary',
'queen',
'colony',
'colonyApiaryHistory',
'colonyLineage',
'queenEvaluation',
'queenColonyHistory',
'inspection',
'taskTemplate',
'task',
'varroaMeasurement',
'swarmControlCheck',
'winterCheck',
'treatment',
'feeding',
'harvest',
'harvestColony',
'extraction',
'extractionHarvest',
'storageContainer',
'honeyBatch',
'honeyMeasurement',
'honeyBatchRelocation',
'product',
'labelTemplate',
'bottlingBatch',
'equipment',
] as const;
// Bundle sections that name a real table - a file's other keys are ignored
// for every file and so never reported as skipped.
const MODEL_SECTIONS = new Set<string>(
Object.values(Prisma.ModelName).map(
(model) => model.charAt(0).toLowerCase() + model.slice(1),
),
);
/**
* Models deliberately left out of the backup, each with its reason. Every
* other Prisma model is a section of the bundle, named like its client
* delegate (`productionBatch`). The model coverage test in
* backup.service.spec.ts fails for a model that is neither, so a new table
* cannot silently drop out of the backup.
*/
export const MODELS_EXCLUDED_FROM_BACKUP: Readonly<
Partial<Record<Prisma.ModelName, string>>
> = {
Permission:
'Code-derived catalog, re-synced from the code on every boot. RolePermission rows reference permissions by resource+action instead, resolved against the target database.',
RefreshToken:
'Live session tokens - a session must never be revivable from a backup file.',
ConsumedToken:
'Replay guard for one-time tokens that expire within minutes, so always stale by the time a backup is restored.',
ApiKey:
'Machine credentials stay out on purpose (ARCHITECTURE.md assumption 14): a new key is issued after a restore. BatchMovement/ShopStockRequest keep apiKeyId only as a plain historical reference.',
IdempotencyRecord:
'Volatile cache of first responses to retried create requests (ARCHITECTURE.md assumption 11) - a restored entry could only replay a stale response.',
};
const DOCUMENT_ENTITY_TYPES = new Set<string>(
Object.values(DocumentEntityType),
);
// Excluded on purpose:
// - Permission: code-derived, re-synced from the catalog on every boot -
// including it would just fight that sync. RolePermission references
// permissions by resource+action (not id) instead, resolved against
// whatever the target database's own sync already produced.
// - RefreshToken: live session tokens, meaningless once restored (expired,
// or a stale session that should never be revivable from a backup file).
// Excluded on purpose: see MODELS_EXCLUDED_FROM_BACKUP.
//
// Role/User/RolePermission ARE included (v2) - Role is matched by its `key`
// (not id, since a fresh deployment's seeded system roles get a new
@@ -84,6 +160,8 @@ function documentsStorageRoot(): string {
@Injectable()
export class BackupService {
private readonly logger = new Logger(BackupService.name);
constructor(
private readonly prisma: PrismaService,
private readonly auditLog: AuditLogService,
@@ -98,7 +176,7 @@ export class BackupService {
/**
* Restores an uploaded file. A sealed backup is restored completely; an
* old unsealed one only with its business data (see TRUSTED_ONLY_TABLES).
* old unsealed one only add-only and limited to LEGACY_UNSEALED_TABLES.
*/
async importFile(
parsed: unknown,
@@ -162,15 +240,28 @@ export class BackupService {
data.honeyMeasurement = await this.prisma.honeyMeasurement.findMany();
data.honeyBatchRelocation =
await this.prisma.honeyBatchRelocation.findMany();
data.honeyBatchContainerAllocation =
await this.prisma.honeyBatchContainerAllocation.findMany();
data.stockItem = await this.prisma.stockItem.findMany();
data.stockMovement = await this.prisma.stockMovement.findMany();
data.product = await this.prisma.product.findMany();
data.labelTemplate = await this.prisma.labelTemplate.findMany();
data.bottlingBatch = await this.prisma.bottlingBatch.findMany();
data.rawMaterialLot = await this.prisma.rawMaterialLot.findMany();
data.productionBatch = await this.prisma.productionBatch.findMany();
data.productionInput = await this.prisma.productionInput.findMany();
data.shopStockRequest = await this.prisma.shopStockRequest.findMany();
data.batchMovement = await this.prisma.batchMovement.findMany();
data.equipment = await this.prisma.equipment.findMany();
data.webhookSubscription = await this.prisma.webhookSubscription.findMany();
data.automationRule = await this.prisma.automationRule.findMany();
data.automationSequence = await this.prisma.automationSequence.findMany();
data.siteSettings = await this.prisma.siteSettings.findMany();
data.backupSettings = await this.prisma.backupSettings.findMany();
data.calendarSyncSettings =
await this.prisma.calendarSyncSettings.findMany();
data.calendarSyncOutboxEntry =
await this.prisma.calendarSyncOutboxEntry.findMany();
const documents = await this.prisma.document.findMany();
data.document = await Promise.all(
@@ -213,21 +304,32 @@ export class BackupService {
throw new BadRequestException('Invalid backup file');
}
const isTrustedOnly = (key: string): boolean =>
(TRUSTED_ONLY_TABLES as readonly string[]).includes(key);
const isAllowedUnsealed = (key: string): boolean =>
(LEGACY_UNSEALED_TABLES as readonly string[]).includes(key);
const skipped = trusted
? []
: TRUSTED_ONLY_TABLES.filter((key) => {
: Object.keys(bundle.data).filter((key) => {
const value = bundle.data[key];
return Array.isArray(value) && value.length > 0;
return (
MODEL_SECTIONS.has(key) &&
!isAllowedUnsealed(key) &&
Array.isArray(value) &&
value.length > 0
);
});
const rowsOf = (key: string): Row[] => {
if (!trusted && isTrustedOnly(key)) {
if (!trusted && !isAllowedUnsealed(key)) {
return [];
}
const value = bundle.data[key];
return Array.isArray(value) ? (value as Row[]) : [];
};
// An unsealed file may add the rows it carries, never rewrite or roll
// back ones that already exist here.
const updateWith = (data: Row): Row => (trusted ? data : {});
// Document files the restored rows no longer point at - deleted only
// once the transaction has committed.
const replacedDocumentFiles: string[] = [];
const imported: Record<string, number> = {};
@@ -324,11 +426,26 @@ export class BackupService {
await tx.apiary.upsert({
where: { id: row.id as string },
create: row as never,
update: row as never,
update: updateWith(row) as never,
});
}
imported.apiary = rowsOf('apiary').length;
// Queens already here before this import keep their mother line when
// the file is unsealed (add-only, see updateWith).
const preexistingQueenIds = new Set<string>(
trusted
? []
: (
await tx.queen.findMany({
where: {
id: { in: rowsOf('queen').map((row) => row.id as string) },
},
select: { id: true },
})
).map((queen) => queen.id),
);
// Inserted without motherQueenId first (a daughter queen can appear
// before her mother in export order) - patched in a second pass
// below once every queen row exists.
@@ -337,7 +454,7 @@ export class BackupService {
await tx.queen.upsert({
where: { id: row.id as string },
create: withoutMother as never,
update: withoutMother as never,
update: updateWith(withoutMother) as never,
});
}
imported.queen = rowsOf('queen').length;
@@ -346,13 +463,13 @@ export class BackupService {
await tx.colony.upsert({
where: { id: row.id as string },
create: row as never,
update: row as never,
update: updateWith(row) as never,
});
}
imported.colony = rowsOf('colony').length;
for (const row of rowsOf('queen')) {
if (row.motherQueenId) {
if (row.motherQueenId && !preexistingQueenIds.has(row.id as string)) {
await tx.queen.update({
where: { id: row.id as string },
data: { motherQueenId: row.motherQueenId as string },
@@ -369,12 +486,12 @@ export class BackupService {
asString(row, 'movedByUserId'),
),
} as never,
update: {
update: updateWith({
...row,
movedByUserId: resolveOptionalUserId(
asString(row, 'movedByUserId'),
),
} as never,
}) as never,
});
}
imported.colonyApiaryHistory = rowsOf('colonyApiaryHistory').length;
@@ -383,7 +500,7 @@ export class BackupService {
await tx.colonyLineage.upsert({
where: { id: row.id as string },
create: row as never,
update: row as never,
update: updateWith(row) as never,
});
}
imported.colonyLineage = rowsOf('colonyLineage').length;
@@ -395,10 +512,10 @@ export class BackupService {
...row,
userId: resolveOptionalUserId(asString(row, 'userId')),
} as never,
update: {
update: updateWith({
...row,
userId: resolveOptionalUserId(asString(row, 'userId')),
} as never,
}) as never,
});
}
imported.queenEvaluation = rowsOf('queenEvaluation').length;
@@ -407,7 +524,7 @@ export class BackupService {
await tx.queenColonyHistory.upsert({
where: { id: row.id as string },
create: row as never,
update: row as never,
update: updateWith(row) as never,
});
}
imported.queenColonyHistory = rowsOf('queenColonyHistory').length;
@@ -419,10 +536,10 @@ export class BackupService {
...row,
userId: resolveUserId(asString(row, 'userId')),
} as never,
update: {
update: updateWith({
...row,
userId: resolveUserId(asString(row, 'userId')),
} as never,
}) as never,
});
}
imported.inspection = rowsOf('inspection').length;
@@ -439,7 +556,7 @@ export class BackupService {
asString(row, 'createdByUserId'),
),
} as never,
update: {
update: updateWith({
...row,
assignedUserId: resolveOptionalUserId(
asString(row, 'assignedUserId'),
@@ -447,7 +564,7 @@ export class BackupService {
createdByUserId: resolveOptionalUserId(
asString(row, 'createdByUserId'),
),
} as never,
}) as never,
});
}
imported.taskTemplate = rowsOf('taskTemplate').length;
@@ -464,7 +581,7 @@ export class BackupService {
asString(row, 'createdByUserId'),
),
} as never,
update: {
update: updateWith({
...row,
assignedUserId: resolveOptionalUserId(
asString(row, 'assignedUserId'),
@@ -472,7 +589,7 @@ export class BackupService {
createdByUserId: resolveOptionalUserId(
asString(row, 'createdByUserId'),
),
} as never,
}) as never,
});
}
imported.task = rowsOf('task').length;
@@ -484,10 +601,10 @@ export class BackupService {
...row,
userId: resolveUserId(asString(row, 'userId')),
} as never,
update: {
update: updateWith({
...row,
userId: resolveUserId(asString(row, 'userId')),
} as never,
}) as never,
});
}
imported.varroaMeasurement = rowsOf('varroaMeasurement').length;
@@ -499,10 +616,10 @@ export class BackupService {
...row,
userId: resolveUserId(asString(row, 'userId')),
} as never,
update: {
update: updateWith({
...row,
userId: resolveUserId(asString(row, 'userId')),
} as never,
}) as never,
});
}
imported.swarmControlCheck = rowsOf('swarmControlCheck').length;
@@ -514,10 +631,10 @@ export class BackupService {
...row,
userId: resolveUserId(asString(row, 'userId')),
} as never,
update: {
update: updateWith({
...row,
userId: resolveUserId(asString(row, 'userId')),
} as never,
}) as never,
});
}
imported.winterCheck = rowsOf('winterCheck').length;
@@ -529,10 +646,10 @@ export class BackupService {
...row,
userId: resolveUserId(asString(row, 'userId')),
} as never,
update: {
update: updateWith({
...row,
userId: resolveUserId(asString(row, 'userId')),
} as never,
}) as never,
});
}
imported.treatment = rowsOf('treatment').length;
@@ -544,10 +661,10 @@ export class BackupService {
...row,
userId: resolveUserId(asString(row, 'userId')),
} as never,
update: {
update: updateWith({
...row,
userId: resolveUserId(asString(row, 'userId')),
} as never,
}) as never,
});
}
imported.feeding = rowsOf('feeding').length;
@@ -559,10 +676,10 @@ export class BackupService {
...row,
userId: resolveUserId(asString(row, 'userId')),
} as never,
update: {
update: updateWith({
...row,
userId: resolveUserId(asString(row, 'userId')),
} as never,
}) as never,
});
}
imported.harvest = rowsOf('harvest').length;
@@ -571,7 +688,7 @@ export class BackupService {
await tx.harvestColony.upsert({
where: { id: row.id as string },
create: row as never,
update: row as never,
update: updateWith(row) as never,
});
}
imported.harvestColony = rowsOf('harvestColony').length;
@@ -583,10 +700,10 @@ export class BackupService {
...row,
userId: resolveUserId(asString(row, 'userId')),
} as never,
update: {
update: updateWith({
...row,
userId: resolveUserId(asString(row, 'userId')),
} as never,
}) as never,
});
}
imported.extraction = rowsOf('extraction').length;
@@ -595,7 +712,7 @@ export class BackupService {
await tx.extractionHarvest.upsert({
where: { id: row.id as string },
create: row as never,
update: row as never,
update: updateWith(row) as never,
});
}
imported.extractionHarvest = rowsOf('extractionHarvest').length;
@@ -604,7 +721,7 @@ export class BackupService {
await tx.storageContainer.upsert({
where: { id: row.id as string },
create: row as never,
update: row as never,
update: updateWith(row) as never,
});
}
imported.storageContainer = rowsOf('storageContainer').length;
@@ -613,7 +730,7 @@ export class BackupService {
await tx.honeyBatch.upsert({
where: { id: row.id as string },
create: row as never,
update: row as never,
update: updateWith(row) as never,
});
}
imported.honeyBatch = rowsOf('honeyBatch').length;
@@ -625,10 +742,10 @@ export class BackupService {
...row,
userId: resolveUserId(asString(row, 'userId')),
} as never,
update: {
update: updateWith({
...row,
userId: resolveUserId(asString(row, 'userId')),
} as never,
}) as never,
});
}
imported.honeyMeasurement = rowsOf('honeyMeasurement').length;
@@ -640,14 +757,25 @@ export class BackupService {
...row,
userId: resolveOptionalUserId(asString(row, 'userId')),
} as never,
update: {
update: updateWith({
...row,
userId: resolveOptionalUserId(asString(row, 'userId')),
} as never,
}) as never,
});
}
imported.honeyBatchRelocation = rowsOf('honeyBatchRelocation').length;
for (const row of rowsOf('honeyBatchContainerAllocation')) {
await tx.honeyBatchContainerAllocation.upsert({
where: { id: row.id as string },
create: row as never,
update: row as never,
});
}
imported.honeyBatchContainerAllocation = rowsOf(
'honeyBatchContainerAllocation',
).length;
for (const row of rowsOf('stockItem')) {
await tx.stockItem.upsert({
where: { id: row.id as string },
@@ -676,7 +804,7 @@ export class BackupService {
await tx.product.upsert({
where: { id: row.id as string },
create: row as never,
update: row as never,
update: updateWith(row) as never,
});
}
imported.product = rowsOf('product').length;
@@ -685,13 +813,28 @@ export class BackupService {
await tx.labelTemplate.upsert({
where: { id: row.id as string },
create: row as never,
update: row as never,
update: updateWith(row) as never,
});
}
imported.labelTemplate = rowsOf('labelTemplate').length;
for (const row of rowsOf('bottlingBatch')) {
await tx.bottlingBatch.upsert({
where: { id: row.id as string },
create: {
...row,
userId: resolveOptionalUserId(asString(row, 'userId')),
} as never,
update: updateWith({
...row,
userId: resolveOptionalUserId(asString(row, 'userId')),
}) as never,
});
}
imported.bottlingBatch = rowsOf('bottlingBatch').length;
for (const row of rowsOf('rawMaterialLot')) {
await tx.rawMaterialLot.upsert({
where: { id: row.id as string },
create: {
...row,
@@ -703,13 +846,62 @@ export class BackupService {
} as never,
});
}
imported.bottlingBatch = rowsOf('bottlingBatch').length;
imported.rawMaterialLot = rowsOf('rawMaterialLot').length;
for (const row of rowsOf('productionBatch')) {
await tx.productionBatch.upsert({
where: { id: row.id as string },
create: {
...row,
userId: resolveOptionalUserId(asString(row, 'userId')),
} as never,
update: {
...row,
userId: resolveOptionalUserId(asString(row, 'userId')),
} as never,
});
}
imported.productionBatch = rowsOf('productionBatch').length;
for (const row of rowsOf('productionInput')) {
await tx.productionInput.upsert({
where: { id: row.id as string },
create: row as never,
update: row as never,
});
}
imported.productionInput = rowsOf('productionInput').length;
for (const row of rowsOf('shopStockRequest')) {
await tx.shopStockRequest.upsert({
where: { id: row.id as string },
create: row as never,
update: row as never,
});
}
imported.shopStockRequest = rowsOf('shopStockRequest').length;
// After every kind of batch and the shop requests it may reference.
for (const row of rowsOf('batchMovement')) {
await tx.batchMovement.upsert({
where: { id: row.id as string },
create: {
...row,
userId: resolveOptionalUserId(asString(row, 'userId')),
} as never,
update: {
...row,
userId: resolveOptionalUserId(asString(row, 'userId')),
} as never,
});
}
imported.batchMovement = rowsOf('batchMovement').length;
for (const row of rowsOf('equipment')) {
await tx.equipment.upsert({
where: { id: row.id as string },
create: row as never,
update: row as never,
update: updateWith(row) as never,
});
}
imported.equipment = rowsOf('equipment').length;
@@ -748,6 +940,23 @@ export class BackupService {
}
imported.automationRule = rowsOf('automationRule').length;
// Keyed by triggerType+scope, its primary key (the table has no id).
// Restoring the counters keeps the next lot number from colliding
// with a restored one.
for (const row of rowsOf('automationSequence')) {
await tx.automationSequence.upsert({
where: {
triggerType_scope: {
triggerType: row.triggerType as never,
scope: row.scope as string,
},
},
create: row as never,
update: row as never,
});
}
imported.automationSequence = rowsOf('automationSequence').length;
for (const row of rowsOf('siteSettings')) {
const { id, ...withoutId } = row;
await tx.siteSettings.upsert({
@@ -758,6 +967,48 @@ export class BackupService {
}
imported.siteSettings = rowsOf('siteSettings').length;
for (const row of rowsOf('backupSettings')) {
const { id, ...withoutId } = row;
await tx.backupSettings.upsert({
where: { id: (id as string) ?? 'singleton' },
create: row as never,
update: withoutId as never,
});
}
imported.backupSettings = rowsOf('backupSettings').length;
for (const row of rowsOf('calendarSyncSettings')) {
const { id, ...withoutId } = row;
await tx.calendarSyncSettings.upsert({
where: { id: (id as string) ?? 'singleton' },
create: row as never,
update: withoutId as never,
});
}
imported.calendarSyncSettings = rowsOf('calendarSyncSettings').length;
// Keyed by sourceType+sourceId, like CalendarSyncService.enqueue -
// the target database may already track the same source under
// another id. graphEventId comes along, so a restored entry keeps
// updating its existing M365 event instead of creating a duplicate.
for (const row of rowsOf('calendarSyncOutboxEntry')) {
const withoutId: Row = { ...row };
delete withoutId.id;
await tx.calendarSyncOutboxEntry.upsert({
where: {
sourceType_sourceId: {
sourceType: row.sourceType as never,
sourceId: row.sourceId as string,
},
},
create: row as never,
update: withoutId as never,
});
}
imported.calendarSyncOutboxEntry = rowsOf(
'calendarSyncOutboxEntry',
).length;
// Writes the file to disk as part of this loop, before the
// surrounding transaction is known to commit - if a later entity in
// this same import fails and the transaction rolls back, a file
@@ -799,6 +1050,13 @@ export class BackupService {
`${crypto.randomUUID()}${extension}`,
);
await fs.writeFile(storagePath, buffer);
const previous = await tx.document.findUnique({
where: { id: row.id as string },
select: { storagePath: true },
});
if (previous) {
replacedDocumentFiles.push(previous.storagePath);
}
const withoutContent: Row = { ...row };
delete withoutContent.contentBase64;
const data = {
@@ -859,6 +1117,39 @@ export class BackupService {
afterData: { imported, skipped, sealed: trusted },
});
return { imported, skipped };
await this.removeReplacedDocumentFiles(replacedDocumentFiles);
return { imported, skipped, sealed: trusted };
}
/**
* Deletes the files restored document rows no longer point at - only now
* that the import has committed, only inside the documents root and only
* while no row still uses them. Best effort: a file left behind wastes
* space, it never breaks a document.
*/
private async removeReplacedDocumentFiles(
storagePaths: string[],
): Promise<void> {
const storageRoot = path.resolve(documentsStorageRoot());
for (const storagePath of storagePaths) {
const resolved = path.resolve(storagePath);
if (!resolved.startsWith(storageRoot + path.sep)) {
this.logger.warn(
`Ersetzte Dokumentdatei liegt außerhalb von ${storageRoot} und bleibt erhalten: ${resolved}`,
);
continue;
}
if ((await this.prisma.document.count({ where: { storagePath } })) > 0) {
continue;
}
try {
await fs.unlink(resolved);
} catch (error: unknown) {
this.logger.warn(
`Ersetzte Dokumentdatei ${resolved} konnte nicht gelöscht werden: ${String(error)}`,
);
}
}
}
}