Files
craftvia/scripts/test-e2e-tenant-isolation.ts
T
msolarczekandClaude Opus 5 2f46552970 L16 Lotse-Chat für Monteure: Tests für Chat-Schleife und Bestätigen, Isolation und Smoke
Fake-Provider mit geskripteten Tool-Aufrufen: Zuordnung, Vorschlag ohne Schreibzugriff,
Ausführung über Services, Blocker, Einmaligkeit/Ablauf/fremder Nutzer, Scope,
Mandantentrennung, Datenminimierung, Einstellung aus, Budget- und Rundengrenze.

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

410 lines
31 KiB
TypeScript

// 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<string, { id: string; update: Record<string, unknown> }>;
/** 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<Rows> {
const t = A.tenantId;
const uid = A.users.tech.id;
const rows: Rows = {};
const put = (model: string, id: string, update: Record<string, unknown>) => (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 });
return rows;
}
type Delegate = Record<string, (args: unknown) => Promise<unknown>>;
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<string, Delegate>)[lc(model)].findUnique({ where: { id } }), (_k, v) => (typeof v === "bigint" ? v.toString() : v));
async function threw(fn: () => Promise<unknown>): Promise<boolean> {
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<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";
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<string, Delegate>;
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<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;
});
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<unknown>, 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");
});