import fs from "node:fs"; import path from "node:path"; import { describe, expect, it } from "vitest"; import { resolveRepoPath } from "~/utils/testing/repoPaths"; const appRoot = resolveRepoPath("apps/web/app"); const centralSessionModule = "utils/session.server.ts"; const appSourceExtensions = new Set([".ts", ".tsx"]); const ignoredDirectories = new Set(["node_modules", ".react-router", "build", "dist", "coverage"]); const ignoredPathSegments = new Set(["__generated__", "generated"]); // Public authentication flows and external callbacks are intentionally outside // the signed-in session CSRF contract. Keep each exception path-specific so new // protected mutating forms must opt into either csrf input or validator coverage. const postFormCsrfExceptions = new Map([ ["modules/users/components/LoginForm.tsx", "public login form creates a session before CSRF exists"], // D-D0a — die Reset-Request-Form lebt jetzt in ForgotPasswordScreen.tsx (mit name="csrf" -> von der // Standard-Pruefung abgedeckt, daher kein Exception-Eintrag mehr noetig). ["routes/invitation.$id.tsx", "public invitation acceptance surface"], // D-D0a — die Reset-Completion-Form lebt jetzt in ResetPasswordScreen.tsx (mit name="csrf"). ]); const protectedPostFormAllowlist = new Map([ ["modules/users/components/RegisterForm.tsx", "includes hidden csrf input and AuthService validates it server-side"], ]); const protectedPostFormFilePatterns = [/^modules\/users\/components\/RegisterForm\.tsx$/]; type SourceFile = { relativePath: string; source: string; }; function collectAppSourceFiles(directory: string = appRoot): SourceFile[] { return fs .readdirSync(directory, { withFileTypes: true }) .flatMap((entry) => { const absolutePath = path.join(directory, entry.name); const relativePath = path.relative(appRoot, absolutePath).split(path.sep).join("/"); if (entry.isDirectory()) { return ignoredDirectories.has(entry.name) || ignoredPathSegments.has(entry.name) ? [] : collectAppSourceFiles(absolutePath); } if (!entry.isFile() || !appSourceExtensions.has(path.extname(entry.name))) { return []; } return [ { relativePath, source: fs.readFileSync(absolutePath, "utf8"), }, ]; }) .sort((left, right) => left.relativePath.localeCompare(right.relativePath)); } function stripLineComments(source: string) { return source .split("\n") .filter((line) => !line.trimStart().startsWith("//")) .join("\n"); } const sourceFiles = collectAppSourceFiles().filter((file) => !/\.(test|spec)\.[tj]sx?$/.test(file.relativePath)); const postFormPattern = /<(?:Form|fetcher\.Form)\b[^>]*\bmethod=(?:"post"|'post'|\{"post"\}|\{'post'\})/; const csrfFieldPattern = /\bname=(?:"csrf"|'csrf'|\{"csrf"\}|\{'csrf'\})/; const validatorPattern = /\bvalidateCSRFToken\s*\(/; describe("CSRF session wiring sweep", () => { it("does not reintroduce legacy crsf spellings in app source files", () => { const legacyMatches = sourceFiles.filter((file) => file.source.includes("crsf")); expect( legacyMatches.map((file) => file.relativePath), "legacy crsf spellings must not appear in app TypeScript sources" ).toEqual([]); }); it("keeps raw csrf session mutations centralized in the session helper", () => { const rawMutationMatches = sourceFiles .filter((file) => file.relativePath !== centralSessionModule) .filter((file) => file.source.includes('session.set("csrf"')); expect( rawMutationMatches.map((file) => file.relativePath), "raw csrf session.set calls must go through apps/web/app/utils/session.server.ts" ).toEqual([]); }); it("keeps protected post forms tied to csrf inputs or validator coverage", () => { const protectedPostFormFiles = sourceFiles .filter((file) => postFormPattern.test(file.source)) .filter((file) => protectedPostFormFilePatterns.some((pattern) => pattern.test(file.relativePath))); const unclassifiedProtectedForms = protectedPostFormFiles.filter((file) => { const sourceWithoutCommentOnlyLines = stripLineComments(file.source); return !csrfFieldPattern.test(sourceWithoutCommentOnlyLines) && !validatorPattern.test(sourceWithoutCommentOnlyLines) && !protectedPostFormAllowlist.has(file.relativePath); }); expect( unclassifiedProtectedForms.map((file) => file.relativePath), "protected Form/fetcher.Form method=post files must include name=\"csrf\", call validateCSRFToken, or be path-allowlisted with a reason" ).toEqual([]); }); it("keeps public csrf exceptions anchored to existing post form files", () => { const postFormPaths = new Set(sourceFiles.filter((file) => postFormPattern.test(file.source)).map((file) => file.relativePath)); const staleExceptionPaths = [...postFormCsrfExceptions.keys(), ...protectedPostFormAllowlist.keys()].filter((allowedPath) => !postFormPaths.has(allowedPath)); expect(staleExceptionPaths, "CSRF form allowlist entries must name existing app/ post form files").toEqual([]); }); it("validates delegated row actions before parsing submitted form data", () => { const source = fs.readFileSync(path.join(appRoot, "modules/rows/utils/RowsRequestUtils.ts"), "utf8"); expect(source.indexOf("await validateCSRFToken(request)")).toBeGreaterThanOrEqual(0); expect(source.indexOf("await validateCSRFToken(request)")).toBeLessThan(source.indexOf("await request.formData()")); }); it("validates direct tenant feedback actions before parsing form data", () => { for (const relativePath of ["routes/app.$tenant.tsx"]) { const source = fs.readFileSync(path.join(appRoot, relativePath), "utf8"); expect(source.indexOf("await validateCSRFToken(request)"), relativePath).toBeGreaterThanOrEqual(0); expect(source.indexOf("await validateCSRFToken(request)"), relativePath).toBeLessThan(source.indexOf("await request.formData()")); } }); it("validates delegated workflow actions before parsing submitted form data", () => { for (const relativePath of [ "modules/workflowEngine/routes/workflow-engine/workflows.index.api.server.tsx", "modules/workflowEngine/routes/workflow-engine/__workflow/workflows.$id.index.api.server.tsx", "modules/workflowEngine/routes/workflow-engine/__workflow/workflows.$id.run.api.server.tsx", "modules/workflowEngine/routes/workflow-engine/__workflow/workflows.$id.run.manual.api.server.tsx", "modules/workflowEngine/routes/workflow-engine/__workflow/workflows.$id.run.stream.api.server.tsx", "modules/workflowEngine/routes/workflow-engine/credentials.api.server.tsx", "modules/workflowEngine/routes/workflow-engine/credentials/credentials.new.api.server.tsx", "modules/workflowEngine/routes/workflow-engine/danger.api.server.tsx", "modules/workflowEngine/routes/workflow-engine/executions.api.server.tsx", "modules/workflowEngine/routes/workflow-engine/templates.api.server.tsx", "modules/workflowEngine/routes/workflow-engine/variables/variables.$id.api.server.tsx", "modules/workflowEngine/routes/workflow-engine/variables/variables.new.api.server.tsx", ]) { const source = fs.readFileSync(path.join(appRoot, relativePath), "utf8"); expect(source.indexOf("await validateCSRFToken(request)"), relativePath).toBeGreaterThanOrEqual(0); expect(source.indexOf("await validateCSRFToken(request)"), relativePath).toBeLessThan(source.indexOf("await request.formData()")); } }); it("posts tenant feedback with the current csrf token", () => { const source = fs.readFileSync(path.join(appRoot, "components/layouts/buttons/AddFeedbackButton.tsx"), "utf8"); expect(source).toContain("useRootData()"); expect(source).toContain('name="csrf"'); expect(source).toContain("value={csrf ?? \"\"}"); }); });