Files
craftvia/scripts/test-security-roles.ts
T
msolarczekandClaude Opus 5 9f4eecf6e2 L12 Zeiterfassung: Tests und authentifizierter Smoke
test-zeiterfassung-service (78 Prüfungen) und test-zeiterfassung-sync (29 Prüfungen),
Rollen-Matrix um field:record_own_time und time:approve ergänzt, smoke-auth um
/m/time, /m/time/new, /m/approvals, Uhr-Leiste und /work-orders/time-approvals.

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

155 lines
13 KiB
TypeScript

// 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 { addManualTimeEntry, approveTimeEntry } from "../src/server/services/field/time-entries";
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" }) },
// L12 Zeiterfassung
{
permission: "field:record_own_time",
label: "Eigene Zeit nachtragen",
run: (c) => addManualTimeEntry(c, { workOrderId: shared.id, type: "work", startedAt: new Date(Date.now() - 26 * 3600_000), durationMinutes: 30, reason: "Rollenprüfung" }),
},
{
permission: "time:approve",
label: "Zeitnachtrag freigeben",
run: async (c) => {
const s = await prisma.workSession.create({ data: { tenantId: A.tenantId, workOrderId: shared.id, userId: A.users.tech2.id, status: "ended", manual: true, startedAt: new Date(Date.now() - 7200_000), endedAt: new Date(Date.now() - 3600_000) } });
const e = await prisma.timeEntry.create({ data: { tenantId: A.tenantId, workSessionId: s.id, userId: A.users.tech2.id, startedAt: s.startedAt, endedAt: s.endedAt, source: "manual", approvalStatus: "pending", correctionReason: "Rollenprüfung" } });
return approveTimeEntry(c, e.id);
},
},
{ 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");
});