L10a Qualität & Abnahmetests: Sicherheitstests (Rollen-Matrix, schädliche Uploads, Login-Sperre/Rate-Limit/Sessions) und Audit-Log append-only für craftvia_app

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-14 18:25:55 +02:00
co-authored by Claude Opus 5
parent 4507862375
commit c297cfbe83
5 changed files with 340 additions and 9 deletions
+31 -9
View File
@@ -149,6 +149,15 @@ async function threw(fn: () => Promise<unknown>): Promise<boolean> {
}
}
/** Bulk write without effect: count 0, or refused by the database (append-only audit_logs under RLS). */
async function noEffect(fn: () => Promise<unknown>): Promise<boolean> {
try {
return ((await fn()) as { count: number }).count === 0;
} catch (err) {
return /permission denied/i.test((err as Error).message);
}
}
function defaultRlsUrl(): string {
const base = new URL(process.env.DATABASE_URL ?? "postgresql://localhost:5432/craftvia?schema=public");
base.username = "craftvia_app";
@@ -178,8 +187,8 @@ runSuite("E2E Mandantentrennung (systematisch)", [SLUG_A, SLUG_B], async () => {
findFirst: (await d.findFirst({ where })) === null,
findUnique: (await d.findUnique({ where }).catch(() => null)) === null,
count: (await d.count({ where })) === 0,
updateMany: ((await d.updateMany({ where, data: row.update })) as { count: number }).count === 0,
deleteMany: ((await d.deleteMany({ where })) as { count: number }).count === 0,
updateMany: await noEffect(() => d.updateMany({ where, data: row.update })),
deleteMany: await noEffect(() => d.deleteMany({ where })),
update: await threw(() => d.update({ where, data: row.update })),
delete: await threw(() => d.delete({ where })),
};
@@ -202,13 +211,26 @@ runSuite("E2E Mandantentrennung (systematisch)", [SLUG_A, SLUG_B], async () => {
const row = rows[model];
if (!row) continue;
const table = topo.nodes.get(model)!.table;
const res = await app.$transaction(async (tx) => {
await tx.$executeRaw`SELECT set_config('app.tenant_id', ${B.tenantId}, true)`;
const seen = await tx.$queryRawUnsafe<{ n: number }[]>(`SELECT count(*)::int AS n FROM "${table}" WHERE id = $1`, row.id);
const updated = await tx.$executeRawUnsafe(`UPDATE "${table}" SET id = id WHERE id = $1`, row.id);
const deleted = await tx.$executeRawUnsafe(`DELETE FROM "${table}" WHERE id = $1`, row.id);
return { seen: seen[0].n, updated, deleted };
});
/** Affected rows under tenant context B; a missing privilege (append-only audit_logs) counts as 0. */
const asB = async (sql: string): Promise<number> => {
try {
return await app.$transaction(async (tx) => {
await tx.$executeRaw`SELECT set_config('app.tenant_id', ${B.tenantId}, true)`;
return tx.$executeRawUnsafe(sql, row.id);
});
} catch (err) {
if (/permission denied/i.test((err as Error).message)) return 0;
throw err;
}
};
const res = {
seen: await app.$transaction(async (tx) => {
await tx.$executeRaw`SELECT set_config('app.tenant_id', ${B.tenantId}, true)`;
return (await tx.$queryRawUnsafe<{ n: number }[]>(`SELECT count(*)::int AS n FROM "${table}" WHERE id = $1`, row.id))[0].n;
}),
updated: await asB(`UPDATE "${table}" SET id = id WHERE id = $1`),
deleted: await asB(`DELETE FROM "${table}" WHERE id = $1`),
};
const own = await app.$transaction(async (tx) => {
await tx.$executeRaw`SELECT set_config('app.tenant_id', ${A.tenantId}, true)`;
return (await tx.$queryRawUnsafe<{ n: number }[]>(`SELECT count(*)::int AS n FROM "${table}" WHERE id = $1`, row.id))[0].n;