Files
craftvia/scripts/test-tenant-transaction.ts
T
msolarczekandClaude Opus 5 4a25f2cc3b Fundament: atomare Mandanten-Transaktionen, iframe-Vorschau, Uploads bis 25 MB, DSGVO-Felder
- db.ts: tenantTransaction() – atomar auch bei RLS_ENFORCED=true (AsyncLocalStorage
  bindet Operationen an eine craftvia_app-Transaktion, Kontext einmal gesetzt,
  verschachtelte Aufrufe treten bei, fremder Mandant wird abgewiesen)
- services/context.ts: inTransaction(ctx, fn); imports/confirm.ts umgestellt
- next.config.ts: EMBEDDABLE_FILE_ROUTES mit frame-ancestors 'self'/SAMEORIGIN
  (PDF-Vorschau Prüfmaske), proxyClientMaxBodySize 26mb (Import bis 25 MB)
- test-rls-enforcement: RLS-URL-Default aus DATABASE_URL (Lane-DBs)
- dsgvo/pii-fields: 26 Personenreferenzen des Craftvia-Domänenmodells
- ARCHITEKTUR §4.8: Transaktions-, Header-, Upload-, Versions- und PII-Regeln
- Test test-tenant-transaction (Commit/Rollback/Fremdmandant/Verschachtelung),
  grün im Owner- und im RLS-Modus

Gate: tsc, lint, build, 31/31 Tests grün.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 12:29:57 +02:00

103 lines
4.4 KiB
TypeScript

import "dotenv/config";
/**
* tenantTransaction(): atomic multi-step writes for one tenant.
* Runs in the mode given by RLS_ENFORCED (owner path by default). The runner executes this
* script once as-is; to verify the RLS path run: RLS_ENFORCED=true npx tsx scripts/test-tenant-transaction.ts
* (requires craftvia_app LOGIN, see test-rls-enforcement.ts — otherwise that mode is skipped).
* (1) commit: two writes inside → both visible afterwards
* (2) rollback: two writes, then throw → nothing visible
* (3) a write for another tenant inside the transaction → rejected, whole tx rolled back
* (4) nested tenantTransaction joins the outer transaction (rollback covers both)
*/
let failures = 0;
function check(name: string, cond: boolean, detail?: unknown) {
if (cond) console.log(` ✓ ${name}`);
else {
failures++;
console.log(` ✗ ${name}`, detail ?? "");
}
}
async function main() {
const { prisma, dbForTenant, tenantTransaction, RLS_ENFORCED } = await import("../src/server/db");
console.log(`Modus: ${RLS_ENFORCED ? "RLS (craftvia_app)" : "Owner"}`);
const stamp = Date.now();
const a = await prisma.tenant.create({ data: { name: "ZZ Tx A", slug: `zz-tx-a-${stamp}` } });
const b = await prisma.tenant.create({ data: { name: "ZZ Tx B", slug: `zz-tx-b-${stamp}` } });
const dbA = dbForTenant(a.id);
const countA = () => prisma.numberSequence.count({ where: { tenantId: a.id } });
try {
// (1) commit
await tenantTransaction(dbA, a.id, async (tx) => {
await tx.numberSequence.create({ data: { tenantId: a.id, key: "customer", prefix: "K-" } });
await tx.numberSequence.create({ data: { tenantId: a.id, key: "work_order", prefix: "A-" } });
});
check("(1) commit: both rows persisted", (await countA()) === 2, await countA());
// (2) rollback
try {
await tenantTransaction(dbA, a.id, async (tx) => {
await tx.numberSequence.create({ data: { tenantId: a.id, key: "report", prefix: "B-" } });
await tx.numberSequence.create({ data: { tenantId: a.id, key: "emergency", prefix: "N-" } });
throw new Error("boom");
});
check("(2) rollback: error propagated", false);
} catch (err) {
check("(2) rollback: error propagated", (err as Error).message === "boom", (err as Error).message);
}
check("(2) rollback: no rows from failed tx", (await countA()) === 2, await countA());
// (3) foreign tenant inside the tx
try {
await tenantTransaction(dbA, a.id, async (tx) => {
await tx.numberSequence.create({ data: { tenantId: a.id, key: "report", prefix: "B-" } });
const foreign = await prisma.numberSequence.create({ data: { tenantId: b.id, key: "customer", prefix: "K-" } });
await tx.numberSequence.update({ where: { id: foreign.id }, data: { prefix: "HACK-" } });
});
check("(3) foreign update rejected", false);
} catch {
check("(3) foreign update rejected", true);
}
const foreignRow = await prisma.numberSequence.findFirst({ where: { tenantId: b.id } });
check("(3) foreign row unchanged", foreignRow?.prefix === "K-", foreignRow?.prefix);
check("(3) own write of failed tx rolled back", (await countA()) === 2, await countA());
// (4) nested join
try {
await tenantTransaction(dbA, a.id, async (tx) => {
await tx.numberSequence.create({ data: { tenantId: a.id, key: "report", prefix: "B-" } });
await tenantTransaction(tx, a.id, async (inner) => {
await inner.numberSequence.create({ data: { tenantId: a.id, key: "emergency", prefix: "N-" } });
});
throw new Error("outer boom");
});
} catch {
/* expected */
}
check("(4) nested writes rolled back with outer tx", (await countA()) === 2, await countA());
} finally {
await prisma.numberSequence.deleteMany({ where: { tenantId: { in: [a.id, b.id] } } });
await prisma.auditLog.deleteMany({ where: { tenantId: { in: [a.id, b.id] } } });
await prisma.tenant.deleteMany({ where: { id: { in: [a.id, b.id] } } });
await prisma.$disconnect();
}
console.log(failures ? `\n${failures} Fehler` : "\nOK");
process.exit(failures ? 1 : 0);
}
main().catch((err) => {
const msg = String((err as Error)?.message ?? err);
if (/craftvia_app|password authentication|permission denied for role/i.test(msg)) {
console.log(`⚠ ÜBERSPRUNGEN: RLS-Verbindung nicht möglich (${msg.split("\n")[0]})`);
process.exit(0);
}
console.error(err);
process.exit(1);
});