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>
This commit is contained in:
@@ -42,9 +42,17 @@ async function expectThrow(fn: () => Promise<unknown>, msg: string) {
|
||||
}
|
||||
}
|
||||
|
||||
const RLS_URL =
|
||||
process.env.RLS_DATABASE_URL ??
|
||||
"postgresql://craftvia_app:craftvia_app_local@localhost:5432/craftvia?schema=public";
|
||||
/**
|
||||
* Default: same host/port/database as DATABASE_URL, but as the restricted role craftvia_app.
|
||||
* Keeps the test pointed at the database that was actually migrated/seeded (e.g. lane DBs).
|
||||
*/
|
||||
function defaultRlsUrl(): string {
|
||||
const base = new URL(process.env.DATABASE_URL ?? "postgresql://localhost:5432/craftvia?schema=public");
|
||||
base.username = "craftvia_app";
|
||||
base.password = "craftvia_app_local";
|
||||
return base.toString();
|
||||
}
|
||||
const RLS_URL = process.env.RLS_DATABASE_URL ?? defaultRlsUrl();
|
||||
|
||||
const SLUG_A = "zz-rls-test-a";
|
||||
const SLUG_B = "zz-rls-test-b";
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
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);
|
||||
});
|
||||
Reference in New Issue
Block a user