Files
ApiCore/ApiCore-Backend/test/backup.e2e-spec.ts
Nick SzaunigandClaude Opus 5.5 10cbe9bf2d
Backend Image / build-and-push (push) Successful in 3m15s
Frontend Image / build-and-push (push) Successful in 6m2s
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]>
2026-10-12 16:38:55 +02:00

442 lines
13 KiB
TypeScript

import { INestApplication } from '@nestjs/common';
import * as fs from 'node:fs/promises';
import * as path from 'node:path';
import { App } from 'supertest/types';
import { PrismaService } from '../src/prisma/prisma.service';
import { AutomationService } from '../src/automation/automation.service';
import { BackupBundle, BackupService } from '../src/backup/backup.service';
import { createTestApp } from './utils/create-test-app';
type Row = Record<string, unknown>;
// Fixtures plus whole-database exports of the shared test database - well
// beyond the 5 s default when other specs run alongside.
const ROUND_TRIP_TIMEOUT_MS = 60_000;
// Wipe order: every row before the rows it references.
const SECTIONS_CHILD_FIRST = [
'batchMovement',
'productionInput',
'shopStockRequest',
'productionBatch',
'rawMaterialLot',
'bottlingBatch',
'honeyBatchContainerAllocation',
'honeyBatch',
'storageContainer',
'extraction',
'harvest',
'apiary',
'product',
'automationSequence',
'calendarSyncOutboxEntry',
] as const;
type Section = (typeof SECTIONS_CHILD_FIRST)[number];
// AutomationSequence has no id of its own.
function identity(row: Row): string {
return typeof row.id === 'string'
? row.id
: `${String(row.triggerType)}|${String(row.scope)}`;
}
/**
* Export -> loss -> import against the real database: production, raw
* material, stock ledger, number sequence and calendar-sync rows come back
* exactly as exported, which also proves the import writes every parent
* before the rows that reference it. Only this spec's own rows are wiped and
* re-imported, so specs running alongside never see their data rewritten.
*/
describe('Backup round trip (e2e)', () => {
let app: INestApplication<App>;
let prisma: PrismaService;
let backup: BackupService;
const suffix = `${Date.now()}-${Math.round(Math.random() * 1e6)}`;
const own: Record<Section, Set<string>> = Object.fromEntries(
SECTIONS_CHILD_FIRST.map((section) => [section, new Set<string>()]),
) as Record<Section, Set<string>>;
let userId: string;
let exported: Record<Section, Row[]>;
const remember = <T extends { id: string }>(section: Section, row: T): T => {
own[section].add(row.id);
return row;
};
function ownRows(bundle: BackupBundle): Record<Section, Row[]> {
return Object.fromEntries(
SECTIONS_CHILD_FIRST.map((section) => [
section,
((bundle.data[section] ?? []) as Row[]).filter((row) =>
own[section].has(identity(row)),
),
]),
) as Record<Section, Row[]>;
}
beforeAll(async () => {
app = await createTestApp();
prisma = app.get(PrismaService);
backup = app.get(BackupService);
// Makes sure the number-format rules exist (the sequence references one).
await app.get(AutomationService).findAll();
const user = await prisma.user.create({
data: {
email: `backup-${suffix}@example.com`,
passwordHash: 'not-used-for-login',
name: 'Backup',
role: { connect: { key: 'BEEKEEPER' } },
},
});
userId = user.id;
const day = new Date('2026-07-01T00:00:00.000Z');
const apiary = remember(
'apiary',
await prisma.apiary.create({ data: { name: `Stand ${suffix}` } }),
);
const harvest = remember(
'harvest',
await prisma.harvest.create({
data: { apiaryId: apiary.id, userId, date: day },
}),
);
const extraction = remember(
'extraction',
await prisma.extraction.create({ data: { date: day, userId } }),
);
const container = remember(
'storageContainer',
await prisma.storageContainer.create({ data: { code: `E-${suffix}` } }),
);
const honeyBatch = remember(
'honeyBatch',
await prisma.honeyBatch.create({
data: { batchNumber: `H-${suffix}`, extractionId: extraction.id },
}),
);
remember(
'honeyBatchContainerAllocation',
await prisma.honeyBatchContainerAllocation.create({
data: {
honeyBatchId: honeyBatch.id,
storageContainerId: container.id,
amount: 25,
},
}),
);
const honey = remember(
'product',
await prisma.product.create({ data: { name: `Honig ${suffix}` } }),
);
const candle = remember(
'product',
await prisma.product.create({
data: {
name: `Kerze ${suffix}`,
kind: 'MANUFACTURED',
shopCategory: 'KERZEN',
},
}),
);
const bottling = remember(
'bottlingBatch',
await prisma.bottlingBatch.create({
data: {
honeyBatchId: honeyBatch.id,
productId: honey.id,
date: day,
jarSize: 500,
jarCount: 10,
totalAmount: 5,
bestBeforeDate: new Date('2028-07-01T00:00:00.000Z'),
lotNumber: `L-${suffix}`,
userId,
},
}),
);
const wax = remember(
'rawMaterialLot',
await prisma.rawMaterialLot.create({
data: {
kind: 'BEESWAX',
lotNumber: `W-${suffix}`,
date: day,
quantity: 1000,
apiaryId: apiary.id,
harvestId: harvest.id,
userId,
},
}),
);
const production = remember(
'productionBatch',
await prisma.productionBatch.create({
data: {
productId: candle.id,
lotNumber: `K-${suffix}`,
date: day,
quantity: 12,
userId,
},
}),
);
remember(
'productionInput',
await prisma.productionInput.create({
data: {
productionBatchId: production.id,
rawMaterialLotId: wax.id,
quantity: 300,
},
}),
);
const shopRequest = remember(
'shopStockRequest',
await prisma.shopStockRequest.create({
data: {
externalId: `order-${suffix}`,
kind: 'SALE',
bottlingBatchId: bottling.id,
quantity: 2,
apiKeyId: 'key-not-in-backup',
},
}),
);
for (const data of [
{
bottlingBatchId: bottling.id,
type: 'SHOP_SALE' as const,
quantity: -2,
shopRequestId: shopRequest.id,
apiKeyId: 'key-not-in-backup',
},
{
rawMaterialLotId: wax.id,
type: 'CONSUMPTION' as const,
quantity: -300,
userId,
},
{
productionBatchId: production.id,
type: 'WITHDRAWAL' as const,
quantity: -1,
reason: 'EIGENBEDARF' as const,
userId,
},
]) {
remember('batchMovement', await prisma.batchMovement.create({ data }));
}
const sequence = await prisma.automationSequence.create({
data: {
triggerType: 'RAW_MATERIAL_LOT_NUMBER_FORMAT',
scope: `backup-${suffix}`,
value: 41,
},
});
own.automationSequence.add(identity(sequence));
remember(
'calendarSyncOutboxEntry',
await prisma.calendarSyncOutboxEntry.create({
data: {
sourceType: 'TASK',
sourceId: `backup-${suffix}`,
status: 'SYNCED',
graphEventId: `graph-${suffix}`,
},
}),
);
}, ROUND_TRIP_TIMEOUT_MS);
afterAll(async () => {
await app.close();
});
it(
'exports the production, raw material and stock ledger tables',
async () => {
exported = ownRows(await backup.exportAll());
for (const section of SECTIONS_CHILD_FIRST) {
expect({ section, count: exported[section].length }).toEqual({
section,
count: own[section].size,
});
}
},
ROUND_TRIP_TIMEOUT_MS,
);
it(
'restores every one of those rows exactly after they were lost',
async () => {
for (const section of SECTIONS_CHILD_FIRST) {
if (section === 'automationSequence') {
await prisma.automationSequence.deleteMany({
where: { scope: `backup-${suffix}` },
});
continue;
}
const delegate = prisma[section] as unknown as {
deleteMany(args: {
where: { id: { in: string[] } };
}): Promise<unknown>;
};
await delegate.deleteMany({ where: { id: { in: [...own[section]] } } });
}
expect(
await prisma.batchMovement.count({
where: { id: { in: [...own.batchMovement] } },
}),
).toBe(0);
await backup.importAll(
{
version: 3,
exportedAt: new Date().toISOString(),
data: exported,
},
userId,
{ trusted: true },
);
const where = (section: Section) => ({
where: { id: { in: [...own[section]] } },
});
expect({
productionBatch: await prisma.productionBatch.count(
where('productionBatch'),
),
rawMaterialLot: await prisma.rawMaterialLot.count(
where('rawMaterialLot'),
),
productionInput: await prisma.productionInput.count(
where('productionInput'),
),
shopStockRequest: await prisma.shopStockRequest.count(
where('shopStockRequest'),
),
batchMovement: await prisma.batchMovement.count(where('batchMovement')),
honeyBatchContainerAllocation:
await prisma.honeyBatchContainerAllocation.count(
where('honeyBatchContainerAllocation'),
),
automationSequence: await prisma.automationSequence.count({
where: { scope: `backup-${suffix}` },
}),
calendarSyncOutboxEntry: await prisma.calendarSyncOutboxEntry.count(
where('calendarSyncOutboxEntry'),
),
}).toEqual({
productionBatch: 1,
rawMaterialLot: 1,
productionInput: 1,
shopStockRequest: 1,
batchMovement: 3,
honeyBatchContainerAllocation: 1,
automationSequence: 1,
calendarSyncOutboxEntry: 1,
});
expect(ownRows(await backup.exportAll())).toEqual(exported);
},
ROUND_TRIP_TIMEOUT_MS,
);
it('adds rows from an unsealed file but never rewrites existing ones or takes ledger entries', async () => {
const existing = await prisma.apiary.create({
data: { name: `Bestand ${suffix}` },
});
const addedId = `apiary-added-${suffix}`;
const forgedId = `forged-movement-${suffix}`;
const summary = await backup.importFile(
{
version: 2,
exportedAt: '2026-01-01T00:00:00.000Z',
data: {
apiary: [
{ id: existing.id, name: 'Überschrieben' },
{ id: addedId, name: `Ergänzt ${suffix}` },
],
batchMovement: [
{
id: forgedId,
productionBatchId: [...own.productionBatch][0],
type: 'CORRECTION',
quantity: 1000,
},
],
},
},
userId,
);
expect(summary).toEqual(
expect.objectContaining({ sealed: false, skipped: ['batchMovement'] }),
);
expect(
(await prisma.apiary.findUniqueOrThrow({ where: { id: existing.id } }))
.name,
).toBe(`Bestand ${suffix}`);
expect(
(await prisma.apiary.findUniqueOrThrow({ where: { id: addedId } })).name,
).toBe(`Ergänzt ${suffix}`);
expect(
await prisma.batchMovement.findUnique({ where: { id: forgedId } }),
).toBeNull();
});
it('removes the file a restored document row replaced', async () => {
const directory = path.join(
path.resolve(process.env.DOCUMENTS_STORAGE_PATH ?? './uploads'),
'colony',
);
await fs.mkdir(directory, { recursive: true });
const oldFile = path.join(directory, `backup-e2e-${suffix}.pdf`);
await fs.writeFile(oldFile, '%PDF-1.4 old');
const document = await prisma.document.create({
data: {
entityType: 'COLONY',
entityId: `colony-${suffix}`,
fileName: 'alt.pdf',
mimeType: 'application/pdf',
sizeBytes: 12,
storagePath: oldFile,
},
});
await backup.importAll(
{
version: 3,
exportedAt: new Date().toISOString(),
data: {
document: [
{
id: document.id,
entityType: 'COLONY',
entityId: `colony-${suffix}`,
fileName: 'neu.pdf',
mimeType: 'application/pdf',
sizeBytes: 12,
contentBase64: Buffer.from('%PDF-1.4 new').toString('base64'),
},
],
},
},
userId,
{ trusted: true },
);
const restored = await prisma.document.findUniqueOrThrow({
where: { id: document.id },
});
expect(restored.storagePath).not.toBe(oldFile);
await expect(fs.readFile(restored.storagePath, 'utf8')).resolves.toBe(
'%PDF-1.4 new',
);
await expect(fs.access(oldFile)).rejects.toThrow();
await fs.unlink(restored.storagePath);
});
});