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
@@ -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(),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user