diff --git a/scripts/check-module-guards.ts b/scripts/check-module-guards.ts index d4edb9d..4ee9524 100644 --- a/scripts/check-module-guards.ts +++ b/scripts/check-module-guards.ts @@ -81,6 +81,10 @@ function checkGatedFile(label: string, src: string, moduleKey: string) { if (!src.includes(`moduleGuard("${moduleKey}")`)) { errors.push(`${label}: erwartet moduleGuard("${moduleKey}") — Modul-Gating fehlt oder falscher Key.`); } + // L15 Testphase: der Lese-Modus überspringt die Schreibsperre abgelaufener Testmandanten → in Actions verboten. + if (/moduleGuard\([^)]*read\s*:/.test(src)) { + errors.push(`${label}: moduleGuard(…, { read: true }) ist nur für Lesepfade erlaubt, nicht in Server-Actions.`); + } for (let i = 0; i < positions.length; i++) { const start = positions[i].index; const end = i + 1 < positions.length ? positions[i + 1].index : src.length; diff --git a/scripts/smoke-testphase.ts b/scripts/smoke-testphase.ts index 5864f98..47950e3 100644 --- a/scripts/smoke-testphase.ts +++ b/scripts/smoke-testphase.ts @@ -89,7 +89,10 @@ async function main() { who: `Monteur abgelaufen (${expiredSlug})`, cookie: await tenantCookie(tech.email, expiredSlug), checks: [ - { path: "/m", mustContain: ["Testphase abgelaufen – nur Lesezugriff."], mustNotContain: ["Daten exportieren"] }, + { path: "/m", mustContain: ["Testphase abgelaufen – nur Lesezugriff.", 'data-trial-banner="expired"'] }, + { path: "/m/orders", mustContain: ["Testphase abgelaufen – nur Lesezugriff."] }, + { path: `/m/orders/${order.id}`, mustContain: [order.number] }, + { path: "/m/emergency", mustContain: ["Testphase abgelaufen – nur Lesezugriff."] }, { path: "/api/v1/field/bundle", mustContain: ['"orders"'] }, { label: "POST /api/v1/sync → gesperrt", path: "/api/v1/sync", method: "POST", body: JSON.stringify({ deviceId: "zz-smoke", operations: [] }), headers: { "content-type": "application/json" }, expect: [422], mustContain: ["trial_expired"] }, { label: "POST /api/v1/uploads → gesperrt", path: "/api/v1/uploads", method: "POST", body: multipart({ clientId: "7c1d6a0e-3b1f-4c55-9d2a-00000000f016", workOrderId: order.id, kind: "photo" }, { name: "a.jpg", type: "image/jpeg", bytes: Buffer.from([0xff, 0xd8, 0xff, 0xd9]) }), expect: [422], mustContain: ["trial_expired"] }, diff --git a/scripts/test-testphase-readonly.ts b/scripts/test-testphase-readonly.ts index 1de964d..5b2f744 100644 --- a/scripts/test-testphase-readonly.ts +++ b/scripts/test-testphase-readonly.ts @@ -99,7 +99,12 @@ async function main() { // static coverage of every write entry point const guard = readFileSync("src/server/action-guard.ts", "utf8"); - ok(/assertModuleEnabled\(session, moduleKey\);[\s\S]*await assertTenantWritable\(session\.user\.tenantId\);[\s\S]*return \{ session, db/.test(guard), "moduleGuard: Schreibsperre für alle Modul-Actions (inkl. Lotse, Uploads per Action)"); + ok(/assertModuleEnabled\(session, moduleKey\);[\s\S]*if \(!opts\.read\) await assertTenantWritable\(session\.user\.tenantId\);[\s\S]*return \{ session, db/.test(guard), "moduleGuard: Schreibsperre für alle Modul-Actions (inkl. Lotse, Uploads per Action)"); + const actionFiles = walk("src/server/actions").filter((f) => f.endsWith(".ts")); + ok(actionFiles.every((f) => !/moduleGuard\([^)]*read\s*:/.test(readFileSync(f, "utf8"))), "keine Server-Action nutzt den Lese-Modus des Guards"); + const readUsers = walk("src").filter((f) => /\.tsx?$/.test(f) && /moduleGuard\([^)]*read\s*:\s*true/.test(readFileSync(f, "utf8"))).sort(); + ok(JSON.stringify(readUsers) === JSON.stringify(["src/app/(app)/imports/[id]/file/route.ts", "src/app/(field)/m/emergency/page.tsx", "src/server/services/field/page-context.ts"]), `Lese-Modus nur in Seitenkontexten/GET-Download (${readUsers.join(", ")})`); + ok(readFileSync("scripts/check-module-guards.ts", "utf8").includes("nur für Lesepfade erlaubt"), "Guard-Check verbietet den Lese-Modus in Actions"); const context = readFileSync("src/server/api/context.ts", "utf8"); ok(/enforceApiRateLimit\(session\.user\.id, moduleKey\);\s*await assertApiWriteAllowed\(tenantId\);/.test(context), "requireApiContext: Schreibsperre für jede /api/v1-Mutation"); const routes = walk("src/app/api/v1").filter((f) => f.endsWith("route.ts")); diff --git a/src/app/(app)/imports/[id]/file/route.ts b/src/app/(app)/imports/[id]/file/route.ts index 9455cef..aa6e3ac 100644 --- a/src/app/(app)/imports/[id]/file/route.ts +++ b/src/app/(app)/imports/[id]/file/route.ts @@ -15,7 +15,7 @@ const INLINE = new Set(["application/pdf", "image/jpeg", "image/png"]); export async function GET(req: Request, { params }: { params: Promise<{ id: string }> }) { let file: { storageKey: string; mimeType: string; fileName: string }; try { - const ctx = ctxFromGuard(await moduleGuard("imports")("import:write")); + const ctx = ctxFromGuard(await moduleGuard("imports", { read: true })("import:write")); // L15: GET download — no trial write lock const { id } = await params; file = await getImportFile(ctx, id); } catch { diff --git a/src/app/(field)/m/emergency/page.tsx b/src/app/(field)/m/emergency/page.tsx index f96b880..6996e2d 100644 --- a/src/app/(field)/m/emergency/page.tsx +++ b/src/app/(field)/m/emergency/page.tsx @@ -6,7 +6,7 @@ import { EmergencyWizard } from "@/components/emergency/emergency-wizard"; /** `/m/emergency` — Notdienst erfassen (spec §19.2, US-010): max. 3 steps, then straight into the call-out. */ export default async function EmergencyPage() { - const ctx = ctxFromGuard(await moduleGuard("emergency")()); + const ctx = ctxFromGuard(await moduleGuard("emergency", { read: true })()); // L15: read path — no trial write lock const t = await getTranslations("emergency.capture"); if (!can(ctx, "emergency:create") || !can(ctx, "field:execute")) { return

{t("noAccess")}

; diff --git a/src/server/action-guard.ts b/src/server/action-guard.ts index b0797fb..048f62d 100644 --- a/src/server/action-guard.ts +++ b/src/server/action-guard.ts @@ -27,7 +27,10 @@ import { assertTenantWritable } from "@/server/services/trial/state"; * Der Vollständigkeitscheck (`scripts/check-module-guards.ts`) verlässt sich darauf, * dass jede Action eines gegateten Moduls über einen so erzeugten `guard(...)` läuft. */ -export function moduleGuard(moduleKey: string) { +export function moduleGuard(moduleKey: string, opts: { read?: boolean } = {}) { + // L15 Testphase: `{ read: true }` nur für Lesepfade (Seitenkontexte, GET-Downloads), die denselben + // DB-autoritativen Guard nutzen — dort greift die Schreibsperre abgelaufener Testmandanten nicht. + // scripts/check-module-guards.ts verbietet den Lese-Modus in Action-Dateien. return async function guard(...permissions: Permission[]) { const session = await requireSession(); const db = dbForTenant(session.user.tenantId); @@ -86,7 +89,7 @@ export function moduleGuard(moduleKey: string) { await assertModuleEnabled(session, moduleKey); // L15 Testphase: abgelaufene Testmandanten sind nur lesbar — zentrale Schreibsperre // (wirft ServiceError "blocked"/"trial_expired"). - await assertTenantWritable(session.user.tenantId); + if (!opts.read) await assertTenantWritable(session.user.tenantId); // `permissions` = DB-authoritative effective set; domain services derive their // scope decisions from it (src/server/services/context.ts#ctxFromGuard). return { session, db, permissions: effective as ReadonlySet }; diff --git a/src/server/services/field/page-context.ts b/src/server/services/field/page-context.ts index d77b137..39c6d7c 100644 --- a/src/server/services/field/page-context.ts +++ b/src/server/services/field/page-context.ts @@ -7,7 +7,7 @@ import { can, ctxFromGuard, type ServiceCtx } from "@/server/services/context"; * the visibility scopes of the services. */ export async function fieldPageContext(): Promise { - return ctxFromGuard(await moduleGuard("field")()); + return ctxFromGuard(await moduleGuard("field", { read: true })()); // L15: read path — no trial write lock } export function canUseFieldApp(ctx: ServiceCtx): boolean {