Merge lane/qualitaet in feature/craftvia-mvp
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,441 @@
|
||||
// L10a Demo-Seed: fachliche Demo-Daten für die Mandanten `demo` und `demo2`.
|
||||
//
|
||||
// Wird von prisma/seed.ts aufgerufen (nur mit SEED_DEMO=true oder außerhalb von Production).
|
||||
// Alle Datensätze entstehen über die ECHTEN Fachservices (Statusmaschine, Zuweisung, Sessions,
|
||||
// Berichte, Notdienst) – damit sind Statushistorie, Audit, Versionen und Berichts-Snapshots
|
||||
// konsistent wie im Echtbetrieb. Idempotent: Kunden über feste Kundennummern (K-D…), Objekte über
|
||||
// Kunde + Name, Aufträge über die externe Auftragsnummer DEMO-…, Notdienst über feste Client-IDs.
|
||||
// Alle Namen, Adressen und Telefonnummern sind frei erfunden (keine echten personenbezogenen Daten).
|
||||
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import sharp from "sharp";
|
||||
import { FakeExtractionProvider } from "../../src/server/ai/extraction/fake";
|
||||
import { createImport } from "../../src/server/services/imports/upload";
|
||||
import { processImport } from "../../src/server/services/imports/process";
|
||||
import { buildPdf } from "../make-sample-pdfs";
|
||||
import { prisma, dbForTenant } from "../../src/server/db";
|
||||
import { ROLE_DEFS, type RoleKey } from "../../src/server/rbac";
|
||||
import type { ServiceCtx } from "../../src/server/services/context";
|
||||
import { localDateKey } from "../../src/lib/reports/dates";
|
||||
import {
|
||||
checklistTogglePayload,
|
||||
materialUpsertPayload,
|
||||
noteCreatePayload,
|
||||
photoAttachPayload,
|
||||
sessionControlPayload,
|
||||
sessionStartPayload,
|
||||
type NoteKind,
|
||||
} from "../../src/lib/sync/ops";
|
||||
import { createCustomer } from "../../src/server/services/customers/customers";
|
||||
import { createContact } from "../../src/server/services/customers/contacts";
|
||||
import { createSite } from "../../src/server/services/sites/sites";
|
||||
import { createTeam } from "../../src/server/services/teams/teams";
|
||||
import { createChecklistTemplate, ensureDefaultOrderTypes } from "../../src/server/services/work-orders/settings";
|
||||
import { createWorkOrder } from "../../src/server/services/work-orders/create";
|
||||
import { assignWorkOrder } from "../../src/server/services/work-orders/assign";
|
||||
import { transitionWorkOrder } from "../../src/server/services/work-orders/transition";
|
||||
import { markBilled, releaseForBilling } from "../../src/server/services/work-orders/release-billing";
|
||||
import { endSession, pauseSession, 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 { storeFile } from "../../src/server/services/documents/store";
|
||||
import { createCompletionReport, createDailyReport } from "../../src/server/services/reports/create";
|
||||
import { updateReportTexts } from "../../src/server/services/reports/edit";
|
||||
import { captureSignature } from "../../src/server/services/reports/signature";
|
||||
import { submitReport } from "../../src/server/services/reports/submit";
|
||||
import { approveReport } from "../../src/server/services/reports/approve";
|
||||
import { createEmergencyOrder } from "../../src/server/services/emergency/create";
|
||||
import type { CreateWorkOrderInput } from "../../src/lib/work-orders/schemas";
|
||||
|
||||
export type DemoUsers = Record<"admin" | "backoffice" | "lead" | "lead2" | "tech" | "tech2" | "tech3", string>;
|
||||
|
||||
const TZ = "Europe/Berlin";
|
||||
const noPdf = { dispatchPdf: async () => undefined };
|
||||
|
||||
function ctxOf(tenantId: string, userId: string, role: RoleKey): ServiceCtx {
|
||||
return { db: dbForTenant(tenantId), tenantId, userId, permissions: new Set<string>(ROLE_DEFS[role].permissions) };
|
||||
}
|
||||
|
||||
/** Local date relative to today (server time zone), e.g. day(-1, 7, 30) = yesterday 07:30. */
|
||||
function day(offset: number, h = 8, m = 0): Date {
|
||||
const x = new Date();
|
||||
x.setDate(x.getDate() + offset);
|
||||
x.setHours(h, m, 0, 0);
|
||||
return x;
|
||||
}
|
||||
|
||||
async function demoJpeg(label: string): Promise<Buffer> {
|
||||
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="768"><rect width="1024" height="768" fill="#dfe6ee"/><rect x="80" y="120" width="864" height="528" fill="#9fb3c8"/><text x="512" y="400" font-size="56" font-family="sans-serif" text-anchor="middle" fill="#1f2d3d">${label}</text></svg>`;
|
||||
return sharp(Buffer.from(svg)).jpeg({ quality: 80 }).toBuffer();
|
||||
}
|
||||
|
||||
async function signaturePng(): Promise<Buffer> {
|
||||
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="600" height="200"><rect width="600" height="200" fill="#ffffff"/><path d="M40 140 C 90 40, 140 180, 190 110 S 290 60, 330 130 S 450 90, 560 120" stroke="#1f2d3d" stroke-width="4" fill="none"/></svg>`;
|
||||
return sharp(Buffer.from(svg)).png().toBuffer();
|
||||
}
|
||||
|
||||
export async function seedDemoTenant(tenantId: string, users: DemoUsers): Promise<{ orders: number; created: number }> {
|
||||
const admin = ctxOf(tenantId, users.admin, "tenant-admin");
|
||||
const bo = ctxOf(tenantId, users.backoffice, "backoffice");
|
||||
const lead = ctxOf(tenantId, users.lead, "team-lead");
|
||||
const lead2 = ctxOf(tenantId, users.lead2, "team-lead");
|
||||
const tech = ctxOf(tenantId, users.tech, "technician");
|
||||
const tech2 = ctxOf(tenantId, users.tech2, "technician");
|
||||
const tech3 = ctxOf(tenantId, users.tech3, "technician");
|
||||
|
||||
// ---------- Auftragsarten + Checklisten-Vorlagen ----------
|
||||
await ensureDefaultOrderTypes(admin.db, tenantId);
|
||||
const orderTypes = new Map((await admin.db.orderType.findMany({ select: { id: true, key: true } })).map((t) => [t.key, t.id]));
|
||||
const type = (key: string) => orderTypes.get(key) ?? null;
|
||||
|
||||
const templates: Parameters<typeof createChecklistTemplate>[1][] = [
|
||||
{
|
||||
name: "Montage Heizung/Sanitär",
|
||||
orderTypeId: type("montage"),
|
||||
items: [
|
||||
{ key: "arbeitsbereich_abgesichert", label: "Arbeitsbereich abgesichert", required: true },
|
||||
{ key: "material_geprueft", label: "Material geprüft", required: false },
|
||||
{ key: "dichtheit_geprueft", label: "Dichtheitsprüfung durchgeführt", required: true },
|
||||
{ key: "funktionspruefung", label: "Funktionsprüfung durchgeführt", required: true },
|
||||
{ key: "kunde_eingewiesen", label: "Kunde eingewiesen", required: false },
|
||||
],
|
||||
requiredPhotos: [
|
||||
{ key: "ausgangszustand", label: "Ausgangszustand" },
|
||||
{ key: "typenschild", label: "Typenschild" },
|
||||
{ key: "fertige_montage", label: "Fertige Montage" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Wartung Heizungsanlage",
|
||||
orderTypeId: type("wartung"),
|
||||
items: [
|
||||
{ key: "brenner_gereinigt", label: "Brenner gereinigt", required: true },
|
||||
{ key: "abgaswerte_gemessen", label: "Abgaswerte gemessen", required: true },
|
||||
{ key: "anlagendruck_geprueft", label: "Anlagendruck geprüft", required: true },
|
||||
],
|
||||
requiredPhotos: [{ key: "typenschild", label: "Typenschild" }],
|
||||
},
|
||||
];
|
||||
for (const tpl of templates) {
|
||||
if (!(await admin.db.checklistTemplate.findFirst({ where: { name: tpl.name }, select: { id: true } }))) await createChecklistTemplate(admin, tpl);
|
||||
}
|
||||
|
||||
// ---------- Teams ----------
|
||||
async function team(name: string, leaderUserId: string, memberIds: string[], extra: { vehicle: string; area: string; phone: string }) {
|
||||
const existing = await admin.db.team.findFirst({ where: { name, deletedAt: null }, select: { id: true } });
|
||||
if (existing) return existing.id;
|
||||
const validFrom = day(-120, 0, 0).toISOString();
|
||||
return (await createTeam(admin, { name, leaderUserId, ...extra, members: memberIds.map((userId) => ({ userId, validFrom })) })).id;
|
||||
}
|
||||
const nord = await team("Team Nord", users.lead, [users.tech, users.tech2], { vehicle: "HH-MB 101", area: "Hamburg Nord, Norderstedt", phone: "040 555 0110" });
|
||||
const sued = await team("Team Süd", users.lead2, [users.tech3], { vehicle: "HH-MB 202", area: "Hamburg Süd, Harburg", phone: "040 555 0120" });
|
||||
|
||||
// ---------- Kunden, Ansprechpartner, Objekte ----------
|
||||
type CustomerSeed = { number: string; companyName?: string; firstName?: string; lastName?: string; street: string; houseNumber: string; postalCode: string; city: string; phone: string; email?: string; contact?: { name: string; role: string; phone: string } };
|
||||
const customerSeeds: CustomerSeed[] = [
|
||||
{ number: "K-D001", companyName: "Hausverwaltung Elbblick GmbH", street: "Große Elbstraße", houseNumber: "140", postalCode: "22767", city: "Hamburg", phone: "040 555 0201", email: "service@elbblick-hv.example", contact: { name: "Frauke Albers", role: "Objektbetreuung", phone: "040 555 0202" } },
|
||||
{ number: "K-D002", companyName: "Bäckerei Morgenrot KG", street: "Osterstraße", houseNumber: "38", postalCode: "20259", city: "Hamburg", phone: "040 555 0211", contact: { name: "Hannes Korn", role: "Inhaber", phone: "0171 555 0212" } },
|
||||
{ number: "K-D003", companyName: "Wohnbau Alstertal eG", street: "Wellingsbütteler Landstraße", houseNumber: "7", postalCode: "22391", city: "Hamburg", phone: "040 555 0221", email: "technik@alstertal-eg.example", contact: { name: "Deniz Yilmaz", role: "Technik", phone: "040 555 0222" } },
|
||||
{ number: "K-D004", companyName: "Praxis am Stadtpark", street: "Jarrestraße", houseNumber: "12", postalCode: "22303", city: "Hamburg", phone: "040 555 0231" },
|
||||
{ number: "K-D005", firstName: "Greta", lastName: "Musterfrau", street: "Lindenallee", houseNumber: "4", postalCode: "21073", city: "Hamburg", phone: "040 555 0241", email: "g.musterfrau@example.org" },
|
||||
{ number: "K-D006", companyName: "Logistikpark Harburg GmbH", street: "Seehafenstraße", houseNumber: "20", postalCode: "21079", city: "Hamburg", phone: "040 555 0251", contact: { name: "Sören Petersen", role: "Facility Management", phone: "0160 555 0252" } },
|
||||
{ number: "K-D007", companyName: "Kita Sonnenblume e. V.", street: "Hallerstraße", houseNumber: "55", postalCode: "20146", city: "Hamburg", phone: "040 555 0261" },
|
||||
];
|
||||
const customers = new Map<string, { id: string; contactId: string | null }>();
|
||||
for (const c of customerSeeds) {
|
||||
let row = await bo.db.customer.findFirst({ where: { customerNumber: c.number }, select: { id: true } });
|
||||
if (!row) {
|
||||
row = await createCustomer(
|
||||
bo,
|
||||
{ customerNumber: c.number, companyName: c.companyName, firstName: c.firstName, lastName: c.lastName, street: c.street, houseNumber: c.houseNumber, postalCode: c.postalCode, city: c.city, phone: c.phone, email: c.email, notes: "Demo-Datensatz" },
|
||||
{ acknowledgeDuplicates: true },
|
||||
);
|
||||
}
|
||||
let contactId: string | null = null;
|
||||
if (c.contact) {
|
||||
const existing = await bo.db.contact.findFirst({ where: { customerId: row.id, name: c.contact.name }, select: { id: true } });
|
||||
contactId = existing?.id ?? (await createContact(bo, row.id, { ...c.contact, preferredChannel: "phone" })).id;
|
||||
}
|
||||
customers.set(c.number, { id: row.id, contactId });
|
||||
}
|
||||
|
||||
type SiteSeed = { customer: string; name: string; street: string; houseNumber: string; postalCode: string; city: string; accessNotes: string; parkingNotes?: string; safetyNotes?: string; technicalNotes?: string };
|
||||
const siteSeeds: SiteSeed[] = [
|
||||
{ customer: "K-D001", name: "Wohnanlage Elbblick – Haus A", street: "Große Elbstraße", houseNumber: "140a", postalCode: "22767", city: "Hamburg", accessNotes: "Schlüssel im Schlüsseltresor neben dem Eingang, Code beim Büro erfragen.", parkingNotes: "Besucherparkplätze im Hof.", technicalNotes: "Heizungsraum im Keller, Gas-Brennwertkessel Baujahr 2014." },
|
||||
{ customer: "K-D001", name: "Wohnanlage Elbblick – Haus B", street: "Große Elbstraße", houseNumber: "140b", postalCode: "22767", city: "Hamburg", accessNotes: "Hausmeister Tel. 040 555 0203 (Mo–Fr 7–15 Uhr).", safetyNotes: "Asbestverdacht im Altbau-Keller – vor Bohrarbeiten Büro informieren." },
|
||||
{ customer: "K-D002", name: "Backstube Osterstraße", street: "Osterstraße", houseNumber: "38", postalCode: "20259", city: "Hamburg", accessNotes: "Lieferanteneingang hinten, ab 4 Uhr besetzt.", parkingNotes: "Ladezone vor dem Haus maximal 30 Minuten." },
|
||||
{ customer: "K-D002", name: "Filiale Eppendorf", street: "Eppendorfer Baum", houseNumber: "9", postalCode: "20249", city: "Hamburg", accessNotes: "Nur außerhalb der Öffnungszeiten (ab 18 Uhr).", technicalNotes: "Wasseraufbereitung unter der Theke." },
|
||||
{ customer: "K-D003", name: "Quartier Poppenbüttel – Block 3", street: "Saseler Damm", houseNumber: "21", postalCode: "22391", city: "Hamburg", accessNotes: "Zugang über Tiefgarage, Transponder beim Hausmeister.", safetyNotes: "Dachzugang nur mit Absturzsicherung." },
|
||||
{ customer: "K-D003", name: "Quartier Poppenbüttel – Block 5", street: "Saseler Damm", houseNumber: "25", postalCode: "22391", city: "Hamburg", accessNotes: "Hauptschlüssel im Büro der eG abholen." },
|
||||
{ customer: "K-D004", name: "Praxisräume 2. OG", street: "Jarrestraße", houseNumber: "12", postalCode: "22303", city: "Hamburg", accessNotes: "Anmeldung am Empfang, Arbeiten nur mittwochs nachmittags.", safetyNotes: "Röntgenraum nicht betreten." },
|
||||
{ customer: "K-D005", name: "Einfamilienhaus Lindenallee", street: "Lindenallee", houseNumber: "4", postalCode: "21073", city: "Hamburg", accessNotes: "Kundin ist vormittags zu Hause, Hund im Garten.", parkingNotes: "Einfahrt frei halten." },
|
||||
{ customer: "K-D006", name: "Halle 1 – Kühllager", street: "Seehafenstraße", houseNumber: "20", postalCode: "21079", city: "Hamburg", accessNotes: "Anmeldung an der Pforte, Besucherausweis erforderlich.", safetyNotes: "Warnweste und Sicherheitsschuhe Pflicht, Staplerverkehr." },
|
||||
{ customer: "K-D006", name: "Verwaltungsgebäude", street: "Seehafenstraße", houseNumber: "22", postalCode: "21079", city: "Hamburg", accessNotes: "Haustechnikraum im EG, Schlüssel an der Pforte.", technicalNotes: "Lüftungsanlage mit Wärmerückgewinnung." },
|
||||
{ customer: "K-D007", name: "Kita-Gebäude Hallerstraße", street: "Hallerstraße", houseNumber: "55", postalCode: "20146", city: "Hamburg", accessNotes: "Nur nach Absprache mit der Leitung, Kinder im Haus.", safetyNotes: "Werkzeug nie unbeaufsichtigt lassen." },
|
||||
{ customer: "K-D007", name: "Gartenhaus / Außenanlage", street: "Hallerstraße", houseNumber: "55", postalCode: "20146", city: "Hamburg", accessNotes: "Tor zum Garten mit Zahlenschloss, Code im Auftrag." },
|
||||
];
|
||||
const sites = new Map<string, string>();
|
||||
for (const s of siteSeeds) {
|
||||
const customer = customers.get(s.customer)!;
|
||||
const existing = await bo.db.site.findFirst({ where: { customerId: customer.id, name: s.name, deletedAt: null }, select: { id: true } });
|
||||
const { customer: _c, ...fields } = s;
|
||||
void _c;
|
||||
sites.set(s.name, existing?.id ?? (await createSite(bo, { ...fields, customerId: customer.id, contactId: customer.contactId })).id);
|
||||
}
|
||||
|
||||
// ---------- Helfer für den Einsatz ----------
|
||||
let created = 0;
|
||||
const jpeg = await demoJpeg("Demo-Foto");
|
||||
const signature = await signaturePng();
|
||||
|
||||
const at = (d: Date) => d.toISOString();
|
||||
const work = (c: ServiceCtx, id: string, d: Date) => startSession(c, sessionStartPayload.parse({ workOrderId: id, mode: "work", at: at(d), deviceInfo: "Demo-Seed" }));
|
||||
const travel = (c: ServiceCtx, id: string, d: Date) => startSession(c, sessionStartPayload.parse({ workOrderId: id, mode: "travel", at: at(d), deviceInfo: "Demo-Seed" }));
|
||||
const pause = (c: ServiceCtx, id: string, d: Date) => pauseSession(c, sessionControlPayload.parse({ workOrderId: id, at: at(d) }));
|
||||
const end = (c: ServiceCtx, id: string, d: Date) => endSession(c, sessionControlPayload.parse({ workOrderId: id, at: at(d) }));
|
||||
const note = (c: ServiceCtx, id: string, kind: NoteKind, text: string) => createNote(c, noteCreatePayload.parse({ workOrderId: id, kind, text }));
|
||||
const checkAll = async (c: ServiceCtx, id: string) => {
|
||||
for (const item of await c.db.checklistItem.findMany({ where: { workOrderId: id, checked: false }, select: { id: true } })) {
|
||||
await toggleChecklistItem(c, checklistTogglePayload.parse({ workOrderId: id, itemId: item.id, checked: true }));
|
||||
}
|
||||
};
|
||||
const photo = async (c: ServiceCtx, id: string, opts: { phase: "before" | "during" | "after"; requirementKey?: string; comment?: string; takenAt?: Date }) => {
|
||||
const upload = await storeFieldUpload(c, { clientId: randomUUID(), workOrderId: id, kind: "photo" }, { bytes: jpeg, name: `${opts.requirementKey ?? opts.phase}.jpg`, type: "image/jpeg" }, { bytes: jpeg, name: "thumb.jpg", type: "image/jpeg" });
|
||||
const req = opts.requirementKey ? await c.db.photoRequirement.findFirst({ where: { workOrderId: id, key: opts.requirementKey }, select: { id: true } }) : null;
|
||||
await attachPhoto(c, photoAttachPayload.parse({ workOrderId: id, documentId: upload.documentId, phase: opts.phase, photoRequirementId: req?.id ?? null, comment: opts.comment, takenAt: opts.takenAt ? at(opts.takenAt) : undefined }));
|
||||
};
|
||||
const material = (c: ServiceCtx, id: string, input: Record<string, unknown>) => upsertMaterialUsage(c, materialUpsertPayload.parse({ workOrderId: id, ...input }));
|
||||
const planMaterial = async (c: ServiceCtx, id: string, index: number, quantity: number, usageStatus: "fully_used" | "partially_used" | "not_used", deviationReason?: string) => {
|
||||
const plan = (await c.db.materialPlan.findMany({ where: { workOrderId: id }, orderBy: { sortOrder: "asc" }, select: { id: true, unit: true } }))[index];
|
||||
if (plan) await material(c, id, { materialPlanId: plan.id, quantity, unit: plan.unit, usageStatus, deviationReason });
|
||||
};
|
||||
|
||||
/** Completion report with signature outcome and optional approvals (team lead → backoffice). */
|
||||
const complete = async (
|
||||
c: ServiceCtx,
|
||||
id: string,
|
||||
opts: { reportDate: Date; workPerformed: string; outcome: "signed" | "customer_absent" | "later"; signer?: string; approve: "none" | "team" | "final"; teamLead: ServiceCtx },
|
||||
) => {
|
||||
const { report } = await createCompletionReport(c, { workOrderId: id, reportDate: localDateKey(opts.reportDate, TZ) });
|
||||
await updateReportTexts(c, { reportId: report.id, texts: { workPerformed: opts.workPerformed, hints: "Anlage läuft störungsfrei.", nextSteps: "Nächste Wartung in 12 Monaten." } });
|
||||
const confirmationText = "Ich bestätige die Ausführung der aufgeführten Arbeiten.";
|
||||
if (opts.outcome === "signed") {
|
||||
const img = await storeFile(c, { bytes: signature, fileName: "unterschrift.png", declaredMime: "image/png", category: "signature", visibility: "customer_report", links: { workOrderId: id } });
|
||||
await captureSignature(c, { reportId: report.id, outcome: "signed", signerName: opts.signer ?? "Frauke Albers", signerRole: "Objektbetreuung", imageDocumentId: img.id, confirmationText });
|
||||
} else {
|
||||
await captureSignature(c, { reportId: report.id, outcome: opts.outcome, reason: opts.outcome === "later" ? "Ansprechpartnerin nicht vor Ort, Unterschrift wird nachgereicht." : "Kunde trotz Terminabsprache nicht angetroffen.", confirmationText });
|
||||
}
|
||||
await submitReport(c, { reportId: report.id });
|
||||
if (opts.approve !== "none") await approveReport(opts.teamLead, { reportId: report.id }, noPdf);
|
||||
if (opts.approve === "final") await approveReport(bo, { reportId: report.id }, noPdf);
|
||||
return report.id;
|
||||
};
|
||||
|
||||
type OrderSeed = Omit<CreateWorkOrderInput, "customerId" | "siteId" | "contactId" | "externalOrderNumber"> & { ext: string; customer: string; site?: string };
|
||||
const order = async (seed: OrderSeed, drive: (id: string) => Promise<void>) => {
|
||||
const existing = await bo.db.workOrder.findFirst({ where: { externalOrderNumber: seed.ext }, select: { id: true } });
|
||||
if (existing) return existing.id;
|
||||
const { ext, customer, site, ...input } = seed;
|
||||
const cust = customers.get(customer)!;
|
||||
const wo = await createWorkOrder(bo, { ...input, externalOrderNumber: ext, customerId: cust.id, siteId: site ? sites.get(site)! : null, contactId: cust.contactId });
|
||||
await drive(wo.id);
|
||||
created++;
|
||||
return wo.id;
|
||||
};
|
||||
const assign = (id: string, teamId: string, userIds: string[]) => assignWorkOrder(bo, { workOrderId: id, teamId, userIds });
|
||||
|
||||
const heating = [
|
||||
{ name: "Gas-Brennwerttherme 24 kW", articleNumber: "GBT-24", plannedQuantity: 1, unit: "Stk" },
|
||||
{ name: "Kupferrohr 18 mm", articleNumber: "CU-18", plannedQuantity: 12, unit: "m" },
|
||||
{ name: "Pressfitting 18 mm", articleNumber: "PF-18", plannedQuantity: 10, unit: "Stk" },
|
||||
];
|
||||
const service = [
|
||||
{ name: "Wartungsset Brenner", articleNumber: "WS-B1", plannedQuantity: 1, unit: "Satz" },
|
||||
{ name: "Dichtungssatz", articleNumber: "DS-12", plannedQuantity: 2, unit: "Stk" },
|
||||
];
|
||||
|
||||
// ---------- 20 Aufträge über alle Statusgruppen ----------
|
||||
// Neu
|
||||
await order({ ext: "DEMO-01", customer: "K-D004", site: "Praxisräume 2. OG", title: "Angebot Waschtisch-Armaturen prüfen", orderTypeId: type("besichtigung"), description: "Besichtigung der Behandlungsräume, Aufnahme der Armaturen.", status: "draft", plannedStart: day(6, 14), plannedEnd: day(6, 16) }, async () => undefined);
|
||||
await order({ ext: "DEMO-02", customer: "K-D003", site: "Quartier Poppenbüttel – Block 5", title: "Strangsanierung Trinkwasser – Aufmaß", orderTypeId: type("montage"), description: "Aus Auftragsbestätigung übernommen, Prüfung erforderlich.", status: "review_required", plannedStart: day(10, 7), plannedEnd: day(14, 16), materials: heating.slice(1) }, async () => undefined);
|
||||
// Geplant / kommend
|
||||
await order({ ext: "DEMO-03", customer: "K-D006", site: "Verwaltungsgebäude", title: "Filterwechsel Lüftungsanlage", orderTypeId: type("wartung"), status: "planned", plannedStart: day(1, 8), plannedEnd: day(1, 12), materials: service }, async () => undefined);
|
||||
await order({ ext: "DEMO-10", customer: "K-D005", site: "Einfamilienhaus Lindenallee", title: "Heizkörper Wohnzimmer tauschen", orderTypeId: type("montage"), priority: "normal", plannedStart: day(2, 8), plannedEnd: day(2, 15), materials: [{ name: "Flachheizkörper 22/600/1200", articleNumber: "HK-22-12", plannedQuantity: 1, unit: "Stk" }] }, (id) => assign(id, sued, [users.tech3]).then(() => undefined));
|
||||
await order({ ext: "DEMO-11", customer: "K-D007", site: "Kita-Gebäude Hallerstraße", title: "Jahreswartung Gasheizung", orderTypeId: type("wartung"), plannedStart: day(5, 9), plannedEnd: day(5, 13), materials: service, technicianNotes: "Arbeiten nur in der Mittagsruhe (12–14 Uhr) mit Lärm." }, (id) => assign(id, nord, [users.tech]).then(() => undefined));
|
||||
// Heute
|
||||
await order({ ext: "DEMO-04", customer: "K-D002", site: "Backstube Osterstraße", title: "Wasserenthärtung warten", orderTypeId: type("wartung"), plannedStart: day(0, 13), plannedEnd: day(0, 15), materials: service }, (id) => assign(id, nord, [users.tech]).then(() => undefined));
|
||||
await order({ ext: "DEMO-05", customer: "K-D001", site: "Wohnanlage Elbblick – Haus B", title: "Zirkulationspumpe tauschen", orderTypeId: type("reparatur"), priority: "high", plannedStart: day(0, 10), plannedEnd: day(0, 12), materials: [{ name: "Zirkulationspumpe 25-40", articleNumber: "ZP-2540", plannedQuantity: 1, unit: "Stk" }] }, async (id) => {
|
||||
await assign(id, nord, [users.tech2]);
|
||||
await transitionWorkOrder(tech2, { workOrderId: id, to: "accepted" });
|
||||
});
|
||||
await order({ ext: "DEMO-06", customer: "K-D006", site: "Halle 1 – Kühllager", title: "Störung Kältemittelleitung", orderTypeId: type("stoerung"), priority: "urgent", plannedStart: day(0, 9), plannedEnd: day(0, 13) }, async (id) => {
|
||||
await assign(id, sued, [users.tech3]);
|
||||
await travel(tech3, id, day(0, 8, 30));
|
||||
});
|
||||
await order({ ext: "DEMO-07", customer: "K-D003", site: "Quartier Poppenbüttel – Block 3", title: "Montage Gas-Brennwerttherme", orderTypeId: type("montage"), plannedStart: day(0, 7), plannedEnd: day(0, 17), materials: heating, scope: "Demontage Altgerät, Montage Neugerät, Abgasanschluss, Inbetriebnahme." }, async (id) => {
|
||||
await assign(id, nord, [users.tech]);
|
||||
await travel(tech, id, day(0, 6, 45));
|
||||
await work(tech, id, day(0, 7, 10));
|
||||
await photo(tech, id, { phase: "before", requirementKey: "ausgangszustand", comment: "Altgerät vor Demontage" });
|
||||
await note(tech, id, "work_done", "Altgerät demontiert, Anschlüsse verschlossen.");
|
||||
await note(tech, id, "deviation", "Abgasrohr muss um 30 cm verlängert werden.");
|
||||
await planMaterial(tech, id, 1, 14, "fully_used", "Leitungsführung um den Schacht");
|
||||
await material(tech, id, { clientId: randomUUID(), name: "Abgasrohr-Verlängerung DN80", articleNumber: "AG-80-05", quantity: 1, unit: "Stk", usageStatus: "additional", deviationReason: "Abgasrohr zu kurz" });
|
||||
});
|
||||
await order({ ext: "DEMO-08", customer: "K-D002", site: "Filiale Eppendorf", title: "Undichte Leitung unter der Theke", orderTypeId: type("reparatur"), plannedStart: day(0, 7), plannedEnd: day(0, 11) }, async (id) => {
|
||||
await assign(id, sued, [users.tech3]);
|
||||
await work(tech3, id, day(0, 7, 5));
|
||||
await note(tech3, id, "problem", "Leitung hinter Kühlvitrine schwer erreichbar.");
|
||||
await pause(tech3, id, day(0, 9, 30));
|
||||
});
|
||||
await order({ ext: "DEMO-09", customer: "K-D007", site: "Gartenhaus / Außenanlage", title: "Außenwasserhahn frostsicher erneuern", orderTypeId: type("reparatur"), plannedStart: day(0, 8), plannedEnd: day(0, 12), materials: [{ name: "Frostsichere Außenarmatur", articleNumber: "FA-12", plannedQuantity: 1, unit: "Stk" }] }, async (id) => {
|
||||
await assign(id, nord, [users.tech2]);
|
||||
await work(tech2, id, day(0, 8, 15));
|
||||
await note(tech2, id, "problem", "Falsche Armatur geliefert (Länge 150 statt 250 mm).");
|
||||
await transitionWorkOrder(tech2, { workOrderId: id, to: "waiting_material" });
|
||||
await pause(tech2, id, day(0, 9));
|
||||
});
|
||||
// Überfällig
|
||||
await order({ ext: "DEMO-12", customer: "K-D001", site: "Wohnanlage Elbblick – Haus B", title: "Thermostatventile Treppenhaus", orderTypeId: type("reparatur"), plannedStart: day(-4, 8), plannedEnd: day(-3, 16) }, (id) => assign(id, nord, [users.tech]).then(() => undefined));
|
||||
await order({ ext: "DEMO-13", customer: "K-D006", site: "Verwaltungsgebäude", title: "Druckminderer erneuern", orderTypeId: type("reparatur"), plannedStart: day(-3, 8), plannedEnd: day(-2, 12) }, async (id) => {
|
||||
await assign(id, sued, [users.tech3]);
|
||||
await transitionWorkOrder(tech3, { workOrderId: id, to: "accepted" });
|
||||
});
|
||||
// Mehrtägig mit Tagesbericht
|
||||
await order({ ext: "DEMO-14", customer: "K-D003", site: "Quartier Poppenbüttel – Block 5", title: "Heizungsverteilung Tiefgarage erneuern (2 Tage)", orderTypeId: type("montage"), plannedStart: day(-1, 7), plannedEnd: day(0, 16), materials: heating.slice(1), applyTemplate: false, checklistItems: [{ label: "Anlage entleert", required: true }, { label: "Druckprobe durchgeführt", required: true }] }, async (id) => {
|
||||
await assign(id, nord, [users.tech, users.tech2]);
|
||||
await work(tech, id, day(-1, 7));
|
||||
await note(tech, id, "work_done", "Tag 1: Alte Verteilung demontiert, neue Konsolen gesetzt.");
|
||||
await planMaterial(tech, id, 0, 8, "partially_used", "Rest wird an Tag 2 verbaut");
|
||||
await photo(tech, id, { phase: "during", comment: "Neue Konsolen", takenAt: day(-1, 14) });
|
||||
await end(tech, id, day(-1, 15, 30));
|
||||
const { report } = await createDailyReport(tech, { workOrderId: id, reportDate: localDateKey(day(-1), TZ) });
|
||||
await updateReportTexts(tech, { reportId: report.id, texts: { workPerformed: "Alte Verteilung demontiert, neue Konsolen gesetzt, Rohrleitungen vorbereitet.", openItems: "Anschluss der Heizkreise an Tag 2." } });
|
||||
await submitReport(tech, { reportId: report.id });
|
||||
await approveReport(lead, { reportId: report.id }, noPdf);
|
||||
});
|
||||
// Dokumentation unvollständig / Zur Prüfung
|
||||
await order({ ext: "DEMO-15", customer: "K-D004", site: "Praxisräume 2. OG", title: "Boiler Sterilisationsraum tauschen", orderTypeId: type("montage"), plannedStart: day(-2, 13), plannedEnd: day(-2, 17), applyTemplate: false, checklistItems: [{ label: "Funktionsprüfung durchgeführt", required: true }], materials: [{ name: "Untertischspeicher 10 l", articleNumber: "UTS-10", plannedQuantity: 1, unit: "Stk" }] }, async (id) => {
|
||||
await assign(id, sued, [users.tech3]);
|
||||
await work(tech3, id, day(-2, 13, 10));
|
||||
await checkAll(tech3, id);
|
||||
await planMaterial(tech3, id, 0, 1, "fully_used");
|
||||
await photo(tech3, id, { phase: "after", comment: "Neuer Speicher montiert", takenAt: day(-2, 16) });
|
||||
await note(tech3, id, "work_done", "Speicher getauscht, Funktion geprüft.");
|
||||
await end(tech3, id, day(-2, 16, 30));
|
||||
await complete(tech3, id, { reportDate: day(-2), workPerformed: "Untertischspeicher demontiert und neu montiert, Dichtheit und Funktion geprüft.", outcome: "later", approve: "none", teamLead: lead2 });
|
||||
});
|
||||
await order({ ext: "DEMO-16", customer: "K-D002", site: "Backstube Osterstraße", title: "Sicherheitsventil Warmwasserspeicher", orderTypeId: type("reparatur"), plannedStart: day(-1, 5), plannedEnd: day(-1, 8), applyTemplate: false, checklistItems: [{ label: "Anlage drucklos gemacht", required: true }] }, async (id) => {
|
||||
await assign(id, nord, [users.tech2]);
|
||||
await work(tech2, id, day(-1, 5, 10));
|
||||
await checkAll(tech2, id);
|
||||
await note(tech2, id, "work_done", "Sicherheitsventil getauscht und Ansprechdruck geprüft.");
|
||||
await material(tech2, id, { clientId: randomUUID(), name: "Sicherheitsventil 6 bar", articleNumber: "SV-6", quantity: 1, unit: "Stk", usageStatus: "additional", deviationReason: "Nicht in der Vorgabe enthalten" });
|
||||
await end(tech2, id, day(-1, 7, 20));
|
||||
await complete(tech2, id, { reportDate: day(-1), workPerformed: "Sicherheitsventil am Warmwasserspeicher getauscht, Funktion geprüft.", outcome: "customer_absent", approve: "team", teamLead: lead });
|
||||
});
|
||||
await order({ ext: "DEMO-17", customer: "K-D007", site: "Kita-Gebäude Hallerstraße", title: "Spülkasten Kinder-WC reparieren", orderTypeId: type("reparatur"), plannedStart: day(-1, 12), plannedEnd: day(-1, 14), applyTemplate: false, checklistItems: [{ label: "Spülung geprüft", required: true }] }, async (id) => {
|
||||
await assign(id, nord, [users.tech]);
|
||||
await work(tech, id, day(-1, 12, 5));
|
||||
await checkAll(tech, id);
|
||||
await note(tech, id, "work_done", "Füllventil und Dichtung ersetzt.");
|
||||
await end(tech, id, day(-1, 13, 10));
|
||||
await transitionWorkOrder(tech, { workOrderId: id, to: "technically_completed" });
|
||||
});
|
||||
// Zur Abrechnung / abgerechnet – beide mit freigegebenem Bericht in der Objekt-Historie von Haus A
|
||||
const historySite = "Wohnanlage Elbblick – Haus A";
|
||||
await order({ ext: "DEMO-18", customer: "K-D001", site: historySite, title: "Jahreswartung Gas-Brennwertkessel", orderTypeId: type("wartung"), plannedStart: day(-8, 8), plannedEnd: day(-8, 12), applyTemplate: false, checklistItems: [{ label: "Brenner gereinigt", required: true }, { label: "Abgaswerte gemessen", required: true }], materials: service }, async (id) => {
|
||||
await assign(id, nord, [users.tech]);
|
||||
await work(tech, id, day(-8, 8, 5));
|
||||
await checkAll(tech, id);
|
||||
await planMaterial(tech, id, 0, 1, "fully_used");
|
||||
await planMaterial(tech, id, 1, 1, "partially_used", "Zweite Dichtung war noch in Ordnung");
|
||||
await photo(tech, id, { phase: "after", comment: "Kessel nach Wartung", takenAt: day(-8, 11) });
|
||||
await note(tech, id, "work_done", "Brenner gereinigt, Zündelektrode eingestellt, Abgaswerte im Sollbereich.");
|
||||
await note(tech, id, "recommendation", "Ausdehnungsgefäß beim nächsten Termin prüfen.");
|
||||
await end(tech, id, day(-8, 11, 30));
|
||||
await complete(tech, id, { reportDate: day(-8), workPerformed: "Jahreswartung durchgeführt: Brenner gereinigt, Zündelektrode eingestellt, Abgasmessung ohne Befund.", outcome: "signed", approve: "final", teamLead: lead });
|
||||
await releaseForBilling(bo, { workOrderId: id });
|
||||
});
|
||||
await order({ ext: "DEMO-19", customer: "K-D001", site: historySite, title: "Heizungspumpe erneuern", orderTypeId: type("reparatur"), plannedStart: day(-21, 9), plannedEnd: day(-21, 13), applyTemplate: false, checklistItems: [{ label: "Anlage entlüftet", required: true }], materials: [{ name: "Hocheffizienzpumpe 25-60", articleNumber: "HP-2560", plannedQuantity: 1, unit: "Stk" }] }, async (id) => {
|
||||
await assign(id, sued, [users.tech3]);
|
||||
await work(tech3, id, day(-21, 9, 10));
|
||||
await checkAll(tech3, id);
|
||||
await planMaterial(tech3, id, 0, 1, "fully_used");
|
||||
await photo(tech3, id, { phase: "after", comment: "Neue Pumpe eingebaut", takenAt: day(-21, 12) });
|
||||
await note(tech3, id, "work_done", "Heizungspumpe getauscht, Anlage entlüftet und hydraulisch abgeglichen.");
|
||||
await note(tech3, id, "follow_up", "Thermostatköpfe im 3. OG klemmen – separaten Auftrag anlegen.");
|
||||
await end(tech3, id, day(-21, 12, 40));
|
||||
await complete(tech3, id, { reportDate: day(-21), workPerformed: "Heizungspumpe durch Hocheffizienzpumpe ersetzt, Anlage entlüftet.", outcome: "signed", signer: "Frauke Albers", approve: "final", teamLead: lead2 });
|
||||
await releaseForBilling(bo, { workOrderId: id });
|
||||
await markBilled(bo, { workOrderId: id });
|
||||
});
|
||||
|
||||
// Notdienst mit vorläufigem Kunden (feste Client-IDs → idempotent)
|
||||
const emergencyExisting = await bo.db.workSession.findFirst({ where: { clientId: "7c1d6a0e-3b1f-4c55-9d2a-00000000d020" }, select: { workOrderId: true } });
|
||||
if (!emergencyExisting) {
|
||||
const r = await createEmergencyOrder(tech, {
|
||||
clientIds: { workOrder: "7c1d6a0e-3b1f-4c55-9d2a-00000000a020", session: "7c1d6a0e-3b1f-4c55-9d2a-00000000d020", customer: "7c1d6a0e-3b1f-4c55-9d2a-00000000c020", site: "7c1d6a0e-3b1f-4c55-9d2a-00000000e020" },
|
||||
customer: { mode: "new", firstName: "Jonas", lastName: "Beispielmann", phone: "040 555 0299", street: "Heußweg", houseNumber: "71", postalCode: "20255", city: "Hamburg" },
|
||||
site: { mode: "new", name: "Mehrfamilienhaus Heußweg 71", street: "Heußweg", houseNumber: "71", postalCode: "20255", city: "Hamburg" },
|
||||
onSiteContact: { name: "Jonas Beispielmann", phone: "040 555 0299" },
|
||||
reason: "Wasserrohrbruch im Keller – Hauptabsperrung defekt",
|
||||
startedAt: day(0, 6, 20).toISOString(),
|
||||
deviceInfo: "Demo-Seed",
|
||||
});
|
||||
await note(tech, r.workOrderId, "work_done", "Wasser abgesperrt, Leck provisorisch abgedichtet.");
|
||||
await material(tech, r.workOrderId, { clientId: randomUUID(), name: "Reparaturschelle 1/2 Zoll", quantity: 1, unit: "Stk", usageStatus: "additional", deviationReason: "Notdienst" });
|
||||
created++;
|
||||
}
|
||||
|
||||
// Import in Prüfung (Prüfmaske): Musterdokument + Extraktion über den Fake-Provider (keine KI nötig)
|
||||
if (!(await bo.db.importJob.findFirst({ where: { extractedText: { startsWith: "DEMO-IMPORT" } }, select: { id: true } }))) {
|
||||
const samplePath = join(process.cwd(), "docs", "craftvia", "samples", "02-elbblick-wartungsauftrag.pdf");
|
||||
const bytes = existsSync(samplePath) ? readFileSync(samplePath) : buildPdf([[{ text: "Wartungsauftrag Hausverwaltung Elbblick GmbH" }]]);
|
||||
const job = await createImport(bo, { bytes, fileName: "wartungsauftrag-elbblick.pdf", mimeType: "application/pdf" }, { dispatch: async () => undefined });
|
||||
await processImport(bo, job.id, {
|
||||
provider: new FakeExtractionProvider({
|
||||
text: "DEMO-IMPORT Wartungsauftrag Hausverwaltung Elbblick GmbH, Große Elbstraße 140, 22767 Hamburg",
|
||||
extraction: {
|
||||
orderNumber: { value: "WA-2026-117", confidence: 0.96 },
|
||||
customerNumber: { value: "K-D001", confidence: 0.91 },
|
||||
companyName: { value: "Hausverwaltung Elbblick GmbH", confidence: 0.95 },
|
||||
customerAddress: { value: { street: "Große Elbstr.", houseNumber: "140", postalCode: "22767", city: "Hamburg", country: "DE" }, confidence: 0.9 },
|
||||
siteName: { value: "Wohnanlage Elbblick – Haus B", confidence: 0.72, source: "Objekt: Haus B" },
|
||||
siteAddress: { value: { street: "Große Elbstraße", houseNumber: "140b", postalCode: "22767", city: "Hamburg" }, confidence: 0.88 },
|
||||
contactName: { value: "Frauke Albers", confidence: 0.83 },
|
||||
title: { value: "Wartung Warmwasserbereitung", confidence: 0.86 },
|
||||
plannedStart: { value: localDateKey(day(12), TZ), confidence: 0.64, source: "KW-Angabe unsicher" },
|
||||
positions: { value: [{ name: "Wartungsset Speicher", articleNumber: "WS-S2", quantity: 1, unit: "Satz", isMaterial: true }, { name: "Arbeitszeit", quantity: 2, unit: "Std", isMaterial: false }], confidence: 0.8 },
|
||||
},
|
||||
}),
|
||||
loadBytes: async () => bytes,
|
||||
});
|
||||
}
|
||||
|
||||
const orders = await bo.db.workOrder.count({ where: { OR: [{ externalOrderNumber: { startsWith: "DEMO-" } }, { isEmergency: true, emergencyReason: { startsWith: "Wasserrohrbruch im Keller" } }] } });
|
||||
return { orders, created };
|
||||
}
|
||||
|
||||
/** Isolation fixture for the second tenant: one customer + one work order. */
|
||||
export async function seedDemo2Tenant(tenantId: string, adminUserId: string): Promise<void> {
|
||||
const admin = ctxOf(tenantId, adminUserId, "tenant-admin");
|
||||
let customer = await admin.db.customer.findFirst({ where: { customerNumber: "K-N001" }, select: { id: true } });
|
||||
if (!customer) {
|
||||
customer = await createCustomer(admin, { customerNumber: "K-N001", companyName: "Werft Nordlicht GmbH", street: "Am Hafen", houseNumber: "3", postalCode: "24103", city: "Kiel", phone: "0431 555 0101", notes: "Demo-Datensatz (Isolationsprüfung)" }, { acknowledgeDuplicates: true });
|
||||
}
|
||||
if (!(await admin.db.workOrder.findFirst({ where: { externalOrderNumber: "DEMO2-01" }, select: { id: true } }))) {
|
||||
const site = await createSite(admin, { customerId: customer.id, name: "Schaltanlage Halle 2", street: "Am Hafen", houseNumber: "3", postalCode: "24103", city: "Kiel", accessNotes: "Anmeldung beim Werkschutz." });
|
||||
await createWorkOrder(admin, { title: "Prüfung Unterverteilung nach DGUV V3", customerId: customer.id, siteId: site.id, externalOrderNumber: "DEMO2-01", status: "planned", plannedStart: day(3, 8), plannedEnd: day(3, 12) });
|
||||
}
|
||||
}
|
||||
|
||||
/** Close the owner client used by the services (the seed has its own client). */
|
||||
export async function closeDemoSeed(): Promise<void> {
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
// L10a shared fixture for the acceptance tests (scripts/test-e2e-*.ts, scripts/test-security-*.ts).
|
||||
// Not a test itself (the runner only picks up scripts/test-*.ts at top level).
|
||||
//
|
||||
// createTenant(slug) builds a complete zz test tenant exactly like a provisioned one: tenant
|
||||
// settings, the four standard roles WITH their permissions in the DB (needed by recipient
|
||||
// resolution, conflict re-apply and requireApiContext), users per role, Team Nord (lead + 2
|
||||
// technicians), Team Süd (lead2 + outsider), a customer with contact and a site.
|
||||
// cleanupTenants() removes every row of all tenant models (FK-safe order) and the test identities.
|
||||
|
||||
import "dotenv/config";
|
||||
import sharp from "sharp";
|
||||
import { ZodError } from "zod";
|
||||
import { prisma, dbForTenant } from "../../src/server/db";
|
||||
import { ROLE_DEFS, type RoleKey } from "../../src/server/rbac";
|
||||
import { ServiceError, type ServiceCtx } from "../../src/server/services/context";
|
||||
|
||||
export const E2E_DOMAIN = "@zz-qualitaet.test";
|
||||
|
||||
let failureCount = 0;
|
||||
export const failures = () => failureCount;
|
||||
|
||||
export function ok(cond: boolean, msg: string): boolean {
|
||||
console.log(`${cond ? "✓" : "✗ FEHLER"} ${msg}`);
|
||||
if (!cond) failureCount++;
|
||||
return cond;
|
||||
}
|
||||
|
||||
export function section(title: string) {
|
||||
console.log(`\n— ${title} —`);
|
||||
}
|
||||
|
||||
/** Error code of a promise: "ok", a ServiceError code, or "error:<message>". */
|
||||
export async function codeOf(p: Promise<unknown> | (() => Promise<unknown>)): Promise<string> {
|
||||
try {
|
||||
await (typeof p === "function" ? p() : p);
|
||||
return "ok";
|
||||
} catch (e) {
|
||||
if (e instanceof ServiceError) return e.code;
|
||||
// Zod validation errors surface as `invalid` in every adapter (toErrorResponse, action state)
|
||||
if (e instanceof ZodError) return "invalid";
|
||||
const code = (e as { code?: unknown }).code;
|
||||
if (typeof code === "string" && ["not_found", "forbidden", "invalid", "conflict", "blocked"].includes(code)) return code;
|
||||
return `error:${(e as Error).message?.split("\n")[0]}`;
|
||||
}
|
||||
}
|
||||
|
||||
export async function expectCode(fn: () => Promise<unknown>, codes: string | string[], msg: string): Promise<string> {
|
||||
const got = await codeOf(fn);
|
||||
const wanted = Array.isArray(codes) ? codes : [codes];
|
||||
ok(wanted.includes(got), `${msg}${wanted.includes(got) ? "" : ` — erhalten ${got}, erwartet ${wanted.join("|")}`}`);
|
||||
return got;
|
||||
}
|
||||
|
||||
export function ctxFor(tenantId: string, userId: string, role: RoleKey): ServiceCtx {
|
||||
return { db: dbForTenant(tenantId), tenantId, userId, permissions: new Set<string>(ROLE_DEFS[role].permissions) };
|
||||
}
|
||||
|
||||
const PERSONAS = {
|
||||
admin: "tenant-admin",
|
||||
backoffice: "backoffice",
|
||||
lead: "team-lead",
|
||||
lead2: "team-lead",
|
||||
tech: "technician",
|
||||
tech2: "technician",
|
||||
outsider: "technician",
|
||||
} as const satisfies Record<string, RoleKey>;
|
||||
export type Persona = keyof typeof PERSONAS;
|
||||
|
||||
export type TenantFixture = {
|
||||
slug: string;
|
||||
tenantId: string;
|
||||
users: Record<Persona, { id: string; email: string; identityId: string }>;
|
||||
ctx: Record<Persona, ServiceCtx>;
|
||||
teamId: string;
|
||||
team2Id: string;
|
||||
customerId: string;
|
||||
contactId: string;
|
||||
siteId: string;
|
||||
};
|
||||
|
||||
export async function createTenant(slug: string, opts: { orgName?: string } = {}): Promise<TenantFixture> {
|
||||
const tenant = await prisma.tenant.create({ data: { name: opts.orgName ?? `ZZ Qualität ${slug}`, slug } });
|
||||
await prisma.tenantSettings.create({ data: { tenantId: tenant.id, orgName: opts.orgName ?? `ZZ Qualität ${slug} GmbH`, timezone: "Europe/Berlin", address: "Prüfweg 1, 20095 Hamburg" } });
|
||||
|
||||
const roleIds = {} as Record<RoleKey, string>;
|
||||
for (const key of Object.keys(ROLE_DEFS) as RoleKey[]) {
|
||||
const perms = await Promise.all(ROLE_DEFS[key].permissions.map((p) => prisma.permission.upsert({ where: { key: p }, update: {}, create: { key: p } })));
|
||||
const role = await prisma.role.create({
|
||||
data: { tenantId: tenant.id, key, name: ROLE_DEFS[key].name, rolePermissions: { create: perms.map((p) => ({ permissionId: p.id })) } },
|
||||
});
|
||||
roleIds[key] = role.id;
|
||||
}
|
||||
|
||||
const users = {} as TenantFixture["users"];
|
||||
const ctx = {} as TenantFixture["ctx"];
|
||||
for (const persona of Object.keys(PERSONAS) as Persona[]) {
|
||||
const email = `${persona}-${slug}${E2E_DOMAIN}`;
|
||||
const identity = await prisma.identity.upsert({ where: { email }, update: {}, create: { email, passwordHash: "x" } });
|
||||
const user = await prisma.user.create({
|
||||
data: { tenantId: tenant.id, identityId: identity.id, email, name: `${persona} ${slug}`, userRoles: { create: [{ roleId: roleIds[PERSONAS[persona]] }] } },
|
||||
});
|
||||
users[persona] = { id: user.id, email, identityId: identity.id };
|
||||
ctx[persona] = ctxFor(tenant.id, user.id, PERSONAS[persona]);
|
||||
}
|
||||
|
||||
const since = new Date(Date.now() - 30 * 86400_000);
|
||||
const team = await prisma.team.create({ data: { tenantId: tenant.id, name: "Team Nord", leaderUserId: users.lead.id } });
|
||||
await prisma.teamMember.createMany({
|
||||
data: [users.tech.id, users.tech2.id].map((userId) => ({ tenantId: tenant.id, teamId: team.id, userId, validFrom: since })),
|
||||
});
|
||||
const team2 = await prisma.team.create({ data: { tenantId: tenant.id, name: "Team Süd", leaderUserId: users.lead2.id } });
|
||||
await prisma.teamMember.create({ data: { tenantId: tenant.id, teamId: team2.id, userId: users.outsider.id, validFrom: since } });
|
||||
|
||||
const customer = await prisma.customer.create({
|
||||
data: { tenantId: tenant.id, customerNumber: `K-${slug}`, companyName: `Hausverwaltung ${slug} GmbH`, street: "Hafenstraße", houseNumber: "12", postalCode: "20457", city: "Hamburg", phone: "040 555 1000", email: `kontakt@${slug}.example` },
|
||||
});
|
||||
const contact = await prisma.contact.create({ data: { tenantId: tenant.id, customerId: customer.id, name: "Frau Prüf", role: "Objektbetreuung", phone: "040 555 1001" } });
|
||||
const site = await prisma.site.create({
|
||||
data: { tenantId: tenant.id, customerId: customer.id, contactId: contact.id, name: `Wohnanlage ${slug}`, street: "Am Kaiserkai", houseNumber: "30", postalCode: "20457", city: "Hamburg", accessNotes: "Schlüssel beim Hausmeister" },
|
||||
});
|
||||
|
||||
return { slug, tenantId: tenant.id, users, ctx, teamId: team.id, team2Id: team2.id, customerId: customer.id, contactId: contact.id, siteId: site.id };
|
||||
}
|
||||
|
||||
/** Deletes all rows of the given tenants (all TENANT_MODELS, FK-safe) plus their test identities. */
|
||||
export async function cleanupTenants(slugs: string[]): Promise<void> {
|
||||
const tenants = await prisma.tenant.findMany({ where: { slug: { in: slugs } }, select: { id: true } });
|
||||
const ids = tenants.map((t) => t.id);
|
||||
if (ids.length) {
|
||||
const w = { where: { tenantId: { in: ids } } };
|
||||
await prisma.syncOperation.deleteMany(w);
|
||||
await prisma.notification.deleteMany(w);
|
||||
await prisma.notificationPreference.deleteMany(w);
|
||||
await prisma.mailLog.deleteMany(w);
|
||||
await prisma.authToken.deleteMany(w);
|
||||
await prisma.aiGeneration.deleteMany(w);
|
||||
await prisma.signature.deleteMany(w);
|
||||
await prisma.report.deleteMany(w);
|
||||
await prisma.photo.deleteMany(w);
|
||||
await prisma.activityNote.deleteMany(w);
|
||||
await prisma.voiceNote.deleteMany(w);
|
||||
await prisma.timeEntry.deleteMany(w);
|
||||
await prisma.materialUsage.deleteMany(w);
|
||||
await prisma.workSession.deleteMany(w);
|
||||
await prisma.materialPlan.deleteMany(w);
|
||||
await prisma.photoRequirement.deleteMany(w);
|
||||
await prisma.checklistItem.deleteMany(w);
|
||||
await prisma.workOrderStatusChange.deleteMany(w);
|
||||
await prisma.workOrderAssignee.deleteMany(w);
|
||||
await prisma.document.updateMany({ ...w, data: { workOrderId: null, siteId: null, customerId: null } });
|
||||
await prisma.workOrder.deleteMany(w);
|
||||
await prisma.importJob.deleteMany(w);
|
||||
await prisma.document.deleteMany(w);
|
||||
await prisma.teamMember.deleteMany(w);
|
||||
await prisma.team.deleteMany(w);
|
||||
await prisma.site.deleteMany(w);
|
||||
await prisma.contact.deleteMany(w);
|
||||
await prisma.customer.deleteMany(w);
|
||||
await prisma.checklistTemplate.deleteMany(w);
|
||||
await prisma.orderType.deleteMany(w);
|
||||
await prisma.numberSequence.deleteMany(w);
|
||||
await prisma.tenantModule.deleteMany(w);
|
||||
await prisma.tenantSettings.deleteMany(w);
|
||||
await prisma.auditLog.deleteMany(w);
|
||||
await prisma.userRole.deleteMany({ where: { user: { tenantId: { in: ids } } } });
|
||||
await prisma.user.deleteMany(w);
|
||||
await prisma.rolePermission.deleteMany({ where: { role: { tenantId: { in: ids } } } });
|
||||
await prisma.role.deleteMany(w);
|
||||
await prisma.tenant.deleteMany({ where: { id: { in: ids } } });
|
||||
}
|
||||
await prisma.identity.deleteMany({ where: { email: { endsWith: E2E_DOMAIN }, memberships: { none: {} } } });
|
||||
}
|
||||
|
||||
/** Runs `body` between setup/cleanup, prints the summary and exits with the right code. */
|
||||
export async function runSuite(name: string, slugs: string[], body: () => Promise<void>): Promise<never> {
|
||||
let crashed = false;
|
||||
try {
|
||||
await cleanupTenants(slugs);
|
||||
await body();
|
||||
} catch (err) {
|
||||
crashed = true;
|
||||
console.error(`\n✗ FEHLER ${name} abgebrochen:`, err);
|
||||
} finally {
|
||||
await cleanupTenants(slugs).catch((e) => console.error("cleanup failed", e));
|
||||
try {
|
||||
const { closeJobQueues } = await import("../../src/server/jobs/queues");
|
||||
await closeJobQueues();
|
||||
} catch {
|
||||
/* queues optional */
|
||||
}
|
||||
try {
|
||||
const { closeQueues } = await import("../../src/server/mail/queue");
|
||||
await closeQueues();
|
||||
} catch {
|
||||
/* mail queue optional */
|
||||
}
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
const n = failures() + (crashed ? 1 : 0);
|
||||
console.log(n === 0 ? `\nOK — ${name}: alle Nachweise erfüllt.` : `\n${n} FEHLER in ${name}.`);
|
||||
process.exit(n === 0 ? 0 : 1);
|
||||
}
|
||||
|
||||
// ---------- binary fixtures ----------
|
||||
|
||||
export async function jpegBytes(label = "Foto"): Promise<Buffer> {
|
||||
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="640" height="480"><rect width="640" height="480" fill="#cfd8e3"/><text x="320" y="250" font-size="40" text-anchor="middle" font-family="sans-serif">${label}</text></svg>`;
|
||||
return sharp(Buffer.from(svg)).jpeg({ quality: 70 }).toBuffer();
|
||||
}
|
||||
|
||||
export async function pngBytes(): Promise<Buffer> {
|
||||
return sharp({ create: { width: 300, height: 100, channels: 3, background: { r: 255, g: 255, b: 255 } } }).png().toBuffer();
|
||||
}
|
||||
|
||||
/** Local calendar day key (Europe/Berlin) with an offset in days. */
|
||||
export function dayKey(offsetDays = 0): string {
|
||||
const d = new Date(Date.now() + offsetDays * 86400_000);
|
||||
return new Intl.DateTimeFormat("en-CA", { timeZone: "Europe/Berlin", year: "numeric", month: "2-digit", day: "2-digit" }).format(d);
|
||||
}
|
||||
|
||||
/** Instant at a given Berlin wall-clock hour of the day `offsetDays` from today. */
|
||||
export function berlinAt(offsetDays: number, hh: number, mm = 0): Date {
|
||||
const key = dayKey(offsetDays);
|
||||
// Europe/Berlin is UTC+1 or UTC+2; resolve the offset of that day at noon.
|
||||
const probe = new Date(`${key}T12:00:00Z`);
|
||||
const berlinNoon = Number(new Intl.DateTimeFormat("en-GB", { timeZone: "Europe/Berlin", hour: "2-digit", hour12: false }).format(probe));
|
||||
const offsetHours = berlinNoon - 12;
|
||||
return new Date(`${key}T${String(hh).padStart(2, "0")}:${String(mm).padStart(2, "0")}:00${offsetHours >= 0 ? "+" : "-"}${String(Math.abs(offsetHours)).padStart(2, "0")}:00`);
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
/**
|
||||
* L10a Last-Seed (Spec §34.1): füllt den Demo-Mandanten mit N Aufträgen (Default 5 000) für
|
||||
* Performance-Messungen der Auftragsliste und des Dashboards.
|
||||
*
|
||||
* npx tsx scripts/seed-load.ts # 5 000 Aufträge (idempotent: fehlende werden ergänzt)
|
||||
* LOAD_ORDERS=20000 npx tsx scripts/seed-load.ts
|
||||
* npx tsx scripts/seed-load.ts --reset # Last-Daten wieder entfernen
|
||||
*
|
||||
* Datenbild: 250 Kunden mit je einem Objekt, Aufträge über alle Status verteilt (Schwerpunkt offen),
|
||||
* Termine −90…+60 Tage, Teams Nord/Süd mit Einzelzuweisungen, je Auftrag 2 Materialvorgaben, jeder
|
||||
* zehnte mit Statushistorie und eingereichtem Bericht. Schnell über createMany (Owner-Client, nur
|
||||
* Testdaten) — fachliche Abläufe testet scripts/test-e2e-*.ts. Kennzeichen: Nummer `L-…`,
|
||||
* externe Auftragsnummer `LOAD-…`, Kundennummer `K-L…`.
|
||||
*
|
||||
* Messung danach: BASE=http://localhost:3110 PERF=1 npx tsx scripts/smoke-auth.ts
|
||||
*/
|
||||
import "dotenv/config";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { Prisma, PrismaClient, type WorkOrderStatus } from "@prisma/client";
|
||||
import { PrismaPg } from "@prisma/adapter-pg";
|
||||
|
||||
const prisma = new PrismaClient({ adapter: new PrismaPg({ connectionString: process.env.DATABASE_URL }) });
|
||||
const TARGET = Number(process.env.LOAD_ORDERS ?? 5000);
|
||||
const CUSTOMERS = 250;
|
||||
const CHUNK = 1000;
|
||||
|
||||
// weighted status mix (open work dominates a real tenant)
|
||||
const STATUS_MIX: [WorkOrderStatus, number][] = [
|
||||
["draft", 3], ["review_required", 2], ["planned", 10], ["assigned", 12], ["accepted", 6], ["en_route", 2],
|
||||
["in_progress", 8], ["paused", 2], ["waiting_material", 2], ["daily_report_created", 2], ["technically_completed", 2],
|
||||
["signature_pending", 2], ["in_review", 5], ["released_for_billing", 8], ["billed", 30], ["cancelled", 4],
|
||||
];
|
||||
const WEIGHT = STATUS_MIX.reduce((s, [, w]) => s + w, 0);
|
||||
|
||||
function statusFor(i: number): WorkOrderStatus {
|
||||
let x = (i * 7919) % WEIGHT;
|
||||
for (const [s, w] of STATUS_MIX) {
|
||||
if (x < w) return s;
|
||||
x -= w;
|
||||
}
|
||||
return "planned";
|
||||
}
|
||||
|
||||
async function reset(tenantId: string) {
|
||||
const orders = await prisma.workOrder.findMany({ where: { tenantId, externalOrderNumber: { startsWith: "LOAD-" } }, select: { id: true } });
|
||||
const ids = orders.map((o) => o.id);
|
||||
for (let i = 0; i < ids.length; i += CHUNK) {
|
||||
const w = { workOrderId: { in: ids.slice(i, i + CHUNK) } };
|
||||
await prisma.report.deleteMany({ where: w });
|
||||
await prisma.workOrderStatusChange.deleteMany({ where: w });
|
||||
await prisma.workOrder.deleteMany({ where: { id: { in: ids.slice(i, i + CHUNK) } } });
|
||||
}
|
||||
await prisma.site.deleteMany({ where: { tenantId, customer: { customerNumber: { startsWith: "K-L" } } } });
|
||||
const c = await prisma.customer.deleteMany({ where: { tenantId, customerNumber: { startsWith: "K-L" } } });
|
||||
console.log(`✔ Last-Daten entfernt: ${ids.length} Aufträge, ${c.count} Kunden`);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const tenant = await prisma.tenant.findUnique({ where: { slug: "demo" } });
|
||||
if (!tenant) throw new Error('Mandant "demo" fehlt — zuerst `npx prisma db seed`.');
|
||||
const tenantId = tenant.id;
|
||||
if (process.argv.includes("--reset")) return reset(tenantId);
|
||||
|
||||
const teams = await prisma.team.findMany({ where: { tenantId, name: { in: ["Team Nord", "Team Süd"] } }, include: { members: true } });
|
||||
if (teams.length < 2) throw new Error("Demo-Teams fehlen — Seed mit Demo-Daten ausführen (SEED_DEMO).");
|
||||
const orderTypes = await prisma.orderType.findMany({ where: { tenantId }, select: { id: true } });
|
||||
const creator = await prisma.user.findFirstOrThrow({ where: { tenantId, email: "backoffice@demo.example" } });
|
||||
|
||||
// customers + sites
|
||||
const existingCustomers = await prisma.customer.count({ where: { tenantId, customerNumber: { startsWith: "K-L" } } });
|
||||
if (existingCustomers < CUSTOMERS) {
|
||||
const rows = Array.from({ length: CUSTOMERS }, (_, i) => ({
|
||||
id: randomUUID(),
|
||||
tenantId,
|
||||
customerNumber: `K-L${String(i + 1).padStart(4, "0")}`,
|
||||
companyName: `Lastkunde ${String(i + 1).padStart(3, "0")} GmbH`,
|
||||
street: "Lastweg",
|
||||
houseNumber: String(i + 1),
|
||||
postalCode: String(20000 + (i % 900)),
|
||||
city: i % 2 ? "Hamburg" : "Lübeck",
|
||||
}));
|
||||
await prisma.customer.createMany({ data: rows, skipDuplicates: true });
|
||||
const customers = await prisma.customer.findMany({ where: { tenantId, customerNumber: { startsWith: "K-L" }, sites: { none: {} } }, select: { id: true, customerNumber: true } });
|
||||
await prisma.site.createMany({ data: customers.map((c) => ({ tenantId, customerId: c.id, name: `Objekt ${c.customerNumber}`, street: "Lastweg", city: "Hamburg", accessNotes: "Schlüssel beim Hausmeister" })) });
|
||||
}
|
||||
const customers = await prisma.customer.findMany({ where: { tenantId, customerNumber: { startsWith: "K-L" } }, select: { id: true, sites: { select: { id: true }, take: 1 } }, orderBy: { customerNumber: "asc" } });
|
||||
|
||||
const existing = await prisma.workOrder.count({ where: { tenantId, externalOrderNumber: { startsWith: "LOAD-" } } });
|
||||
const missing = Math.max(0, TARGET - existing);
|
||||
console.log(`Mandant demo: ${existing} Last-Aufträge vorhanden, lege ${missing} an …`);
|
||||
const t0 = Date.now();
|
||||
const day = 86400_000;
|
||||
for (let offset = existing; offset < existing + missing; offset += CHUNK) {
|
||||
const n = Math.min(CHUNK, existing + missing - offset);
|
||||
const orders: Prisma.WorkOrderCreateManyInput[] = [];
|
||||
const assignees: Prisma.WorkOrderAssigneeCreateManyInput[] = [];
|
||||
const plans: Prisma.MaterialPlanCreateManyInput[] = [];
|
||||
const history: Prisma.WorkOrderStatusChangeCreateManyInput[] = [];
|
||||
const reports: Prisma.ReportCreateManyInput[] = [];
|
||||
for (let k = 0; k < n; k++) {
|
||||
const i = offset + k;
|
||||
const status = statusFor(i);
|
||||
const team = teams[i % 2];
|
||||
const customer = customers[i % customers.length];
|
||||
const start = new Date(Date.now() + (((i * 37) % 150) - 90) * day + ((i % 9) + 7) * 3600_000);
|
||||
const id = randomUUID();
|
||||
const planned = !["draft", "review_required"].includes(status);
|
||||
orders.push({
|
||||
id,
|
||||
tenantId,
|
||||
number: `L-${String(i + 1).padStart(6, "0")}`,
|
||||
externalOrderNumber: `LOAD-${i + 1}`,
|
||||
customerId: customer.id,
|
||||
siteId: customer.sites[0]?.id ?? null,
|
||||
orderTypeId: orderTypes.length ? orderTypes[i % orderTypes.length].id : null,
|
||||
priority: i % 17 === 0 ? "urgent" : i % 5 === 0 ? "high" : "normal",
|
||||
status,
|
||||
title: `Lastauftrag ${i + 1}: ${["Wartung Heizung", "Reparatur Leitung", "Montage Therme", "Störung Lüftung"][i % 4]}`,
|
||||
description: "Automatisch erzeugter Auftrag für Lasttests.",
|
||||
plannedStart: start,
|
||||
plannedEnd: new Date(start.getTime() + 4 * 3600_000),
|
||||
assignedTeamId: planned ? team.id : null,
|
||||
teamLeadUserId: planned ? team.leaderUserId : null,
|
||||
isEmergency: i % 97 === 0,
|
||||
createdById: creator.id,
|
||||
createdAt: new Date(start.getTime() - 14 * day),
|
||||
});
|
||||
const member = team.members[i % Math.max(1, team.members.length)];
|
||||
if (planned && member) assignees.push({ tenantId, workOrderId: id, userId: member.userId });
|
||||
plans.push(
|
||||
{ tenantId, workOrderId: id, name: "Kupferrohr 15 mm", plannedQuantity: new Prisma.Decimal(10), unit: "m" },
|
||||
{ tenantId, workOrderId: id, name: "Pressfitting 15 mm", plannedQuantity: new Prisma.Decimal(6), unit: "Stk", sortOrder: 1 },
|
||||
);
|
||||
if (i % 10 === 0) {
|
||||
history.push({ tenantId, workOrderId: id, fromStatus: null, toStatus: "planned", actorId: creator.id }, { tenantId, workOrderId: id, fromStatus: "planned", toStatus: status, actorId: creator.id });
|
||||
if (["in_review", "released_for_billing", "billed"].includes(status)) {
|
||||
reports.push({ tenantId, workOrderId: id, type: "completion", reportDate: start, lineageId: randomUUID(), status: status === "in_review" ? "submitted" : "approved", content: {}, createdById: creator.id });
|
||||
}
|
||||
}
|
||||
}
|
||||
await prisma.workOrder.createMany({ data: orders });
|
||||
await prisma.workOrderAssignee.createMany({ data: assignees, skipDuplicates: true });
|
||||
await prisma.materialPlan.createMany({ data: plans });
|
||||
if (history.length) await prisma.workOrderStatusChange.createMany({ data: history });
|
||||
if (reports.length) await prisma.report.createMany({ data: reports });
|
||||
process.stdout.write(` ${offset + n - existing}/${missing}\r`);
|
||||
}
|
||||
await prisma.$executeRawUnsafe("ANALYZE work_orders, work_order_assignees, material_plans, reports, customers, sites");
|
||||
const total = await prisma.workOrder.count({ where: { tenantId } });
|
||||
console.log(`\n✔ ${missing} Last-Aufträge in ${((Date.now() - t0) / 1000).toFixed(1)} s angelegt — Mandant demo hat jetzt ${total} Aufträge.`);
|
||||
}
|
||||
|
||||
main()
|
||||
.then(() => prisma.$disconnect())
|
||||
.catch(async (e) => {
|
||||
console.error(e);
|
||||
await prisma.$disconnect();
|
||||
process.exit(1);
|
||||
});
|
||||
+201
-45
@@ -3,8 +3,15 @@
|
||||
* builds the session user via finalizeIdentityLogin (same code path as a real login after
|
||||
* password/MFA checks) and encodes the Auth.js session cookie with AUTH_SECRET.
|
||||
*
|
||||
* Usage (from repo root, dev server running):
|
||||
* BASE=http://localhost:3099 npx tsx scripts/smoke-auth.ts
|
||||
* L10a: covers all core pages per role (admin, backoffice, team lead, technician Nord/Süd, second
|
||||
* tenant) on the demo data of prisma/seed.ts; ids are resolved from the DB at runtime. Each check
|
||||
* verifies status, expected text and the absence of error pages/foreign data. PERF=1 measures the
|
||||
* server render time (second, warm request) and flags pages above PERF_BUDGET_MS (default 2000).
|
||||
*
|
||||
* Usage (from repo root, dev server running, demo seed loaded):
|
||||
* BASE=http://localhost:3110 npx tsx scripts/smoke-auth.ts
|
||||
* BASE=http://localhost:3110 PERF=1 npx tsx scripts/smoke-auth.ts # with timings (after scripts/seed-load.ts)
|
||||
* ONLY=monteur npx tsx scripts/smoke-auth.ts # filter by e-mail substring
|
||||
*/
|
||||
import "dotenv/config";
|
||||
import { encode } from "next-auth/jwt";
|
||||
@@ -13,40 +20,163 @@ import { finalizeIdentityLogin } from "../src/server/auth";
|
||||
|
||||
const BASE = process.env.BASE ?? "http://localhost:3099";
|
||||
const COOKIE = BASE.startsWith("https") ? "__Secure-authjs.session-token" : "authjs.session-token";
|
||||
const PERF = process.env.PERF === "1";
|
||||
const BUDGET = Number(process.env.PERF_BUDGET_MS ?? 2000);
|
||||
|
||||
type Check = { path: string; expect?: number[]; mustContain?: string };
|
||||
type Check = { path: string; expect?: number[]; mustContain?: string | string[]; mustNotContain?: string[]; redirectTo?: string; perf?: boolean };
|
||||
type Plan = { email: string; tenant: string; checks: Check[] };
|
||||
|
||||
const PLAN: Record<string, Check[]> = {
|
||||
"backoffice@demo.example": [
|
||||
{ path: "/dashboard", expect: [200] },
|
||||
{ path: "/work-orders", expect: [200] },
|
||||
{ path: "/imports", expect: [200] },
|
||||
{ path: "/customers", expect: [200] },
|
||||
{ path: "/sites", expect: [200] },
|
||||
{ path: "/teams", expect: [200] },
|
||||
{ path: "/reports", expect: [200] },
|
||||
{ path: "/documents", expect: [200] },
|
||||
{ path: "/notifications", expect: [200] },
|
||||
{ path: "/search?q=a", expect: [200] },
|
||||
{ path: "/work-orders/emergency-review", expect: [200] },
|
||||
{ path: "/settings/order-types", expect: [200] },
|
||||
{ path: "/settings/audit", expect: [200, 403, 307] },
|
||||
],
|
||||
"monteur@demo.example": [
|
||||
{ path: "/", expect: [307, 308] },
|
||||
{ path: "/m", expect: [200] },
|
||||
{ path: "/m/orders", expect: [200] },
|
||||
{ path: "/m/emergency", expect: [200] },
|
||||
{ path: "/m/sync", expect: [200] },
|
||||
{ path: "/m/profile", expect: [200] },
|
||||
],
|
||||
};
|
||||
async function ids() {
|
||||
const demo = await prisma.tenant.findUniqueOrThrow({ where: { slug: "demo" } });
|
||||
const order = async (ext: string) => prisma.workOrder.findFirst({ where: { tenantId: demo.id, externalOrderNumber: ext }, select: { id: true, number: true } });
|
||||
const [d07, d08, d14, d15, d16, d17, d18] = await Promise.all(["DEMO-07", "DEMO-08", "DEMO-14", "DEMO-15", "DEMO-16", "DEMO-17", "DEMO-18"].map(order));
|
||||
if (!d07 || !d18) throw new Error("Demo-Aufträge fehlen — `npx prisma db seed` (SEED_DEMO) ausführen.");
|
||||
const emergency = await prisma.workOrder.findFirst({ where: { tenantId: demo.id, isEmergency: true, emergencyReason: { startsWith: "Wasserrohrbruch" } }, select: { id: true, number: true } });
|
||||
const customer = await prisma.customer.findFirstOrThrow({ where: { tenantId: demo.id, customerNumber: "K-D001" } });
|
||||
const foreignCustomer = await prisma.customer.findFirstOrThrow({ where: { tenantId: demo.id, customerNumber: "K-D004" } });
|
||||
const site = await prisma.site.findFirstOrThrow({ where: { tenantId: demo.id, name: "Wohnanlage Elbblick – Haus A" } });
|
||||
const approved = await prisma.report.findFirstOrThrow({ where: { workOrderId: d18.id, status: "approved" } });
|
||||
const inReview = d16 ? await prisma.report.findFirst({ where: { workOrderId: d16.id } }) : null;
|
||||
const imp = await prisma.importJob.findFirst({ where: { tenantId: demo.id, status: "review_required" }, orderBy: { createdAt: "desc" } });
|
||||
const photo = await prisma.photo.findFirst({ where: { workOrderId: d07.id }, select: { documentId: true } });
|
||||
return { d07, d08, d14, d15, d16, d17, d18, emergency, customer, foreignCustomer, site, approved, inReview, imp, photo };
|
||||
}
|
||||
|
||||
async function cookieFor(email: string): Promise<string> {
|
||||
function plans(x: Awaited<ReturnType<typeof ids>>): Plan[] {
|
||||
const wo = (o: { id: string } | null, suffix = "") => (o ? `/work-orders/${o.id}${suffix}` : "/work-orders");
|
||||
const m = (o: { id: string } | null, suffix = "") => (o ? `/m/orders/${o.id}${suffix}` : "/m/orders");
|
||||
const tabs = ["overview", "checklist", "material", "times", "photos", "notes", "reports", "documents", "history"];
|
||||
return [
|
||||
{
|
||||
email: "admin@demo.example",
|
||||
tenant: "demo",
|
||||
checks: [
|
||||
{ path: "/dashboard", mustContain: ["Offene Aufträge", "Überfällig"] },
|
||||
{ path: "/settings" },
|
||||
{ path: "/settings/users", mustContain: "backoffice@demo.example" },
|
||||
{ path: "/settings/audit" },
|
||||
{ path: "/settings/email" },
|
||||
{ path: "/settings/lotse" },
|
||||
{ path: "/settings/lotse/protocol" },
|
||||
{ path: "/settings/order-types", mustContain: "Montage" },
|
||||
{ path: "/settings/checklists", mustContain: "Montage Heizung/Sanitär" },
|
||||
{ path: "/settings/numbering" },
|
||||
{ path: "/account" },
|
||||
{ path: "/teams", mustContain: ["Team Nord", "Team Süd"] },
|
||||
],
|
||||
},
|
||||
{
|
||||
email: "backoffice@demo.example",
|
||||
tenant: "demo",
|
||||
checks: [
|
||||
{ path: "/", expect: [307, 308], redirectTo: "/dashboard" },
|
||||
{ path: "/dashboard", mustContain: "Berichte zur Prüfung", perf: true },
|
||||
{ path: "/work-orders", perf: true },
|
||||
{ path: "/work-orders?q=DEMO-07", mustContain: x.d07.number },
|
||||
{ path: "/work-orders?view=table", perf: true },
|
||||
{ path: "/work-orders?preset=overdue", perf: true },
|
||||
{ path: "/work-orders?group=in_review", perf: true },
|
||||
{ path: "/work-orders?q=Elbblick", perf: true },
|
||||
{ path: "/work-orders?page=3", perf: true },
|
||||
...tabs.map((t) => ({ path: wo(x.d07, `?tab=${t}`), mustContain: x.d07.number })),
|
||||
{ path: wo(x.d18, "?tab=reports"), mustContain: x.d18.number },
|
||||
{ path: "/work-orders/conflicts" },
|
||||
{ path: "/work-orders/emergency-review", mustContain: x.emergency?.number },
|
||||
...(x.emergency ? [{ path: `/work-orders/emergency-review/${x.emergency.id}`, mustContain: x.emergency.number }] : []),
|
||||
{ path: "/imports" },
|
||||
...(x.imp ? [{ path: `/imports/${x.imp.id}`, mustContain: "Hausverwaltung Elbblick" }] : []),
|
||||
{ path: "/customers", mustContain: "Hausverwaltung Elbblick" },
|
||||
{ path: "/customers?status=provisional" },
|
||||
{ path: `/customers/${x.customer.id}`, mustContain: "Hausverwaltung Elbblick" },
|
||||
{ path: "/sites", mustContain: "Elbblick" },
|
||||
{ path: `/sites/${x.site.id}`, mustContain: "Haus A" },
|
||||
{ path: `/sites/${x.site.id}?tab=history`, mustContain: x.d18.number },
|
||||
{ path: "/reports", perf: true },
|
||||
{ path: `/reports/${x.approved.id}`, mustContain: x.d18.number },
|
||||
...(x.inReview ? [{ path: `/reports/${x.inReview.id}` }] : []),
|
||||
{ path: "/documents" },
|
||||
{ path: "/notifications" },
|
||||
{ path: "/search?q=Elbblick", mustContain: "Elbblick", perf: true },
|
||||
{ path: "/teams" },
|
||||
{ path: "/settings/users", expect: [200, 307, 403] },
|
||||
],
|
||||
},
|
||||
{
|
||||
email: "teamleiter@demo.example",
|
||||
tenant: "demo",
|
||||
checks: [
|
||||
{ path: "/", expect: [307, 308], redirectTo: "/m" },
|
||||
{ path: "/m", perf: true },
|
||||
{ path: "/m/orders", perf: true },
|
||||
{ path: m(x.d14), mustContain: x.d14?.number },
|
||||
{ path: m(x.d16), mustContain: x.d16?.number },
|
||||
{ path: "/reports" },
|
||||
...(x.inReview ? [{ path: `/reports/${x.inReview.id}`, mustContain: x.d16?.number }] : []),
|
||||
{ path: m(x.d08), expect: [404] },
|
||||
],
|
||||
},
|
||||
{
|
||||
email: "monteur@demo.example",
|
||||
tenant: "demo",
|
||||
checks: [
|
||||
{ path: "/", expect: [307, 308], redirectTo: "/m" },
|
||||
{ path: "/m", mustContain: x.d07.number, perf: true },
|
||||
{ path: "/m/orders", perf: true },
|
||||
{ path: "/m/orders?tab=running", mustContain: x.d07.number },
|
||||
{ path: m(x.d07), mustContain: [x.d07.number, "Schlüssel"] },
|
||||
{ path: m(x.d07, "/photos") },
|
||||
{ path: m(x.d07, "/materials"), mustContain: "Kupferrohr" },
|
||||
{ path: m(x.d07, "/notes") },
|
||||
{ path: m(x.d07, "/checklist") },
|
||||
{ path: m(x.d07, "/time") },
|
||||
{ path: m(x.d07, "/report?type=daily") },
|
||||
{ path: m(x.d17, "/report?type=completion") },
|
||||
{ path: m(x.d17, "/sign") },
|
||||
...(x.emergency ? [{ path: m(x.emergency), mustContain: x.emergency.number }] : []),
|
||||
{ path: "/m/emergency" },
|
||||
{ path: "/m/sync" },
|
||||
{ path: "/m/offline" },
|
||||
{ path: "/m/profile", mustContain: "Max Monteur" },
|
||||
...(x.photo ? [{ path: `/files/${x.photo.documentId}`, expect: [200] }] : []),
|
||||
{ path: m(x.d08), expect: [404] },
|
||||
{ path: `/customers/${x.foreignCustomer.id}`, expect: [404, 307] },
|
||||
{ path: "/dashboard", expect: [307, 308], redirectTo: "/m" },
|
||||
],
|
||||
},
|
||||
{
|
||||
email: "monteur3@demo.example",
|
||||
tenant: "demo",
|
||||
checks: [
|
||||
{ path: "/m", mustContain: x.d08?.number },
|
||||
{ path: m(x.d15, "/sign"), mustContain: x.d15?.number },
|
||||
{ path: m(x.d07), expect: [404] },
|
||||
],
|
||||
},
|
||||
{
|
||||
email: "admin2@demo.example",
|
||||
tenant: "demo2",
|
||||
checks: [
|
||||
{ path: "/dashboard", mustContain: "Offene Aufträge", mustNotContain: ["Elbblick"] },
|
||||
{ path: "/work-orders", mustContain: "DGUV", mustNotContain: [x.d07.number, "Elbblick"] },
|
||||
{ path: "/customers", mustNotContain: ["Elbblick"] },
|
||||
{ path: "/search?q=Elbblick", mustNotContain: ["Hausverwaltung Elbblick"] },
|
||||
{ path: wo(x.d07), expect: [404] },
|
||||
{ path: `/customers/${x.customer.id}`, expect: [404] },
|
||||
{ path: `/sites/${x.site.id}`, expect: [404] },
|
||||
{ path: `/reports/${x.approved.id}`, expect: [404] },
|
||||
...(x.emergency ? [{ path: `/work-orders/emergency-review/${x.emergency.id}`, expect: [404] }] : []),
|
||||
...(x.imp ? [{ path: `/imports/${x.imp.id}`, expect: [404] }] : []),
|
||||
...(x.photo ? [{ path: `/files/${x.photo.documentId}`, expect: [404] }] : []),
|
||||
],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
async function cookieFor(email: string, tenant: string): Promise<string> {
|
||||
const identity = await prisma.identity.findUnique({ where: { email } });
|
||||
if (!identity) throw new Error(`identity ${email} not found (seed?)`);
|
||||
const user = await finalizeIdentityLogin(identity.id, "demo");
|
||||
if (!user) throw new Error(`no active membership for ${email}`);
|
||||
const user = await finalizeIdentityLogin(identity.id, tenant);
|
||||
if (!user) throw new Error(`no active membership for ${email} in ${tenant}`);
|
||||
const token = {
|
||||
sub: user.id,
|
||||
name: user.name,
|
||||
@@ -66,26 +196,52 @@ async function cookieFor(email: string): Promise<string> {
|
||||
return `${COOKIE}=${value}`;
|
||||
}
|
||||
|
||||
async function fetchTimed(path: string, cookie: string) {
|
||||
const t0 = performance.now();
|
||||
const res = await fetch(BASE + path, { headers: { cookie }, redirect: "manual", signal: AbortSignal.timeout(120_000) });
|
||||
const body = res.status === 200 || res.status === 404 ? await res.text() : "";
|
||||
return { res, body, ms: Math.round(performance.now() - t0) };
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const only = process.env.ONLY;
|
||||
let failures = 0;
|
||||
for (const [email, checks] of Object.entries(PLAN)) {
|
||||
const cookie = await cookieFor(email);
|
||||
console.log(`\n== ${email}`);
|
||||
for (const c of checks) {
|
||||
const res = await fetch(BASE + c.path, { headers: { cookie }, redirect: "manual" });
|
||||
const body = res.status === 200 ? await res.text() : "";
|
||||
const errorPage = /Application error|Internal Server Error|NEXT_REDIRECT|Unhandled Runtime Error/i.test(body);
|
||||
const okStatus = !c.expect || c.expect.includes(res.status);
|
||||
const okText = !c.mustContain || body.includes(c.mustContain);
|
||||
const ok = okStatus && okText && !errorPage;
|
||||
if (!ok) failures++;
|
||||
const slow: string[] = [];
|
||||
// PERF=1 runs only the perf-marked pages and ignores their content checks: with scripts/seed-load.ts data the
|
||||
// demo orders are no longer guaranteed on the first page / in the capped mobile lists.
|
||||
const all = plans(await ids())
|
||||
.filter((p) => !only || p.email.includes(only))
|
||||
.map((p) => (PERF ? { ...p, checks: p.checks.filter((c) => c.perf).map((c) => ({ ...c, mustContain: undefined })) } : p))
|
||||
.filter((p) => p.checks.length > 0);
|
||||
let total = 0;
|
||||
for (const plan of all) {
|
||||
const cookie = await cookieFor(plan.email, plan.tenant);
|
||||
console.log(`\n== ${plan.email} (${plan.tenant})`);
|
||||
for (const c of plan.checks) {
|
||||
total++;
|
||||
let { res, body, ms } = await fetchTimed(c.path, cookie);
|
||||
if (PERF && c.perf) ({ res, body, ms } = await fetchTimed(c.path, cookie)); // warm measurement
|
||||
const expect = c.expect ?? [200];
|
||||
const errorPage = /Application error|Internal Server Error|Unhandled Runtime Error|NEXT_REDIRECT|digest:/i.test(res.status === 200 ? body : "");
|
||||
const okStatus = expect.includes(res.status);
|
||||
const loc = res.headers.get("location");
|
||||
console.log(`${ok ? "✓" : "✗"} ${String(res.status).padEnd(3)} ${c.path}${loc ? ` → ${loc}` : ""}${errorPage ? " [error page]" : ""}`);
|
||||
const okRedirect = !c.redirectTo || (loc ? new URL(loc, BASE).pathname === c.redirectTo : false);
|
||||
const must = (Array.isArray(c.mustContain) ? c.mustContain : c.mustContain ? [c.mustContain] : []).filter(Boolean) as string[];
|
||||
const missing = res.status === 200 ? must.filter((s) => !body.includes(s)) : [];
|
||||
const leaked = (c.mustNotContain ?? []).filter((s) => body.includes(s));
|
||||
const ok = okStatus && okRedirect && !errorPage && missing.length === 0 && leaked.length === 0;
|
||||
if (!ok) failures++;
|
||||
const perfNote = PERF && c.perf ? ` ${ms} ms${ms > BUDGET ? " ⚠ über Budget" : ""}` : ` (${ms} ms)`;
|
||||
if (PERF && c.perf && ms > BUDGET) slow.push(`${plan.email} ${c.path} ${ms} ms`);
|
||||
console.log(
|
||||
`${ok ? "✓" : "✗"} ${String(res.status).padEnd(3)} ${c.path}${loc ? ` → ${loc}` : ""}${perfNote}${errorPage ? " [error page]" : ""}${missing.length ? ` [fehlt: ${missing.join(", ")}]` : ""}${leaked.length ? ` [fremde Daten: ${leaked.join(", ")}]` : ""}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
await prisma.$disconnect();
|
||||
console.log(failures ? `\n${failures} Fehler` : "\nOK");
|
||||
process.exit(failures ? 1 : 0);
|
||||
if (PERF) console.log(slow.length ? `\n⚠ ${slow.length} Seite(n) über ${BUDGET} ms:\n${slow.join("\n")}` : `\nPerformance: alle gemessenen Seiten ≤ ${BUDGET} ms`);
|
||||
console.log(failures ? `\n${failures} von ${total} Prüfungen fehlgeschlagen` : `\nOK — ${total} Prüfungen`);
|
||||
process.exit(failures || (PERF && slow.length) ? 1 : 0);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
// L10a E2E §43.3 / §39 „Notdiensteinsatz" — Monteur legt Notdienst mit vorläufigem Kunden an → Einsatz →
|
||||
// Abschluss ohne Unterschrift (Kunde abwesend) → Backoffice wird informiert → Backoffice ordnet den
|
||||
// bestehenden Kunden zu, bestätigt das Objekt, ergänzt den Auftrag → Berichtsfreigabe → Abrechnung.
|
||||
// Dazu Rollen (Monteur/Teamleiter dürfen nicht prüfen) und Mandantentrennung.
|
||||
//
|
||||
// Lauf: npx tsx scripts/test-e2e-emergency.ts
|
||||
|
||||
import "dotenv/config";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { prisma } from "../src/server/db";
|
||||
import { createEmergencyOrder } from "../src/server/services/emergency/create";
|
||||
import {
|
||||
assignEmergencyToCustomer,
|
||||
completeEmergencyOrderData,
|
||||
correctEmergencySite,
|
||||
getEmergencyReview,
|
||||
listEmergencyReviews,
|
||||
releaseEmergencyForBilling,
|
||||
} from "../src/server/services/emergency/review";
|
||||
import { markBilled } from "../src/server/services/work-orders/release-billing";
|
||||
import { endSession } from "../src/server/services/field/sessions";
|
||||
import { createNote } from "../src/server/services/field/notes";
|
||||
import { upsertMaterialUsage } from "../src/server/services/field/materials";
|
||||
import { storeFieldUpload } from "../src/server/services/field/uploads";
|
||||
import { attachPhoto } from "../src/server/services/field/photos";
|
||||
import { createCompletionReport } from "../src/server/services/reports/create";
|
||||
import { updateReportTexts } from "../src/server/services/reports/edit";
|
||||
import { captureSignature } from "../src/server/services/reports/signature";
|
||||
import { submitReport } from "../src/server/services/reports/submit";
|
||||
import { approveReport } from "../src/server/services/reports/approve";
|
||||
import { getCustomer } from "../src/server/services/customers/customers";
|
||||
import { getSiteHistory } from "../src/server/services/sites/history";
|
||||
import { materialUpsertPayload, noteCreatePayload, photoAttachPayload, sessionControlPayload } from "../src/lib/sync/ops";
|
||||
import { codeOf, createTenant, expectCode, jpegBytes, ok, runSuite, section } from "./lib/e2e-fixture";
|
||||
|
||||
const SLUG_A = "zz-q-e2e-nd-a";
|
||||
const SLUG_B = "zz-q-e2e-nd-b";
|
||||
const noPdf = { dispatchPdf: async () => undefined };
|
||||
|
||||
runSuite("E2E Notdienst", [SLUG_A, SLUG_B], async () => {
|
||||
const A = await createTenant(SLUG_A);
|
||||
const B = await createTenant(SLUG_B);
|
||||
const bo = A.ctx.backoffice;
|
||||
const tech = A.ctx.tech;
|
||||
|
||||
section("1. Monteur legt Notdienst mit vorläufigem Kunden an");
|
||||
const startedAt = new Date(Date.now() - 2 * 3600_000);
|
||||
const r = await createEmergencyOrder(tech, {
|
||||
clientIds: { workOrder: randomUUID(), session: randomUUID(), customer: randomUUID(), site: randomUUID() },
|
||||
// same phone + postal code as the existing customer → duplicate candidate for the backoffice
|
||||
customer: { mode: "new", lastName: "Hausverwaltung Hafen", phone: "040 5551000", street: "Hafenstraße", houseNumber: "12", postalCode: "20457", city: "Hamburg" },
|
||||
site: { mode: "new", name: "Keller", street: "Hafenstraße", houseNumber: "12", postalCode: "20457", city: "Hamburg" },
|
||||
onSiteContact: { name: "Hausmeister Hafen", phone: "0171 5550000" },
|
||||
reason: "Heizung ausgefallen, Wohnungen ohne Warmwasser",
|
||||
startedAt: startedAt.toISOString(),
|
||||
});
|
||||
const wo = await prisma.workOrder.findUniqueOrThrow({ where: { id: r.workOrderId }, include: { customer: true, site: true } });
|
||||
ok(/^N-\d+$/.test(wo.number) && wo.isEmergency && wo.status === "in_progress", `Notdienstauftrag ${wo.number} in Arbeit`);
|
||||
ok(wo.customer.status === "provisional" && wo.customer.customerNumber === null && wo.site?.status === "provisional", "Kunde und Objekt vorläufig (ohne Kundennummer)");
|
||||
ok((await prisma.workSession.count({ where: { workOrderId: wo.id, status: "running" } })) === 1, "Einsatz läuft (Arbeitszeit gestartet)");
|
||||
ok((await prisma.notification.count({ where: { tenantId: A.tenantId, userId: A.users.backoffice.id, type: "emergency.created", entityId: wo.id } })) === 1, "Backoffice erhält „Notdienst angelegt“");
|
||||
await expectCode(() => getCustomer(A.ctx.outsider, wo.customerId), "not_found", "anderer Monteur sieht den vorläufigen Kunden nicht");
|
||||
await expectCode(() => getCustomer(B.ctx.admin, wo.customerId), "not_found", "Mandant B sieht den vorläufigen Kunden nicht");
|
||||
|
||||
section("2. Dokumentation und Abschluss ohne Unterschrift");
|
||||
await createNote(tech, noteCreatePayload.parse({ workOrderId: wo.id, kind: "work_done", text: "Umwälzpumpe getauscht, Anlage wieder in Betrieb." }));
|
||||
await upsertMaterialUsage(tech, materialUpsertPayload.parse({ workOrderId: wo.id, clientId: randomUUID(), name: "Umwälzpumpe 25-40", quantity: 1, unit: "Stk", usageStatus: "additional", deviationReason: "Pumpe defekt" }));
|
||||
const up = await storeFieldUpload(tech, { clientId: randomUUID(), workOrderId: wo.id, kind: "photo" }, { bytes: await jpegBytes("Pumpe"), name: "pumpe.jpg", type: "image/jpeg" });
|
||||
await attachPhoto(tech, photoAttachPayload.parse({ workOrderId: wo.id, documentId: up.documentId, phase: "after" }));
|
||||
await endSession(tech, sessionControlPayload.parse({ workOrderId: wo.id }));
|
||||
const report = (await createCompletionReport(tech, { workOrderId: wo.id })).report;
|
||||
await updateReportTexts(tech, { reportId: report.id, texts: { workPerformed: "Umwälzpumpe getauscht, Warmwasser wieder verfügbar." } });
|
||||
await expectCode(() => captureSignature(tech, { reportId: report.id, outcome: "customer_absent", confirmationText: "x" }), "invalid", "Kunde abwesend ohne Begründung → invalid");
|
||||
await captureSignature(tech, { reportId: report.id, outcome: "customer_absent", reason: "Nachts niemand erreichbar", confirmationText: "Bestätigt" });
|
||||
await submitReport(tech, { reportId: report.id });
|
||||
ok((await prisma.workOrder.findUniqueOrThrow({ where: { id: wo.id } })).status === "in_review", "Abschlussbericht eingereicht → Zur Prüfung");
|
||||
ok((await prisma.notification.count({ where: { tenantId: A.tenantId, userId: A.users.backoffice.id, type: "emergency.completed", entityId: wo.id } })) === 1, "Backoffice erhält „Notdienst abgeschlossen“ (In-App)");
|
||||
ok((await prisma.notification.count({ where: { tenantId: A.tenantId, userId: A.users.backoffice.id, type: "work_order.signature_missing", entityId: wo.id } })) === 1, "Backoffice erhält Hinweis „Unterschrift fehlt“");
|
||||
const mails = await prisma.mailLog.count({ where: { tenantId: A.tenantId, template: "craftvia_emergency" } });
|
||||
ok(mails >= 1, `Pflicht-E-Mail an das Backoffice eingereiht/protokolliert (${mails})`);
|
||||
|
||||
section("3. Rollen und Mandantentrennung der Nachbearbeitung");
|
||||
await expectCode(() => listEmergencyReviews(tech), "forbidden", "Monteur: Prüfliste → forbidden");
|
||||
await expectCode(() => getEmergencyReview(A.ctx.lead, wo.id), "forbidden", "Teamleiter: Prüfung → forbidden");
|
||||
await expectCode(() => getEmergencyReview(B.ctx.backoffice, wo.id), "not_found", "Mandant B: Prüfung → not_found");
|
||||
await expectCode(() => assignEmergencyToCustomer(B.ctx.backoffice, { workOrderId: wo.id, targetCustomerId: A.customerId }), "not_found", "Mandant B: Kunde zuordnen → not_found");
|
||||
ok(!(await listEmergencyReviews(B.ctx.backoffice, { filter: "all" })).some((x) => x.id === wo.id), "Mandant B: Notdienst nicht in der Liste");
|
||||
|
||||
section("4. Backoffice prüft am nächsten Arbeitstag die Stammdaten");
|
||||
const listed = (await listEmergencyReviews(bo)).find((x) => x.id === wo.id);
|
||||
ok(!!listed && listed.progress.count === 0 && listed.progress.total === 5, "in „Zu prüfen“ mit 0 von 5 Prüfschritten");
|
||||
const review = await getEmergencyReview(bo, wo.id);
|
||||
ok(review.duplicates.some((d) => d.customerId === A.customerId), "bestehender Kunde als Dublettenkandidat vorgeschlagen");
|
||||
await expectCode(() => releaseEmergencyForBilling(bo, { workOrderId: wo.id }), "blocked", "Abrechnung mit vorläufigen Stammdaten → blocked");
|
||||
const assigned = await assignEmergencyToCustomer(bo, { workOrderId: wo.id, targetCustomerId: A.customerId });
|
||||
const provisional = await prisma.customer.findUniqueOrThrow({ where: { id: assigned.sourceId } });
|
||||
const afterAssign = await prisma.workOrder.findUniqueOrThrow({ where: { id: wo.id }, include: { site: true } });
|
||||
ok(afterAssign.customerId === A.customerId && provisional.status === "merged" && provisional.mergedIntoId === A.customerId, "Auftrag dem bestehenden Kunden zugeordnet, vorläufiger Kunde zusammengeführt");
|
||||
ok(afterAssign.site?.customerId === A.customerId && afterAssign.version > wo.version, "Einsatzort mit umgehängt, Auftragsversion erhöht (Offline-Schutz)");
|
||||
await correctEmergencySite(bo, { workOrderId: wo.id, patch: { name: "Heizungskeller Hafenstraße 12", accessNotes: "Hausmeister öffnet" }, confirm: true });
|
||||
ok((await prisma.site.findUniqueOrThrow({ where: { id: afterAssign.siteId! } })).status === "active", "Objekt korrigiert und bestätigt");
|
||||
const notdienst = await prisma.orderType.findFirstOrThrow({ where: { tenantId: A.tenantId, key: "notdienst" } });
|
||||
await completeEmergencyOrderData(bo, { workOrderId: wo.id, title: "Notdienst: Ausfall Heizung", orderTypeId: notdienst.id, billingType: "time_material" });
|
||||
await expectCode(() => releaseEmergencyForBilling(bo, { workOrderId: wo.id }), "blocked", "Abrechnung ohne freigegebenen Bericht → blocked");
|
||||
await approveReport(A.ctx.lead, { reportId: report.id }, noPdf);
|
||||
await approveReport(bo, { reportId: report.id }, noPdf);
|
||||
ok((await getEmergencyReview(bo, wo.id)).progress.count === 4, "4 von 5 Prüfschritten erledigt");
|
||||
|
||||
section("5. Auftrag wird zur Abrechnung freigegeben");
|
||||
const released = await releaseEmergencyForBilling(bo, { workOrderId: wo.id });
|
||||
ok(released.status === "released_for_billing", "Notdienst zur Abrechnung freigegeben");
|
||||
ok((await getEmergencyReview(bo, wo.id)).progress.count === 5, "5 von 5 Prüfschritten erledigt");
|
||||
ok(!(await listEmergencyReviews(bo)).some((x) => x.id === wo.id) && (await listEmergencyReviews(bo, { filter: "all" })).some((x) => x.id === wo.id), "aus „Zu prüfen“ entfernt, unter „Alle“ sichtbar");
|
||||
ok((await codeOf(markBilled(tech, { workOrderId: wo.id }))) === "forbidden", "Monteur darf nicht abrechnen");
|
||||
ok((await markBilled(bo, { workOrderId: wo.id })).status === "billed", "abgerechnet");
|
||||
const audits = await prisma.auditLog.count({ where: { tenantId: A.tenantId, entity: "emergency", entityId: wo.id } });
|
||||
ok(audits >= 4, `Prüfschritte auditiert (${audits})`);
|
||||
|
||||
const hist = await getSiteHistory(bo, afterAssign.siteId!);
|
||||
const entry = hist.items.find((i) => i.workOrderId === wo.id);
|
||||
ok(!!entry && entry.isEmergency && entry.approvedReports.length === 1 && !entry.signed, "Objekt-Historie: Notdienst mit freigegebenem Bericht, ohne Unterschrift");
|
||||
});
|
||||
@@ -0,0 +1,151 @@
|
||||
// L10a E2E §43.3 „Fehlende Pflichtfotos" und „Fehlende Unterschrift".
|
||||
//
|
||||
// Pflichtfotos: Pflichtfoto + Checklistenpunkt „mit Foto" blockieren technisch abschließen,
|
||||
// Abschlussbericht und Sync-Op mit strukturierter Blocker-Liste; Blocker verschwinden erst mit den
|
||||
// passenden Fotos. Unterschrift: ohne Unterschrift → „Unterschrift ausstehend“ (kein Weg zur Prüfung
|
||||
// oder Abrechnung), Ablehnung/Abwesenheit nur mit Grund (→ Prüfung + Hinweis ans Backoffice),
|
||||
// „später“ + Nachreichen, „nicht erforderlich“ nur ohne Pflicht, erfasste Unterschrift unveränderlich.
|
||||
//
|
||||
// Lauf: npx tsx scripts/test-e2e-guards.ts
|
||||
|
||||
import "dotenv/config";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { prisma } from "../src/server/db";
|
||||
import type { CompletionBlocker } from "../src/lib/work-orders/status";
|
||||
import { ServiceError } from "../src/server/services/context";
|
||||
import { createWorkOrder } from "../src/server/services/work-orders/create";
|
||||
import { assignWorkOrder } from "../src/server/services/work-orders/assign";
|
||||
import { transitionWorkOrder } from "../src/server/services/work-orders/transition";
|
||||
import { getCompletionBlockers } from "../src/server/services/work-orders/completion";
|
||||
import { releaseForBilling } from "../src/server/services/work-orders/release-billing";
|
||||
import { endSession, startSession } from "../src/server/services/field/sessions";
|
||||
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 { applyOperations } from "../src/server/services/sync/apply";
|
||||
import { storeFile } from "../src/server/services/documents/store";
|
||||
import { createCompletionReport } from "../src/server/services/reports/create";
|
||||
import { updateReportTexts } from "../src/server/services/reports/edit";
|
||||
import { captureSignature } from "../src/server/services/reports/signature";
|
||||
import { submitReport } from "../src/server/services/reports/submit";
|
||||
import { contentOf } from "../src/server/services/reports/common";
|
||||
import { checklistTogglePayload, photoAttachPayload, sessionControlPayload, sessionStartPayload } from "../src/lib/sync/ops";
|
||||
import { codeOf, createTenant, expectCode, jpegBytes, ok, pngBytes, runSuite, section, type TenantFixture } from "./lib/e2e-fixture";
|
||||
|
||||
const SLUG_A = "zz-q-e2e-guard-a";
|
||||
const SLUG_B = "zz-q-e2e-guard-b";
|
||||
|
||||
async function blockersOf(fn: () => Promise<unknown>): Promise<CompletionBlocker[] | string> {
|
||||
try {
|
||||
await fn();
|
||||
return "ok";
|
||||
} catch (err) {
|
||||
return err instanceof ServiceError && err.code === "blocked" ? (err.details as CompletionBlocker[]) : String((err as { code?: string }).code ?? (err as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
/** Order assigned to tech, worked and ended; ready for the completion step. */
|
||||
async function workedOrder(A: TenantFixture, input: { signatureRequired: boolean; title: string; photoRequirements?: { key: string; label: string }[]; checklistItems?: { label: string; required: boolean; requiresPhoto?: boolean }[] }) {
|
||||
const wo = await createWorkOrder(A.ctx.backoffice, { customerId: A.customerId, siteId: A.siteId, applyTemplate: false, ...input });
|
||||
await assignWorkOrder(A.ctx.backoffice, { workOrderId: wo.id, teamId: A.teamId, userIds: [A.users.tech.id] });
|
||||
await startSession(A.ctx.tech, sessionStartPayload.parse({ workOrderId: wo.id, mode: "work", at: new Date(Date.now() - 3600_000).toISOString() }));
|
||||
await endSession(A.ctx.tech, sessionControlPayload.parse({ workOrderId: wo.id }));
|
||||
return wo.id;
|
||||
}
|
||||
|
||||
async function readyReport(A: TenantFixture, workOrderId: string) {
|
||||
const { report } = await createCompletionReport(A.ctx.tech, { workOrderId });
|
||||
await updateReportTexts(A.ctx.tech, { reportId: report.id, texts: { workPerformed: "Arbeiten ausgeführt." } });
|
||||
return report.id;
|
||||
}
|
||||
|
||||
const status = async (id: string) => (await prisma.workOrder.findUniqueOrThrow({ where: { id } })).status;
|
||||
|
||||
runSuite("E2E Pflichtfotos und Unterschrift", [SLUG_A, SLUG_B], async () => {
|
||||
const A = await createTenant(SLUG_A);
|
||||
const B = await createTenant(SLUG_B);
|
||||
const tech = A.ctx.tech;
|
||||
const jpeg = await jpegBytes("Nachweis");
|
||||
|
||||
// ================= Pflichtfotos =================
|
||||
section("Fehlende Pflichtfotos blockieren den Abschluss");
|
||||
const w1 = await workedOrder(A, {
|
||||
title: "Wärmepumpe montieren",
|
||||
signatureRequired: true,
|
||||
photoRequirements: [{ key: "typenschild", label: "Typenschild" }],
|
||||
checklistItems: [{ label: "Funktionsprüfung durchgeführt", required: true, requiresPhoto: true }],
|
||||
});
|
||||
const b1 = await blockersOf(() => transitionWorkOrder(tech, { workOrderId: w1, to: "technically_completed" }));
|
||||
ok(Array.isArray(b1) && b1.some((b) => b.kind === "photo_requirement" && b.label === "Typenschild") && b1.some((b) => b.kind === "checklist_item"), "technisch abschließen → blocked mit Pflichtfoto + Checklistenpunkt");
|
||||
const b2 = await blockersOf(() => createCompletionReport(tech, { workOrderId: w1 }));
|
||||
ok(Array.isArray(b2) && b2.length === 2, "Abschlussbericht → blocked mit derselben Liste");
|
||||
const listed = await getCompletionBlockers(tech, w1);
|
||||
ok(listed.length === 2, "Blocker-Liste für die UI abrufbar");
|
||||
await expectCode(() => getCompletionBlockers(B.ctx.tech, w1), "not_found", "Mandant B: Blocker-Liste → not_found");
|
||||
await expectCode(() => getCompletionBlockers(A.ctx.outsider, w1), "not_found", "Monteur ohne Zuweisung: Blocker-Liste → not_found");
|
||||
const wo1 = await prisma.workOrder.findUniqueOrThrow({ where: { id: w1 }, include: { checklistItems: true, photoRequirements: true } });
|
||||
const viaSync = await applyOperations(tech, { deviceId: "g", operations: [{ clientOpId: randomUUID(), opType: "work_order.transition", baseVersion: wo1.version, payload: { workOrderId: w1, to: "technically_completed" }, clientCreatedAt: new Date().toISOString() }] });
|
||||
ok(viaSync.results[0].status === "rejected" && viaSync.results[0].errorCode === "blocked", "auch offline (Sync) → rejected blocked");
|
||||
|
||||
await toggleChecklistItem(tech, checklistTogglePayload.parse({ workOrderId: w1, itemId: wo1.checklistItems[0].id, checked: true }));
|
||||
const b3 = await getCompletionBlockers(tech, w1);
|
||||
ok(b3.some((b) => b.kind === "checklist_item"), "abgehakt ohne Foto → Checklistenpunkt „mit Foto“ bleibt offen");
|
||||
const notAnImage = Buffer.from("%PDF-1.4 kein Foto");
|
||||
await expectCode(() => storeFieldUpload(tech, { clientId: randomUUID(), workOrderId: w1, kind: "photo" }, { bytes: notAnImage, name: "fake.jpg", type: "image/jpeg" }), "invalid", "Nicht-Bild als Foto → invalid");
|
||||
const p1 = await storeFieldUpload(tech, { clientId: randomUUID(), workOrderId: w1, kind: "photo" }, { bytes: jpeg, name: "funktion.jpg", type: "image/jpeg" });
|
||||
await attachPhoto(tech, photoAttachPayload.parse({ workOrderId: w1, documentId: p1.documentId, checklistItemId: wo1.checklistItems[0].id, phase: "after" }));
|
||||
const b4 = await getCompletionBlockers(tech, w1);
|
||||
ok(b4.length === 1 && b4[0].kind === "photo_requirement", "Foto am Checklistenpunkt → nur noch Pflichtfoto offen");
|
||||
const p2 = await storeFieldUpload(tech, { clientId: randomUUID(), workOrderId: w1, kind: "photo" }, { bytes: jpeg, name: "typenschild.jpg", type: "image/jpeg" });
|
||||
await expectCode(() => attachPhoto(A.ctx.tech2, photoAttachPayload.parse({ workOrderId: w1, documentId: p2.documentId, photoRequirementId: wo1.photoRequirements[0].id })), ["not_found", "forbidden"], "Foto eines anderen Nutzers kann nicht angehängt werden");
|
||||
await attachPhoto(tech, photoAttachPayload.parse({ workOrderId: w1, documentId: p2.documentId, photoRequirementId: wo1.photoRequirements[0].id, phase: "after" }));
|
||||
ok((await getCompletionBlockers(tech, w1)).length === 0, "alle Pflichtfotos vorhanden → keine Blocker");
|
||||
ok((await codeOf(createCompletionReport(tech, { workOrderId: w1 }))) === "ok", "Abschlussbericht jetzt möglich");
|
||||
|
||||
// ================= Unterschrift =================
|
||||
section("Fehlende Unterschrift: ohne Erfassung bleibt der Auftrag „Unterschrift ausstehend“");
|
||||
const w2 = await workedOrder(A, { title: "Ohne Unterschrift", signatureRequired: true });
|
||||
const r2 = await readyReport(A, w2);
|
||||
await submitReport(tech, { reportId: r2 });
|
||||
ok((await status(w2)) === "signature_pending", "Abschluss ohne Unterschrift → signature_pending");
|
||||
ok((await prisma.notification.count({ where: { tenantId: A.tenantId, type: "work_order.signature_missing", entityId: w2, userId: A.users.backoffice.id } })) >= 1, "Backoffice erhält „Unterschrift fehlt“");
|
||||
const b5 = await blockersOf(() => transitionWorkOrder(tech, { workOrderId: w2, to: "in_review" }));
|
||||
ok(Array.isArray(b5) && b5.some((b) => b.kind === "missing_field" && b.field === "signature"), "zur Prüfung ohne Unterschrift → blocked (signature)");
|
||||
await expectCode(() => releaseForBilling(A.ctx.backoffice, { workOrderId: w2 }), "invalid", "Abrechnung aus „Unterschrift ausstehend“ nicht möglich");
|
||||
const img2 = await storeFile(tech, { bytes: await pngBytes(), fileName: "u.png", declaredMime: "image/png", category: "signature", visibility: "customer_report", links: { workOrderId: w2 } });
|
||||
await captureSignature(tech, { reportId: r2, outcome: "signed", signerName: "Herr Nachtrag", imageDocumentId: img2.id, confirmationText: "Bestätigt" });
|
||||
ok((await status(w2)) === "in_review", "nachgereichte Unterschrift → automatisch zur Prüfung");
|
||||
await expectCode(() => captureSignature(tech, { reportId: r2, outcome: "refused", reason: "doch nicht", confirmationText: "x" }), "conflict", "erfasste Unterschrift kann nicht überschrieben werden");
|
||||
|
||||
section("Unterschrift verweigert / Kunde abwesend: nur mit Grund");
|
||||
const w3 = await workedOrder(A, { title: "Kunde verweigert", signatureRequired: true });
|
||||
const r3 = await readyReport(A, w3);
|
||||
await expectCode(() => captureSignature(tech, { reportId: r3, outcome: "refused", confirmationText: "x" }), "invalid", "Verweigerung ohne Grund → invalid");
|
||||
await expectCode(() => captureSignature(tech, { reportId: r3, outcome: "customer_absent", reason: " ", confirmationText: "x" }), "invalid", "Abwesenheit mit leerem Grund → invalid");
|
||||
await expectCode(() => captureSignature(tech, { reportId: r3, outcome: "signed", signerName: "Ohne Bild", confirmationText: "x" }), "invalid", "Unterschrift ohne Bild → invalid");
|
||||
await expectCode(() => captureSignature(tech, { reportId: r3, outcome: "signed", signerName: "Fremdes Bild", imageDocumentId: img2.id, confirmationText: "x" }), "invalid", "Unterschriftsbild eines anderen Auftrags → invalid");
|
||||
await captureSignature(tech, { reportId: r3, outcome: "refused", reason: "Kunde mit Ausführung nicht einverstanden", confirmationText: "Bestätigt" });
|
||||
await submitReport(tech, { reportId: r3 });
|
||||
const rep3 = await prisma.report.findUniqueOrThrow({ where: { id: r3 } });
|
||||
ok((await status(w3)) === "in_review", "Verweigerung mit Grund → Zur Prüfung");
|
||||
ok(contentOf(rep3).signature?.outcome === "refused" && contentOf(rep3).signature?.reason === "Kunde mit Ausführung nicht einverstanden", "Grund im Berichts-Snapshot");
|
||||
ok((await prisma.notification.count({ where: { tenantId: A.tenantId, type: "work_order.signature_missing", entityId: w3 } })) >= 1, "Backoffice wird über die fehlende Unterschrift informiert");
|
||||
await expectCode(() => captureSignature(B.ctx.tech, { reportId: r3, outcome: "later", reason: "x", confirmationText: "x" }), "not_found", "Mandant B kann keine Unterschrift erfassen");
|
||||
await expectCode(() => captureSignature(A.ctx.outsider, { reportId: r3, outcome: "later", reason: "x", confirmationText: "x" }), "not_found", "Monteur ohne Zuweisung kann keine Unterschrift erfassen");
|
||||
|
||||
section("„Später“ und „nicht erforderlich“");
|
||||
const w4 = await workedOrder(A, { title: "Später unterschreiben", signatureRequired: true });
|
||||
const r4 = await readyReport(A, w4);
|
||||
await expectCode(() => captureSignature(tech, { reportId: r4, outcome: "not_required", confirmationText: "x" }), "forbidden", "Monteur: „nicht erforderlich“ bei Pflicht-Unterschrift → forbidden");
|
||||
await captureSignature(tech, { reportId: r4, outcome: "later", reason: "Ansprechpartner erst morgen da", confirmationText: "Bestätigt" });
|
||||
await submitReport(tech, { reportId: r4 });
|
||||
ok((await status(w4)) === "signature_pending", "„später“ → Unterschrift ausstehend");
|
||||
await captureSignature(tech, { reportId: r4, outcome: "customer_absent", reason: "Auch am Folgetag nicht erreichbar", confirmationText: "Bestätigt" });
|
||||
ok((await status(w4)) === "in_review", "Nachtrag „Kunde abwesend“ mit Grund → Zur Prüfung");
|
||||
|
||||
const w5 = await workedOrder(A, { title: "Besichtigung ohne Unterschrift", signatureRequired: false });
|
||||
const r5 = await readyReport(A, w5);
|
||||
await captureSignature(tech, { reportId: r5, outcome: "not_required", confirmationText: "Bestätigt" });
|
||||
await submitReport(tech, { reportId: r5 });
|
||||
ok((await status(w5)) === "in_review", "Auftrag ohne Unterschriftspflicht: „nicht erforderlich“ → Zur Prüfung");
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
// L10a E2E §43.3 „Dublettenprüfung" beim Import (Spec §7.3, US-003) — Kandidaten werden gefunden
|
||||
// (Kundennummer, Firmenname, Adresse Straße/Str., E-Mail unabhängig von Groß-/Kleinschreibung,
|
||||
// Telefon unabhängig vom Format), aber NIE automatisch zugeordnet oder zusammengeführt; die
|
||||
// Entscheidung (bestehend / neu) trifft das Backoffice und wird protokolliert. Manuelle Anlage
|
||||
// zeigt dieselben Kandidaten. Mandant B findet nichts von A, Monteure haben keinen Zugriff.
|
||||
//
|
||||
// Lauf: npx tsx scripts/test-e2e-import-duplicates.ts
|
||||
|
||||
import "dotenv/config";
|
||||
import { prisma } from "../src/server/db";
|
||||
import type { WorkOrderExtraction } from "../src/server/ai/providers";
|
||||
import { FakeExtractionProvider } from "../src/server/ai/extraction/fake";
|
||||
import { createImport } from "../src/server/services/imports/upload";
|
||||
import { processImport } from "../src/server/services/imports/process";
|
||||
import { confirmImport } from "../src/server/services/imports/confirm";
|
||||
import { getImportDetail } from "../src/server/services/imports/queries";
|
||||
import { extractionToForm } from "../src/lib/imports/review";
|
||||
import { createCustomer } from "../src/server/services/customers/customers";
|
||||
import { findDuplicateCustomers } from "../src/server/services/customers/duplicates";
|
||||
import { mergeCustomers } from "../src/server/services/customers/merge";
|
||||
import type { ServiceCtx } from "../src/server/services/context";
|
||||
import { buildPdf } from "./make-sample-pdfs";
|
||||
import { codeOf, createTenant, expectCode, ok, runSuite, section } from "./lib/e2e-fixture";
|
||||
|
||||
const SLUG_A = "zz-q-e2e-dup-a";
|
||||
const SLUG_B = "zz-q-e2e-dup-b";
|
||||
|
||||
type Candidate = { customerId: string; score: number; reasons: string[] };
|
||||
|
||||
async function importWith(ctx: ServiceCtx, extraction: Partial<WorkOrderExtraction>) {
|
||||
const pdf = buildPdf([[{ text: "Auftrag" }]]);
|
||||
const job = await createImport(ctx, { bytes: pdf, fileName: "auftrag.pdf", mimeType: "application/pdf" }, { dispatch: async () => undefined });
|
||||
const processed = await processImport(ctx, job.id, { provider: new FakeExtractionProvider({ extraction }), loadBytes: async () => pdf });
|
||||
return { job, candidates: (processed.duplicateCandidates ?? []) as Candidate[] };
|
||||
}
|
||||
|
||||
runSuite("E2E Dublettenprüfung beim Import", [SLUG_A, SLUG_B], async () => {
|
||||
const A = await createTenant(SLUG_A);
|
||||
const B = await createTenant(SLUG_B);
|
||||
const bo = A.ctx.backoffice;
|
||||
|
||||
const firm = await prisma.customer.create({
|
||||
data: { tenantId: A.tenantId, customerNumber: "K-50001", companyName: "Musterbau Haustechnik GmbH", street: "Hafenstraße", houseNumber: "12", postalCode: "20457", city: "Hamburg", phone: "+49 40 5551234" },
|
||||
});
|
||||
const person = await prisma.customer.create({
|
||||
data: { tenantId: A.tenantId, customerNumber: "K-50002", firstName: "Erika", lastName: "Beispiel", email: "erika@beispiel.example", street: "Lindenallee", houseNumber: "4", postalCode: "21073", city: "Hamburg" },
|
||||
});
|
||||
const foreign = await prisma.customer.create({ data: { tenantId: B.tenantId, customerNumber: "K-50001", companyName: "Musterbau Haustechnik GmbH", postalCode: "20457", phone: "040 5551234" } });
|
||||
const firmBefore = await prisma.customer.findUniqueOrThrow({ where: { id: firm.id } });
|
||||
|
||||
section("1. Firmenkunde: Nummer, Name, Adresse (Str.), Telefon in anderem Format");
|
||||
const i1 = await importWith(bo, {
|
||||
customerNumber: { value: "K-50001", confidence: 0.9 },
|
||||
companyName: { value: "Musterbau Haustechnik GmbH", confidence: 0.95 },
|
||||
customerAddress: { value: { street: "Hafenstr.", houseNumber: "12", postalCode: "20457", city: "Hamburg" }, confidence: 0.9 },
|
||||
phone: { value: "040 / 555 12 34", confidence: 0.9 },
|
||||
title: { value: "Wartung Lüftung", confidence: 0.9 },
|
||||
});
|
||||
const top = i1.candidates[0];
|
||||
ok(top?.customerId === firm.id, "bestehender Firmenkunde ist bester Kandidat");
|
||||
ok(!!top && ["customer_number", "company_name", "address", "phone"].every((r) => top.reasons.includes(r)), `Gründe: ${top?.reasons.join(", ")}`);
|
||||
ok(!i1.candidates.some((c) => c.customerId === foreign.id), "Kandidaten nie aus Mandant B");
|
||||
ok((await prisma.workOrder.count({ where: { tenantId: A.tenantId } })) === 0, "keine automatische Zuordnung, kein Auftrag ohne Bestätigung");
|
||||
ok((await prisma.customer.findUniqueOrThrow({ where: { id: firm.id } })).updatedAt.getTime() === firmBefore.updatedAt.getTime(), "bestehender Kunde unverändert");
|
||||
|
||||
section("2. Privatkunde: E-Mail in anderer Schreibweise");
|
||||
const i2 = await importWith(bo, {
|
||||
customerFirstName: { value: "Erika", confidence: 0.9 },
|
||||
customerLastName: { value: "Beispiel", confidence: 0.9 },
|
||||
email: { value: "ERIKA@Beispiel.example", confidence: 0.9 },
|
||||
title: { value: "Reparatur Mischbatterie", confidence: 0.9 },
|
||||
});
|
||||
ok(i2.candidates.some((c) => c.customerId === person.id && c.reasons.includes("email")), "Privatkunde über E-Mail gefunden");
|
||||
|
||||
section("3. Neukunde ohne Überschneidung");
|
||||
const i3 = await importWith(bo, {
|
||||
companyName: { value: "Kieler Förde Wohnbau AG", confidence: 0.9 },
|
||||
customerAddress: { value: { street: "Holtenauer Straße", houseNumber: "200", postalCode: "24105", city: "Kiel" }, confidence: 0.9 },
|
||||
title: { value: "Neubau Heizzentrale", confidence: 0.9 },
|
||||
});
|
||||
ok(i3.candidates.length === 0, "kein Kandidat bei Neukunde");
|
||||
|
||||
section("4. Entscheidung des Backoffice");
|
||||
const countBefore = await prisma.customer.count({ where: { tenantId: A.tenantId } });
|
||||
const d2 = await getImportDetail(bo, i2.job.id);
|
||||
const c2 = await confirmImport(bo, i2.job.id, extractionToForm(d2.extraction.fields, { customerId: person.id }));
|
||||
ok(c2.customerId === person.id && (await prisma.customer.count({ where: { tenantId: A.tenantId } })) === countBefore, "Kandidat übernommen → kein neuer Kunde");
|
||||
const d1 = await getImportDetail(bo, i1.job.id);
|
||||
const f1 = extractionToForm(d1.extraction.fields);
|
||||
ok(f1.customerMode === "new", "Formular schlägt ohne Entscheidung keinen bestehenden Kunden automatisch vor");
|
||||
await expectCode(() => confirmImport(bo, i1.job.id, f1), "conflict", "„neu anlegen“ mit vergebener Kundennummer → conflict, keine Teilanlage");
|
||||
f1.customer!.customerNumber = "";
|
||||
const c1 = await confirmImport(bo, i1.job.id, f1);
|
||||
const newFirm = await prisma.customer.findUniqueOrThrow({ where: { id: c1.customerId } });
|
||||
ok(newFirm.id !== firm.id && /^K-\d+$/.test(newFirm.customerNumber ?? ""), `bewusst neu angelegt mit eigener Nummer (${newFirm.customerNumber})`);
|
||||
ok((await prisma.customer.count({ where: { tenantId: A.tenantId, status: "merged" } })) === 0, "keine automatische Zusammenführung");
|
||||
const job1 = await prisma.importJob.findUniqueOrThrow({ where: { id: i1.job.id } });
|
||||
ok((job1.corrections as { decisions?: { customerMode?: string } }).decisions?.customerMode === "new", "Entscheidung „neu“ am Import protokolliert");
|
||||
const d3 = await getImportDetail(bo, i3.job.id);
|
||||
const c3 = await confirmImport(bo, i3.job.id, extractionToForm(d3.extraction.fields));
|
||||
ok((await prisma.customer.findUniqueOrThrow({ where: { id: c3.customerId } })).companyName === "Kieler Förde Wohnbau AG", "Neukunde angelegt");
|
||||
|
||||
section("5. Manuelle Anlage und Zusammenführung nur mit Bestätigung");
|
||||
const dupErr = await codeOf(createCustomer(bo, { companyName: "Musterbau Haustechnik GmbH", phone: "040-5551234" }));
|
||||
ok(dupErr === "conflict", "manuelle Anlage → „Mögliche Dublette“ (conflict)");
|
||||
const manual = await createCustomer(bo, { companyName: "Musterbau Haustechnik GmbH", phone: "040-5551234" }, { acknowledgeDuplicates: true });
|
||||
ok(manual.status === "active", "„Trotzdem neu anlegen“ möglich");
|
||||
await expectCode(() => mergeCustomers(bo, { sourceId: manual.id, targetId: firm.id, confirm: false } as never), "invalid", "Zusammenführen ohne Bestätigung → invalid");
|
||||
await expectCode(() => mergeCustomers(A.ctx.tech, { sourceId: manual.id, targetId: firm.id, confirm: true }), "forbidden", "Monteur darf nicht zusammenführen");
|
||||
await expectCode(() => mergeCustomers(B.ctx.backoffice, { sourceId: foreign.id, targetId: firm.id, confirm: true }), "not_found", "Mandant B kann nicht mit Kunden von A zusammenführen");
|
||||
const merged = await mergeCustomers(bo, { sourceId: manual.id, targetId: firm.id, confirm: true });
|
||||
ok(!!merged && (await prisma.customer.findUniqueOrThrow({ where: { id: manual.id } })).mergedIntoId === firm.id, "mit Bestätigung zusammengeführt");
|
||||
|
||||
section("6. Mandantentrennung und Rollen");
|
||||
const fromB = await findDuplicateCustomers(B.ctx.backoffice, { customerNumber: "K-50001", companyName: "Musterbau Haustechnik GmbH", email: "erika@beispiel.example" });
|
||||
ok(fromB.every((c) => c.customerId === foreign.id), "Dublettenprüfung in Mandant B findet nur B-Kunden");
|
||||
ok((await findDuplicateCustomers(A.ctx.outsider, { customerNumber: "K-50001" })).length === 0, "Monteur ohne Aufträge findet keine Kunden");
|
||||
await expectCode(() => getImportDetail(A.ctx.tech, i3.job.id), "forbidden", "Monteur: Prüfmaske → forbidden");
|
||||
await expectCode(() => confirmImport(B.ctx.backoffice, i3.job.id, extractionToForm(d3.extraction.fields)), "not_found", "Mandant B: Import von A bestätigen → not_found");
|
||||
});
|
||||
@@ -0,0 +1,145 @@
|
||||
// L10a E2E §43.3 „Mehrtägiger Auftrag mit Tagesbericht" — zwei Einsatztage, zwei Tagesberichte, Abschluss.
|
||||
//
|
||||
// Tag 1 (vorgestern) und Tag 2 (gestern) werden mit echten Zeitstempeln erfasst (Session-Segmente,
|
||||
// Fotos mit Aufnahmezeit; Notizen/Material werden als „am Einsatztag erfasst" zurückdatiert, weil
|
||||
// die Services den Erfassungszeitpunkt selbst setzen). Nachweise: Tagesbericht enthält nur die Daten
|
||||
// seines Tages, Auftrag bleibt offen (daily_report_created → in_progress am nächsten Tag), ein
|
||||
// Tagesbericht je Tag, Abschlussbericht fasst beide Tage zusammen, Freigabe + Abrechnung,
|
||||
// Mandantentrennung und Scope.
|
||||
//
|
||||
// Lauf: npx tsx scripts/test-e2e-multiday-order.ts
|
||||
|
||||
import "dotenv/config";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { prisma } from "../src/server/db";
|
||||
import { createWorkOrder } from "../src/server/services/work-orders/create";
|
||||
import { assignWorkOrder } from "../src/server/services/work-orders/assign";
|
||||
import { releaseForBilling } from "../src/server/services/work-orders/release-billing";
|
||||
import { endSession, 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 { storeFile } from "../src/server/services/documents/store";
|
||||
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 { captureSignature } from "../src/server/services/reports/signature";
|
||||
import { contentOf } from "../src/server/services/reports/common";
|
||||
import { listReports } from "../src/server/services/reports/queries";
|
||||
import { getSiteHistory } from "../src/server/services/sites/history";
|
||||
import type { ServiceCtx } from "../src/server/services/context";
|
||||
import { checklistTogglePayload, materialUpsertPayload, noteCreatePayload, photoAttachPayload, sessionControlPayload, sessionStartPayload } from "../src/lib/sync/ops";
|
||||
import { berlinAt, createTenant, dayKey, expectCode, jpegBytes, ok, pngBytes, runSuite, section } from "./lib/e2e-fixture";
|
||||
|
||||
const SLUG_A = "zz-q-e2e-multi-a";
|
||||
const SLUG_B = "zz-q-e2e-multi-b";
|
||||
const noPdf = { dispatchPdf: async () => undefined };
|
||||
|
||||
runSuite("E2E mehrtägiger Auftrag", [SLUG_A, SLUG_B], async () => {
|
||||
const A = await createTenant(SLUG_A);
|
||||
const B = await createTenant(SLUG_B);
|
||||
const bo = A.ctx.backoffice;
|
||||
const DAY1 = dayKey(-2);
|
||||
const DAY2 = dayKey(-1);
|
||||
const jpeg = await jpegBytes("Baustelle");
|
||||
|
||||
const wo = await createWorkOrder(bo, {
|
||||
title: "Heizungsverteilung erneuern (2 Tage)",
|
||||
customerId: A.customerId,
|
||||
siteId: A.siteId,
|
||||
plannedStart: berlinAt(-2, 7),
|
||||
plannedEnd: berlinAt(-1, 16),
|
||||
signatureRequired: true,
|
||||
applyTemplate: false,
|
||||
checklistItems: [{ label: "Druckprobe durchgeführt", required: true }],
|
||||
materials: [{ name: "Kupferrohr 22 mm", articleNumber: "CU-22", plannedQuantity: 20, unit: "m" }],
|
||||
});
|
||||
await assignWorkOrder(bo, { workOrderId: wo.id, teamId: A.teamId, userIds: [A.users.tech.id, A.users.tech2.id] });
|
||||
const plan = await prisma.materialPlan.findFirstOrThrow({ where: { workOrderId: wo.id } });
|
||||
|
||||
/** Note/material recorded on a past field day (the services stamp "now"; the device was on site then). */
|
||||
async function noteOn(ctx: ServiceCtx, when: Date, text: string) {
|
||||
const { noteId } = await createNote(ctx, noteCreatePayload.parse({ workOrderId: wo.id, kind: "work_done", text }));
|
||||
await prisma.activityNote.update({ where: { id: noteId }, data: { createdAt: when } });
|
||||
}
|
||||
async function photoOn(ctx: ServiceCtx, when: Date, comment: string) {
|
||||
const up = await storeFieldUpload(ctx, { clientId: randomUUID(), workOrderId: wo.id, kind: "photo" }, { bytes: jpeg, name: "foto.jpg", type: "image/jpeg" });
|
||||
await attachPhoto(ctx, photoAttachPayload.parse({ workOrderId: wo.id, documentId: up.documentId, phase: "during", comment, takenAt: when.toISOString() }));
|
||||
}
|
||||
|
||||
// ---------- Tag 1 ----------
|
||||
section(`Tag 1 (${DAY1}): Einsatz und Tagesbericht`);
|
||||
const tech = A.ctx.tech;
|
||||
await startSession(tech, sessionStartPayload.parse({ workOrderId: wo.id, mode: "work", at: berlinAt(-2, 7).toISOString() }));
|
||||
await noteOn(tech, berlinAt(-2, 11), "Tag 1: alte Verteilung demontiert");
|
||||
const u1 = await upsertMaterialUsage(tech, materialUpsertPayload.parse({ workOrderId: wo.id, materialPlanId: plan.id, quantity: 8, unit: "m", usageStatus: "partially_used", deviationReason: "Rest folgt an Tag 2" }));
|
||||
await prisma.materialUsage.update({ where: { id: u1.usageId }, data: { createdAt: berlinAt(-2, 12) } });
|
||||
await photoOn(tech, berlinAt(-2, 13), "Tag 1 Konsolen");
|
||||
await endSession(tech, sessionControlPayload.parse({ workOrderId: wo.id, at: berlinAt(-2, 15).toISOString() }));
|
||||
|
||||
await expectCode(() => createDailyReport(B.ctx.tech, { workOrderId: wo.id, reportDate: DAY1 }), "not_found", "Mandant B: Tagesbericht am Auftrag von A → not_found");
|
||||
await expectCode(() => createDailyReport(A.ctx.outsider, { workOrderId: wo.id, reportDate: DAY1 }), "not_found", "Monteur ohne Zuweisung: Tagesbericht → not_found");
|
||||
const d1 = (await createDailyReport(tech, { workOrderId: wo.id, reportDate: DAY1 })).report;
|
||||
const c1 = contentOf(d1);
|
||||
ok((await prisma.workOrder.findUniqueOrThrow({ where: { id: wo.id } })).status === "daily_report_created", "Auftrag → Tagesbericht erstellt (bleibt offen)");
|
||||
ok(c1.time.totalMinutes === 480, `Tagesbericht 1: nur Arbeitszeit von Tag 1 (${c1.time.totalMinutes} min)`);
|
||||
ok(c1.photos.length === 1 && c1.texts.workPerformed.includes("Tag 1") && !c1.texts.workPerformed.includes("Tag 2"), "Tagesbericht 1: Fotos und Tätigkeiten nur von Tag 1");
|
||||
ok(c1.materials.used.length === 1 && c1.materials.used[0].deviationReason === "Rest folgt an Tag 2", "Tagesbericht 1: Material von Tag 1 mit Abweichungsgrund");
|
||||
await submitReport(tech, { reportId: d1.id });
|
||||
await approveReport(A.ctx.lead, { reportId: d1.id }, noPdf);
|
||||
const d1Approved = await approveReport(bo, { reportId: d1.id }, noPdf);
|
||||
ok(d1Approved.status === "approved", "Tagesbericht 1 eingereicht, vom Teamleiter geprüft und vom Backoffice freigegeben");
|
||||
await expectCode(() => createDailyReport(tech, { workOrderId: wo.id, reportDate: DAY1 }), "conflict", "zweiter Tagesbericht für denselben Tag → conflict");
|
||||
|
||||
// ---------- Tag 2 ----------
|
||||
section(`Tag 2 (${DAY2}): zweiter Monteur, zweiter Tagesbericht`);
|
||||
const tech2 = A.ctx.tech2;
|
||||
const s2 = await startSession(tech2, sessionStartPayload.parse({ workOrderId: wo.id, mode: "work", at: berlinAt(-1, 7).toISOString() }));
|
||||
ok(s2.workOrderStatus === "in_progress", "Arbeitsbeginn am Folgetag → Auftrag wieder in Arbeit");
|
||||
await noteOn(tech2, berlinAt(-1, 10), "Tag 2: Heizkreise angeschlossen, Druckprobe bestanden");
|
||||
const u2 = await upsertMaterialUsage(tech2, materialUpsertPayload.parse({ workOrderId: wo.id, clientId: randomUUID(), name: "Kugelhahn 22 mm", quantity: 4, unit: "Stk", usageStatus: "additional", deviationReason: "Absperrung je Heizkreis" }));
|
||||
await prisma.materialUsage.update({ where: { id: u2.usageId }, data: { createdAt: berlinAt(-1, 11) } });
|
||||
await photoOn(tech2, berlinAt(-1, 12), "Tag 2 fertig");
|
||||
const item = await prisma.checklistItem.findFirstOrThrow({ where: { workOrderId: wo.id } });
|
||||
await toggleChecklistItem(tech2, checklistTogglePayload.parse({ workOrderId: wo.id, itemId: item.id, checked: true }));
|
||||
await endSession(tech2, sessionControlPayload.parse({ workOrderId: wo.id, at: berlinAt(-1, 14).toISOString() }));
|
||||
const d2 = (await createDailyReport(tech2, { workOrderId: wo.id, reportDate: DAY2 })).report;
|
||||
const c2 = contentOf(d2);
|
||||
ok(c2.time.totalMinutes === 420 && c2.photos.length === 1, `Tagesbericht 2: nur Zeiten/Fotos von Tag 2 (${c2.time.totalMinutes} min)`);
|
||||
ok(c2.texts.workPerformed.includes("Tag 2") && !c2.texts.workPerformed.includes("Tag 1"), "Tagesbericht 2: nur Tätigkeiten von Tag 2");
|
||||
ok(c2.materials.additional.length === 1 && c2.materials.used.length === 0, "Tagesbericht 2: nur Material von Tag 2");
|
||||
ok(c2.reportNumber !== c1.reportNumber && d2.lineageId !== d1.lineageId, `eigene Berichtsnummer je Tag (${c1.reportNumber}, ${c2.reportNumber})`);
|
||||
await submitReport(tech2, { reportId: d2.id });
|
||||
|
||||
// ---------- Abschluss ----------
|
||||
section("Abschlussbericht über beide Tage, Freigabe, Abrechnung");
|
||||
const completion = (await createCompletionReport(tech2, { workOrderId: wo.id, reportDate: DAY2 })).report;
|
||||
const cc = contentOf(completion);
|
||||
ok(cc.workDates.join(",") === `${DAY1},${DAY2}` && cc.time.totalMinutes === 900, `Abschlussbericht: beide Einsatztage, Gesamtzeit ${cc.time.totalMinutes} min`);
|
||||
ok(cc.photos.length === 2 && cc.materials.used.length === 1 && cc.materials.additional.length === 1, "Abschlussbericht: alle Fotos und Materialien");
|
||||
await updateReportTexts(tech2, { reportId: completion.id, texts: { workPerformed: "Heizungsverteilung in zwei Tagen erneuert, Druckprobe bestanden." } });
|
||||
const img = await storeFile(tech2, { bytes: await pngBytes(), fileName: "unterschrift.png", declaredMime: "image/png", category: "signature", visibility: "customer_report", links: { workOrderId: wo.id } });
|
||||
await captureSignature(tech2, { reportId: completion.id, outcome: "signed", signerName: "Frau Prüf", imageDocumentId: img.id, confirmationText: "Bestätigt." });
|
||||
await submitReport(tech2, { reportId: completion.id });
|
||||
ok((await prisma.workOrder.findUniqueOrThrow({ where: { id: wo.id } })).status === "in_review", "Auftrag nach Abschluss „Zur Prüfung“");
|
||||
for (const r of [d2.id, completion.id]) {
|
||||
await approveReport(A.ctx.lead, { reportId: r }, noPdf);
|
||||
await approveReport(bo, { reportId: r }, noPdf);
|
||||
}
|
||||
const released = await releaseForBilling(bo, { workOrderId: wo.id });
|
||||
ok(released.status === "released_for_billing", "zur Abrechnung freigegeben");
|
||||
|
||||
const reports = await listReports(bo, { status: "all" });
|
||||
const mine = reports.items.filter((r) => r.workOrder.id === wo.id);
|
||||
ok(mine.filter((r) => r.type === "daily").length === 2 && mine.filter((r) => r.type === "completion").length === 1, "Berichtsliste: 2 Tagesberichte + 1 Abschlussbericht");
|
||||
ok((await listReports(B.ctx.backoffice, { status: "all" })).items.every((r) => r.workOrder.id !== wo.id), "Mandant B sieht keine Berichte von A");
|
||||
ok((await listReports(A.ctx.outsider, { status: "all" })).items.every((r) => r.workOrder.id !== wo.id), "Monteur ohne Zuweisung sieht keine Berichte des Auftrags");
|
||||
|
||||
const hist = await getSiteHistory(bo, A.siteId);
|
||||
const entry = hist.items.find((i) => i.workOrderId === wo.id);
|
||||
ok(!!entry && entry.approvedReports.length === 3 && entry.workDone.length === 2, "Objekt-Historie: 3 freigegebene Berichte, Tätigkeiten beider Tage");
|
||||
ok(!!entry && entry.date.getTime() === berlinAt(-2, 7).getTime(), "Objekt-Historie: Einsatzbeginn = erster Einsatztag");
|
||||
});
|
||||
@@ -0,0 +1,161 @@
|
||||
// L10a E2E §43.3 „Offline-Erfassung und Synchronisation" — derselbe Pfad wie POST /api/v1/sync:
|
||||
// ein offline gesammelter Batch (12 Ops, 2 Tage alt) → alle applied; Wiederholung → duplicate;
|
||||
// Pflichtfoto fehlt → blocked; veraltete Statusänderung → conflict (nichts überschrieben, Event
|
||||
// sync.failed, Backoffice-Konfliktliste) → Backoffice übernimmt bzw. verwirft; ungültige/nicht
|
||||
// verfügbare Ops; fremde clientOpId; Mandantentrennung und Scope.
|
||||
//
|
||||
// Lauf: npx tsx scripts/test-e2e-offline-sync.ts
|
||||
|
||||
import "dotenv/config";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { prisma } from "../src/server/db";
|
||||
import { CONFLICTING_OPS, SYNC_OP_TYPES, type SyncOperationInput } from "../src/lib/sync/envelope";
|
||||
import { EXTERNAL_OPS } from "../src/server/services/sync/external-ops";
|
||||
import { applyOperations } from "../src/server/services/sync/apply";
|
||||
import { storeFieldUpload } from "../src/server/services/field/uploads";
|
||||
import { getFieldBundle } from "../src/server/services/field/queries";
|
||||
import { createWorkOrder } from "../src/server/services/work-orders/create";
|
||||
import { assignWorkOrder } from "../src/server/services/work-orders/assign";
|
||||
import { updateWorkOrder } from "../src/server/services/work-orders/update";
|
||||
import { applySyncConflict, discardSyncConflict, listSyncConflicts } from "../src/server/services/work-orders/conflicts";
|
||||
import { createTenant, expectCode, jpegBytes, ok, runSuite, section } from "./lib/e2e-fixture";
|
||||
|
||||
const SLUG_A = "zz-q-e2e-sync-a";
|
||||
const SLUG_B = "zz-q-e2e-sync-b";
|
||||
|
||||
function op(opType: SyncOperationInput["opType"], payload: Record<string, unknown>, extra: Partial<SyncOperationInput> = {}, at = new Date()): SyncOperationInput {
|
||||
return { clientOpId: randomUUID(), opType, payload, clientCreatedAt: at.toISOString(), ...extra };
|
||||
}
|
||||
|
||||
runSuite("E2E Offline-Sync", [SLUG_A, SLUG_B], async () => {
|
||||
const A = await createTenant(SLUG_A);
|
||||
const B = await createTenant(SLUG_B);
|
||||
const bo = A.ctx.backoffice;
|
||||
const tech = A.ctx.tech;
|
||||
|
||||
const created = await createWorkOrder(bo, {
|
||||
title: "Offline-Einsatz Tiefgarage",
|
||||
customerId: A.customerId,
|
||||
siteId: A.siteId,
|
||||
plannedStart: new Date(),
|
||||
applyTemplate: false,
|
||||
checklistItems: [{ label: "Absperrung gesetzt", required: true }],
|
||||
photoRequirements: [{ key: "fertige_montage", label: "Fertige Montage" }],
|
||||
materials: [{ name: "Brandschutzmanschette", plannedQuantity: 4, unit: "Stk" }],
|
||||
});
|
||||
await assignWorkOrder(bo, { workOrderId: created.id, teamId: A.teamId, userIds: [A.users.tech.id] });
|
||||
|
||||
section("1. Vorab-Download und Offline-Erfassung");
|
||||
const bundle = await getFieldBundle(tech);
|
||||
const offline = bundle.orders.find((o) => o.id === created.id)!;
|
||||
ok(!!offline, "Auftrag im Offline-Bundle des Monteurs");
|
||||
const photoDoc = await storeFieldUpload(tech, { clientId: randomUUID(), workOrderId: created.id, kind: "photo" }, { bytes: await jpegBytes("offline"), name: "offline.jpg", type: "image/jpeg" });
|
||||
const t0 = Date.now() - 2 * 86400_000;
|
||||
const at = (min: number) => new Date(t0 + min * 60_000);
|
||||
const ids = { travel: randomUUID(), note1: randomUUID(), note2: randomUUID(), extra: randomUUID(), photo: randomUUID() };
|
||||
const w = created.id;
|
||||
const batch: SyncOperationInput[] = [
|
||||
op("work_order.transition", { workOrderId: w, to: "accepted" }, { baseVersion: offline.version }, at(0)),
|
||||
op("session.start", { workOrderId: w, mode: "travel", clientId: ids.travel, offline: true, at: at(1).toISOString() }, {}, at(1)),
|
||||
op("session.start", { workOrderId: w, mode: "work", at: at(30).toISOString() }, {}, at(30)),
|
||||
op("note.create", { workOrderId: w, clientId: ids.note1, kind: "work_done", text: "Manschetten gesetzt (offline)" }, {}, at(40)),
|
||||
op("checklist.toggle", { workOrderId: w, itemId: offline.checklistItems[0].id, checked: true }, {}, at(41)),
|
||||
op("material.upsert", { workOrderId: w, materialPlanId: offline.materialPlans[0].id, quantity: 4, unit: "Stk", usageStatus: "fully_used" }, {}, at(42)),
|
||||
op("material.upsert", { workOrderId: w, clientId: ids.extra, name: "Brandschutzkitt", quantity: 1, unit: "Stk", usageStatus: "additional", deviationReason: "Fuge zu breit" }, {}, at(43)),
|
||||
op("photo.attach", { workOrderId: w, clientId: ids.photo, documentId: photoDoc.documentId, phase: "during" }, {}, at(44)),
|
||||
op("session.pause", { workOrderId: w, at: at(60).toISOString() }, {}, at(60)),
|
||||
op("session.resume", { workOrderId: w, at: at(75).toISOString() }, {}, at(75)),
|
||||
op("note.create", { workOrderId: w, clientId: ids.note2, kind: "general", text: "Keine Auffälligkeiten" }, {}, at(80)),
|
||||
op("session.end", { workOrderId: w, at: at(120).toISOString() }, {}, at(120)),
|
||||
];
|
||||
ok((await prisma.activityNote.count({ where: { workOrderId: w } })) === 0, "offline: noch nichts auf dem Server");
|
||||
|
||||
section("2. Verbindung da: ein Batch");
|
||||
const res = await applyOperations(tech, { deviceId: "e2e-device", operations: batch });
|
||||
const statuses = res.results.map((x) => x.status);
|
||||
ok(statuses.every((s) => s === "applied"), `alle 12 Ops applied (${statuses.join(",")})`);
|
||||
if (!statuses.every((s) => s === "applied")) console.log(res.results.filter((x) => x.status !== "applied"));
|
||||
ok(!!res.results[1].idMap?.[ids.travel] && !!res.results[3].idMap?.[ids.note1] && !!res.results[7].idMap?.[ids.photo], "idMap bildet Client-IDs auf Server-IDs ab");
|
||||
ok(typeof res.results[0].entityVersion === "number" && res.results[0].entityVersion! > offline.version, "Statusänderung liefert neue Auftragsversion");
|
||||
const wo1 = await prisma.workOrder.findUniqueOrThrow({ where: { id: w } });
|
||||
ok(wo1.status === "in_progress", `Auftragsstatus nach Sync: in Arbeit (${wo1.status})`);
|
||||
ok((await prisma.activityNote.count({ where: { workOrderId: w } })) === 2 && (await prisma.materialUsage.count({ where: { workOrderId: w } })) === 2 && (await prisma.photo.count({ where: { workOrderId: w } })) === 1, "Notizen, Material und Foto angelegt");
|
||||
const session = await prisma.workSession.findFirstOrThrow({ where: { workOrderId: w }, include: { entries: { orderBy: { startedAt: "asc" } } } });
|
||||
ok(session.status === "ended" && session.startedOffline && session.entries.map((e) => e.type).join(",") === "travel,work,break,work", "Session offline gestartet, Segmente Anfahrt/Arbeit/Pause/Arbeit");
|
||||
ok(session.startedAt.getTime() === at(1).getTime() && session.endedAt?.getTime() === at(120).getTime(), "Zeitstempel des Geräts übernommen (2 Tage alt)");
|
||||
const stored = await prisma.syncOperation.findMany({ where: { tenantId: A.tenantId, clientOpId: { in: batch.map((b) => b.clientOpId) } } });
|
||||
ok(stored.length === 12 && stored.every((s) => s.status === "applied" && s.userId === A.users.tech.id), "12 SyncOperations protokolliert");
|
||||
|
||||
section("3. Wiederholung (Verbindungsabbruch nach dem Senden)");
|
||||
const replay = await applyOperations(tech, { deviceId: "e2e-device", operations: batch });
|
||||
ok(replay.results.every((x) => x.status === "duplicate"), "gleicher Batch erneut → 12× duplicate");
|
||||
ok(replay.results[3].idMap?.[ids.note1] === res.results[3].idMap?.[ids.note1], "duplicate liefert gespeicherte idMap");
|
||||
ok((await prisma.activityNote.count({ where: { workOrderId: w } })) === 2 && (await prisma.materialUsage.count({ where: { workOrderId: w } })) === 2, "keine Doppelanlagen");
|
||||
const stolen = await applyOperations(A.ctx.tech2, { deviceId: "other", operations: [batch[3]] });
|
||||
ok(stolen.results[0].status === "rejected" && stolen.results[0].errorCode === "invalid", "fremde clientOpId eines anderen Nutzers → rejected");
|
||||
|
||||
section("4. Pflichtfoto fehlt → Abschluss offline abgelehnt");
|
||||
const blocked = await applyOperations(tech, { deviceId: "e2e-device", operations: [op("work_order.transition", { workOrderId: w, to: "technically_completed" }, { baseVersion: wo1.version })] });
|
||||
ok(blocked.results[0].status === "rejected" && blocked.results[0].errorCode === "blocked" && /photo_requirement/.test(blocked.results[0].message ?? ""), "technisch abschließen ohne Pflichtfoto → rejected blocked (Blocker-Liste)");
|
||||
ok((await prisma.workOrder.findUniqueOrThrow({ where: { id: w } })).status === "in_progress", "Status unverändert");
|
||||
|
||||
section("5. Konflikt: Büro ändert den Auftrag während der Monteur offline ist");
|
||||
const stale = (await prisma.workOrder.findUniqueOrThrow({ where: { id: w } })).version;
|
||||
await updateWorkOrder(bo, w, { technicianNotes: "Bitte zusätzlich Revisionsklappe prüfen" });
|
||||
const conflictBatch = [
|
||||
op("work_order.transition", { workOrderId: w, to: "waiting_material" }, { baseVersion: stale }),
|
||||
op("note.create", { workOrderId: w, clientId: randomUUID(), kind: "problem", text: "Material fehlt" }),
|
||||
];
|
||||
const conflict = await applyOperations(tech, { deviceId: "e2e-device", operations: conflictBatch });
|
||||
ok(conflict.results[0].status === "conflict" && conflict.results[0].errorCode === "conflict", "veraltete Statusänderung → conflict");
|
||||
ok(conflict.results[1].status === "applied", "unabhängige Notiz trotzdem applied");
|
||||
const wo2 = await prisma.workOrder.findUniqueOrThrow({ where: { id: w } });
|
||||
ok(wo2.status === "in_progress" && wo2.technicianNotes === "Bitte zusätzlich Revisionsklappe prüfen", "nichts überschrieben (Status + Büroänderung erhalten)");
|
||||
const conflictOp = await prisma.syncOperation.findFirstOrThrow({ where: { tenantId: A.tenantId, clientOpId: conflictBatch[0].clientOpId } });
|
||||
ok(conflictOp.status === "conflict" && conflictOp.baseVersion === stale, "Konflikt mit Basisversion gespeichert");
|
||||
ok((await prisma.notification.count({ where: { tenantId: A.tenantId, type: "sync.failed", userId: A.users.tech.id } })) >= 1, "Monteur wird über den Konflikt informiert");
|
||||
ok((await prisma.notification.count({ where: { tenantId: A.tenantId, type: "sync.failed", userId: A.users.backoffice.id } })) >= 1, "Backoffice wird über den Konflikt informiert");
|
||||
const list = await listSyncConflicts(bo);
|
||||
ok(list.some((c) => c.id === conflictOp.id && c.workOrder?.id === w && c.userName === `tech ${SLUG_A}`), "Konflikt in der Backoffice-Liste (Auftrag, Gerätenutzer)");
|
||||
await expectCode(() => listSyncConflicts(tech), "forbidden", "Monteur: Konfliktliste → forbidden");
|
||||
|
||||
section("6. Backoffice löst Konflikte");
|
||||
await expectCode(() => applySyncConflict(B.ctx.backoffice, conflictOp.id), "not_found", "Mandant B kann den Konflikt nicht übernehmen");
|
||||
await expectCode(() => discardSyncConflict(B.ctx.backoffice, conflictOp.id), "not_found", "Mandant B kann den Konflikt nicht verwerfen");
|
||||
ok(!(await listSyncConflicts(B.ctx.backoffice)).some((c) => c.id === conflictOp.id), "Mandant B sieht den Konflikt nicht");
|
||||
await applySyncConflict(bo, conflictOp.id);
|
||||
const wo3 = await prisma.workOrder.findUniqueOrThrow({ where: { id: w } });
|
||||
const resolved = await prisma.syncOperation.findUniqueOrThrow({ where: { id: conflictOp.id } });
|
||||
ok(wo3.status === "waiting_material" && resolved.status === "applied" && resolved.resolvedById === A.users.backoffice.id, "Übernehmen: Änderung als Gerätenutzer angewendet, Konflikt erledigt");
|
||||
const second = await applyOperations(tech, { deviceId: "e2e-device", operations: [op("work_order.transition", { workOrderId: w, to: "in_progress" }, { baseVersion: stale })] });
|
||||
const secondOp = await prisma.syncOperation.findFirstOrThrow({ where: { tenantId: A.tenantId, clientOpId: second.results[0].clientOpId } });
|
||||
ok(second.results[0].status === "conflict", "zweiter veralteter Stand → conflict");
|
||||
await discardSyncConflict(bo, secondOp.id);
|
||||
ok((await prisma.syncOperation.findUniqueOrThrow({ where: { id: secondOp.id } })).errorCode === "discarded" && (await prisma.workOrder.findUniqueOrThrow({ where: { id: w } })).status === "waiting_material", "Verwerfen: nichts angewendet, als verworfen markiert");
|
||||
await expectCode(() => discardSyncConflict(bo, secondOp.id), "not_found", "erledigter Konflikt kann nicht erneut bearbeitet werden");
|
||||
ok((await prisma.auditLog.count({ where: { tenantId: A.tenantId, entity: "sync_operation", entityId: { in: [conflictOp.id, secondOp.id] } } })) >= 4, "Konflikte und Lösungen auditiert");
|
||||
|
||||
section("7. Ungültige Ops, Scope, Mandantentrennung");
|
||||
const invalid = await applyOperations(tech, { deviceId: "e2e-device", operations: [op("note.create", { workOrderId: w, text: "" })] });
|
||||
ok(invalid.results[0].status === "rejected" && invalid.results[0].errorCode === "invalid", "ungültige Payload → rejected invalid");
|
||||
// An op type without server handler (neither field handler nor registered in services/sync/external-ops.ts)
|
||||
// must be answered without being stored, so devices can retry after the owning lane is deployed.
|
||||
const FIELD_OPS = ["session.start", "session.pause", "session.resume", "session.end", "work_order.transition", "note.create", "checklist.toggle", "material.upsert", "photo.attach", "voice.attach"];
|
||||
const unregistered = SYNC_OP_TYPES.find((t) => !FIELD_OPS.includes(t) && !EXTERNAL_OPS[t] && !CONFLICTING_OPS.includes(t));
|
||||
if (unregistered) {
|
||||
const unavailable = op(unregistered, { reportId: "x" });
|
||||
const na = await applyOperations(tech, { deviceId: "e2e-device", operations: [unavailable] });
|
||||
ok(na.results[0].status === "rejected" && (await prisma.syncOperation.count({ where: { clientOpId: unavailable.clientOpId } })) === 0, `nicht verfügbare Op ${unregistered} → rejected, nicht gespeichert (später wiederholbar)`);
|
||||
} else {
|
||||
console.log("• alle Sync-Op-Typen sind registriert — Prüfung „nicht verfügbare Op“ entfällt");
|
||||
}
|
||||
const notesBefore = await prisma.activityNote.count({ where: { workOrderId: w } });
|
||||
const outsider = await applyOperations(A.ctx.outsider, { deviceId: "o", operations: [op("note.create", { workOrderId: w, text: "fremd" }), op("session.start", { workOrderId: w, mode: "work" })] });
|
||||
ok(outsider.results.every((x) => x.status === "rejected" && x.errorCode === "not_found"), "Monteur ohne Zuweisung → not_found");
|
||||
const foreign = await applyOperations(B.ctx.tech, { deviceId: "b", operations: [op("note.create", { workOrderId: w, text: "Mandant B" }), op("work_order.transition", { workOrderId: w, to: "cancelled", reason: "x" }, { baseVersion: wo3.version })] });
|
||||
ok(foreign.results.every((x) => x.status === "rejected" && x.errorCode === "not_found"), "Mandant B → not_found (kein Versions-Leak)");
|
||||
ok(foreign.results.every((x) => x.entityVersion === undefined), "Mandant B erhält keine Versionsnummer");
|
||||
await expectCode(() => storeFieldUpload(B.ctx.tech, { clientId: randomUUID(), workOrderId: w, kind: "photo" }, { bytes: Buffer.from("xx"), name: "b.jpg", type: "image/jpeg" }), "not_found", "Mandant B kann keine Datei an den Auftrag hochladen");
|
||||
ok((await prisma.activityNote.count({ where: { workOrderId: w } })) === notesBefore && (await prisma.workOrder.findUniqueOrThrow({ where: { id: w } })).status === "waiting_material", "Auftrag von A unverändert");
|
||||
ok(!(await getFieldBundle(B.ctx.tech)).orders.some((o) => o.id === w), "Mandant B erhält den Auftrag nicht im Bundle");
|
||||
});
|
||||
@@ -0,0 +1,240 @@
|
||||
// L10a E2E §43.3 / §38 „Regulärer Auftrag vom Import bis zur Freigabe" — Service-Ebene gegen die DB.
|
||||
//
|
||||
// Import (Fake-Provider) → Dublettenkandidat + Objektkandidat → Prüfung/Bestätigung → Zuweisung →
|
||||
// Vorab-Download (Bundle) → Annehmen/Losfahren/Arbeit → Material/Fotos/Zeiten/Notizen/Checkliste →
|
||||
// Abschlussbericht → Unterschrift → Teamleiter- und Backoffice-Freigabe → PDF (skip-fähig ohne
|
||||
// Chromium) → Abrechnung → abgerechnet → Objekt-Historie. Dazu Mandantentrennung (Mandant B) und
|
||||
// Rollen/Scope (Monteur ohne Zuweisung) an jedem Schritt.
|
||||
//
|
||||
// Lauf: npx tsx scripts/test-e2e-regular-order.ts (auch mit RLS_ENFORCED=true lauffähig)
|
||||
|
||||
import "dotenv/config";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { prisma } from "../src/server/db";
|
||||
import type { WorkOrderExtraction } from "../src/server/ai/providers";
|
||||
import { FakeExtractionProvider } from "../src/server/ai/extraction/fake";
|
||||
import { createImport } from "../src/server/services/imports/upload";
|
||||
import { processImport } from "../src/server/services/imports/process";
|
||||
import { confirmImport } from "../src/server/services/imports/confirm";
|
||||
import { getImportDetail } from "../src/server/services/imports/queries";
|
||||
import { extractionToForm } from "../src/lib/imports/review";
|
||||
import { assignWorkOrder } from "../src/server/services/work-orders/assign";
|
||||
import { addChecklistItem, addPhotoRequirement } from "../src/server/services/work-orders/checklist";
|
||||
import { transitionWorkOrder } from "../src/server/services/work-orders/transition";
|
||||
import { markBilled, releaseForBilling } from "../src/server/services/work-orders/release-billing";
|
||||
import { getWorkOrderDetail } from "../src/server/services/work-orders/detail";
|
||||
import { getFieldBundle, getFieldOrderDetail } from "../src/server/services/field/queries";
|
||||
import { endSession, pauseSession, resumeSession, 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 { storeFile } from "../src/server/services/documents/store";
|
||||
import { authorizeDocumentAccess } from "../src/server/services/documents/access";
|
||||
import { createCompletionReport } from "../src/server/services/reports/create";
|
||||
import { updateReportTexts } from "../src/server/services/reports/edit";
|
||||
import { captureSignature } from "../src/server/services/reports/signature";
|
||||
import { submitReport } from "../src/server/services/reports/submit";
|
||||
import { approveReport } from "../src/server/services/reports/approve";
|
||||
import { generateReportPdf } from "../src/server/services/reports/pdf";
|
||||
import { openReportFile } from "../src/server/services/reports/files";
|
||||
import { contentOf, requireVisibleReport } from "../src/server/services/reports/common";
|
||||
import { pdfRendererAvailable } from "../src/server/pdf/render";
|
||||
import { readStoredBytes } from "../src/server/services/documents/read";
|
||||
import { getSiteHistory } from "../src/server/services/sites/history";
|
||||
import {
|
||||
checklistTogglePayload,
|
||||
materialUpsertPayload,
|
||||
noteCreatePayload,
|
||||
photoAttachPayload,
|
||||
sessionControlPayload,
|
||||
sessionStartPayload,
|
||||
} from "../src/lib/sync/ops";
|
||||
import { buildPdf } from "./make-sample-pdfs";
|
||||
import { codeOf, createTenant, expectCode, jpegBytes, ok, pngBytes, runSuite, section } from "./lib/e2e-fixture";
|
||||
|
||||
const SLUG_A = "zz-q-e2e-reg-a";
|
||||
const SLUG_B = "zz-q-e2e-reg-b";
|
||||
|
||||
runSuite("E2E regulärer Auftrag", [SLUG_A, SLUG_B], async () => {
|
||||
const A = await createTenant(SLUG_A);
|
||||
const B = await createTenant(SLUG_B);
|
||||
const bo = A.ctx.backoffice;
|
||||
const tech = A.ctx.tech;
|
||||
|
||||
// ---------- 1. Import ----------
|
||||
section("1. Import einer Auftragsbestätigung (Fake-Provider)");
|
||||
const pdf = buildPdf([[{ text: "Auftragsbestätigung AB-2026-4711" }, { text: `Hausverwaltung ${SLUG_A} GmbH, Hafenstraße 12, 20457 Hamburg` }]]);
|
||||
const extraction: Partial<WorkOrderExtraction> = {
|
||||
orderNumber: { value: "AB-2026-4711", confidence: 0.97 },
|
||||
customerNumber: { value: `K-${SLUG_A}`, confidence: 0.95 },
|
||||
companyName: { value: `Hausverwaltung ${SLUG_A} GmbH`, confidence: 0.95 },
|
||||
customerAddress: { value: { street: "Hafenstrasse", houseNumber: "12", postalCode: "20457", city: "Hamburg", country: "DE" }, confidence: 0.9 },
|
||||
siteName: { value: `Wohnanlage ${SLUG_A}`, confidence: 0.75, source: "Bauvorhaben" },
|
||||
siteAddress: { value: { street: "Am Kaiserkai", houseNumber: "30", postalCode: "20457", city: "Hamburg" }, confidence: 0.9 },
|
||||
title: { value: "Austausch Heizungspumpe", confidence: 0.88 },
|
||||
plannedStart: { value: "01.10.2026", confidence: 0.9 },
|
||||
plannedEnd: { value: "02.10.2026", confidence: 0.9 },
|
||||
positions: {
|
||||
value: [
|
||||
{ name: "Hocheffizienzpumpe 25-60", articleNumber: "HP-2560", quantity: 1, unit: "Stk", isMaterial: true },
|
||||
{ name: "Montagearbeiten", quantity: 3, unit: "Std", isMaterial: false },
|
||||
],
|
||||
confidence: 0.86,
|
||||
},
|
||||
};
|
||||
const dispatched: unknown[] = [];
|
||||
const job = await createImport(bo, { bytes: pdf, fileName: "AB-2026-4711.pdf", mimeType: "application/pdf" }, { dispatch: async (p) => void dispatched.push(p) });
|
||||
ok(job.status === "uploaded" && dispatched.length === 1, "Upload angenommen, Extraktion eingereiht");
|
||||
const processed = await processImport(bo, job.id, { provider: new FakeExtractionProvider({ extraction, text: "AB-2026-4711" }), loadBytes: async () => pdf });
|
||||
ok(processed.status === "review_required", "Extraktion → Prüfung erforderlich");
|
||||
ok((await prisma.workOrder.count({ where: { tenantId: A.tenantId } })) === 0, "kein Auftrag ohne Bestätigung");
|
||||
await expectCode(() => getImportDetail(B.ctx.backoffice, job.id), "not_found", "Mandant B sieht den Import von A nicht");
|
||||
await expectCode(() => getImportDetail(tech, job.id), "forbidden", "Monteur hat keinen Zugriff auf die Prüfmaske");
|
||||
|
||||
// ---------- 2. Prüfung / Bestätigung ----------
|
||||
section("2. Prüfung: bestehender Kunde + bestehendes Objekt zugeordnet");
|
||||
const detail = await getImportDetail(bo, job.id);
|
||||
ok(detail.customerCandidates[0]?.customerId === A.customerId, "Dublettenprüfung schlägt den bestehenden Kunden vor");
|
||||
const siteCandidates = (detail.extraction as unknown as { siteCandidates?: { siteId: string }[] }).siteCandidates ?? [];
|
||||
ok(siteCandidates.some((s) => s.siteId === A.siteId), "bestehendes Objekt an gleicher Adresse als Kandidat");
|
||||
const form = extractionToForm(detail.extraction.fields, { customerId: A.customerId, siteId: A.siteId });
|
||||
form.order!.title = "Heizungspumpe Wohnanlage tauschen";
|
||||
const confirmed = await confirmImport(bo, job.id, form);
|
||||
const wo0 = await prisma.workOrder.findUniqueOrThrow({ where: { id: confirmed.workOrderId }, include: { materialPlans: true } });
|
||||
ok(wo0.status === "planned" && wo0.customerId === A.customerId && wo0.siteId === A.siteId && wo0.sourceImportId === job.id, "Auftrag geplant, Kunde/Objekt zugeordnet, Import verknüpft");
|
||||
ok(wo0.materialPlans.length === 1 && wo0.materialPlans[0].articleNumber === "HP-2560", "Materialvorgabe aus Positionen übernommen");
|
||||
const woId = wo0.id;
|
||||
|
||||
// Backoffice ergänzt Pflichtpunkt + Pflichtfoto.
|
||||
const item = await addChecklistItem(bo, woId, { label: "Anlage entlüftet", required: true });
|
||||
await addPhotoRequirement(bo, woId, { key: "typenschild", label: "Typenschild neue Pumpe" });
|
||||
|
||||
// ---------- 3. Zuweisung ----------
|
||||
section("3. Zuweisung an Team Nord");
|
||||
await expectCode(() => assignWorkOrder(tech, { workOrderId: woId, teamId: A.teamId, userIds: [A.users.tech.id] }), "forbidden", "Monteur darf nicht zuweisen");
|
||||
await expectCode(() => assignWorkOrder(B.ctx.backoffice, { workOrderId: woId, teamId: B.teamId }), "not_found", "Mandant B kann den Auftrag von A nicht zuweisen");
|
||||
const assigned = await assignWorkOrder(bo, { workOrderId: woId, teamId: A.teamId, userIds: [A.users.tech.id] });
|
||||
ok(assigned.status === "assigned", "Auftrag zugewiesen");
|
||||
ok((await prisma.notification.count({ where: { tenantId: A.tenantId, userId: A.users.tech.id, type: "work_order.assigned", entityId: woId } })) === 1, "Monteur erhält Benachrichtigung „Auftrag zugewiesen“");
|
||||
|
||||
// ---------- 4. Vorab-Download ----------
|
||||
section("4. Team lädt Auftrag und Dokumente (Bundle)");
|
||||
const bundle = await getFieldBundle(tech);
|
||||
const bundled = bundle.orders.find((o) => o.id === woId);
|
||||
ok(!!bundled && bundled.site?.accessNotes === "Schlüssel beim Hausmeister" && bundled.materialPlans.length === 1 && bundled.photoRequirements.length === 1, "Bundle enthält Auftrag mit Zugangshinweis, Material, Pflichtfoto");
|
||||
ok(!(await getFieldBundle(A.ctx.outsider)).orders.some((o) => o.id === woId), "Monteur ohne Zuweisung (anderes Team) erhält den Auftrag nicht");
|
||||
ok(!(await getFieldBundle(B.ctx.tech)).orders.some((o) => o.id === woId), "Mandant B erhält den Auftrag nicht");
|
||||
ok(!!bundled && !bundled.documents.some((d) => d.category === "order_confirmation"), "interne Auftragsbestätigung (backoffice_only) nicht im Monteur-Bundle");
|
||||
await expectCode(() => getFieldOrderDetail(A.ctx.outsider, woId), "not_found", "Auftragsdetail für fremden Monteur → not_found");
|
||||
|
||||
// ---------- 5. Einsatz ----------
|
||||
section("5. Einsatz: Annehmen, Losfahren, Arbeiten, Material, Fotos, Zeiten, Notizen");
|
||||
const started = new Date(Date.now() - 3 * 3600_000);
|
||||
await transitionWorkOrder(tech, { workOrderId: woId, to: "accepted" });
|
||||
await startSession(tech, sessionStartPayload.parse({ workOrderId: woId, mode: "travel", at: started.toISOString() }));
|
||||
const workStart = await startSession(tech, sessionStartPayload.parse({ workOrderId: woId, mode: "work", at: new Date(started.getTime() + 30 * 60_000).toISOString() }));
|
||||
ok(workStart.workOrderStatus === "in_progress", "Arbeit gestartet → in Arbeit");
|
||||
await expectCode(() => startSession(A.ctx.outsider, sessionStartPayload.parse({ workOrderId: woId, mode: "work" })), "not_found", "fremder Monteur kann keinen Einsatz starten");
|
||||
await expectCode(() => startSession(B.ctx.tech, sessionStartPayload.parse({ workOrderId: woId, mode: "work" })), "not_found", "Mandant B kann keinen Einsatz starten");
|
||||
|
||||
const jpeg = await jpegBytes("Typenschild");
|
||||
const before = await storeFieldUpload(tech, { clientId: randomUUID(), workOrderId: woId, kind: "photo" }, { bytes: jpeg, name: "vorher.jpg", type: "image/jpeg" });
|
||||
await attachPhoto(tech, photoAttachPayload.parse({ workOrderId: woId, documentId: before.documentId, phase: "before", comment: "Alte Pumpe" }));
|
||||
await createNote(tech, noteCreatePayload.parse({ workOrderId: woId, kind: "work_done", text: "Alte Pumpe demontiert, neue Pumpe eingesetzt." }));
|
||||
await pauseSession(tech, sessionControlPayload.parse({ workOrderId: woId, at: new Date(started.getTime() + 90 * 60_000).toISOString() }));
|
||||
ok((await prisma.workOrder.findUniqueOrThrow({ where: { id: woId } })).status === "paused", "Pause → Auftrag pausiert");
|
||||
await resumeSession(tech, sessionControlPayload.parse({ workOrderId: woId, at: new Date(started.getTime() + 105 * 60_000).toISOString() }));
|
||||
await upsertMaterialUsage(tech, materialUpsertPayload.parse({ workOrderId: woId, materialPlanId: wo0.materialPlans[0].id, quantity: 1, unit: "Stk", usageStatus: "fully_used" }));
|
||||
await upsertMaterialUsage(tech, materialUpsertPayload.parse({ workOrderId: woId, clientId: randomUUID(), name: "Flachdichtung 1 Zoll", quantity: 2, unit: "Stk", usageStatus: "additional", deviationReason: "Alte Dichtungen porös" }));
|
||||
await expectCode(
|
||||
() => upsertMaterialUsage(tech, materialUpsertPayload.parse({ workOrderId: woId, clientId: randomUUID(), name: "Ohne Grund", quantity: 1, unit: "Stk", usageStatus: "additional" })),
|
||||
"invalid",
|
||||
"Zusatzmaterial ohne Begründung → invalid",
|
||||
);
|
||||
const req = await prisma.photoRequirement.findFirstOrThrow({ where: { workOrderId: woId } });
|
||||
const plate = await storeFieldUpload(tech, { clientId: randomUUID(), workOrderId: woId, kind: "photo" }, { bytes: jpeg, name: "typenschild.jpg", type: "image/jpeg" });
|
||||
await attachPhoto(tech, photoAttachPayload.parse({ workOrderId: woId, documentId: plate.documentId, phase: "after", photoRequirementId: req.id }));
|
||||
await toggleChecklistItem(tech, checklistTogglePayload.parse({ workOrderId: woId, itemId: item.id, checked: true }));
|
||||
|
||||
await expectCode(() => createCompletionReport(tech, { workOrderId: woId }), "blocked", "Abschlussbericht bei laufender Arbeitszeit → blocked");
|
||||
const ended = await endSession(tech, sessionControlPayload.parse({ workOrderId: woId }));
|
||||
ok(ended.workSeconds > 0 && ended.breakSeconds >= 14 * 60 && ended.travelSeconds >= 29 * 60, `Zeiten berechnet (Arbeit ${Math.round(ended.workSeconds / 60)} min, Pause ${Math.round(ended.breakSeconds / 60)} min, Anfahrt ${Math.round(ended.travelSeconds / 60)} min)`);
|
||||
|
||||
// ---------- 6. Abschlussbericht + Unterschrift ----------
|
||||
section("6. Abschlussbericht und Kundenunterschrift");
|
||||
const { report } = await createCompletionReport(tech, { workOrderId: woId });
|
||||
const content = contentOf(report);
|
||||
ok(content.photos.length === 2 && content.materials.used.length === 1 && content.materials.additional.length === 1, "Berichts-Snapshot enthält Fotos und Material (Soll/Ist + Zusatz)");
|
||||
ok(content.time.totalMinutes > 0 && content.site?.name === `Wohnanlage ${SLUG_A}`, "Berichts-Snapshot enthält Arbeitszeit und Objekt");
|
||||
ok(content.texts.workPerformed.includes("Alte Pumpe demontiert"), "„Ausgeführte Leistungen“ aus den Tätigkeitsnotizen vorbelegt");
|
||||
await updateReportTexts(tech, { reportId: report.id, texts: { workPerformed: "" } });
|
||||
await expectCode(() => submitReport(tech, { reportId: report.id }), "blocked", "Absenden ohne „Ausgeführte Leistungen“ → blocked");
|
||||
await updateReportTexts(tech, { reportId: report.id, texts: { workPerformed: "Heizungspumpe getauscht, Anlage entlüftet und geprüft." } });
|
||||
const sigImg = await storeFile(tech, { bytes: await pngBytes(), fileName: "unterschrift.png", declaredMime: "image/png", category: "signature", visibility: "customer_report", links: { workOrderId: woId } });
|
||||
await captureSignature(tech, { reportId: report.id, outcome: "signed", signerName: "Frau Prüf", signerRole: "Objektbetreuung", imageDocumentId: sigImg.id, confirmationText: "Arbeiten ordnungsgemäß ausgeführt." });
|
||||
await expectCode(() => captureSignature(B.ctx.tech, { reportId: report.id, outcome: "later", reason: "x", confirmationText: "x" }), "not_found", "Mandant B kann keine Unterschrift erfassen");
|
||||
await submitReport(tech, { reportId: report.id });
|
||||
const afterSubmit = await prisma.workOrder.findUniqueOrThrow({ where: { id: woId } });
|
||||
ok(afterSubmit.status === "in_review", "Unterschrift erfasst → Auftrag „Zur Prüfung“");
|
||||
ok((await prisma.notification.count({ where: { tenantId: A.tenantId, userId: A.users.lead.id, type: "report.submitted", entityId: report.id } })) === 1, "Teamleiter wird zur Prüfung benachrichtigt");
|
||||
|
||||
// ---------- 7. Freigabe ----------
|
||||
section("7. Teamleiter- und Backoffice-Freigabe");
|
||||
await expectCode(() => approveReport(tech, { reportId: report.id }, { dispatchPdf: async () => undefined }), "forbidden", "Monteur darf nicht freigeben");
|
||||
await expectCode(() => approveReport(B.ctx.backoffice, { reportId: report.id }, { dispatchPdf: async () => undefined }), "not_found", "Mandant B kann nicht freigeben");
|
||||
await expectCode(() => releaseForBilling(bo, { workOrderId: woId }), "blocked", "Abrechnung vor Berichtsfreigabe → blocked");
|
||||
const teamApproved = await approveReport(A.ctx.lead, { reportId: report.id }, { dispatchPdf: async () => undefined });
|
||||
ok(teamApproved.status === "team_approved", "Teamleiter prüft → team_approved");
|
||||
const pdfJobs: string[] = [];
|
||||
const approved = await approveReport(bo, { reportId: report.id }, { dispatchPdf: async (_c, id) => void pdfJobs.push(id) });
|
||||
ok(approved.status === "approved" && pdfJobs[0] === report.id, "Backoffice gibt frei → approved, PDF-Job angestoßen");
|
||||
await expectCode(() => updateReportTexts(tech, { reportId: report.id, texts: { workPerformed: "nachträglich geändert" } }), ["conflict", "invalid", "blocked"], "freigegebener Bericht ist unveränderlich");
|
||||
|
||||
// ---------- 8. PDF ----------
|
||||
section("8. PDF-Arbeitsnachweis");
|
||||
const renderer = await pdfRendererAvailable();
|
||||
if (!renderer.ok) {
|
||||
console.log(`⚠ PDF-Erzeugung übersprungen: kein Chromium startbar (${renderer.reason})`);
|
||||
} else {
|
||||
const pdfRes = await generateReportPdf(bo, report.id);
|
||||
const stored = await prisma.report.findUniqueOrThrow({ where: { id: report.id } });
|
||||
const doc = await prisma.document.findUniqueOrThrow({ where: { id: pdfRes.documentId } });
|
||||
ok(!pdfRes.skipped && stored.pdfDocumentId === doc.id && stored.pdfChecksum === doc.checksum, "PDF erzeugt, Prüfsumme am Bericht");
|
||||
ok(doc.category === "completion_report" && doc.siteId === A.siteId && doc.workOrderId === woId, "PDF als Abschlussbericht an Auftrag und Objekt abgelegt");
|
||||
const bytes = await readStoredBytes(doc.storageKey);
|
||||
if (bytes) ok(Buffer.from(bytes).subarray(0, 4).toString() === "%PDF", "Datei beginnt mit %PDF");
|
||||
await expectCode(() => openReportFile(B.ctx.backoffice, report.id, "pdf"), "not_found", "Mandant B kann das PDF nicht laden");
|
||||
await expectCode(() => authorizeDocumentAccess(A.ctx.outsider, doc.id), "not_found", "fremder Monteur kann das PDF nicht laden");
|
||||
}
|
||||
|
||||
// ---------- 9. Abrechnung ----------
|
||||
section("9. Abrechnungsfreigabe und abgerechnet");
|
||||
await expectCode(() => releaseForBilling(tech, { workOrderId: woId }), "forbidden", "Monteur darf nicht zur Abrechnung freigeben");
|
||||
await expectCode(() => releaseForBilling(A.ctx.lead, { workOrderId: woId }), "forbidden", "Teamleiter darf nicht zur Abrechnung freigeben");
|
||||
const released = await releaseForBilling(bo, { workOrderId: woId });
|
||||
ok(released.status === "released_for_billing", "Auftrag zur Abrechnung freigegeben");
|
||||
const billed = await markBilled(bo, { workOrderId: woId });
|
||||
ok(billed.status === "billed", "Auftrag abgerechnet");
|
||||
ok((await codeOf(transitionWorkOrder(bo, { workOrderId: woId, to: "cancelled", reason: "zu spät" }))) === "invalid", "abgerechneter Auftrag kann nicht storniert werden");
|
||||
|
||||
const history = await prisma.workOrderStatusChange.findMany({ where: { workOrderId: woId }, orderBy: { createdAt: "asc" }, select: { toStatus: true } });
|
||||
const path = history.map((h) => h.toStatus).join(">");
|
||||
ok(path === "planned>assigned>accepted>en_route>in_progress>paused>in_progress>technically_completed>in_review>released_for_billing>billed", `Statushistorie vollständig (${path})`);
|
||||
ok((await prisma.auditLog.count({ where: { tenantId: A.tenantId, entity: "work_order", entityId: woId } })) >= 10, "jede Statusänderung auditiert");
|
||||
const woDetail = await getWorkOrderDetail(bo, woId);
|
||||
ok(woDetail.id === woId, "Backoffice-Detail lesbar");
|
||||
|
||||
// ---------- 10. Objekt-Historie ----------
|
||||
section("10. Einsatz erscheint in der Objekt-Historie");
|
||||
const hist = await getSiteHistory(bo, A.siteId);
|
||||
const entry = hist.items.find((i) => i.workOrderId === woId);
|
||||
ok(!!entry && entry.approvedReports.length === 1 && entry.signed && entry.photoCount === 2, "Historie: freigegebener Bericht, Unterschrift, Fotos");
|
||||
ok(!!entry && entry.materials.some((m) => m.name === "Hocheffizienzpumpe 25-60") && entry.workDone.length === 1, "Historie: Material und durchgeführte Arbeiten");
|
||||
const techHistory = await getSiteHistory(A.ctx.tech2, A.siteId);
|
||||
ok(techHistory.onlyApproved && techHistory.items.some((i) => i.workOrderId === woId), "Teamkollege sieht den freigegebenen Einsatz in der Objekt-Historie");
|
||||
await expectCode(() => getSiteHistory(A.ctx.outsider, A.siteId), "not_found", "Monteur ohne Auftrag am Objekt → not_found");
|
||||
await expectCode(() => getSiteHistory(B.ctx.backoffice, A.siteId), "not_found", "Mandant B → not_found");
|
||||
await expectCode(() => requireVisibleReport(B.ctx.backoffice, report.id), "not_found", "Mandant B kann den Bericht nicht lesen");
|
||||
});
|
||||
@@ -0,0 +1,401 @@
|
||||
// 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 });
|
||||
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");
|
||||
});
|
||||
@@ -0,0 +1,164 @@
|
||||
// L10a Sicherheitstest §43.4 „Rate Limiting", „Session-Handling", „Audit-Log-Manipulation" (Service-/DB-Ebene).
|
||||
//
|
||||
// Login: 5 Fehlversuche → Identität gesperrt (auch das richtige Passwort wird abgewiesen), Fehlversuche
|
||||
// und Sperre auditiert, unbekannte E-Mail ohne Orakel, Ausnahme „letzter aktiver Mandanten-Admin"
|
||||
// (keine DoS-Sperre, aber protokolliert). Rate-Limit der Wiederherstellungs-Abläufe je IP und Konto.
|
||||
// Session-Kill-Switch (isTokenStillValid, fail-closed ohne iat). Audit-Log: kein Fachpfad (Services,
|
||||
// Actions, Route Handler) ändert oder löscht Audit-Einträge (statische Prüfung), die App-Rolle
|
||||
// craftvia_app hat auf audit_logs weder UPDATE noch DELETE (Migration *_qualitaet_audit_append_only),
|
||||
// der Mandanten-Guard verhindert fremde Zugriffe. Passwort-Reset/MFA-Härtung: test-reset-flow.ts,
|
||||
// test-mfa-hardening.ts; Session-Cookies über HTTP: test-security-http.ts.
|
||||
//
|
||||
// Lauf: npx tsx scripts/test-security-auth.ts
|
||||
|
||||
import "dotenv/config";
|
||||
import { readdirSync, readFileSync, statSync } from "node:fs";
|
||||
import { join, relative } from "node:path";
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import { PrismaPg } from "@prisma/adapter-pg";
|
||||
import { prisma, dbForTenant } from "../src/server/db";
|
||||
import { hashPassword } from "../src/server/password";
|
||||
import { authorizeTenantCredentials } from "../src/server/auth";
|
||||
import { checkRateLimit, resetRateLimits } from "../src/server/rate-limit";
|
||||
import { isTokenStillValid } from "../src/server/sessions";
|
||||
import { writeAuditLog } from "../src/server/audit";
|
||||
import { createTenant, ok, runSuite, section } from "./lib/e2e-fixture";
|
||||
|
||||
const SLUG = "zz-q-sec-auth";
|
||||
const SLUG_B = "zz-q-sec-auth-b";
|
||||
const PASSWORD = "Pruef-Passwort-2026!";
|
||||
|
||||
function walk(dir: string): string[] {
|
||||
return readdirSync(dir).flatMap((name) => {
|
||||
const p = join(dir, name);
|
||||
return statSync(p).isDirectory() ? walk(p) : /\.(ts|tsx)$/.test(name) ? [p] : [];
|
||||
});
|
||||
}
|
||||
|
||||
function defaultRlsUrl(): string {
|
||||
const base = new URL(process.env.DATABASE_URL ?? "postgresql://localhost:5432/craftvia?schema=public");
|
||||
base.username = "craftvia_app";
|
||||
base.password = "craftvia_app_local";
|
||||
return base.toString();
|
||||
}
|
||||
|
||||
runSuite("Sicherheit: Login-Sperre, Rate-Limit, Sessions, Audit-Log", [SLUG, SLUG_B], async () => {
|
||||
const A = await createTenant(SLUG);
|
||||
const B = await createTenant(SLUG_B);
|
||||
const hash = await hashPassword(PASSWORD);
|
||||
await prisma.identity.updateMany({ where: { id: { in: [A.users.tech.identityId, A.users.admin.identityId] } }, data: { passwordHash: hash, failedLogins: 0, lockedUntil: null } });
|
||||
const techEmail = A.users.tech.email;
|
||||
|
||||
section("Login: Sperre nach Fehlversuchen");
|
||||
ok((await authorizeTenantCredentials({ email: techEmail, password: PASSWORD, tenant: SLUG })) !== null, "richtiges Passwort → Anmeldung möglich");
|
||||
for (let i = 1; i <= 5; i++) {
|
||||
const res = await authorizeTenantCredentials({ email: techEmail, password: `falsch-${i}`, tenant: SLUG });
|
||||
if (res !== null) ok(false, `Fehlversuch ${i} wurde angenommen`);
|
||||
}
|
||||
const locked = await prisma.identity.findUniqueOrThrow({ where: { id: A.users.tech.identityId } });
|
||||
ok(!!locked.lockedUntil && locked.lockedUntil.getTime() > Date.now() + 10 * 60_000, `nach 5 Fehlversuchen gesperrt bis ${locked.lockedUntil?.toISOString()}`);
|
||||
ok((await authorizeTenantCredentials({ email: techEmail, password: PASSWORD, tenant: SLUG })) === null, "gesperrt: auch das richtige Passwort wird abgewiesen");
|
||||
const denied = await prisma.auditLog.findMany({ where: { tenantId: A.tenantId, entity: "login", action: "denied", entityId: A.users.tech.identityId } });
|
||||
ok(denied.length >= 6 && denied.some((d) => (d.after as { locked?: boolean } | null)?.locked === true) && denied.some((d) => (d.after as { reason?: string } | null)?.reason === "locked"), `Fehlversuche, Sperre und Versuch während der Sperre auditiert (${denied.length})`);
|
||||
await prisma.identity.update({ where: { id: locked.id }, data: { lockedUntil: new Date(Date.now() - 1000) } });
|
||||
ok((await authorizeTenantCredentials({ email: techEmail, password: PASSWORD, tenant: SLUG })) !== null, "nach Ablauf der Sperre wieder anmeldbar, Zähler zurückgesetzt");
|
||||
ok((await prisma.identity.findUniqueOrThrow({ where: { id: locked.id } })).failedLogins === 0, "Fehlversuchszähler nach erfolgreicher Anmeldung 0");
|
||||
ok((await authorizeTenantCredentials({ email: `unbekannt${Date.now()}@zz-qualitaet.test`, password: PASSWORD, tenant: SLUG })) === null, "unbekannte E-Mail → gleiche Antwort (null), kein Konto-Orakel");
|
||||
ok((await authorizeTenantCredentials({ email: techEmail, password: PASSWORD, tenant: SLUG_B })) === null, "Anmeldung in einem Mandanten ohne Mitgliedschaft abgewiesen");
|
||||
|
||||
section("Login: letzter aktiver Mandanten-Admin wird nicht ausgesperrt (DoS-Schutz)");
|
||||
for (let i = 0; i < 6; i++) await authorizeTenantCredentials({ email: A.users.admin.email, password: "falsch", tenant: SLUG });
|
||||
const admin = await prisma.identity.findUniqueOrThrow({ where: { id: A.users.admin.identityId } });
|
||||
ok(!admin.lockedUntil || admin.lockedUntil.getTime() < Date.now(), "letzter Admin nicht gesperrt");
|
||||
ok((await prisma.auditLog.count({ where: { tenantId: A.tenantId, entity: "login", action: "denied", entityId: admin.id } })) >= 6, "Fehlversuche des Admins trotzdem auditiert");
|
||||
console.log(" Hinweis: Das Login zählt Fehlversuche je Identität (Sperre); ein IP-basiertes Limit gegen Password-Spraying über viele Konten gibt es nur für Reset/Einladung (siehe Bericht).");
|
||||
|
||||
section("Rate-Limit Wiederherstellung (je IP und je Konto)");
|
||||
resetRateLimits();
|
||||
const results = Array.from({ length: 6 }, () => checkRateLimit("passwordResetRequest", { ip: "203.0.113.7", account: "opfer@zz-qualitaet.test" }).allowed);
|
||||
ok(results.slice(0, 5).every(Boolean) && results[5] === false, "Reset-Anfrage: 5 erlaubt, 6. blockiert");
|
||||
const blocked = checkRateLimit("passwordResetRequest", { ip: "203.0.113.7", account: "anderes@zz-qualitaet.test" });
|
||||
ok(!blocked.allowed && blocked.retryAfterSeconds > 0, "gleiche IP, anderes Konto → weiter blockiert (mit Wartezeit)");
|
||||
ok(!checkRateLimit("passwordResetRequest", { ip: "198.51.100.1", account: "opfer@zz-qualitaet.test" }).allowed, "anderes Konto-Ziel über neue IP → Konto-Zähler blockiert");
|
||||
ok(checkRateLimit("passwordResetRequest", { ip: "198.51.100.2", account: "frei@zz-qualitaet.test" }).allowed, "unbeteiligte IP + Konto → erlaubt");
|
||||
resetRateLimits();
|
||||
const redeem = Array.from({ length: 11 }, () => checkRateLimit("passwordResetRedeem", { ip: "203.0.113.9" }).allowed);
|
||||
ok(redeem.filter(Boolean).length === 10 && !redeem[10], "Reset-Einlösung: 10 Versuche je IP, dann blockiert");
|
||||
resetRateLimits();
|
||||
|
||||
section("Session-Kill-Switch");
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
ok(isTokenStillValid(now, null), "ohne Invalidierungsmarke gültig");
|
||||
ok(!isTokenStillValid(now - 60, new Date(now * 1000)), "Token vor der Marke ausgestellt → ungültig");
|
||||
ok(isTokenStillValid(now + 1, new Date(now * 1000)), "Token nach der Marke → gültig");
|
||||
ok(!isTokenStillValid(undefined, new Date()), "Token ohne iat bei gesetzter Marke → fail-closed");
|
||||
|
||||
section("Audit-Log: kein Fachpfad ändert oder löscht Einträge");
|
||||
const root = process.cwd();
|
||||
const scanned = ["src/app", "src/server/actions", "src/server/services", "src/server/api", "src/server/jobs", "src/lib"].flatMap((d) => walk(join(root, d)));
|
||||
const offenders = scanned.filter((file) => {
|
||||
const text = readFileSync(file, "utf8");
|
||||
return /auditLog\s*\.\s*(update|updateMany|delete|deleteMany|upsert)\b/.test(text) || /(UPDATE|DELETE\s+FROM|TRUNCATE)\s+"?audit_logs/i.test(text);
|
||||
});
|
||||
ok(scanned.length > 100 && offenders.length === 0, `${scanned.length} Dateien geprüft, keine Mutation von audit_logs${offenders.length ? ` — ${offenders.map((f) => relative(root, f)).join(", ")}` : ""}`);
|
||||
const apiRoutes = walk(join(root, "src/app/api"));
|
||||
ok(!apiRoutes.some((f) => /audit/i.test(relative(root, f))), "keine /api-Route für Audit-Einträge");
|
||||
const viewer = readFileSync(join(root, "src/server/services/audit/viewer.ts"), "utf8");
|
||||
ok(!/\.(create|update|delete|upsert)\w*\(/.test(viewer.replace(/writeAuditLog/g, "")), "Audit-Viewer-Service ist rein lesend");
|
||||
|
||||
const privileges = await prisma.$queryRawUnsafe<{ sel: boolean; ins: boolean; upd: boolean; del: boolean }[]>(
|
||||
`SELECT has_table_privilege('craftvia_app', 'audit_logs', 'SELECT') AS sel, has_table_privilege('craftvia_app', 'audit_logs', 'INSERT') AS ins,
|
||||
has_table_privilege('craftvia_app', 'audit_logs', 'UPDATE') AS upd, has_table_privilege('craftvia_app', 'audit_logs', 'DELETE') AS del`,
|
||||
);
|
||||
ok(privileges[0].sel && privileges[0].ins, "App-Rolle darf Audit-Einträge lesen und anlegen");
|
||||
ok(!privileges[0].upd && !privileges[0].del, "App-Rolle darf Audit-Einträge weder ändern noch löschen (append-only)");
|
||||
|
||||
await writeAuditLog({ tenantId: A.tenantId, actorId: A.users.backoffice.id, action: "update", entity: "zz_audit_probe", entityId: "p1", before: { v: 1 }, after: { v: 2 } });
|
||||
const probe = await prisma.auditLog.findFirstOrThrow({ where: { tenantId: A.tenantId, entity: "zz_audit_probe" } });
|
||||
ok(probe.actorId === A.users.backoffice.id && (probe.before as { v: number }).v === 1, "writeAuditLog protokolliert Akteur und before/after");
|
||||
let foreignBlocked = false;
|
||||
try {
|
||||
await dbForTenant(B.tenantId).auditLog.update({ where: { id: probe.id }, data: { entity: "manipuliert" } });
|
||||
} catch {
|
||||
foreignBlocked = true;
|
||||
}
|
||||
ok(foreignBlocked && (await prisma.auditLog.count({ where: { tenantId: B.tenantId, entity: "manipuliert" } })) === 0, "fremder Mandant kann den Eintrag nicht ändern (Tenant-Guard)");
|
||||
let deletedByB = -1;
|
||||
try {
|
||||
deletedByB = (await dbForTenant(B.tenantId).auditLog.deleteMany({ where: { id: probe.id } })).count;
|
||||
} catch (err) {
|
||||
// RLS_ENFORCED=true: the app role has no DELETE privilege on audit_logs at all
|
||||
if (/permission denied/i.test((err as Error).message)) deletedByB = 0;
|
||||
else throw err;
|
||||
}
|
||||
ok(deletedByB === 0, "fremder Mandant kann den Eintrag nicht löschen");
|
||||
|
||||
const app = new PrismaClient({ adapter: new PrismaPg({ connectionString: process.env.RLS_DATABASE_URL ?? defaultRlsUrl() }) });
|
||||
try {
|
||||
await app.$queryRawUnsafe("SELECT 1");
|
||||
const attempt = async (sql: string) => {
|
||||
try {
|
||||
await app.$transaction(async (tx) => {
|
||||
await tx.$executeRaw`SELECT set_config('app.tenant_id', ${A.tenantId}, true)`;
|
||||
await tx.$executeRawUnsafe(sql, probe.id);
|
||||
});
|
||||
return "executed";
|
||||
} catch (err) {
|
||||
return /permission denied/i.test((err as Error).message) ? "denied" : `error:${(err as Error).message.split("\n")[0]}`;
|
||||
}
|
||||
};
|
||||
ok((await attempt(`UPDATE audit_logs SET entity = 'manipuliert' WHERE id = $1`)) === "denied", "craftvia_app im eigenen Mandanten: UPDATE audit_logs → permission denied");
|
||||
ok((await attempt(`DELETE FROM audit_logs WHERE id = $1`)) === "denied", "craftvia_app im eigenen Mandanten: DELETE audit_logs → permission denied");
|
||||
const readable = await app.$transaction(async (tx) => {
|
||||
await tx.$executeRaw`SELECT set_config('app.tenant_id', ${A.tenantId}, true)`;
|
||||
return tx.auditLog.count({ where: { id: probe.id } });
|
||||
});
|
||||
ok(readable === 1, "craftvia_app liest den eigenen Eintrag weiterhin");
|
||||
} catch (err) {
|
||||
if (process.env.RLS_TEST_REQUIRED === "true") throw err;
|
||||
console.log(`⚠ DB-Rollenprüfung übersprungen: craftvia_app nicht verbindbar (${(err as Error).message.split("\n")[0]})`);
|
||||
} finally {
|
||||
await app.$disconnect();
|
||||
}
|
||||
ok((await prisma.auditLog.findUniqueOrThrow({ where: { id: probe.id } })).entity === "zz_audit_probe", "Audit-Eintrag nach allen Versuchen unverändert");
|
||||
});
|
||||
@@ -0,0 +1,383 @@
|
||||
// L10a Sicherheitstest §43.4 über HTTP gegen den echten Next.js-Server (Route Handler, Proxy, Auth.js-Cookies).
|
||||
//
|
||||
// Server: SECURITY_BASE=http://localhost:3110 (laufender Server) — sonst wird der Produktions-Build (.next,
|
||||
// z. B. aus `npm run gate`) per `next start` auf einem freien Port gestartet. Ohne Build und ohne
|
||||
// SECURITY_BASE wird der Test mit Hinweis übersprungen (Exit 0).
|
||||
// Sessions werden wie in scripts/smoke-auth.ts ohne Passworteingabe ausgestellt (finalizeIdentityLogin +
|
||||
// Auth.js encode); nur der Login-Sperrtest nutzt ein Test-Passwort einer zz-Test-Identität.
|
||||
//
|
||||
// Nachweise: (1) ohne Session: /api/v1 → 401, Datei-Routen liefern keine Bytes; (2) manipulierte IDs:
|
||||
// jede /api/v1-Route und jede Datei-Route mit IDs aus Mandant A (Session aus Mandant B bzw. Monteur ohne
|
||||
// Zuweisung) → 404, nie 200/403, keine Daten im Body; unsinnige IDs → 404 statt 500; (3) unzulässige
|
||||
// Rollenaktionen → 403 + Audit „denied“; (4) schädliche Uploads über alle Upload-Routen; Polyglot wird nur
|
||||
// als Anhang mit nosniff ausgeliefert; Pfad-Traversal/CRLF im Dateinamen; Übergröße; Server bleibt
|
||||
// erreichbar; (5) CSRF: fremder Origin → 403; (6) Session-Handling: manipuliertes/abgelaufenes/fremd
|
||||
// signiertes Cookie, Kill-Switch, deaktiviertes Konto; (7) Audit-Log nicht über HTTP änderbar;
|
||||
// (8) Login-Sperre über den echten Credentials-Endpunkt; (9) Sicherheits-Header.
|
||||
// Statuscodes: `invalid` wird als 400 oder 422 akzeptiert, `blocked` als 409 oder 422 (Vereinheitlichung L10b).
|
||||
//
|
||||
// Lauf: npx tsx scripts/test-security-http.ts (oder SECURITY_BASE=http://localhost:3110 npx tsx …)
|
||||
|
||||
import "dotenv/config";
|
||||
import { spawn, type ChildProcess } from "node:child_process";
|
||||
import { existsSync } from "node:fs";
|
||||
import { createServer } from "node:net";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { encode } from "next-auth/jwt";
|
||||
import { prisma } from "../src/server/db";
|
||||
import { finalizeIdentityLogin } from "../src/server/auth";
|
||||
import { hashPassword } from "../src/server/password";
|
||||
import { createWorkOrder } from "../src/server/services/work-orders/create";
|
||||
import { assignWorkOrder } from "../src/server/services/work-orders/assign";
|
||||
import { startSession } from "../src/server/services/field/sessions";
|
||||
import { storeFieldUpload } from "../src/server/services/field/uploads";
|
||||
import { createNote } from "../src/server/services/field/notes";
|
||||
import { createDailyReport } from "../src/server/services/reports/create";
|
||||
import { createImport } from "../src/server/services/imports/upload";
|
||||
import { noteCreatePayload, sessionStartPayload } from "../src/lib/sync/ops";
|
||||
import { buildPdf } from "./make-sample-pdfs";
|
||||
import { createTenant, jpegBytes, ok, runSuite, section, type TenantFixture } from "./lib/e2e-fixture";
|
||||
|
||||
const SLUG_A = "zz-q-sec-http-a";
|
||||
const SLUG_B = "zz-q-sec-http-b";
|
||||
const MARK_A = `Hausverwaltung ${SLUG_A}`;
|
||||
const INVALID = [400, 422];
|
||||
|
||||
async function freePort(): Promise<number> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const srv = createServer();
|
||||
srv.once("error", reject);
|
||||
srv.listen(0, () => {
|
||||
const port = (srv.address() as { port: number }).port;
|
||||
srv.close(() => resolve(port));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function startServer(): Promise<{ base: string; stop: () => void; logs: () => string } | null> {
|
||||
if (process.env.SECURITY_BASE) return { base: process.env.SECURITY_BASE.replace(/\/$/, ""), stop: () => undefined, logs: () => "" };
|
||||
if (!existsSync(".next/BUILD_ID")) return null;
|
||||
const port = await freePort();
|
||||
let output = "";
|
||||
const child: ChildProcess = spawn(process.execPath, ["node_modules/next/dist/bin/next", "start", "-p", String(port)], {
|
||||
env: { ...process.env, NODE_ENV: "production", PORT: String(port) },
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
child.stdout?.on("data", (d) => (output += d));
|
||||
child.stderr?.on("data", (d) => (output += d));
|
||||
const base = `http://localhost:${port}`;
|
||||
for (let i = 0; i < 120; i++) {
|
||||
try {
|
||||
const res = await fetch(`${base}/api/v1/work-orders`, { signal: AbortSignal.timeout(2000) });
|
||||
if (res.status === 401) return { base, stop: () => child.kill("SIGTERM"), logs: () => output };
|
||||
} catch {
|
||||
/* not ready yet */
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
}
|
||||
child.kill("SIGTERM");
|
||||
throw new Error(`next start did not become ready on ${base}:\n${output.slice(-2000)}`);
|
||||
}
|
||||
|
||||
type Res = { status: number; text: string; headers: Headers };
|
||||
|
||||
runSuite("Sicherheit über HTTP", [SLUG_A, SLUG_B], async () => {
|
||||
const server = await startServer();
|
||||
if (!server) {
|
||||
console.log("⚠ ÜBERSPRUNGEN: kein Produktions-Build (.next/BUILD_ID) und kein SECURITY_BASE gesetzt. `npm run build` oder SECURITY_BASE=http://localhost:3110.");
|
||||
return;
|
||||
}
|
||||
const { base } = server;
|
||||
console.log(`Server: ${base}`);
|
||||
const cookieName = base.startsWith("https") ? "__Secure-authjs.session-token" : "authjs.session-token";
|
||||
|
||||
try {
|
||||
const A = await createTenant(SLUG_A);
|
||||
const B = await createTenant(SLUG_B);
|
||||
|
||||
async function cookieFor(f: TenantFixture, persona: keyof TenantFixture["users"], opts: { secret?: string; maxAge?: number } = {}) {
|
||||
const user = await finalizeIdentityLogin(f.users[persona].identityId, f.slug);
|
||||
if (!user) throw new Error(`no session user for ${persona}`);
|
||||
const token = {
|
||||
sub: user.id, name: user.name, email: user.email, userId: user.id, identityId: user.identityId, tenantId: user.tenantId, tenantSlug: user.tenantSlug,
|
||||
activeMembershipId: user.activeMembershipId, memberships: user.memberships, roles: user.roles, permissions: user.permissions, isPlatformAdmin: user.isPlatformAdmin, mfaEnrolled: user.mfaEnrolled,
|
||||
};
|
||||
const value = await encode({ token, secret: opts.secret ?? process.env.AUTH_SECRET!, salt: cookieName, maxAge: opts.maxAge ?? 30 * 60 });
|
||||
return `${cookieName}=${value}`;
|
||||
}
|
||||
|
||||
async function call(method: string, path: string, opts: { cookie?: string; json?: unknown; form?: FormData; origin?: string | null; accept?: string } = {}): Promise<Res> {
|
||||
const headers: Record<string, string> = {};
|
||||
if (opts.cookie) headers.cookie = opts.cookie;
|
||||
if (opts.accept) headers.accept = opts.accept;
|
||||
const origin = opts.origin === undefined ? (method === "GET" ? null : base) : opts.origin;
|
||||
if (origin) headers.origin = origin;
|
||||
let body: BodyInit | undefined;
|
||||
if (opts.json !== undefined) {
|
||||
headers["content-type"] = "application/json";
|
||||
body = JSON.stringify(opts.json);
|
||||
} else if (opts.form) body = opts.form;
|
||||
const res = await fetch(base + path, { method, headers, body, redirect: "manual", signal: AbortSignal.timeout(60_000) });
|
||||
return { status: res.status, text: await res.text(), headers: res.headers };
|
||||
}
|
||||
|
||||
const leaks = (r: Res) => r.text.includes(MARK_A) || /prisma|stack|at \/|node_modules|PrismaClient|SELECT /i.test(r.text);
|
||||
const expectStatus = (label: string, r: Res, allowed: number[]) =>
|
||||
ok(allowed.includes(r.status) && !leaks(r), `${label} → ${r.status}${allowed.includes(r.status) ? "" : ` (erwartet ${allowed.join("|")})`}${leaks(r) ? " — Body enthält Daten/Interna" : ""}`);
|
||||
|
||||
// ---------- Daten in Mandant A ----------
|
||||
const bo = A.ctx.backoffice;
|
||||
const wo = await createWorkOrder(bo, { title: "HTTP-Sicherheit A", customerId: A.customerId, siteId: A.siteId, applyTemplate: false, materials: [{ name: "Rohr", plannedQuantity: 1, unit: "m" }] });
|
||||
await assignWorkOrder(bo, { workOrderId: wo.id, teamId: A.teamId, userIds: [A.users.tech.id] });
|
||||
const free = await createWorkOrder(bo, { title: "Unzugeordnet A", customerId: A.customerId });
|
||||
await startSession(A.ctx.tech, sessionStartPayload.parse({ workOrderId: wo.id, mode: "work", at: new Date(Date.now() - 3600_000).toISOString() }));
|
||||
const jpeg = await jpegBytes("HTTP");
|
||||
const photo = await storeFieldUpload(A.ctx.tech, { clientId: randomUUID(), workOrderId: wo.id, kind: "photo" }, { bytes: jpeg, name: "a.jpg", type: "image/jpeg" });
|
||||
await createNote(A.ctx.tech, noteCreatePayload.parse({ workOrderId: wo.id, kind: "work_done", text: "HTTP-Test" }));
|
||||
const report = (await createDailyReport(A.ctx.tech, { workOrderId: wo.id })).report;
|
||||
const pdf = buildPdf([[{ text: "HTTP-Sicherheit" }]]);
|
||||
const imp = await createImport(bo, { bytes: pdf, fileName: "auftrag.pdf", mimeType: "application/pdf" }, { dispatch: async () => undefined });
|
||||
|
||||
const cA = { bo: await cookieFor(A, "backoffice"), admin: await cookieFor(A, "admin"), tech: await cookieFor(A, "tech"), outsider: await cookieFor(A, "outsider"), lead: await cookieFor(A, "lead") };
|
||||
const cB = { bo: await cookieFor(B, "backoffice"), admin: await cookieFor(B, "admin"), tech: await cookieFor(B, "tech") };
|
||||
|
||||
// ================= (1) ohne Session =================
|
||||
section("(1) Ohne Session");
|
||||
const anonymous: [string, string][] = [
|
||||
["GET", "/api/v1/work-orders"], ["POST", "/api/v1/work-orders"], ["GET", `/api/v1/work-orders/${wo.id}`], ["POST", `/api/v1/work-orders/${wo.id}/transition`],
|
||||
["GET", "/api/v1/customers"], ["GET", `/api/v1/customers/${A.customerId}`], ["GET", `/api/v1/sites/${A.siteId}/history`], ["POST", "/api/v1/sync"],
|
||||
["POST", "/api/v1/uploads"], ["GET", "/api/v1/field/bundle"], ["GET", `/api/v1/field/documents/${photo.documentId}`], ["GET", `/api/v1/reports/${report.id}/pdf`],
|
||||
["GET", `/api/v1/imports/${imp.id}`], ["POST", "/api/v1/work-orders/import"],
|
||||
];
|
||||
for (const [m, p] of anonymous) {
|
||||
const r = await call(m, p, { json: m === "POST" ? {} : undefined });
|
||||
ok(r.status === 401 && !leaks(r) && /unauthorized/.test(r.text), `${m} ${p} ohne Session → ${r.status}`);
|
||||
}
|
||||
for (const p of [`/files/${photo.documentId}`, `/imports/${imp.id}/file`, "/dashboard", "/m"]) {
|
||||
const r = await call("GET", p);
|
||||
ok([302, 303, 307].includes(r.status) && (r.headers.get("location") ?? "").includes("/login") && r.text.length < 2000 && !r.text.includes("JFIF"), `GET ${p} ohne Session → Umleitung zur Anmeldung, keine Datei`);
|
||||
}
|
||||
|
||||
// ================= (2) manipulierte IDs =================
|
||||
section("(2) Manipulierte IDs: Mandant B greift auf IDs aus Mandant A zu");
|
||||
const jpegForm = () => {
|
||||
const f = new FormData();
|
||||
f.set("file", new File([new Uint8Array(jpeg)], "b.jpg", { type: "image/jpeg" }));
|
||||
return f;
|
||||
};
|
||||
const uploadForm = (workOrderId: string, bytes: Uint8Array = jpeg, name = "b.jpg", type = "image/jpeg") => {
|
||||
const f = new FormData();
|
||||
f.set("file", new File([new Uint8Array(bytes)], name, { type }));
|
||||
f.set("clientId", randomUUID());
|
||||
f.set("workOrderId", workOrderId);
|
||||
f.set("kind", "photo");
|
||||
return f;
|
||||
};
|
||||
const validReviewForm = { customerMode: "new", customer: { companyName: "B-Kunde" }, siteMode: "none", site: {}, contact: {}, order: { title: "B" }, positions: [] };
|
||||
const foreign: [string, string, Parameters<typeof call>[2]][] = [
|
||||
["GET", `/api/v1/work-orders/${wo.id}`, { cookie: cB.bo }],
|
||||
["PATCH", `/api/v1/work-orders/${wo.id}`, { cookie: cB.bo, json: { title: "B" } }],
|
||||
["POST", `/api/v1/work-orders/${wo.id}/assign`, { cookie: cB.bo, json: { teamId: B.teamId } }],
|
||||
["POST", `/api/v1/work-orders/${wo.id}/transition`, { cookie: cB.bo, json: { to: "cancelled", reason: "B" } }],
|
||||
["GET", `/api/v1/work-orders/${wo.id}/materials`, { cookie: cB.bo }],
|
||||
["POST", `/api/v1/work-orders/${wo.id}/materials`, { cookie: cB.bo, json: { name: "B", plannedQuantity: 1, unit: "Stk" } }],
|
||||
["POST", `/api/v1/work-orders/${wo.id}/documents`, { cookie: cB.bo, form: jpegForm() }],
|
||||
["POST", `/api/v1/work-orders/${wo.id}/daily-report`, { cookie: cB.tech, json: {} }],
|
||||
["POST", `/api/v1/work-orders/${wo.id}/completion-report`, { cookie: cB.tech, json: {} }],
|
||||
["POST", `/api/v1/reports/${report.id}/approve`, { cookie: cB.bo }],
|
||||
["GET", `/api/v1/reports/${report.id}/pdf`, { cookie: cB.bo }],
|
||||
["GET", `/api/v1/reports/${report.id}/files/${photo.documentId}`, { cookie: cB.bo }],
|
||||
["GET", `/api/v1/customers/${A.customerId}`, { cookie: cB.bo }],
|
||||
["PATCH", `/api/v1/customers/${A.customerId}`, { cookie: cB.bo, json: { companyName: "B" } }],
|
||||
["GET", `/api/v1/sites/${A.siteId}/history`, { cookie: cB.bo }],
|
||||
["GET", `/api/v1/imports/${imp.id}`, { cookie: cB.bo }],
|
||||
["POST", `/api/v1/imports/${imp.id}/confirm`, { cookie: cB.admin, json: validReviewForm }],
|
||||
["GET", `/api/v1/field/documents/${photo.documentId}`, { cookie: cB.tech }],
|
||||
["GET", `/api/v1/field/documents/${photo.documentId}?variant=preview`, { cookie: cB.tech }],
|
||||
["POST", "/api/v1/uploads", { cookie: cB.tech, form: uploadForm(wo.id) }],
|
||||
["GET", `/files/${photo.documentId}`, { cookie: cB.admin }],
|
||||
["GET", `/files/${imp.documentId}`, { cookie: cB.admin }],
|
||||
["GET", `/imports/${imp.id}/file`, { cookie: cB.admin }],
|
||||
];
|
||||
for (const [m, p, o] of foreign) expectStatus(`B: ${m} ${p.replace(/[a-z0-9]{20,}/g, "<A-id>")}`, await call(m, p, o), [404]);
|
||||
const syncB = await call("POST", "/api/v1/sync", { cookie: cB.tech, json: { deviceId: "b", operations: [{ clientOpId: randomUUID(), opType: "note.create", payload: { workOrderId: wo.id, text: "B" }, clientCreatedAt: new Date().toISOString() }] } });
|
||||
ok(syncB.status === 200 && /"not_found"/.test(syncB.text) && !/"applied"/.test(syncB.text), "B: POST /api/v1/sync mit Auftrag aus A → Op rejected not_found");
|
||||
const bundleB = await call("GET", "/api/v1/field/bundle", { cookie: cB.tech });
|
||||
ok(bundleB.status === 200 && !bundleB.text.includes(wo.id) && !leaks(bundleB), "B: Bundle enthält keine Aufträge aus A");
|
||||
const listB = await call("GET", "/api/v1/work-orders", { cookie: cB.bo });
|
||||
ok(listB.status === 200 && !listB.text.includes(wo.id) && !listB.text.includes(free.id), "B: Auftragsliste ohne Aufträge aus A");
|
||||
const customersB = await call("GET", `/api/v1/customers?q=${encodeURIComponent(SLUG_A)}`, { cookie: cB.bo });
|
||||
ok(customersB.status === 200 && !leaks(customersB), "B: Kundensuche nach A-Namen findet nichts");
|
||||
|
||||
section("(2b) Monteur ohne Zuweisung und unsinnige IDs");
|
||||
for (const [m, p, o] of [
|
||||
["GET", `/api/v1/work-orders/${wo.id}`, { cookie: cA.outsider }],
|
||||
["POST", `/api/v1/work-orders/${wo.id}/transition`, { cookie: cA.outsider, json: { to: "paused" } }],
|
||||
["GET", `/api/v1/field/documents/${photo.documentId}`, { cookie: cA.outsider }],
|
||||
["POST", "/api/v1/uploads", { cookie: cA.outsider, form: uploadForm(wo.id) }],
|
||||
["GET", `/api/v1/customers/${A.customerId}`, { cookie: cA.outsider }],
|
||||
["GET", `/files/${photo.documentId}`, { cookie: cA.outsider }],
|
||||
] as [string, string, Parameters<typeof call>[2]][]) {
|
||||
const r = await call(m, p, o);
|
||||
ok(r.status === 404 && !r.text.includes("HTTP-Sicherheit A"), `Monteur ohne Zuweisung: ${m} ${p.replace(/[a-z0-9]{20,}/g, "<id>")} → ${r.status}`);
|
||||
}
|
||||
for (const bad of ["..%2F..%2Fetc%2Fpasswd", "' OR '1'='1", "x".repeat(300), "%00", "../../api/v1/customers"]) {
|
||||
const r1 = await call("GET", `/api/v1/work-orders/${encodeURIComponent(bad)}`, { cookie: cA.bo });
|
||||
const r2 = await call("GET", `/files/${encodeURIComponent(bad)}`, { cookie: cA.bo });
|
||||
ok([400, 404].includes(r1.status) && [400, 404].includes(r2.status) && !leaks(r1) && !leaks(r2), `unsinnige ID ${JSON.stringify(bad.slice(0, 24))} → ${r1.status}/${r2.status} (kein 500)`);
|
||||
}
|
||||
|
||||
// ================= (3) Rollen =================
|
||||
section("(3) Unzulässige Rollenaktionen über HTTP");
|
||||
const deniedBefore = await prisma.auditLog.count({ where: { tenantId: A.tenantId, actorId: A.users.tech.id, action: "denied" } });
|
||||
const roleCases: [string, string, string, Parameters<typeof call>[2]][] = [
|
||||
["Monteur legt Kunden an", "POST", "/api/v1/customers", { cookie: cA.tech, json: { companyName: "Monteur GmbH" } }],
|
||||
["Monteur ändert Kunden", "PATCH", `/api/v1/customers/${A.customerId}`, { cookie: cA.tech, json: { companyName: "Monteur" } }],
|
||||
["Monteur legt Auftrag an", "POST", "/api/v1/work-orders", { cookie: cA.tech, json: { title: "x", customerId: A.customerId } }],
|
||||
["Monteur weist zu", "POST", `/api/v1/work-orders/${wo.id}/assign`, { cookie: cA.tech, json: { teamId: A.teamId } }],
|
||||
["Monteur ändert Materialvorgabe", "POST", `/api/v1/work-orders/${wo.id}/materials`, { cookie: cA.tech, json: { name: "x", plannedQuantity: 1, unit: "Stk" } }],
|
||||
["Monteur gibt Bericht frei", "POST", `/api/v1/reports/${report.id}/approve`, { cookie: cA.tech }],
|
||||
["Monteur öffnet Import", "GET", `/api/v1/imports/${imp.id}`, { cookie: cA.tech }],
|
||||
["Monteur lädt Auftragsbestätigung hoch", "POST", "/api/v1/work-orders/import", { cookie: cA.tech, form: (() => { const f = new FormData(); f.set("file", new File([new Uint8Array(pdf)], "a.pdf", { type: "application/pdf" })); return f; })() }],
|
||||
["Teamleiter legt Objekt an", "POST", "/api/v1/sites", { cookie: cA.lead, json: { customerId: A.customerId, name: "Teamleiter-Objekt" } }],
|
||||
["Backoffice nutzt Einsatz-Upload", "POST", "/api/v1/uploads", { cookie: cA.bo, form: uploadForm(wo.id) }],
|
||||
];
|
||||
for (const [label, m, p, o] of roleCases) expectStatus(label, await call(m, p, o), [403]);
|
||||
ok((await prisma.auditLog.count({ where: { tenantId: A.tenantId, actorId: A.users.tech.id, action: "denied" } })) > deniedBefore, "abgewiesene Rollenaktionen als „denied“ auditiert");
|
||||
const techList = await call("GET", "/api/v1/work-orders", { cookie: cA.tech });
|
||||
ok(techList.status === 200 && techList.text.includes(wo.id) && !techList.text.includes(free.id), "Monteur: Auftragsliste nur mit eigenen Aufträgen");
|
||||
|
||||
// ================= (4) schädliche Uploads =================
|
||||
section("(4) Schädliche Uploads über HTTP");
|
||||
const docForm = (bytes: Uint8Array, name: string, type: string) => {
|
||||
const f = new FormData();
|
||||
f.set("file", new File([new Uint8Array(bytes)], name, { type }));
|
||||
f.set("category", "other");
|
||||
f.set("visibility", "team");
|
||||
f.set("customerId", A.customerId);
|
||||
return f;
|
||||
};
|
||||
const exe = Buffer.concat([Buffer.from("MZ\x90\x00", "binary"), Buffer.alloc(256, 0x41)]);
|
||||
const html = Buffer.from("<html><script>alert(document.cookie)</script></html>");
|
||||
const up = (bytes: Uint8Array, name: string, type: string, extra: Parameters<typeof call>[2] = {}) => call("POST", "/documents/upload", { cookie: cA.bo, form: docForm(bytes, name, type), accept: "application/json", ...extra });
|
||||
const rExe = await up(exe, "rechnung.pdf", "application/pdf");
|
||||
ok(INVALID.includes(rExe.status) && /type_mismatch/.test(rExe.text), `EXE als rechnung.pdf → ${rExe.status} type_mismatch`);
|
||||
const rHtml = await up(html, "bild.jpg", "image/jpeg");
|
||||
ok(INVALID.includes(rHtml.status) && /type_mismatch/.test(rHtml.text), `HTML als bild.jpg → ${rHtml.status}`);
|
||||
const rSvg = await up(Buffer.from("<svg xmlns='http://www.w3.org/2000/svg'><script>alert(1)</script></svg>"), "logo.svg", "image/svg+xml");
|
||||
ok(INVALID.includes(rSvg.status) && /unsupported_type/.test(rSvg.text), `SVG mit Skript → ${rSvg.status}`);
|
||||
const rPoly = await up(Buffer.concat([jpeg, html]), "polyglot.jpg", "image/jpeg");
|
||||
const polyId = (JSON.parse(rPoly.text || "{}") as { data?: { id?: string } }).data?.id;
|
||||
ok(rPoly.status === 201 && !!polyId, "Polyglot (JPEG + HTML) als Bild angenommen");
|
||||
if (polyId) {
|
||||
const dl = await call("GET", `/files/${polyId}`, { cookie: cA.bo });
|
||||
ok(dl.status === 200 && dl.headers.get("content-type") === "image/jpeg" && /^attachment;/.test(dl.headers.get("content-disposition") ?? "") && dl.headers.get("x-content-type-options") === "nosniff", "Polyglot-Download: image/jpeg, attachment, nosniff (kein Ausführen als HTML)");
|
||||
ok(/default-src 'self'/.test(dl.headers.get("content-security-policy") ?? ""), "Datei-Auslieferung mit Content-Security-Policy");
|
||||
await expectStatus("Polyglot aus Mandant B", await call("GET", `/files/${polyId}`, { cookie: cB.admin }), [404]);
|
||||
}
|
||||
const rTrav = await up(pdf, "../../../etc/passwd.pdf", "application/pdf");
|
||||
const travId = (JSON.parse(rTrav.text || "{}") as { data?: { id?: string } }).data?.id;
|
||||
const travDoc = travId ? await prisma.document.findUnique({ where: { id: travId } }) : null;
|
||||
ok(rTrav.status === 201 && !!travDoc && !/[\\/]/.test(travDoc.fileName) && travDoc.storageKey.startsWith(`${A.tenantId}/`) && !travDoc.storageKey.includes(".."), `Pfad-Traversal im Dateinamen → gespeichert als ${travDoc?.fileName}, Key im Mandantenpräfix`);
|
||||
const rCrlf = await up(pdf, "bericht\r\nX-Injected: 1.pdf", "application/pdf");
|
||||
const crlfId = (JSON.parse(rCrlf.text || "{}") as { data?: { id?: string } }).data?.id;
|
||||
if (crlfId) {
|
||||
const dl = await call("GET", `/files/${crlfId}`, { cookie: cA.bo });
|
||||
ok(dl.status === 200 && !dl.headers.has("x-injected") && !/[\r\n]/.test(dl.headers.get("content-disposition") ?? ""), "CR/LF im Dateinamen führt nicht zu Header-Injection beim Download");
|
||||
} else ok(INVALID.includes(rCrlf.status), `CR/LF-Dateiname abgewiesen (${rCrlf.status})`);
|
||||
const big = Buffer.concat([pdf, Buffer.alloc(25 * 1024 * 1024 + 1024)]);
|
||||
const rBig = await up(big, "gross.pdf", "application/pdf");
|
||||
ok([400, 413, 422].includes(rBig.status) && !leaks(rBig), `PDF > 25 MB → ${rBig.status}`);
|
||||
const rUpExe = await call("POST", "/api/v1/uploads", { cookie: cA.tech, form: uploadForm(wo.id, exe, "foto.jpg", "image/jpeg") });
|
||||
ok(INVALID.includes(rUpExe.status) && !leaks(rUpExe), `Einsatz-Upload: EXE als Foto → ${rUpExe.status}`);
|
||||
const rUpBig = await call("POST", "/api/v1/uploads", { cookie: cA.tech, form: uploadForm(wo.id, Buffer.concat([jpeg, Buffer.alloc(26 * 1024 * 1024)])) });
|
||||
ok([400, 413, 422].includes(rUpBig.status), `Einsatz-Upload > 25 MB → ${rUpBig.status}`);
|
||||
const rImpExe = await call("POST", "/api/v1/work-orders/import", { cookie: cA.bo, form: (() => { const f = new FormData(); f.set("file", new File([new Uint8Array(exe)], "auftrag.pdf", { type: "application/pdf" })); return f; })() });
|
||||
ok(INVALID.includes(rImpExe.status) && !leaks(rImpExe), `Import: EXE als PDF → ${rImpExe.status}`);
|
||||
const alive = await call("GET", "/api/v1/work-orders", { cookie: cA.bo });
|
||||
ok(alive.status === 200, "Server nach Übergrößen-Uploads weiter erreichbar");
|
||||
|
||||
// ================= (5) CSRF =================
|
||||
section("(5) CSRF: fremder Origin");
|
||||
const evil = "https://evil.example";
|
||||
expectStatus("Dokument-Upload mit fremdem Origin", await up(pdf, "csrf.pdf", "application/pdf", { origin: evil }), [403]);
|
||||
expectStatus("PATCH Kunde mit fremdem Origin", await call("PATCH", `/api/v1/customers/${A.customerId}`, { cookie: cA.bo, json: { companyName: "CSRF" }, origin: evil }), [403]);
|
||||
expectStatus("Sync mit fremdem Origin", await call("POST", "/api/v1/sync", { cookie: cA.tech, json: { deviceId: "x", operations: [] }, origin: evil }), [403]);
|
||||
ok((await prisma.customer.findUniqueOrThrow({ where: { id: A.customerId } })).companyName === MARK_A + " GmbH", "Kunde durch CSRF-Versuche unverändert");
|
||||
|
||||
// ================= (6) Sessions =================
|
||||
section("(6) Session-Handling");
|
||||
const probe = (cookie?: string) => call("GET", "/api/v1/customers?pageSize=1", { cookie });
|
||||
ok((await probe(cA.bo)).status === 200, "gültige Session → 200");
|
||||
const value = cA.bo.split("=")[1];
|
||||
const tampered = `${cookieName}=${value.slice(0, -6)}${value.slice(-6).split("").reverse().join("")}`;
|
||||
ok((await probe(tampered)).status === 401, "manipuliertes Session-Cookie → 401");
|
||||
ok((await probe(await cookieFor(A, "backoffice", { secret: "x".repeat(48) }))).status === 401, "mit fremdem Schlüssel signiertes Cookie → 401");
|
||||
ok((await probe(await cookieFor(A, "backoffice", { maxAge: -60 }))).status === 401, "abgelaufenes Session-Cookie → 401");
|
||||
const page = await call("GET", "/dashboard", { cookie: tampered });
|
||||
ok([302, 303, 307].includes(page.status) && (page.headers.get("location") ?? "").includes("/login"), "Seite mit manipuliertem Cookie → Anmeldung");
|
||||
await prisma.identity.update({ where: { id: A.users.backoffice.identityId }, data: { sessionsValidAfter: new Date(Date.now() + 5000) } });
|
||||
ok((await probe(cA.bo)).status === 401, "Kill-Switch (alle Sitzungen beendet) → alte Session 401");
|
||||
await prisma.identity.update({ where: { id: A.users.backoffice.identityId }, data: { sessionsValidAfter: null } });
|
||||
await prisma.user.update({ where: { id: A.users.backoffice.id }, data: { status: "DEACTIVATED" } });
|
||||
const deact = await probe(cA.bo);
|
||||
ok([401, 403].includes(deact.status) && !leaks(deact), `deaktiviertes Konto mit gültigem Cookie → ${deact.status}`);
|
||||
await prisma.user.update({ where: { id: A.users.backoffice.id }, data: { status: "ACTIVE" } });
|
||||
ok((await probe(cA.bo)).status === 200, "reaktiviert → wieder 200");
|
||||
|
||||
// ================= (7) Audit-Log =================
|
||||
section("(7) Audit-Log nicht über HTTP änderbar");
|
||||
const auditRow = await prisma.auditLog.findFirstOrThrow({ where: { tenantId: A.tenantId }, orderBy: { createdAt: "asc" } });
|
||||
const snapshot = JSON.stringify(auditRow);
|
||||
for (const p of ["/api/v1/audit", "/api/v1/audit-logs", `/api/v1/audit-logs/${auditRow.id}`, `/api/v1/audit/${auditRow.id}`, `/settings/audit/${auditRow.id}`]) {
|
||||
for (const m of ["PATCH", "PUT", "DELETE"]) {
|
||||
const r = await call(m, p, { cookie: cA.admin, json: { entity: "manipuliert" } });
|
||||
if (r.status >= 200 && r.status < 300) ok(false, `${m} ${p} → ${r.status} (erwartet kein 2xx)`);
|
||||
}
|
||||
}
|
||||
ok(JSON.stringify(await prisma.auditLog.findUniqueOrThrow({ where: { id: auditRow.id } })) === snapshot, "keine Route ändert/löscht Audit-Einträge (PATCH/PUT/DELETE auf Audit-Pfade ohne Wirkung)");
|
||||
const viewer = await call("GET", "/settings/audit", { cookie: cA.admin });
|
||||
// read-only by construction (no mutating service/route, see test-security-auth.ts static scan); the page renders
|
||||
ok(viewer.status === 200 && viewer.text.includes(auditRow.entity), `Audit-Protokoll (Admin) rendert Einträge des eigenen Mandanten (${viewer.status})`);
|
||||
const auditB = await call("GET", `/settings/audit?detail=${auditRow.id}`, { cookie: cB.admin });
|
||||
ok(auditB.status === 200 && !auditB.text.includes(auditRow.id.slice(0, 20) + "\"") && !auditB.text.includes(MARK_A), "Audit-Detail aus Mandant B zeigt keinen Eintrag von A");
|
||||
|
||||
// ================= (8) Login-Sperre =================
|
||||
section("(8) Login-Sperre über den Credentials-Endpunkt");
|
||||
const providers = await call("GET", "/api/auth/providers");
|
||||
if (providers.status !== 200 || !providers.text.includes('"credentials"')) {
|
||||
console.log(`⚠ Login-Test übersprungen: Credentials-Provider nicht gefunden (${providers.status})`);
|
||||
} else {
|
||||
const password = "Http-Pruef-Passwort-2026!";
|
||||
await prisma.identity.update({ where: { id: A.users.tech2.identityId }, data: { passwordHash: await hashPassword(password), failedLogins: 0, lockedUntil: null } });
|
||||
const csrf = await fetch(`${base}/api/auth/csrf`);
|
||||
const { csrfToken } = (await csrf.json()) as { csrfToken: string };
|
||||
const jar = csrf.headers.getSetCookie().map((c) => c.split(";")[0]).join("; ");
|
||||
const login = async (pw: string) => {
|
||||
const res = await fetch(`${base}/api/auth/callback/credentials`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/x-www-form-urlencoded", cookie: jar, origin: base },
|
||||
body: new URLSearchParams({ email: A.users.tech2.email, password: pw, tenant: SLUG_A, csrfToken }),
|
||||
redirect: "manual",
|
||||
});
|
||||
return res.headers.getSetCookie().some((c) => c.startsWith(`${cookieName}=`) && !c.startsWith(`${cookieName}=;`));
|
||||
};
|
||||
ok(await login(password), "Positivkontrolle: richtiges Passwort setzt ein Session-Cookie");
|
||||
for (let i = 0; i < 5; i++) ok(!(await login(`falsch-${i}`)), `Fehlversuch ${i + 1} → kein Session-Cookie`);
|
||||
const locked = await prisma.identity.findUniqueOrThrow({ where: { id: A.users.tech2.identityId } });
|
||||
ok(!!locked.lockedUntil && locked.lockedUntil > new Date(), "nach 5 Fehlversuchen über HTTP gesperrt");
|
||||
ok(!(await login(password)), "gesperrt: richtiges Passwort erhält kein Session-Cookie");
|
||||
}
|
||||
|
||||
// ================= (9) Header =================
|
||||
section("(9) Sicherheits-Header");
|
||||
const loginPage = await call("GET", "/login");
|
||||
const h = loginPage.headers;
|
||||
ok(/frame-ancestors 'none'/.test(h.get("content-security-policy") ?? "") && h.get("x-frame-options") === "DENY", "Clickjacking-Schutz (CSP frame-ancestors, X-Frame-Options)");
|
||||
ok(h.get("x-content-type-options") === "nosniff" && /max-age=\d+/.test(h.get("strict-transport-security") ?? "") && !!h.get("referrer-policy"), "nosniff, HSTS, Referrer-Policy gesetzt");
|
||||
const apiErr = await call("GET", `/api/v1/work-orders/${wo.id}`, { cookie: cB.bo });
|
||||
ok(/no-store/.test(apiErr.headers.get("cache-control") ?? "") || apiErr.status === 404, "API-Antworten nicht cachebar");
|
||||
} finally {
|
||||
server.stop();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,138 @@
|
||||
// L10a Sicherheitstest §43.4 „Unzulässige Rollenaktionen" — Matrix aus src/server/rbac.ts.
|
||||
//
|
||||
// Für jede Permission des Katalogs mit einem fachlichen Schreib-/Lesepfad wird ein repräsentativer
|
||||
// Service-Aufruf mit allen vier Standardrollen ausgeführt. Erwartung wird UNABHÄNGIG aus ROLE_DEFS
|
||||
// abgeleitet: fehlt die Permission → `forbidden` (bzw. bei Sichtbarkeitsrechten `not_found`), ist sie
|
||||
// vorhanden → niemals `forbidden`. Neue Permissions ohne Matrix-Eintrag lassen den Test scheitern
|
||||
// (bewusste Ausnahmen sind unten begründet).
|
||||
//
|
||||
// Lauf: npx tsx scripts/test-security-roles.ts
|
||||
|
||||
import "dotenv/config";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { prisma } from "../src/server/db";
|
||||
import { PERMISSIONS, ROLE_DEFS, ROLE_KEYS, type Permission, type RoleKey } from "../src/server/rbac";
|
||||
import type { ServiceCtx } from "../src/server/services/context";
|
||||
import { createCustomer, listCustomers } from "../src/server/services/customers/customers";
|
||||
import { mergeCustomers } from "../src/server/services/customers/merge";
|
||||
import { createSite, listSites } from "../src/server/services/sites/sites";
|
||||
import { createTeam, listTeams } from "../src/server/services/teams/teams";
|
||||
import { getWorkOrderDetail } from "../src/server/services/work-orders/detail";
|
||||
import { updateWorkOrder } from "../src/server/services/work-orders/update";
|
||||
import { assignWorkOrder } from "../src/server/services/work-orders/assign";
|
||||
import { releaseForBilling } from "../src/server/services/work-orders/release-billing";
|
||||
import { cancelWorkOrder } from "../src/server/services/work-orders/cancel";
|
||||
import { createOrderType } from "../src/server/services/work-orders/settings";
|
||||
import { listImports } from "../src/server/services/imports/queries";
|
||||
import { createNote } from "../src/server/services/field/notes";
|
||||
import { correctTimeEntry } from "../src/server/services/field/time-correction";
|
||||
import { createDailyReport } from "../src/server/services/reports/create";
|
||||
import { approveReport } from "../src/server/services/reports/approve";
|
||||
import { listReports } from "../src/server/services/reports/queries";
|
||||
import { searchCustomersForEmergency } from "../src/server/services/emergency/lookup";
|
||||
import { listEmergencyReviews } from "../src/server/services/emergency/review";
|
||||
import { authorizeDocumentAccess, updateDocumentMeta } from "../src/server/services/documents/access";
|
||||
import { listNotifications } from "../src/server/services/notifications/inbox";
|
||||
import { queryAuditLog } from "../src/server/services/audit/viewer";
|
||||
import { getAiGenerationContent } from "../src/server/services/lotse/protocol";
|
||||
import { checkCompleteness } from "../src/server/services/lotse/completeness";
|
||||
import { noteCreatePayload } from "../src/lib/sync/ops";
|
||||
import { codeOf, createTenant, ok, runSuite, section, type Persona, type TenantFixture } from "./lib/e2e-fixture";
|
||||
|
||||
const SLUG = "zz-q-sec-roles";
|
||||
|
||||
/** Permissions whose enforcement lives outside the domain services (covered by other test scripts). */
|
||||
const COVERED_ELSEWHERE: Partial<Record<Permission, string>> = {
|
||||
"user:read": "Nutzerverwaltung (Fundament) — scripts/test-tenant-users-authz.ts",
|
||||
"user:manage": "Nutzerverwaltung (Fundament) — scripts/test-tenant-users-authz.ts",
|
||||
"role:manage": "Rollenverwaltung (Fundament) — scripts/test-tenant-users-authz.ts",
|
||||
"document:read": "Download-Route /files/<id> (requireApiContext) — scripts/test-security-http.ts",
|
||||
"report:read": "Lesepfad über reportScope — unten als Sichtbarkeitsprüfung",
|
||||
"work_order:read_team": "Scope-Recht — unten als Sichtbarkeitsprüfung (read_all vs. read_team)",
|
||||
};
|
||||
|
||||
const PERSONA_OF: Record<RoleKey, Persona> = { "tenant-admin": "admin", backoffice: "backoffice", "team-lead": "lead", technician: "tech" };
|
||||
|
||||
type Probe = { permission: Permission; label: string; run: (ctx: ServiceCtx, role: RoleKey) => Promise<unknown>; denied?: string };
|
||||
|
||||
async function rawOrder(A: TenantFixture, status: string, extra: Record<string, unknown> = {}) {
|
||||
return prisma.workOrder.create({
|
||||
data: { tenantId: A.tenantId, number: `R-${randomUUID().slice(0, 8)}`, customerId: A.customerId, siteId: A.siteId, title: "Rollenprüfung", status: status as never, assignedTeamId: A.teamId, teamLeadUserId: A.users.lead.id, ...extra },
|
||||
});
|
||||
}
|
||||
|
||||
runSuite("Sicherheit: Rollen-Matrix", [SLUG], async () => {
|
||||
const A = await createTenant(SLUG);
|
||||
const shared = await rawOrder(A, "in_progress", { assignees: { create: [{ tenantId: A.tenantId, userId: A.users.tech.id }] } });
|
||||
const session = await prisma.workSession.create({ data: { tenantId: A.tenantId, workOrderId: shared.id, userId: A.users.tech.id, status: "ended", startedAt: new Date(Date.now() - 7200_000), endedAt: new Date(Date.now() - 3600_000) } });
|
||||
const entry = await prisma.timeEntry.create({ data: { tenantId: A.tenantId, workSessionId: session.id, userId: A.users.tech.id, startedAt: session.startedAt, endedAt: session.endedAt } });
|
||||
const draftReport = await prisma.report.create({ data: { tenantId: A.tenantId, workOrderId: shared.id, type: "daily", reportDate: new Date(), lineageId: randomUUID(), status: "draft", content: {} } });
|
||||
const teamDoc = await prisma.document.create({ data: { tenantId: A.tenantId, workOrderId: shared.id, category: "photo", fileName: "a.jpg", storageKey: `${A.tenantId}/a.jpg`, mimeType: "image/jpeg", fileSize: 1, checksum: "0".repeat(64), lineageId: randomUUID(), visibility: "team" } });
|
||||
const internalDoc = await prisma.document.create({ data: { tenantId: A.tenantId, workOrderId: shared.id, category: "order_confirmation", fileName: "intern.pdf", storageKey: `${A.tenantId}/intern.pdf`, mimeType: "application/pdf", fileSize: 1, checksum: "0".repeat(64), lineageId: randomUUID(), visibility: "backoffice_only" } });
|
||||
const leadDoc = await prisma.document.create({ data: { tenantId: A.tenantId, workOrderId: shared.id, category: "other", fileName: "tl.pdf", storageKey: `${A.tenantId}/tl.pdf`, mimeType: "application/pdf", fileSize: 1, checksum: "0".repeat(64), lineageId: randomUUID(), visibility: "team_lead" } });
|
||||
const unassigned = await prisma.workOrder.create({ data: { tenantId: A.tenantId, number: "R-FREE", customerId: A.customerId, title: "Ohne Team", status: "planned" } });
|
||||
|
||||
const probes: Probe[] = [
|
||||
{ permission: "tenant:manage", label: "KI-Protokoll mit Inhalten", run: (c) => getAiGenerationContent(c, "unknown-id") },
|
||||
{ permission: "audit:read", label: "Audit-Protokoll lesen", run: (c) => queryAuditLog(c, {}) },
|
||||
{ permission: "customer:read", label: "Kundenliste", run: (c) => listCustomers(c) },
|
||||
{ permission: "customer:write", label: "Kunde anlegen", run: (c, r) => createCustomer(c, { companyName: `Rollen ${r} ${randomUUID()}` }, { acknowledgeDuplicates: true }) },
|
||||
{
|
||||
permission: "customer:merge",
|
||||
label: "Kunden zusammenführen",
|
||||
run: async (c) => {
|
||||
const s = await prisma.customer.create({ data: { tenantId: A.tenantId, companyName: `Quelle ${randomUUID()}` } });
|
||||
const t = await prisma.customer.create({ data: { tenantId: A.tenantId, companyName: `Ziel ${randomUUID()}` } });
|
||||
return mergeCustomers(c, { sourceId: s.id, targetId: t.id, confirm: true });
|
||||
},
|
||||
},
|
||||
{ permission: "site:read", label: "Objektliste", run: (c) => listSites(c) },
|
||||
{ permission: "site:write", label: "Objekt anlegen", run: (c) => createSite(c, { customerId: A.customerId, name: `Objekt ${randomUUID()}` }) },
|
||||
{ permission: "team:read", label: "Teamliste", run: (c) => listTeams(c) },
|
||||
{ permission: "team:manage", label: "Team anlegen", run: (c) => createTeam(c, { name: `Team ${randomUUID()}` }) },
|
||||
{ permission: "work_order:read_all", label: "Auftrag ohne Team/Zuweisung lesen", run: (c) => getWorkOrderDetail(c, unassigned.id), denied: "not_found" },
|
||||
{ permission: "work_order:write", label: "Auftrag bearbeiten", run: (c) => updateWorkOrder(c, shared.id, { technicianNotes: `Rolle ${randomUUID()}` }) },
|
||||
{ permission: "work_order:assign", label: "Auftrag zuweisen", run: async (c) => assignWorkOrder(c, { workOrderId: (await rawOrder(A, "planned")).id, teamId: A.teamId }) },
|
||||
{ permission: "work_order:release_billing", label: "Zur Abrechnung freigeben", run: async (c) => releaseForBilling(c, { workOrderId: (await rawOrder(A, "in_review")).id }) },
|
||||
{ permission: "work_order:cancel", label: "Auftrag stornieren", run: async (c) => cancelWorkOrder(c, { workOrderId: (await rawOrder(A, "assigned")).id, reason: "Rollenprüfung" }) },
|
||||
{ permission: "import:write", label: "Importe", run: (c) => listImports(c) },
|
||||
{ permission: "field:execute", label: "Tätigkeitsnotiz erfassen", run: (c) => createNote(c, noteCreatePayload.parse({ workOrderId: shared.id, text: "Rollenprüfung" })) },
|
||||
{ permission: "field:correct_time", label: "Arbeitszeit korrigieren", run: (c) => correctTimeEntry(c, { timeEntryId: entry.id, startedAt: session.startedAt, endedAt: session.endedAt, reason: "Rollenprüfung" }) },
|
||||
{ permission: "report:write", label: "Tagesbericht anlegen", run: async (c) => createDailyReport(c, { workOrderId: (await rawOrder(A, "planned")).id }) },
|
||||
{ permission: "report:approve_team", label: "Bericht freigeben (Teamleiter oder Backoffice)", run: (c) => approveReport(c, { reportId: draftReport.id }, { dispatchPdf: async () => undefined }) },
|
||||
{ permission: "emergency:create", label: "Notdienst-Kundensuche", run: (c) => searchCustomersForEmergency(c, "Hausverwaltung") },
|
||||
{ permission: "emergency:review", label: "Notdienst-Prüfliste", run: (c) => listEmergencyReviews(c) },
|
||||
{ permission: "document:write", label: "Dokument-Metadaten ändern", run: (c) => updateDocumentMeta(c, teamDoc.id, { title: `Titel ${randomUUID()}` }) },
|
||||
{ permission: "document:read_internal", label: "internes Dokument (backoffice_only) öffnen", run: (c) => authorizeDocumentAccess(c, internalDoc.id), denied: "not_found" },
|
||||
{ permission: "notification:read", label: "Benachrichtigungen", run: (c) => listNotifications(c, {}) },
|
||||
{ permission: "settings:templates", label: "Auftragsart anlegen", run: (c) => createOrderType(c, { key: `rolle_${randomUUID().slice(0, 8).replace(/-/g, "")}`, name: "Rollenprüfung" }) },
|
||||
{ permission: "lotse:use", label: "Lotse-Vollständigkeitsprüfung", run: (c) => checkCompleteness(c, shared.id) },
|
||||
];
|
||||
|
||||
section("Matrix-Abdeckung");
|
||||
const probed = new Set(probes.map((p) => p.permission));
|
||||
probed.add("report:approve"); // shares the approval probe (report:approve OR report:approve_team)
|
||||
const uncovered = PERMISSIONS.filter((p) => !probed.has(p) && !COVERED_ELSEWHERE[p]);
|
||||
ok(uncovered.length === 0, `jede Permission hat eine Prüfung oder begründete Ausnahme${uncovered.length ? ` — fehlt: ${uncovered.join(", ")}` : ""}`);
|
||||
|
||||
for (const probe of probes) {
|
||||
section(`${probe.permission} — ${probe.label}`);
|
||||
for (const role of ROLE_KEYS) {
|
||||
const perms = ROLE_DEFS[role].permissions as readonly string[];
|
||||
const allowed = probe.permission === "report:approve_team" ? perms.includes("report:approve_team") || perms.includes("report:approve") : perms.includes(probe.permission);
|
||||
const got = await codeOf(probe.run(A.ctx[PERSONA_OF[role]], role));
|
||||
const denied = probe.denied ?? "forbidden";
|
||||
ok(allowed ? got !== "forbidden" && got !== denied : got === denied, `${role.padEnd(12)} ${allowed ? "erlaubt " : "verboten"} → ${got}`);
|
||||
}
|
||||
}
|
||||
|
||||
section("Sichtbarkeit: read_team / report:read / Teamleiter-Dokumente");
|
||||
ok((await codeOf(getWorkOrderDetail(A.ctx.tech, shared.id))) === "ok", "Monteur mit Zuweisung sieht den Auftrag");
|
||||
ok((await codeOf(getWorkOrderDetail(A.ctx.outsider, shared.id))) === "not_found", "Monteur ohne Zuweisung (anderes Team) → not_found");
|
||||
ok((await codeOf(getWorkOrderDetail(A.ctx.lead2, shared.id))) === "not_found", "Teamleiter eines anderen Teams → not_found");
|
||||
ok((await listReports(A.ctx.outsider, { status: "all" })).items.length === 0, "Berichtsliste des fremden Monteurs leer");
|
||||
ok((await listReports(A.ctx.tech, { status: "all" })).items.some((r) => r.id === draftReport.id), "Berichtsliste des zugewiesenen Monteurs enthält den Bericht");
|
||||
ok((await codeOf(authorizeDocumentAccess(A.ctx.lead, leadDoc.id))) === "ok", "Teamleiter öffnet Teamleiter-Dokument");
|
||||
ok((await codeOf(authorizeDocumentAccess(A.ctx.tech, leadDoc.id))) === "not_found", "Monteur öffnet Teamleiter-Dokument nicht");
|
||||
ok((await prisma.workOrder.findUniqueOrThrow({ where: { id: shared.id } })).status === "in_progress", "Auftrag durch verbotene Aktionen nicht verändert");
|
||||
});
|
||||
Binary file not shown.
Reference in New Issue
Block a user