// L10a E2E §43.3 „Mandantentrennung" — systematisch über ALLE Fachmodelle und Fachservices. // // Teil 1 (Datenzugriff): Für JEDES Modell aus TENANT_MODELS (src/server/backup/topology.ts, Spiegel von // db.ts) wird in Mandant A eine Zeile angelegt; aus Mandant B (dbForTenant) werden findMany, // findFirst, findUnique, count, update, updateMany, delete, deleteMany auf genau diese Zeile // versucht → nichts gelesen, nichts geändert. Ein neues Modell ohne Fixture lässt den Test scheitern. // create mit fremder tenantId landet im eigenen Mandanten. // Teil 2 (Postgres-RLS): dieselben Zeilen über die Rolle craftvia_app mit Kontext B → 0 Zeilen sichtbar, // UPDATE/DELETE wirkungslos; Kontext A sieht die Zeile (skip, falls craftvia_app nicht verbinden kann). // Teil 3 (Fachservices): jeder id-basierte Lese-/Schreibpfad (Kunden, Objekte, Teams, Aufträge, Einsatz, // Dokumente, Berichte, Import, Notdienst, Sync, Benachrichtigungen, Audit, Lotse) mit IDs aus A → // not_found (bzw. invalid bei Referenzen), Listen/Suche/Dashboard enthalten keine A-Daten, A unverändert. // // Lauf: npx tsx scripts/test-e2e-tenant-isolation.ts (zusätzlich mit RLS_ENFORCED=true) import "dotenv/config"; import { randomUUID } from "node:crypto"; import { Prisma, PrismaClient } from "@prisma/client"; import { PrismaPg } from "@prisma/adapter-pg"; import { prisma, dbForTenant } from "../src/server/db"; import { TENANT_MODELS, buildTenantTopology } from "../src/server/backup/topology"; import { getCustomer, updateCustomer, deleteCustomer, confirmProvisionalCustomer, listCustomers, listCustomerWorkOrders } from "../src/server/services/customers/customers"; import { createContact } from "../src/server/services/customers/contacts"; import { mergeCustomers } from "../src/server/services/customers/merge"; import { getSite, updateSite, deleteSite, createSite, listSites } from "../src/server/services/sites/sites"; import { getSiteHistory } from "../src/server/services/sites/history"; import { getTeam, updateTeam, deleteTeam, 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 { transitionWorkOrder } from "../src/server/services/work-orders/transition"; import { assignWorkOrder } from "../src/server/services/work-orders/assign"; import { cancelWorkOrder } from "../src/server/services/work-orders/cancel"; import { addMaterialPlan } from "../src/server/services/work-orders/materials"; import { addChecklistItem, addPhotoRequirement } from "../src/server/services/work-orders/checklist"; import { getCompletionBlockers } from "../src/server/services/work-orders/completion"; import { markBilled, releaseForBilling } from "../src/server/services/work-orders/release-billing"; import { createWorkOrder } from "../src/server/services/work-orders/create"; import { uploadWorkOrderDocument } from "../src/server/services/work-orders/documents"; import { listWorkOrders } from "../src/server/services/work-orders/list"; import { searchAll } from "../src/server/services/work-orders/search"; import { getDashboardTiles } from "../src/server/services/work-orders/dashboard"; import { applySyncConflict, discardSyncConflict, listSyncConflicts } from "../src/server/services/work-orders/conflicts"; import { startSession } from "../src/server/services/field/sessions"; import { createNote } from "../src/server/services/field/notes"; import { upsertMaterialUsage } from "../src/server/services/field/materials"; import { toggleChecklistItem } from "../src/server/services/field/checklist"; import { storeFieldUpload } from "../src/server/services/field/uploads"; import { attachPhoto } from "../src/server/services/field/photos"; import { attachVoiceNote } from "../src/server/services/field/voice"; import { correctTimeEntry } from "../src/server/services/field/time-correction"; import { getFieldBundle, getFieldOrderDetail } from "../src/server/services/field/queries"; import { storeFile } from "../src/server/services/documents/store"; import { authorizeDocumentAccess, deleteDocument, listDocuments, openDocumentContent, updateDocumentMeta } from "../src/server/services/documents/access"; import { createCompletionReport, createDailyReport } from "../src/server/services/reports/create"; import { updateReportTexts } from "../src/server/services/reports/edit"; import { submitReport } from "../src/server/services/reports/submit"; import { approveReport } from "../src/server/services/reports/approve"; import { rejectReport } from "../src/server/services/reports/reject"; import { captureSignature } from "../src/server/services/reports/signature"; import { createNewVersion } from "../src/server/services/reports/new-version"; import { openReportFile } from "../src/server/services/reports/files"; import { generateReportPdf } from "../src/server/services/reports/pdf"; import { requireVisibleReport } from "../src/server/services/reports/common"; import { getReportDetail, listReports } from "../src/server/services/reports/queries"; import { getImportDetail, listImports } from "../src/server/services/imports/queries"; import { confirmImport, discardImport, retryImport } from "../src/server/services/imports/confirm"; import { getEmergencyReview, confirmEmergencyCustomer, listEmergencyReviews } from "../src/server/services/emergency/review"; import { applyOperations } from "../src/server/services/sync/apply"; import { listNotifications, markRead } from "../src/server/services/notifications/inbox"; import { getAuditEntry, queryAuditLog } from "../src/server/services/audit/viewer"; import { getAiGenerationContent, listAiGenerations } from "../src/server/services/lotse/protocol"; import { getLotseReportState } from "../src/server/services/lotse/state"; import { noteCreatePayload, sessionStartPayload, materialUpsertPayload, checklistTogglePayload, photoAttachPayload, voiceAttachPayload } from "../src/lib/sync/ops"; import type { ServiceCtx } from "../src/server/services/context"; import { codeOf, createTenant, jpegBytes, ok, runSuite, section, type TenantFixture } from "./lib/e2e-fixture"; const SLUG_A = "zz-q-e2e-iso-a"; const SLUG_B = "zz-q-e2e-iso-b"; const MARK = "HACKED-BY-B"; type Rows = Record }>; /** One row per tenant model in tenant A (owner client) + the field a foreign update would try to change. */ async function createModelRows(A: TenantFixture): Promise { const t = A.tenantId; const uid = A.users.tech.id; const rows: Rows = {}; const put = (model: string, id: string, update: Record) => (rows[model] = { id, update }); put("User", uid, { name: MARK }); put("Role", (await prisma.role.findFirstOrThrow({ where: { tenantId: t } })).id, { name: MARK }); put("AuditLog", (await prisma.auditLog.create({ data: { tenantId: t, action: "create", entity: "zz", entityId: "x", after: { v: 1 } } })).id, { entity: MARK }); put("MailLog", (await prisma.mailLog.create({ data: { tenantId: t, to: "a@zz-qualitaet.test", template: "zz" } })).id, { status: MARK }); put("NotificationPreference", (await prisma.notificationPreference.create({ data: { tenantId: t, userId: uid, eventType: "zz.event" } })).id, { eventType: MARK }); put("AuthToken", (await prisma.authToken.create({ data: { principalType: "tenant_user", principalId: uid, tenantId: t, type: "password_reset", tokenHash: randomUUID(), expiresAt: new Date(Date.now() + 3600_000) } })).id, { type: MARK }); put("TenantSettings", (await prisma.tenantSettings.findFirstOrThrow({ where: { tenantId: t } })).id, { orgName: MARK }); put("TenantModule", (await prisma.tenantModule.create({ data: { tenantId: t, moduleKey: "lotse", enabled: true } })).id, { moduleKey: MARK }); put("NumberSequence", (await prisma.numberSequence.create({ data: { tenantId: t, key: "zz", prefix: "Z-" } })).id, { prefix: MARK }); const orderType = await prisma.orderType.create({ data: { tenantId: t, key: "zz_typ", name: "ZZ Typ" } }); put("OrderType", orderType.id, { name: MARK }); put("ChecklistTemplate", (await prisma.checklistTemplate.create({ data: { tenantId: t, name: "ZZ Vorlage" } })).id, { name: MARK }); put("Customer", A.customerId, { companyName: MARK }); put("Contact", A.contactId, { name: MARK }); put("Site", A.siteId, { name: MARK }); put("Team", A.teamId, { name: MARK }); put("TeamMember", (await prisma.teamMember.findFirstOrThrow({ where: { tenantId: t, userId: uid } })).id, { validTo: new Date(0) }); const wo = await prisma.workOrder.create({ data: { tenantId: t, number: "ZZ-ISO-1", customerId: A.customerId, siteId: A.siteId, title: "Isolation", status: "in_progress", assignedTeamId: A.teamId } }); put("WorkOrder", wo.id, { title: MARK }); put("WorkOrderAssignee", (await prisma.workOrderAssignee.create({ data: { tenantId: t, workOrderId: wo.id, userId: uid } })).id, { userId: A.users.tech2.id }); put("WorkOrderStatusChange", (await prisma.workOrderStatusChange.create({ data: { tenantId: t, workOrderId: wo.id, toStatus: "in_progress", actorId: uid } })).id, { reason: MARK }); put("ChecklistItem", (await prisma.checklistItem.create({ data: { tenantId: t, workOrderId: wo.id, key: "zz", label: "ZZ" } })).id, { label: MARK }); put("PhotoRequirement", (await prisma.photoRequirement.create({ data: { tenantId: t, workOrderId: wo.id, key: "zz", label: "ZZ" } })).id, { label: MARK }); const plan = await prisma.materialPlan.create({ data: { tenantId: t, workOrderId: wo.id, name: "ZZ", plannedQuantity: new Prisma.Decimal(1), unit: "Stk" } }); put("MaterialPlan", plan.id, { name: MARK }); put("MaterialUsage", (await prisma.materialUsage.create({ data: { tenantId: t, workOrderId: wo.id, materialPlanId: plan.id, name: "ZZ", actualQuantity: new Prisma.Decimal(1), unit: "Stk", usageStatus: "fully_used" } })).id, { notes: MARK }); const session = await prisma.workSession.create({ data: { tenantId: t, workOrderId: wo.id, userId: uid, status: "ended", startedAt: new Date(Date.now() - 3600_000), endedAt: new Date() } }); put("WorkSession", session.id, { deviceInfo: MARK }); put("TimeEntry", (await prisma.timeEntry.create({ data: { tenantId: t, workSessionId: session.id, userId: uid, startedAt: session.startedAt, endedAt: new Date() } })).id, { correctionReason: MARK }); put("ActivityNote", (await prisma.activityNote.create({ data: { tenantId: t, workOrderId: wo.id, authorId: uid, text: "ZZ" } })).id, { text: MARK }); const doc = (key: string) => prisma.document.create({ data: { tenantId: t, workOrderId: wo.id, category: "photo", fileName: `${key}.jpg`, storageKey: `${t}/${key}`, mimeType: "image/jpeg", fileSize: 1, checksum: "0".repeat(64), lineageId: randomUUID(), visibility: "team" } }); const d1 = await doc("iso-photo"); const d2 = await doc("iso-voice"); const d3 = await doc("iso-import"); put("Document", d1.id, { title: MARK }); put("Photo", (await prisma.photo.create({ data: { tenantId: t, workOrderId: wo.id, documentId: d1.id, takenAt: new Date() } })).id, { comment: MARK }); put("VoiceNote", (await prisma.voiceNote.create({ data: { tenantId: t, workOrderId: wo.id, documentId: d2.id, recordedAt: new Date() } })).id, { transcript: MARK }); const report = await prisma.report.create({ data: { tenantId: t, workOrderId: wo.id, type: "daily", reportDate: new Date(), lineageId: randomUUID(), content: {} } }); put("Report", report.id, { rejectionReason: MARK }); put("Signature", (await prisma.signature.create({ data: { tenantId: t, reportId: report.id, outcome: "later", reason: "ZZ", signedAt: new Date() } })).id, { reason: MARK }); put("ImportJob", (await prisma.importJob.create({ data: { tenantId: t, documentId: d3.id, status: "uploaded" } })).id, { errorMessage: MARK }); put("Notification", (await prisma.notification.create({ data: { tenantId: t, userId: uid, type: "zz", title: "ZZ", message: "ZZ" } })).id, { title: MARK }); put("SyncOperation", (await prisma.syncOperation.create({ data: { tenantId: t, userId: uid, clientOpId: randomUUID(), opType: "note.create", payload: {}, status: "conflict", clientCreatedAt: new Date() } })).id, { errorCode: MARK }); put("AiGeneration", (await prisma.aiGeneration.create({ data: { tenantId: t, kind: "report_draft", provider: "fake", model: "zz" } })).id, { model: MARK }); // L14 Abrechnungsübersicht put("WorkOrderMilestone", (await prisma.workOrderMilestone.create({ data: { tenantId: t, workOrderId: wo.id, title: "ZZ" } })).id, { title: MARK }); put("BillingRecord", (await prisma.billingRecord.create({ data: { tenantId: t, workOrderId: wo.id, kind: "order_completion", periodFrom: new Date(Date.now() - 3600_000), periodTo: new Date() } })).id, { invoiceNumber: MARK }); // L16 Lotse-Chat für Monteure const lotseConversation = await prisma.lotseConversation.create({ data: { tenantId: t, userId: uid, workOrderId: wo.id } }); put("LotseConversation", lotseConversation.id, { workOrderId: MARK }); put("LotseMessage", (await prisma.lotseMessage.create({ data: { tenantId: t, conversationId: lotseConversation.id, role: "user", text: "ZZ" } })).id, { text: MARK }); put("LotseActionProposal", (await prisma.lotseActionProposal.create({ data: { tenantId: t, conversationId: lotseConversation.id, userId: uid, kind: "add_note", payload: {}, payloadHash: "0".repeat(64), expiresAt: new Date(Date.now() + 1_800_000) } })).id, { kind: MARK }); put("TenantExport", (await prisma.tenantExport.create({ data: { tenantId: t, status: "done", fileName: "zz.zip" } })).id, { fileName: MARK }); // L15 Testphase return rows; } type Delegate = Record Promise>; const lc = (m: string) => m.charAt(0).toLowerCase() + m.slice(1); const snapshotRow = async (model: string, id: string) => JSON.stringify(await (prisma as unknown as Record)[lc(model)].findUnique({ where: { id } }), (_k, v) => (typeof v === "bigint" ? v.toString() : v)); async function threw(fn: () => Promise): Promise { try { await fn(); return false; } catch { return true; } } /** Bulk write without effect: count 0, or refused by the database (append-only audit_logs under RLS). */ async function noEffect(fn: () => Promise): Promise { 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"; base.password = "craftvia_app_local"; return base.toString(); } runSuite("E2E Mandantentrennung (systematisch)", [SLUG_A, SLUG_B], async () => { const A = await createTenant(SLUG_A); const B = await createTenant(SLUG_B); console.log(`RLS_ENFORCED=${process.env.RLS_ENFORCED === "true" ? "true" : "false"}`); // ================= Teil 1: alle Tenant-Modelle ================= section("Teil 1: jedes Tenant-Modell aus Mandant B unerreichbar"); const rows = await createModelRows(A); const missing = TENANT_MODELS.filter((m) => !rows[m]); ok(missing.length === 0, `Fixture deckt alle ${TENANT_MODELS.length} Tenant-Modelle ab${missing.length ? ` — fehlt: ${missing.join(", ")}` : ""}`); const dbB = dbForTenant(B.tenantId) as unknown as Record; for (const model of TENANT_MODELS) { const row = rows[model]; if (!row) continue; const d = dbB[lc(model)]; const before = await snapshotRow(model, row.id); const where = { id: row.id }; const results = { findMany: ((await d.findMany({ where })) as unknown[]).length === 0, findFirst: (await d.findFirst({ where })) === null, findUnique: (await d.findUnique({ where }).catch(() => null)) === null, count: (await d.count({ where })) === 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 })), }; const after = await snapshotRow(model, row.id); const failed = Object.entries(results).filter(([, v]) => !v).map(([k]) => k); ok(failed.length === 0 && before === after && after !== "null", `${model}: lesen/ändern/löschen aus B abgewiesen, Zeile unverändert${failed.length ? ` — durchgelassen: ${failed.join(", ")}` : ""}${before !== after ? " — ZEILE GEÄNDERT" : ""}`); } const injected = await dbForTenant(B.tenantId).customer.create({ data: { tenantId: A.tenantId, companyName: "Untergeschoben" } }); ok(injected.tenantId === B.tenantId, "create mit fremder tenantId landet im eigenen Mandanten (B)"); const auditA = await prisma.auditLog.findFirstOrThrow({ where: { tenantId: A.tenantId, entity: "zz" } }); ok((await threw(() => dbForTenant(B.tenantId).auditLog.update({ where: { id: auditA.id }, data: { after: { v: 2 } } }))) && (await prisma.auditLog.findUniqueOrThrow({ where: { id: auditA.id } })).entity === "zz", "Audit-Log von A aus B nicht manipulierbar"); // ================= Teil 2: Postgres-RLS ================= section("Teil 2: Postgres Row Level Security (Rolle craftvia_app)"); const app = new PrismaClient({ adapter: new PrismaPg({ connectionString: process.env.RLS_DATABASE_URL ?? defaultRlsUrl() }) }); try { await app.$queryRawUnsafe("SELECT 1"); const topo = buildTenantTopology(); for (const model of TENANT_MODELS) { const row = rows[model]; if (!row) continue; const table = topo.nodes.get(model)!.table; /** Affected rows under tenant context B; a missing privilege (append-only audit_logs) counts as 0. */ const asB = async (sql: string): Promise => { 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; }); ok(res.seen === 0 && res.updated === 0 && res.deleted === 0 && own === 1, `RLS ${table}: Kontext B sieht/ändert/löscht nichts, Kontext A sieht die Zeile`); } const noCtx = (await app.$queryRawUnsafe<{ n: number }[]>(`SELECT count(*)::int AS n FROM "work_orders" WHERE tenant_id = $1`, A.tenantId))[0].n; ok(noCtx === 0, "RLS ohne Mandantenkontext: 0 Zeilen (fail-closed)"); } catch (err) { if (process.env.RLS_TEST_REQUIRED === "true") throw err; console.log(`⚠ Teil 2 übersprungen: craftvia_app nicht verbindbar (${(err as Error).message.split("\n")[0]})`); } finally { await app.$disconnect(); } // ================= Teil 3: Fachservices ================= section("Teil 3: Fachservices mit IDs aus Mandant A"); // real objects in A (built through the services) const woA = await createWorkOrder(A.ctx.backoffice, { title: "Isolation real", customerId: A.customerId, siteId: A.siteId, applyTemplate: false, checklistItems: [{ label: "Prüfen", required: false }], materials: [{ name: "Rohr", plannedQuantity: 2, unit: "m" }] }); await assignWorkOrder(A.ctx.backoffice, { workOrderId: woA.id, teamId: A.teamId, userIds: [A.users.tech.id] }); await startSession(A.ctx.tech, sessionStartPayload.parse({ workOrderId: woA.id, mode: "work", at: new Date(Date.now() - 7200_000).toISOString() })); const photoA = await storeFieldUpload(A.ctx.tech, { clientId: randomUUID(), workOrderId: woA.id, kind: "photo" }, { bytes: await jpegBytes("A"), name: "a.jpg", type: "image/jpeg" }); await createNote(A.ctx.tech, noteCreatePayload.parse({ workOrderId: woA.id, kind: "work_done", text: "Arbeit in A" })); const dailyA = (await createDailyReport(A.ctx.tech, { workOrderId: woA.id })).report; const itemA = await prisma.checklistItem.findFirstOrThrow({ where: { workOrderId: woA.id } }); const planA = await prisma.materialPlan.findFirstOrThrow({ where: { workOrderId: woA.id } }); const teA = await prisma.timeEntry.findFirstOrThrow({ where: { tenantId: A.tenantId, userId: A.users.tech.id, workSession: { workOrderId: woA.id } } }); const docA = await prisma.document.findUniqueOrThrow({ where: { id: photoA.documentId } }); const provisional = await prisma.customer.create({ data: { tenantId: A.tenantId, lastName: "Vorläufig", status: "provisional", isProvisional: true } }); const ids = { ...Object.fromEntries(Object.entries(rows).map(([k, v]) => [k, v.id])), woA: woA.id, dailyA: dailyA.id, docA: docA.id }; const watched = async () => JSON.stringify( await Promise.all([ prisma.customer.findUnique({ where: { id: A.customerId } }), prisma.site.findUnique({ where: { id: A.siteId } }), prisma.team.findUnique({ where: { id: A.teamId }, include: { members: true } }), prisma.workOrder.findUnique({ where: { id: woA.id }, include: { assignees: true, checklistItems: true, materialPlans: true, materialUsages: true, notes: true, photos: true } }), prisma.report.findUnique({ where: { id: dailyA.id } }), prisma.document.findUnique({ where: { id: docA.id } }), prisma.importJob.findUnique({ where: { id: rows.ImportJob.id } }), prisma.timeEntry.findUnique({ where: { id: teA.id } }), prisma.notification.findUnique({ where: { id: rows.Notification.id } }), prisma.syncOperation.findUnique({ where: { id: rows.SyncOperation.id } }), ]), ); const beforeAll = await watched(); const bo: ServiceCtx = B.ctx.backoffice; const admin: ServiceCtx = B.ctx.admin; const tech: ServiceCtx = B.ctx.tech; const NF = ["not_found"]; const REF = ["not_found", "invalid"]; const checks: [string, () => Promise, string[]][] = [ // Kunden / Objekte / Teams ["getCustomer", () => getCustomer(bo, A.customerId), NF], ["updateCustomer", () => updateCustomer(bo, A.customerId, { companyName: MARK }), NF], ["deleteCustomer", () => deleteCustomer(bo, A.customerId), NF], ["confirmProvisionalCustomer", () => confirmProvisionalCustomer(bo, provisional.id), NF], ["createContact (Kunde A)", () => createContact(bo, A.customerId, { name: MARK }), NF], ["listCustomerWorkOrders (Kunde A)", async () => { const r = await listCustomerWorkOrders(bo, A.customerId); if (JSON.stringify(r).includes(woA.id)) throw new Error("leak"); throw Object.assign(new Error("empty"), { code: "not_found" }); }, NF], ["mergeCustomers (Ziel A)", () => mergeCustomers(admin, { sourceId: B.customerId, targetId: A.customerId, confirm: true }), NF], ["getSite", () => getSite(bo, A.siteId), NF], ["updateSite", () => updateSite(bo, A.siteId, { name: MARK }), NF], ["deleteSite", () => deleteSite(bo, A.siteId), NF], ["getSiteHistory", () => getSiteHistory(bo, A.siteId), NF], ["createSite (Kunde A)", () => createSite(bo, { customerId: A.customerId, name: MARK }), REF], ["getTeam", () => getTeam(admin, A.teamId), NF], ["updateTeam", () => updateTeam(admin, A.teamId, { name: MARK }), NF], ["deleteTeam", () => deleteTeam(admin, A.teamId), NF], // Aufträge ["getWorkOrderDetail", () => getWorkOrderDetail(bo, woA.id), NF], ["updateWorkOrder", () => updateWorkOrder(bo, woA.id, { title: MARK }), NF], ["transitionWorkOrder", () => transitionWorkOrder(bo, { workOrderId: woA.id, to: "cancelled", reason: MARK }), NF], ["assignWorkOrder", () => assignWorkOrder(bo, { workOrderId: woA.id, teamId: B.teamId }), NF], ["cancelWorkOrder", () => cancelWorkOrder(bo, { workOrderId: woA.id, reason: MARK }), NF], ["addMaterialPlan", () => addMaterialPlan(bo, woA.id, { name: MARK, plannedQuantity: 1, unit: "Stk" }), NF], ["addChecklistItem", () => addChecklistItem(bo, woA.id, { label: MARK }), NF], ["addPhotoRequirement", () => addPhotoRequirement(bo, woA.id, { label: MARK }), NF], ["getCompletionBlockers", () => getCompletionBlockers(bo, woA.id), NF], ["releaseForBilling", () => releaseForBilling(bo, { workOrderId: woA.id }), NF], ["markBilled", () => markBilled(bo, { workOrderId: woA.id }), NF], ["createWorkOrder (Kunde A)", () => createWorkOrder(bo, { title: MARK, customerId: A.customerId }), REF], ["uploadWorkOrderDocument", async () => uploadWorkOrderDocument(bo, { workOrderId: woA.id, bytes: await jpegBytes("B"), fileName: "b.jpg", declaredMime: "image/jpeg", category: "other", visibility: "team" }), NF], // Einsatz ["getFieldOrderDetail", () => getFieldOrderDetail(tech, woA.id), NF], ["startSession", () => startSession(tech, sessionStartPayload.parse({ workOrderId: woA.id, mode: "work" })), NF], ["createNote", () => createNote(tech, noteCreatePayload.parse({ workOrderId: woA.id, text: MARK })), NF], ["upsertMaterialUsage", () => upsertMaterialUsage(tech, materialUpsertPayload.parse({ workOrderId: woA.id, materialPlanId: planA.id, quantity: 9, unit: "m", usageStatus: "fully_used" })), NF], ["toggleChecklistItem", () => toggleChecklistItem(tech, checklistTogglePayload.parse({ workOrderId: woA.id, itemId: itemA.id, checked: true })), NF], ["storeFieldUpload", async () => storeFieldUpload(tech, { clientId: randomUUID(), workOrderId: woA.id, kind: "photo" }, { bytes: await jpegBytes("B"), name: "b.jpg", type: "image/jpeg" }), NF], ["attachPhoto (Dokument A)", () => attachPhoto(tech, photoAttachPayload.parse({ workOrderId: woA.id, documentId: docA.id })), NF], ["attachVoiceNote (Dokument A)", () => attachVoiceNote(tech, voiceAttachPayload.parse({ workOrderId: woA.id, documentId: rows.Document.id })), NF], ["correctTimeEntry", () => correctTimeEntry(admin, { timeEntryId: teA.id, startedAt: new Date(Date.now() - 600_000), reason: MARK }), NF], // Dokumente ["authorizeDocumentAccess", () => authorizeDocumentAccess(bo, docA.id), NF], ["openDocumentContent", () => openDocumentContent(bo, docA.id), NF], ["updateDocumentMeta", () => updateDocumentMeta(bo, docA.id, { title: MARK }), NF], ["deleteDocument", () => deleteDocument(bo, docA.id), NF], ["storeFile (Auftrag A)", async () => storeFile(bo, { bytes: await jpegBytes("B"), fileName: "b.jpg", declaredMime: "image/jpeg", category: "photo", visibility: "team", links: { workOrderId: woA.id } }), NF], ["storeFile (Version von A)", async () => storeFile(bo, { bytes: await jpegBytes("B"), fileName: "b.jpg", declaredMime: "image/jpeg", category: "photo", visibility: "team", lineageId: docA.lineageId }), REF], // Berichte ["requireVisibleReport", () => requireVisibleReport(bo, dailyA.id), NF], ["getReportDetail", () => getReportDetail(bo, dailyA.id), NF], ["createDailyReport", () => createDailyReport(tech, { workOrderId: woA.id }), NF], ["createCompletionReport", () => createCompletionReport(tech, { workOrderId: woA.id }), NF], ["updateReportTexts", () => updateReportTexts(tech, { reportId: dailyA.id, texts: { hints: MARK } }), NF], ["submitReport", () => submitReport(tech, { reportId: dailyA.id }), NF], ["approveReport", () => approveReport(bo, { reportId: dailyA.id }, { dispatchPdf: async () => undefined }), NF], ["rejectReport", () => rejectReport(bo, { reportId: dailyA.id, reason: MARK }), NF], ["captureSignature", () => captureSignature(tech, { reportId: dailyA.id, outcome: "later", reason: MARK, confirmationText: MARK }), NF], ["createNewVersion", () => createNewVersion(bo, { reportId: dailyA.id }), NF], ["openReportFile", () => openReportFile(bo, dailyA.id, docA.id), NF], ["generateReportPdf", () => generateReportPdf(bo, dailyA.id), NF], ["getLotseReportState", () => getLotseReportState(tech, dailyA.id), NF], // Import / Notdienst / Sync / Benachrichtigungen / Audit / KI ["getImportDetail", () => getImportDetail(bo, rows.ImportJob.id), NF], ["confirmImport", () => confirmImport(bo, rows.ImportJob.id, {}), REF], ["discardImport", () => discardImport(bo, rows.ImportJob.id), NF], ["retryImport", () => retryImport(bo, rows.ImportJob.id, { dispatch: async () => undefined }), NF], ["getEmergencyReview", () => getEmergencyReview(bo, woA.id), NF], ["confirmEmergencyCustomer", () => confirmEmergencyCustomer(bo, woA.id), NF], ["applySyncConflict", () => applySyncConflict(bo, rows.SyncOperation.id), NF], ["discardSyncConflict", () => discardSyncConflict(bo, rows.SyncOperation.id), NF], ["markRead (Benachrichtigung A)", () => markRead(tech, rows.Notification.id), NF], ["getAuditEntry", () => getAuditEntry(admin, rows.AuditLog.id), NF], ["getAiGenerationContent", () => getAiGenerationContent(admin, rows.AiGeneration.id), NF], ]; for (const [name, fn, expected] of checks) { const got = await codeOf(fn); ok(expected.includes(got), `${name} aus Mandant B → ${got}`); } const sync = await applyOperations(tech, { deviceId: "b", operations: [ { clientOpId: randomUUID(), opType: "note.create", payload: { workOrderId: woA.id, text: MARK }, clientCreatedAt: new Date().toISOString() }, { clientOpId: randomUUID(), opType: "work_order.transition", baseVersion: 1, payload: { workOrderId: woA.id, to: "paused" }, clientCreatedAt: new Date().toISOString() }, ], }); ok(sync.results.every((r) => r.status === "rejected" && r.errorCode === "not_found"), "Sync-Ops auf Auftrag von A → rejected not_found"); section("Listen, Suche, Dashboard enthalten keine Daten von A"); const aIds = Object.values(ids); const noLeak = (label: string, value: unknown) => { const text = JSON.stringify(value, (_k, v) => (typeof v === "bigint" ? v.toString() : v)); const leaked = aIds.filter((id) => text.includes(id)); ok(leaked.length === 0 && !text.includes(`Hausverwaltung ${SLUG_A}`), `${label}: keine A-Daten${leaked.length ? ` — enthält ${leaked.length} IDs` : ""}`); }; noLeak("listWorkOrders", await listWorkOrders(bo, { sort: "plannedStart", dir: "asc", page: 1, pageSize: 100 })); noLeak("listReports", await listReports(bo, { status: "all" })); noLeak("listDocuments", await listDocuments(bo, { pageSize: 500 })); noLeak("listCustomers", await listCustomers(bo, { status: "all", pageSize: 100 })); noLeak("listSites", await listSites(bo, { status: "all", pageSize: 100 })); noLeak("listTeams", await listTeams(bo, { includeInactive: true })); noLeak("searchAll", await searchAll(bo, { q: SLUG_A.slice(0, 12) })); noLeak("searchAll (Auftragstitel)", await searchAll(bo, { q: "Isolation" })); noLeak("listImports", await listImports(bo)); noLeak("listEmergencyReviews", await listEmergencyReviews(bo, { filter: "all" })); noLeak("listSyncConflicts", await listSyncConflicts(bo)); noLeak("listNotifications", await listNotifications(tech, {})); noLeak("queryAuditLog", await queryAuditLog(admin, {})); noLeak("listAiGenerations", await listAiGenerations(admin)); noLeak("getFieldBundle", await getFieldBundle(tech)); const tiles = await getDashboardTiles(bo, {}); ok(tiles.open === 0 && tiles.running === 0 && tiles.syncConflicts === 0 && tiles.reportsToReview === 0, "Dashboard von B zählt keine Aufträge/Konflikte/Berichte von A"); ok((await watched()) === beforeAll, "Daten von Mandant A nach allen Versuchen unverändert"); });