// L17 Pakete — Durchsetzung der Paketstufe an allen Mechanismen: Action (assertModuleEnabled im // moduleGuard), API (requireApiContext/toErrorResponse), Layout-Guard-Funktionen (moduleState, // requirePlanFeature), Sync (Offline-Op → rejected mit Klartext), Planung (planningAccess & Co.), // isLotseEnabled, Navigation; TenantModule aus + Profi; Wechsel Profi → Basis → Profi ohne Datenverlust; // nur der Betreiber ändert die Stufe. // Lauf: npx tsx scripts/test-pakete-gates.ts (auch mit RLS_ENFORCED=true) import "dotenv/config"; import { randomUUID } from "node:crypto"; import { readFileSync } from "node:fs"; import { join } from "node:path"; import type { Session } from "next-auth"; import { prisma, dbForTenant } from "../src/server/db"; import { assertModuleEnabled, ModuleDisabledError } from "../src/server/modules"; import { effectiveModules, isModuleActive, isPlanFeatureAvailable, moduleState, PROFI_HINT_PATH, requirePlanFeature } from "../src/server/plan"; import { toErrorResponse } from "../src/server/api/respond"; import { applyOperations } from "../src/server/services/sync/apply"; import { getPlanningBoard } from "../src/server/services/planning/board"; import { getLiveSituation } from "../src/server/services/planning/live"; import { scheduleWorkOrder } from "../src/server/services/planning/schedule"; import { recommendAssignments } from "../src/server/services/planning/recommend"; import { updateTeamPlanningSettings } from "../src/server/services/planning/team-settings"; import { getPlanningToday } from "../src/server/services/planning/summary"; import { runPlanningWatch } from "../src/server/services/planning/watch"; import { isLotseEnabled, updateLotseSettings } from "../src/server/services/lotse/settings"; import { billingModuleActive } from "../src/server/services/work-orders/transition"; import { updateTenantPlan } from "../src/server/services/plans/platform"; import { problemKey } from "../src/lib/offline/outbox-core"; import type { SyncOperationInput } from "../src/lib/sync/envelope"; import { codeOf, createTenant, expectCode, ok, runSuite, section } from "./lib/e2e-fixture"; const SLUG_A = "zz-pak-gates-a"; const SLUG_B = "zz-pak-gates-b"; const PLATFORM_EMAIL = "platform-pakete@zz-pakete.test"; const READONLY_EMAIL = "readonly-pakete@zz-pakete.test"; const fakeSession = (tenantId: string, userId: string) => ({ user: { tenantId, id: userId } }) as unknown as Session; const src = (p: string) => readFileSync(join(process.cwd(), p), "utf8"); function emergencyOp(): SyncOperationInput { return { clientOpId: randomUUID(), opType: "emergency.create", payload: { clientIds: { workOrder: randomUUID(), session: randomUUID(), customer: randomUUID(), site: randomUUID() }, customer: { mode: "new", firstName: "Lutz", lastName: "Meier", phone: "0171 1234567", street: "Elbchaussee", houseNumber: "12", postalCode: "22763", city: "Hamburg" }, site: { mode: "new", street: "Elbchaussee", houseNumber: "12", postalCode: "22763", city: "Hamburg" }, onSiteContact: { name: "Lutz Meier", phone: "0171 1234567" }, reason: "Wasserrohrbruch im Keller", }, clientCreatedAt: new Date().toISOString(), }; } async function extraCleanup(tenantIds: string[]) { await prisma.lotseChatSeat.deleteMany({ where: { tenantId: { in: tenantIds } } }); await prisma.workOrderMilestone.deleteMany({ where: { tenantId: { in: tenantIds } } }); await prisma.platformAdmin.deleteMany({ where: { email: { in: [PLATFORM_EMAIL, READONLY_EMAIL] } } }); } runSuite("L17 Pakete – Durchsetzung", [SLUG_A, SLUG_B], async () => { await extraCleanup([]); const A = await createTenant(SLUG_A); const B = await createTenant(SLUG_B); try { await prisma.tenant.update({ where: { id: A.tenantId }, data: { tier: "BASIS" } }); const platform = await prisma.platformAdmin.create({ data: { email: PLATFORM_EMAIL, name: "ZZ Plattform", passwordHash: "x", role: "full" } }); const readonly = await prisma.platformAdmin.create({ data: { email: READONLY_EMAIL, name: "ZZ Nur-Lesen", passwordHash: "x", role: "readonly" } }); section("Stufe am Mandanten"); ok((await prisma.tenant.findUniqueOrThrow({ where: { id: B.tenantId } })).tier === "PROFI", "neuer Mandant: Default Profi"); section("Layout-Guard-Funktionen (moduleState / requirePlanFeature)"); for (const key of ["billing", "emergency", "imports", "lotse"]) ok((await moduleState(A.tenantId, key)) === "not_in_plan", `Basis: ${key} → not_in_plan`); for (const key of ["customers", "sites", "teams", "work_orders", "field", "reports", "documents", "notifications"]) ok((await moduleState(A.tenantId, key)) === "enabled", `Basis: ${key} aktiv`); for (const key of ["billing", "emergency", "imports", "lotse"]) ok((await moduleState(B.tenantId, key)) === "enabled", `Profi: ${key} aktiv`); let redirected = ""; try { await requirePlanFeature(A.tenantId, "planning"); } catch (err) { redirected = String((err as { digest?: string }).digest ?? (err as Error).message); } ok(redirected.includes(PROFI_HINT_PATH), `Planungs-Layout (Basis) → Hinweis ${PROFI_HINT_PATH}`); ok((await codeOf(requirePlanFeature(B.tenantId, "planning"))) === "ok", "Planungs-Layout (Profi) → kein Redirect"); const modulesSrc = src("src/server/modules.ts"); ok(/export async function requireModule[\s\S]*moduleState\(/.test(modulesSrc) && modulesSrc.includes("PROFI_HINT_PATH"), "requireModule nutzt moduleState (Stufe + Schalter) und leitet auf den Profi-Hinweis"); ok(src("src/app/(app)/planning/layout.tsx").includes('requirePlanFeature(session.user.tenantId, "planning")'), "Planungs-Layout prüft das Paket-Feature"); section("Action (moduleGuard → assertModuleEnabled)"); let actionErr: unknown = null; try { await assertModuleEnabled(fakeSession(A.tenantId, A.users.admin.id), "billing"); } catch (err) { actionErr = err; } ok(actionErr instanceof ModuleDisabledError && actionErr.reason === "not_in_plan", "Basis: Abrechnungs-Action → ModuleDisabledError(not_in_plan)"); const denied = await prisma.auditLog.findFirst({ where: { tenantId: A.tenantId, action: "denied", entity: "module", entityId: "billing" } }); ok(!!denied, "verweigerter Zugriff wird auditiert"); for (const key of ["emergency", "imports", "lotse"]) { ok((await codeOf(assertModuleEnabled(fakeSession(A.tenantId, A.users.admin.id), key))).startsWith("error:"), `Basis: ${key}-Action gesperrt`); } ok((await codeOf(assertModuleEnabled(fakeSession(A.tenantId, A.users.admin.id), "customers"))) === "ok", "Basis: Kunden-Action erlaubt"); ok((await codeOf(assertModuleEnabled(fakeSession(B.tenantId, B.users.admin.id), "billing"))) === "ok", "Profi: Abrechnungs-Action erlaubt"); ok(/await assertModuleEnabled\(session, moduleKey\)/.test(src("src/server/action-guard.ts")), "moduleGuard prüft über assertModuleEnabled"); section("API (requireApiContext → 403 mit Klartext)"); ok(/if \(moduleKey\) await assertModuleEnabled\(session, moduleKey\)/.test(src("src/server/api/context.ts")), "requireApiContext prüft über assertModuleEnabled"); const res = toErrorResponse(actionErr); const body = (await res.json()) as { error: { code: string; details?: { reason?: string; message?: string } } }; ok(res.status === 403 && body.error.details?.reason === "not_in_plan" && /Paket Profi/.test(body.error.details?.message ?? ""), "API: 403 mit reason not_in_plan und Klartext"); const planningErr = await getPlanningBoard(A.ctx.backoffice, { from: "2026-09-21", to: "2026-09-25" }).catch((e) => e); const planningRes = toErrorResponse(planningErr); ok(planningRes.status === 403, "Planungs-API (Basis) → 403"); section("Planung (Feature ohne eigenes Modul)"); ok(!(await isPlanFeatureAvailable(A.tenantId, "planning")) && (await isPlanFeatureAvailable(B.tenantId, "planning")), "Feature planning: Basis nein, Profi ja"); await expectCode(() => getPlanningBoard(A.ctx.backoffice, { from: "2026-09-21", to: "2026-09-25" }), "forbidden", "Basis: Plantafel forbidden"); await expectCode(() => getLiveSituation(A.ctx.backoffice), "forbidden", "Basis: Live-Lage forbidden"); const woA = await prisma.workOrder.create({ data: { tenantId: A.tenantId, number: "ZZ-PAK-1", customerId: A.customerId, siteId: A.siteId, title: "Paket-Test", status: "draft" } }); await expectCode(() => scheduleWorkOrder(A.ctx.backoffice, { workOrderId: woA.id, teamId: A.teamId, plannedStart: new Date().toISOString(), baseVersion: woA.version } as never), "forbidden", "Basis: Einplanen forbidden"); await expectCode(() => recommendAssignments(A.ctx.backoffice, { workOrderId: woA.id }), "forbidden", "Basis: Empfehlungen forbidden"); await expectCode(() => updateTeamPlanningSettings(A.ctx.admin, A.teamId, { dailyCapacityMinutes: 400, workingDays: 31 }), "forbidden", "Basis: Kolonnenkapazität forbidden"); ok((await getPlanningToday(A.ctx.backoffice)) === null, "Basis: Dashboard-Kachel Planung ausgeblendet"); const watch = await runPlanningWatch(A.tenantId); ok(watch.overrun === 0 && watch.followupAtRisk === 0 && watch.capacityFreed === 0, "Basis: keine Verzugs-/Frühfertig-Meldungen"); ok((await codeOf(getPlanningBoard(B.ctx.backoffice, { from: "2026-09-21", to: "2026-09-25" }))) === "ok", "Profi: Plantafel erlaubt"); ok((await codeOf(updateTeamPlanningSettings(B.ctx.admin, B.teamId, { dailyCapacityMinutes: 420, workingDays: 31 }))) === "ok", "Profi: Kolonnenkapazität erlaubt"); section("Lotse, Abrechnung, Navigation"); ok(!(await isLotseEnabled(A.ctx.admin)) && (await isLotseEnabled(B.ctx.admin)), "isLotseEnabled: Basis aus, Profi an"); ok(!(await billingModuleActive(A.ctx.admin)) && (await billingModuleActive(B.ctx.admin)), "Abrechnungsübersicht: Basis aus (direktes „abgerechnet“ wie bei deaktiviertem Modul)"); const effA = await effectiveModules(A.tenantId); ok(["billing", "emergency", "imports", "lotse"].every((k) => effA.inactive.has(k) && effA.notInPlan.has(k)) && effA.lockedFeatures.has("planning"), "Navigation Basis: gesperrte Module + Planung"); const effB = await effectiveModules(B.tenantId); ok(effB.inactive.size === 0 && effB.lockedFeatures.size === 0, "Navigation Profi: alles sichtbar"); section("Sync (Offline-Op eines gesperrten Moduls → rejected mit Klartext)"); const opA = emergencyOp(); const syncA = await applyOperations(A.ctx.tech, { deviceId: "zz-dev", operations: [opA] }); const rA = syncA.results[0]; ok(rA.status === "rejected" && rA.errorCode === "forbidden" && (rA.message ?? "").startsWith("not_in_plan") && /Paket Profi/.test(rA.message ?? ""), `Basis: emergency.create → rejected mit Klartext (${rA.message?.slice(0, 60)}…)`); const storedA = await prisma.syncOperation.findFirst({ where: { tenantId: A.tenantId, clientOpId: opA.clientOpId } }); ok(storedA?.status === "rejected", "abgewiesene Op gespeichert (nicht still verworfen)"); ok((await prisma.workOrder.count({ where: { tenantId: A.tenantId, isEmergency: true } })) === 0, "kein Notdienstauftrag angelegt"); const again = await applyOperations(A.ctx.tech, { deviceId: "zz-dev", operations: [opA] }); ok(again.results[0].status === "duplicate", "Wiederholung derselben Op → duplicate (Ergebnis bleibt)"); ok(problemKey({ status: "rejected", opType: "emergency.create", lastError: { code: "forbidden", message: rA.message } }) === "notInPlan", "Gerät zeigt Klartext „Im Paket Profi enthalten“ (problem.notInPlan)"); const msA = await prisma.workOrderMilestone.create({ data: { tenantId: A.tenantId, workOrderId: woA.id, title: "ZZ" } }); const mOp: SyncOperationInput = { clientOpId: randomUUID(), opType: "milestone.reach", payload: { workOrderId: woA.id, milestoneId: msA.id }, clientCreatedAt: new Date().toISOString() }; const mRes = (await applyOperations(A.ctx.tech, { deviceId: "zz-dev", operations: [mOp] })).results[0]; ok(mRes.status === "rejected" && (mRes.message ?? "").startsWith("not_in_plan"), "Basis: milestone.reach → rejected"); const opB = emergencyOp(); const rB = (await applyOperations(B.ctx.tech, { deviceId: "zz-dev", operations: [opB] })).results[0]; ok(rB.status === "applied", `Profi: emergency.create angewendet (${rB.status}${rB.message ? `: ${rB.message}` : ""})`); section("TenantModule aus + Profi → gesperrt"); await prisma.tenantModule.upsert({ where: { tenantId_moduleKey: { tenantId: B.tenantId, moduleKey: "emergency" } }, update: { enabled: false }, create: { tenantId: B.tenantId, moduleKey: "emergency", enabled: false } }); ok((await moduleState(B.tenantId, "emergency")) === "disabled", "Profi + Schalter aus → disabled"); let offErr: unknown = null; try { await assertModuleEnabled(fakeSession(B.tenantId, B.users.admin.id), "emergency"); } catch (err) { offErr = err; } ok(offErr instanceof ModuleDisabledError && offErr.reason === "disabled", "Action → ModuleDisabledError(disabled)"); const rOff = (await applyOperations(B.ctx.tech, { deviceId: "zz-dev", operations: [emergencyOp()] })).results[0]; ok(rOff.status === "rejected" && (rOff.message ?? "").startsWith("module_disabled"), "Sync → rejected (module_disabled)"); ok(problemKey({ status: "rejected", opType: "emergency.create", lastError: { code: "forbidden", message: rOff.message } }) === "moduleDisabled", "Gerät: problem.moduleDisabled"); await prisma.tenantModule.update({ where: { tenantId_moduleKey: { tenantId: B.tenantId, moduleKey: "emergency" } }, data: { enabled: true } }); ok(await isModuleActive(B.tenantId, "emergency"), "wieder eingeschaltet → aktiv"); section("Nur der Betreiber ändert die Stufe"); await expectCode(() => updateTenantPlan({ platformAdminId: B.users.admin.id }, B.tenantId, { tier: "BASIS", lotseChatSeats: 5, lotseChatHardLimit: false }), "forbidden", "Mandanten-Admin (kein Plattform-Konto) → forbidden"); await expectCode(() => updateTenantPlan({ platformAdminId: readonly.id }, B.tenantId, { tier: "BASIS", lotseChatSeats: 5, lotseChatHardLimit: false }), "forbidden", "Nur-Lesen-Plattform-Admin → forbidden"); await expectCode(() => updateTenantPlan({ platformAdminId: platform.id }, B.tenantId, { tier: "GOLD" as never, lotseChatSeats: 5, lotseChatHardLimit: false }), "invalid", "ungültige Stufe → invalid"); await expectCode(() => updateTenantPlan({ platformAdminId: platform.id }, B.tenantId, { tier: "PROFI", lotseChatSeats: -1, lotseChatHardLimit: false }), "invalid", "negative Platzanzahl → invalid"); await updateLotseSettings(B.ctx.admin, { enabled: true, addressForm: "neutral" }); const bAfterSettings = await prisma.tenant.findUniqueOrThrow({ where: { id: B.tenantId } }); ok(bAfterSettings.tier === "PROFI" && bAfterSettings.lotseChatSeats === 0, "Lotse-Einstellungen des Mandanten ändern weder Stufe noch Plätze"); ok(!/\btier:|lotseChatSeats|lotseChatHardLimit/.test(src("src/server/services/lotse/settings.ts") + src("src/server/actions/tenant-settings.ts") + src("src/server/actions/lotse-settings.ts")), "keine Mandanten-Action schreibt Stufe/Plätze"); section("Wechsel Profi → Basis → Profi ohne Datenverlust"); await prisma.lotseChatSeat.create({ data: { tenantId: B.tenantId, userId: B.users.tech.id } }); const conv = await prisma.lotseConversation.create({ data: { tenantId: B.tenantId, userId: B.users.tech.id } }); await prisma.lotseMessage.create({ data: { tenantId: B.tenantId, conversationId: conv.id, role: "user", text: "Hallo" } }); const snapshot = async () => ({ emergencies: await prisma.workOrder.count({ where: { tenantId: B.tenantId, isEmergency: true } }), seats: await prisma.lotseChatSeat.count({ where: { tenantId: B.tenantId } }), messages: await prisma.lotseMessage.count({ where: { tenantId: B.tenantId } }), teams: await prisma.team.findMany({ where: { tenantId: B.tenantId }, select: { id: true, dailyCapacityMinutes: true }, orderBy: { id: "asc" } }), }); const before = await snapshot(); const down = await updateTenantPlan({ platformAdminId: platform.id }, B.tenantId, { tier: "BASIS", lotseChatSeats: 3, lotseChatHardLimit: true }); ok(down.tier === "BASIS" && down.lotseChatSeats === 3 && down.lotseChatHardLimit, "Betreiber setzt Basis, 3 Plätze, hartes Limit"); const audit = await prisma.auditLog.findFirst({ where: { tenantId: B.tenantId, scope: "platform", entity: "tenant_plan" }, orderBy: { createdAt: "desc" } }); ok(!!audit && (audit.before as { tier?: string })?.tier === "PROFI" && (audit.after as { tier?: string })?.tier === "BASIS" && audit.actorId === platform.id, "Plattform-Audit mit before/after"); ok((await moduleState(B.tenantId, "emergency")) === "not_in_plan" && !(await isPlanFeatureAvailable(B.tenantId, "planning")), "Basis: Zugriff endet"); await expectCode(() => getPlanningBoard(B.ctx.backoffice, { from: "2026-09-21", to: "2026-09-25" }), "forbidden", "Basis: Plantafel gesperrt"); ok(JSON.stringify(await snapshot()) === JSON.stringify(before), "Basis: Notdiensteinsätze, Plätze, Chats, Kolonnen unverändert"); await updateTenantPlan({ platformAdminId: platform.id }, B.tenantId, { tier: "PROFI", lotseChatSeats: 3, lotseChatHardLimit: false }); ok((await moduleState(B.tenantId, "emergency")) === "enabled" && (await isPlanFeatureAvailable(B.tenantId, "planning")), "zurück auf Profi: Zugriff wieder da"); ok(JSON.stringify(await snapshot()) === JSON.stringify(before), "zurück auf Profi: alle Daten vorhanden"); ok((await codeOf(getPlanningBoard(B.ctx.backoffice, { from: "2026-09-21", to: "2026-09-25" }))) === "ok", "Plantafel wieder erlaubt"); section("Mandantentrennung der Stufe"); ok((await prisma.tenant.findUniqueOrThrow({ where: { id: A.tenantId } })).tier === "BASIS", "Änderung an B lässt A unberührt"); ok((await dbForTenant(B.tenantId).syncOperation.findMany({ where: { clientOpId: opA.clientOpId } })).length === 0, "B sieht die abgewiesene Op von A nicht"); } finally { await extraCleanup([A.tenantId, B.tenantId]); await prisma.lotseMessage.deleteMany({ where: { tenantId: { in: [A.tenantId, B.tenantId] } } }); await prisma.lotseConversation.deleteMany({ where: { tenantId: { in: [A.tenantId, B.tenantId] } } }); } });