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