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
@@ -0,0 +1,7 @@
-- L10a Qualität & Abnahmetests: Audit-Log append-only für die eingeschränkte App-Rolle.
-- Spec §26 (revisionsfähige Protokollierung) und §43.4 („Audit-Log-Manipulation"):
-- Die App schreibt Audit-Einträge ausschließlich per INSERT (writeAuditLog, Owner-Client) und liest sie
-- im Audit-Viewer. Mit RLS_ENFORCED=true soll auch ein kompromittierter Fachpfad über die Rolle
-- craftvia_app bestehende Einträge weder ändern noch löschen können. Backup/Restore und DSGVO-Löschung
-- laufen über die Owner-Rolle und sind nicht betroffen.
REVOKE UPDATE, DELETE, TRUNCATE ON TABLE audit_logs FROM craftvia_app;
+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 { function defaultRlsUrl(): string {
const base = new URL(process.env.DATABASE_URL ?? "postgresql://localhost:5432/craftvia?schema=public"); const base = new URL(process.env.DATABASE_URL ?? "postgresql://localhost:5432/craftvia?schema=public");
base.username = "craftvia_app"; base.username = "craftvia_app";
@@ -178,8 +187,8 @@ runSuite("E2E Mandantentrennung (systematisch)", [SLUG_A, SLUG_B], async () => {
findFirst: (await d.findFirst({ where })) === null, findFirst: (await d.findFirst({ where })) === null,
findUnique: (await d.findUnique({ where }).catch(() => null)) === null, findUnique: (await d.findUnique({ where }).catch(() => null)) === null,
count: (await d.count({ where })) === 0, count: (await d.count({ where })) === 0,
updateMany: ((await d.updateMany({ where, data: row.update })) as { count: number }).count === 0, updateMany: await noEffect(() => d.updateMany({ where, data: row.update })),
deleteMany: ((await d.deleteMany({ where })) as { count: number }).count === 0, deleteMany: await noEffect(() => d.deleteMany({ where })),
update: await threw(() => d.update({ where, data: row.update })), update: await threw(() => d.update({ where, data: row.update })),
delete: await threw(() => d.delete({ where })), delete: await threw(() => d.delete({ where })),
}; };
@@ -202,13 +211,26 @@ runSuite("E2E Mandantentrennung (systematisch)", [SLUG_A, SLUG_B], async () => {
const row = rows[model]; const row = rows[model];
if (!row) continue; if (!row) continue;
const table = topo.nodes.get(model)!.table; const table = topo.nodes.get(model)!.table;
const res = await app.$transaction(async (tx) => { /** Affected rows under tenant context B; a missing privilege (append-only audit_logs) counts as 0. */
await tx.$executeRaw`SELECT set_config('app.tenant_id', ${B.tenantId}, true)`; const asB = async (sql: string): Promise<number> => {
const seen = await tx.$queryRawUnsafe<{ n: number }[]>(`SELECT count(*)::int AS n FROM "${table}" WHERE id = $1`, row.id); try {
const updated = await tx.$executeRawUnsafe(`UPDATE "${table}" SET id = id WHERE id = $1`, row.id); return await app.$transaction(async (tx) => {
const deleted = await tx.$executeRawUnsafe(`DELETE FROM "${table}" WHERE id = $1`, row.id); await tx.$executeRaw`SELECT set_config('app.tenant_id', ${B.tenantId}, true)`;
return { seen: seen[0].n, updated, deleted }; 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) => { const own = await app.$transaction(async (tx) => {
await tx.$executeRaw`SELECT set_config('app.tenant_id', ${A.tenantId}, true)`; 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; return (await tx.$queryRawUnsafe<{ n: number }[]>(`SELECT count(*)::int AS n FROM "${table}" WHERE id = $1`, row.id))[0].n;
+164
View File
@@ -0,0 +1,164 @@
// L10a Sicherheitstest §43.4 „Rate Limiting", „Session-Handling", „Audit-Log-Manipulation" (Service-/DB-Ebene).
//
// Login: 5 Fehlversuche → Identität gesperrt (auch das richtige Passwort wird abgewiesen), Fehlversuche
// und Sperre auditiert, unbekannte E-Mail ohne Orakel, Ausnahme „letzter aktiver Mandanten-Admin"
// (keine DoS-Sperre, aber protokolliert). Rate-Limit der Wiederherstellungs-Abläufe je IP und Konto.
// Session-Kill-Switch (isTokenStillValid, fail-closed ohne iat). Audit-Log: kein Fachpfad (Services,
// Actions, Route Handler) ändert oder löscht Audit-Einträge (statische Prüfung), die App-Rolle
// craftvia_app hat auf audit_logs weder UPDATE noch DELETE (Migration *_qualitaet_audit_append_only),
// der Mandanten-Guard verhindert fremde Zugriffe. Passwort-Reset/MFA-Härtung: test-reset-flow.ts,
// test-mfa-hardening.ts; Session-Cookies über HTTP: test-security-http.ts.
//
// Lauf: npx tsx scripts/test-security-auth.ts
import "dotenv/config";
import { readdirSync, readFileSync, statSync } from "node:fs";
import { join, relative } from "node:path";
import { PrismaClient } from "@prisma/client";
import { PrismaPg } from "@prisma/adapter-pg";
import { prisma, dbForTenant } from "../src/server/db";
import { hashPassword } from "../src/server/password";
import { authorizeTenantCredentials } from "../src/server/auth";
import { checkRateLimit, resetRateLimits } from "../src/server/rate-limit";
import { isTokenStillValid } from "../src/server/sessions";
import { writeAuditLog } from "../src/server/audit";
import { createTenant, ok, runSuite, section } from "./lib/e2e-fixture";
const SLUG = "zz-q-sec-auth";
const SLUG_B = "zz-q-sec-auth-b";
const PASSWORD = "Pruef-Passwort-2026!";
function walk(dir: string): string[] {
return readdirSync(dir).flatMap((name) => {
const p = join(dir, name);
return statSync(p).isDirectory() ? walk(p) : /\.(ts|tsx)$/.test(name) ? [p] : [];
});
}
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();
}
runSuite("Sicherheit: Login-Sperre, Rate-Limit, Sessions, Audit-Log", [SLUG, SLUG_B], async () => {
const A = await createTenant(SLUG);
const B = await createTenant(SLUG_B);
const hash = await hashPassword(PASSWORD);
await prisma.identity.updateMany({ where: { id: { in: [A.users.tech.identityId, A.users.admin.identityId] } }, data: { passwordHash: hash, failedLogins: 0, lockedUntil: null } });
const techEmail = A.users.tech.email;
section("Login: Sperre nach Fehlversuchen");
ok((await authorizeTenantCredentials({ email: techEmail, password: PASSWORD, tenant: SLUG })) !== null, "richtiges Passwort → Anmeldung möglich");
for (let i = 1; i <= 5; i++) {
const res = await authorizeTenantCredentials({ email: techEmail, password: `falsch-${i}`, tenant: SLUG });
if (res !== null) ok(false, `Fehlversuch ${i} wurde angenommen`);
}
const locked = await prisma.identity.findUniqueOrThrow({ where: { id: A.users.tech.identityId } });
ok(!!locked.lockedUntil && locked.lockedUntil.getTime() > Date.now() + 10 * 60_000, `nach 5 Fehlversuchen gesperrt bis ${locked.lockedUntil?.toISOString()}`);
ok((await authorizeTenantCredentials({ email: techEmail, password: PASSWORD, tenant: SLUG })) === null, "gesperrt: auch das richtige Passwort wird abgewiesen");
const denied = await prisma.auditLog.findMany({ where: { tenantId: A.tenantId, entity: "login", action: "denied", entityId: A.users.tech.identityId } });
ok(denied.length >= 6 && denied.some((d) => (d.after as { locked?: boolean } | null)?.locked === true) && denied.some((d) => (d.after as { reason?: string } | null)?.reason === "locked"), `Fehlversuche, Sperre und Versuch während der Sperre auditiert (${denied.length})`);
await prisma.identity.update({ where: { id: locked.id }, data: { lockedUntil: new Date(Date.now() - 1000) } });
ok((await authorizeTenantCredentials({ email: techEmail, password: PASSWORD, tenant: SLUG })) !== null, "nach Ablauf der Sperre wieder anmeldbar, Zähler zurückgesetzt");
ok((await prisma.identity.findUniqueOrThrow({ where: { id: locked.id } })).failedLogins === 0, "Fehlversuchszähler nach erfolgreicher Anmeldung 0");
ok((await authorizeTenantCredentials({ email: `unbekannt${Date.now()}@zz-qualitaet.test`, password: PASSWORD, tenant: SLUG })) === null, "unbekannte E-Mail → gleiche Antwort (null), kein Konto-Orakel");
ok((await authorizeTenantCredentials({ email: techEmail, password: PASSWORD, tenant: SLUG_B })) === null, "Anmeldung in einem Mandanten ohne Mitgliedschaft abgewiesen");
section("Login: letzter aktiver Mandanten-Admin wird nicht ausgesperrt (DoS-Schutz)");
for (let i = 0; i < 6; i++) await authorizeTenantCredentials({ email: A.users.admin.email, password: "falsch", tenant: SLUG });
const admin = await prisma.identity.findUniqueOrThrow({ where: { id: A.users.admin.identityId } });
ok(!admin.lockedUntil || admin.lockedUntil.getTime() < Date.now(), "letzter Admin nicht gesperrt");
ok((await prisma.auditLog.count({ where: { tenantId: A.tenantId, entity: "login", action: "denied", entityId: admin.id } })) >= 6, "Fehlversuche des Admins trotzdem auditiert");
console.log(" Hinweis: Das Login zählt Fehlversuche je Identität (Sperre); ein IP-basiertes Limit gegen Password-Spraying über viele Konten gibt es nur für Reset/Einladung (siehe Bericht).");
section("Rate-Limit Wiederherstellung (je IP und je Konto)");
resetRateLimits();
const results = Array.from({ length: 6 }, () => checkRateLimit("passwordResetRequest", { ip: "203.0.113.7", account: "opfer@zz-qualitaet.test" }).allowed);
ok(results.slice(0, 5).every(Boolean) && results[5] === false, "Reset-Anfrage: 5 erlaubt, 6. blockiert");
const blocked = checkRateLimit("passwordResetRequest", { ip: "203.0.113.7", account: "anderes@zz-qualitaet.test" });
ok(!blocked.allowed && blocked.retryAfterSeconds > 0, "gleiche IP, anderes Konto → weiter blockiert (mit Wartezeit)");
ok(!checkRateLimit("passwordResetRequest", { ip: "198.51.100.1", account: "opfer@zz-qualitaet.test" }).allowed, "anderes Konto-Ziel über neue IP → Konto-Zähler blockiert");
ok(checkRateLimit("passwordResetRequest", { ip: "198.51.100.2", account: "frei@zz-qualitaet.test" }).allowed, "unbeteiligte IP + Konto → erlaubt");
resetRateLimits();
const redeem = Array.from({ length: 11 }, () => checkRateLimit("passwordResetRedeem", { ip: "203.0.113.9" }).allowed);
ok(redeem.filter(Boolean).length === 10 && !redeem[10], "Reset-Einlösung: 10 Versuche je IP, dann blockiert");
resetRateLimits();
section("Session-Kill-Switch");
const now = Math.floor(Date.now() / 1000);
ok(isTokenStillValid(now, null), "ohne Invalidierungsmarke gültig");
ok(!isTokenStillValid(now - 60, new Date(now * 1000)), "Token vor der Marke ausgestellt → ungültig");
ok(isTokenStillValid(now + 1, new Date(now * 1000)), "Token nach der Marke → gültig");
ok(!isTokenStillValid(undefined, new Date()), "Token ohne iat bei gesetzter Marke → fail-closed");
section("Audit-Log: kein Fachpfad ändert oder löscht Einträge");
const root = process.cwd();
const scanned = ["src/app", "src/server/actions", "src/server/services", "src/server/api", "src/server/jobs", "src/lib"].flatMap((d) => walk(join(root, d)));
const offenders = scanned.filter((file) => {
const text = readFileSync(file, "utf8");
return /auditLog\s*\.\s*(update|updateMany|delete|deleteMany|upsert)\b/.test(text) || /(UPDATE|DELETE\s+FROM|TRUNCATE)\s+"?audit_logs/i.test(text);
});
ok(scanned.length > 100 && offenders.length === 0, `${scanned.length} Dateien geprüft, keine Mutation von audit_logs${offenders.length ? ` — ${offenders.map((f) => relative(root, f)).join(", ")}` : ""}`);
const apiRoutes = walk(join(root, "src/app/api"));
ok(!apiRoutes.some((f) => /audit/i.test(relative(root, f))), "keine /api-Route für Audit-Einträge");
const viewer = readFileSync(join(root, "src/server/services/audit/viewer.ts"), "utf8");
ok(!/\.(create|update|delete|upsert)\w*\(/.test(viewer.replace(/writeAuditLog/g, "")), "Audit-Viewer-Service ist rein lesend");
const privileges = await prisma.$queryRawUnsafe<{ sel: boolean; ins: boolean; upd: boolean; del: boolean }[]>(
`SELECT has_table_privilege('craftvia_app', 'audit_logs', 'SELECT') AS sel, has_table_privilege('craftvia_app', 'audit_logs', 'INSERT') AS ins,
has_table_privilege('craftvia_app', 'audit_logs', 'UPDATE') AS upd, has_table_privilege('craftvia_app', 'audit_logs', 'DELETE') AS del`,
);
ok(privileges[0].sel && privileges[0].ins, "App-Rolle darf Audit-Einträge lesen und anlegen");
ok(!privileges[0].upd && !privileges[0].del, "App-Rolle darf Audit-Einträge weder ändern noch löschen (append-only)");
await writeAuditLog({ tenantId: A.tenantId, actorId: A.users.backoffice.id, action: "update", entity: "zz_audit_probe", entityId: "p1", before: { v: 1 }, after: { v: 2 } });
const probe = await prisma.auditLog.findFirstOrThrow({ where: { tenantId: A.tenantId, entity: "zz_audit_probe" } });
ok(probe.actorId === A.users.backoffice.id && (probe.before as { v: number }).v === 1, "writeAuditLog protokolliert Akteur und before/after");
let foreignBlocked = false;
try {
await dbForTenant(B.tenantId).auditLog.update({ where: { id: probe.id }, data: { entity: "manipuliert" } });
} catch {
foreignBlocked = true;
}
ok(foreignBlocked && (await prisma.auditLog.count({ where: { tenantId: B.tenantId, entity: "manipuliert" } })) === 0, "fremder Mandant kann den Eintrag nicht ändern (Tenant-Guard)");
let deletedByB = -1;
try {
deletedByB = (await dbForTenant(B.tenantId).auditLog.deleteMany({ where: { id: probe.id } })).count;
} catch (err) {
// RLS_ENFORCED=true: the app role has no DELETE privilege on audit_logs at all
if (/permission denied/i.test((err as Error).message)) deletedByB = 0;
else throw err;
}
ok(deletedByB === 0, "fremder Mandant kann den Eintrag nicht löschen");
const app = new PrismaClient({ adapter: new PrismaPg({ connectionString: process.env.RLS_DATABASE_URL ?? defaultRlsUrl() }) });
try {
await app.$queryRawUnsafe("SELECT 1");
const attempt = async (sql: string) => {
try {
await app.$transaction(async (tx) => {
await tx.$executeRaw`SELECT set_config('app.tenant_id', ${A.tenantId}, true)`;
await tx.$executeRawUnsafe(sql, probe.id);
});
return "executed";
} catch (err) {
return /permission denied/i.test((err as Error).message) ? "denied" : `error:${(err as Error).message.split("\n")[0]}`;
}
};
ok((await attempt(`UPDATE audit_logs SET entity = 'manipuliert' WHERE id = $1`)) === "denied", "craftvia_app im eigenen Mandanten: UPDATE audit_logs → permission denied");
ok((await attempt(`DELETE FROM audit_logs WHERE id = $1`)) === "denied", "craftvia_app im eigenen Mandanten: DELETE audit_logs → permission denied");
const readable = await app.$transaction(async (tx) => {
await tx.$executeRaw`SELECT set_config('app.tenant_id', ${A.tenantId}, true)`;
return tx.auditLog.count({ where: { id: probe.id } });
});
ok(readable === 1, "craftvia_app liest den eigenen Eintrag weiterhin");
} catch (err) {
if (process.env.RLS_TEST_REQUIRED === "true") throw err;
console.log(`⚠ DB-Rollenprüfung übersprungen: craftvia_app nicht verbindbar (${(err as Error).message.split("\n")[0]})`);
} finally {
await app.$disconnect();
}
ok((await prisma.auditLog.findUniqueOrThrow({ where: { id: probe.id } })).entity === "zz_audit_probe", "Audit-Eintrag nach allen Versuchen unverändert");
});
+138
View File
@@ -0,0 +1,138 @@
// L10a Sicherheitstest §43.4 „Unzulässige Rollenaktionen" — Matrix aus src/server/rbac.ts.
//
// Für jede Permission des Katalogs mit einem fachlichen Schreib-/Lesepfad wird ein repräsentativer
// Service-Aufruf mit allen vier Standardrollen ausgeführt. Erwartung wird UNABHÄNGIG aus ROLE_DEFS
// abgeleitet: fehlt die Permission → `forbidden` (bzw. bei Sichtbarkeitsrechten `not_found`), ist sie
// vorhanden → niemals `forbidden`. Neue Permissions ohne Matrix-Eintrag lassen den Test scheitern
// (bewusste Ausnahmen sind unten begründet).
//
// Lauf: npx tsx scripts/test-security-roles.ts
import "dotenv/config";
import { randomUUID } from "node:crypto";
import { prisma } from "../src/server/db";
import { PERMISSIONS, ROLE_DEFS, ROLE_KEYS, type Permission, type RoleKey } from "../src/server/rbac";
import type { ServiceCtx } from "../src/server/services/context";
import { createCustomer, listCustomers } from "../src/server/services/customers/customers";
import { mergeCustomers } from "../src/server/services/customers/merge";
import { createSite, listSites } from "../src/server/services/sites/sites";
import { createTeam, listTeams } from "../src/server/services/teams/teams";
import { getWorkOrderDetail } from "../src/server/services/work-orders/detail";
import { updateWorkOrder } from "../src/server/services/work-orders/update";
import { assignWorkOrder } from "../src/server/services/work-orders/assign";
import { releaseForBilling } from "../src/server/services/work-orders/release-billing";
import { cancelWorkOrder } from "../src/server/services/work-orders/cancel";
import { createOrderType } from "../src/server/services/work-orders/settings";
import { listImports } from "../src/server/services/imports/queries";
import { createNote } from "../src/server/services/field/notes";
import { correctTimeEntry } from "../src/server/services/field/time-correction";
import { createDailyReport } from "../src/server/services/reports/create";
import { approveReport } from "../src/server/services/reports/approve";
import { listReports } from "../src/server/services/reports/queries";
import { searchCustomersForEmergency } from "../src/server/services/emergency/lookup";
import { listEmergencyReviews } from "../src/server/services/emergency/review";
import { authorizeDocumentAccess, updateDocumentMeta } from "../src/server/services/documents/access";
import { listNotifications } from "../src/server/services/notifications/inbox";
import { queryAuditLog } from "../src/server/services/audit/viewer";
import { getAiGenerationContent } from "../src/server/services/lotse/protocol";
import { checkCompleteness } from "../src/server/services/lotse/completeness";
import { noteCreatePayload } from "../src/lib/sync/ops";
import { codeOf, createTenant, ok, runSuite, section, type Persona, type TenantFixture } from "./lib/e2e-fixture";
const SLUG = "zz-q-sec-roles";
/** Permissions whose enforcement lives outside the domain services (covered by other test scripts). */
const COVERED_ELSEWHERE: Partial<Record<Permission, string>> = {
"user:read": "Nutzerverwaltung (Fundament) — scripts/test-tenant-users-authz.ts",
"user:manage": "Nutzerverwaltung (Fundament) — scripts/test-tenant-users-authz.ts",
"role:manage": "Rollenverwaltung (Fundament) — scripts/test-tenant-users-authz.ts",
"document:read": "Download-Route /files/<id> (requireApiContext) — scripts/test-security-http.ts",
"report:read": "Lesepfad über reportScope — unten als Sichtbarkeitsprüfung",
"work_order:read_team": "Scope-Recht — unten als Sichtbarkeitsprüfung (read_all vs. read_team)",
};
const PERSONA_OF: Record<RoleKey, Persona> = { "tenant-admin": "admin", backoffice: "backoffice", "team-lead": "lead", technician: "tech" };
type Probe = { permission: Permission; label: string; run: (ctx: ServiceCtx, role: RoleKey) => Promise<unknown>; denied?: string };
async function rawOrder(A: TenantFixture, status: string, extra: Record<string, unknown> = {}) {
return prisma.workOrder.create({
data: { tenantId: A.tenantId, number: `R-${randomUUID().slice(0, 8)}`, customerId: A.customerId, siteId: A.siteId, title: "Rollenprüfung", status: status as never, assignedTeamId: A.teamId, teamLeadUserId: A.users.lead.id, ...extra },
});
}
runSuite("Sicherheit: Rollen-Matrix", [SLUG], async () => {
const A = await createTenant(SLUG);
const shared = await rawOrder(A, "in_progress", { assignees: { create: [{ tenantId: A.tenantId, userId: A.users.tech.id }] } });
const session = await prisma.workSession.create({ data: { tenantId: A.tenantId, workOrderId: shared.id, userId: A.users.tech.id, status: "ended", startedAt: new Date(Date.now() - 7200_000), endedAt: new Date(Date.now() - 3600_000) } });
const entry = await prisma.timeEntry.create({ data: { tenantId: A.tenantId, workSessionId: session.id, userId: A.users.tech.id, startedAt: session.startedAt, endedAt: session.endedAt } });
const draftReport = await prisma.report.create({ data: { tenantId: A.tenantId, workOrderId: shared.id, type: "daily", reportDate: new Date(), lineageId: randomUUID(), status: "draft", content: {} } });
const teamDoc = await prisma.document.create({ data: { tenantId: A.tenantId, workOrderId: shared.id, category: "photo", fileName: "a.jpg", storageKey: `${A.tenantId}/a.jpg`, mimeType: "image/jpeg", fileSize: 1, checksum: "0".repeat(64), lineageId: randomUUID(), visibility: "team" } });
const internalDoc = await prisma.document.create({ data: { tenantId: A.tenantId, workOrderId: shared.id, category: "order_confirmation", fileName: "intern.pdf", storageKey: `${A.tenantId}/intern.pdf`, mimeType: "application/pdf", fileSize: 1, checksum: "0".repeat(64), lineageId: randomUUID(), visibility: "backoffice_only" } });
const leadDoc = await prisma.document.create({ data: { tenantId: A.tenantId, workOrderId: shared.id, category: "other", fileName: "tl.pdf", storageKey: `${A.tenantId}/tl.pdf`, mimeType: "application/pdf", fileSize: 1, checksum: "0".repeat(64), lineageId: randomUUID(), visibility: "team_lead" } });
const unassigned = await prisma.workOrder.create({ data: { tenantId: A.tenantId, number: "R-FREE", customerId: A.customerId, title: "Ohne Team", status: "planned" } });
const probes: Probe[] = [
{ permission: "tenant:manage", label: "KI-Protokoll mit Inhalten", run: (c) => getAiGenerationContent(c, "unknown-id") },
{ permission: "audit:read", label: "Audit-Protokoll lesen", run: (c) => queryAuditLog(c, {}) },
{ permission: "customer:read", label: "Kundenliste", run: (c) => listCustomers(c) },
{ permission: "customer:write", label: "Kunde anlegen", run: (c, r) => createCustomer(c, { companyName: `Rollen ${r} ${randomUUID()}` }, { acknowledgeDuplicates: true }) },
{
permission: "customer:merge",
label: "Kunden zusammenführen",
run: async (c) => {
const s = await prisma.customer.create({ data: { tenantId: A.tenantId, companyName: `Quelle ${randomUUID()}` } });
const t = await prisma.customer.create({ data: { tenantId: A.tenantId, companyName: `Ziel ${randomUUID()}` } });
return mergeCustomers(c, { sourceId: s.id, targetId: t.id, confirm: true });
},
},
{ permission: "site:read", label: "Objektliste", run: (c) => listSites(c) },
{ permission: "site:write", label: "Objekt anlegen", run: (c) => createSite(c, { customerId: A.customerId, name: `Objekt ${randomUUID()}` }) },
{ permission: "team:read", label: "Teamliste", run: (c) => listTeams(c) },
{ permission: "team:manage", label: "Team anlegen", run: (c) => createTeam(c, { name: `Team ${randomUUID()}` }) },
{ permission: "work_order:read_all", label: "Auftrag ohne Team/Zuweisung lesen", run: (c) => getWorkOrderDetail(c, unassigned.id), denied: "not_found" },
{ permission: "work_order:write", label: "Auftrag bearbeiten", run: (c) => updateWorkOrder(c, shared.id, { technicianNotes: `Rolle ${randomUUID()}` }) },
{ permission: "work_order:assign", label: "Auftrag zuweisen", run: async (c) => assignWorkOrder(c, { workOrderId: (await rawOrder(A, "planned")).id, teamId: A.teamId }) },
{ permission: "work_order:release_billing", label: "Zur Abrechnung freigeben", run: async (c) => releaseForBilling(c, { workOrderId: (await rawOrder(A, "in_review")).id }) },
{ permission: "work_order:cancel", label: "Auftrag stornieren", run: async (c) => cancelWorkOrder(c, { workOrderId: (await rawOrder(A, "assigned")).id, reason: "Rollenprüfung" }) },
{ permission: "import:write", label: "Importe", run: (c) => listImports(c) },
{ permission: "field:execute", label: "Tätigkeitsnotiz erfassen", run: (c) => createNote(c, noteCreatePayload.parse({ workOrderId: shared.id, text: "Rollenprüfung" })) },
{ permission: "field:correct_time", label: "Arbeitszeit korrigieren", run: (c) => correctTimeEntry(c, { timeEntryId: entry.id, startedAt: session.startedAt, endedAt: session.endedAt, reason: "Rollenprüfung" }) },
{ permission: "report:write", label: "Tagesbericht anlegen", run: async (c) => createDailyReport(c, { workOrderId: (await rawOrder(A, "planned")).id }) },
{ permission: "report:approve_team", label: "Bericht freigeben (Teamleiter oder Backoffice)", run: (c) => approveReport(c, { reportId: draftReport.id }, { dispatchPdf: async () => undefined }) },
{ permission: "emergency:create", label: "Notdienst-Kundensuche", run: (c) => searchCustomersForEmergency(c, "Hausverwaltung") },
{ permission: "emergency:review", label: "Notdienst-Prüfliste", run: (c) => listEmergencyReviews(c) },
{ permission: "document:write", label: "Dokument-Metadaten ändern", run: (c) => updateDocumentMeta(c, teamDoc.id, { title: `Titel ${randomUUID()}` }) },
{ permission: "document:read_internal", label: "internes Dokument (backoffice_only) öffnen", run: (c) => authorizeDocumentAccess(c, internalDoc.id), denied: "not_found" },
{ permission: "notification:read", label: "Benachrichtigungen", run: (c) => listNotifications(c, {}) },
{ permission: "settings:templates", label: "Auftragsart anlegen", run: (c) => createOrderType(c, { key: `rolle_${randomUUID().slice(0, 8).replace(/-/g, "")}`, name: "Rollenprüfung" }) },
{ permission: "lotse:use", label: "Lotse-Vollständigkeitsprüfung", run: (c) => checkCompleteness(c, shared.id) },
];
section("Matrix-Abdeckung");
const probed = new Set(probes.map((p) => p.permission));
probed.add("report:approve"); // shares the approval probe (report:approve OR report:approve_team)
const uncovered = PERMISSIONS.filter((p) => !probed.has(p) && !COVERED_ELSEWHERE[p]);
ok(uncovered.length === 0, `jede Permission hat eine Prüfung oder begründete Ausnahme${uncovered.length ? ` — fehlt: ${uncovered.join(", ")}` : ""}`);
for (const probe of probes) {
section(`${probe.permission} — ${probe.label}`);
for (const role of ROLE_KEYS) {
const perms = ROLE_DEFS[role].permissions as readonly string[];
const allowed = probe.permission === "report:approve_team" ? perms.includes("report:approve_team") || perms.includes("report:approve") : perms.includes(probe.permission);
const got = await codeOf(probe.run(A.ctx[PERSONA_OF[role]], role));
const denied = probe.denied ?? "forbidden";
ok(allowed ? got !== "forbidden" && got !== denied : got === denied, `${role.padEnd(12)} ${allowed ? "erlaubt " : "verboten"} → ${got}`);
}
}
section("Sichtbarkeit: read_team / report:read / Teamleiter-Dokumente");
ok((await codeOf(getWorkOrderDetail(A.ctx.tech, shared.id))) === "ok", "Monteur mit Zuweisung sieht den Auftrag");
ok((await codeOf(getWorkOrderDetail(A.ctx.outsider, shared.id))) === "not_found", "Monteur ohne Zuweisung (anderes Team) → not_found");
ok((await codeOf(getWorkOrderDetail(A.ctx.lead2, shared.id))) === "not_found", "Teamleiter eines anderen Teams → not_found");
ok((await listReports(A.ctx.outsider, { status: "all" })).items.length === 0, "Berichtsliste des fremden Monteurs leer");
ok((await listReports(A.ctx.tech, { status: "all" })).items.some((r) => r.id === draftReport.id), "Berichtsliste des zugewiesenen Monteurs enthält den Bericht");
ok((await codeOf(authorizeDocumentAccess(A.ctx.lead, leadDoc.id))) === "ok", "Teamleiter öffnet Teamleiter-Dokument");
ok((await codeOf(authorizeDocumentAccess(A.ctx.tech, leadDoc.id))) === "not_found", "Monteur öffnet Teamleiter-Dokument nicht");
ok((await prisma.workOrder.findUniqueOrThrow({ where: { id: shared.id } })).status === "in_progress", "Auftrag durch verbotene Aktionen nicht verändert");
});
Binary file not shown.