- Datenmodell: Testphasen-Lebenszyklus am Mandanten (plan, trialEndsAt, readOnlySince, deletionDueAt, Versandmarker), TrialSignup (Plattform, Hashes statt Klartext), TenantExport (RLS), Onboarding-Status - /testen: 5-Schritte-Wizard (Betrieb, Admin-Konto, Enddatum, Einrichtung, Zusammenfassung), Bestätigung per POST, direkte Anmeldung über login-ticket; Rate-Limit je IP/E-Mail, Honeypot, Enumeration-Schutz, Slug-Kollisionen - Plattform: Wizard „Testmandant anlegen“ mit Einladung, Badges/Filter, Enddatum ändern, umwandeln, beenden, Löschung vormerken/abbrechen (Bestätigung + Audit) - Schreibsperre nach Ablauf zentral in moduleGuard und requireApiContext (non-GET über withApi), Upload-Routen, Einstellungen/Nutzerverwaltung, Worker-Jobs; Banner Backoffice + mobil - Datenexport (ZIP mit CSV/JSON + Dateien) als Worker-Job, auch im Nur-Lesen-Zustand - Täglicher Job trial-lifecycle: Erinnerungen 7/3/1, Ablauf, Löschhinweis, Löschung über das Offboarding - Erste-Schritte-Checkliste im Dashboard, Mail-Vorlagen de/en, Tests + Smoke, Betriebsdoku Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
406 lines
31 KiB
TypeScript
406 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 });
|
|
put("TenantExport", (await prisma.tenantExport.create({ data: { tenantId: t, status: "done", fileName: "zz.zip" } })).id, { fileName: MARK }); // L15 Testphase
|
|
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");
|
|
});
|