- 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>
103 lines
4.4 KiB
TypeScript
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);
|
|
});
|