fix: Paket 0 – data integrity, security hardening and field usability
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:
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;
|
||||
}
|
||||
}
|
||||
@@ -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],
|
||||
})
|
||||
|
||||
@@ -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(),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user