From 0b68d23d588fc2d5260c493e1f5b58323ec4438b Mon Sep 17 00:00:00 2001 From: Danijel Martinek Date: Mon, 11 May 2026 16:40:18 +0200 Subject: [PATCH] feat(generators): wire audit entry + e2e byte-identical reconstruction test --- .../__tests__/core-package-audit.e2e.test.ts | 50 +++++++++++++++ turbo/generators/config.ts | 64 ++++++++++++++++++- 2 files changed, 112 insertions(+), 2 deletions(-) create mode 100644 turbo/generators/__tests__/core-package-audit.e2e.test.ts diff --git a/turbo/generators/__tests__/core-package-audit.e2e.test.ts b/turbo/generators/__tests__/core-package-audit.e2e.test.ts new file mode 100644 index 0000000..8d05136 --- /dev/null +++ b/turbo/generators/__tests__/core-package-audit.e2e.test.ts @@ -0,0 +1,50 @@ +import { describe, it, expect } from "vitest"; +import { mkdtempSync, cpSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { execSync } from "node:child_process"; +import { join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { computeSnapshot } from "../lib/snapshot.js"; +import expectedSnapshot from "../__snapshots__/core-package/audit.snapshot.json" with { type: "json" }; + +// Repo root is 2 levels up from turbo/generators/__tests__ +const REPO_ROOT = resolve(fileURLToPath(import.meta.url), "..", "..", "..", ".."); + +/** + * Strip "@repo/core-audit" from a package.json file in the tmp tree. + * Required when simulating a fresh scaffold: core-audit does not exist yet + * but the snapshot was captured before removal. Apps in the current tree + * may still list it as a dependency. + */ +function stripCoreAuditDep(pkgJsonPath: string): void { + const raw = readFileSync(pkgJsonPath, "utf8"); + const parsed = JSON.parse(raw) as Record>; + for (const section of ["dependencies", "devDependencies", "peerDependencies"] as const) { + if (parsed[section]?.["@repo/core-audit"]) { + delete parsed[section]["@repo/core-audit"]; + } + } + writeFileSync(pkgJsonPath, JSON.stringify(parsed, null, 2) + "\n"); +} + +describe("e2e: core-package audit", () => { + it("byte-identical reconstruction matches snapshot", { timeout: 120_000 }, () => { + const tmp = mkdtempSync(join(tmpdir(), "e2e-audit-")); + cpSync(REPO_ROOT, tmp, { + recursive: true, + filter: (src) => + !src.includes("node_modules") && + !src.includes(".turbo") && + !src.includes("packages/core-audit"), + }); + + // Strip @repo/core-audit from apps/web-next/package.json so pnpm install + // succeeds without the package being present (simulating the post-removal state). + stripCoreAuditDep(join(tmp, "apps", "web-next", "package.json")); + + execSync(`cd ${tmp} && pnpm install --frozen-lockfile=false`, { stdio: "ignore" }); + execSync(`cd ${tmp} && pnpm turbo gen core-package --args audit`, { stdio: "ignore" }); + const result = computeSnapshot(join(tmp, "packages/core-audit")); + expect(result).toEqual(expectedSnapshot); + }); +}); diff --git a/turbo/generators/config.ts b/turbo/generators/config.ts index bd5051c..c8f22e2 100644 --- a/turbo/generators/config.ts +++ b/turbo/generators/config.ts @@ -666,16 +666,28 @@ import noRealtimeHandlerReexport from "./rules/no-realtime-handler-reexport.js"; }, printUiNextSteps, ], + audit: () => [ + () => { + assertOptionalPackageNotPresent("core-audit"); + return "Guard passed — packages/core-audit does not exist yet."; + }, + ...emitTemplateTree("core-package/audit", "packages/core-audit"), + () => { + addToTranspilePackages("apps/web-next/next.config.mjs", "@repo/core-audit"); + return "Added @repo/core-audit to transpilePackages."; + }, + printAuditNextSteps, + ], }; plop.setGenerator("core-package", { - description: "Scaffold an optional core package (realtime, events, trpc, ui)", + description: "Scaffold an optional core package (realtime, events, trpc, ui, audit)", prompts: [ { type: "list", name: "name", message: "Which optional core package?", - choices: ["realtime", "events", "trpc", "ui"], + choices: ["realtime", "events", "trpc", "ui", "audit"], }, ], actions: (answers) => { @@ -1324,6 +1336,54 @@ function printRealtimeNextSteps(): string { ].join("\n"); } +function printAuditNextSteps(): string { + return [ + "─────────────────────────────────────────────────────────────", + "@repo/core-audit scaffolded into packages/core-audit/.", + "", + "Manual wiring required (compliance-critical):", + "", + "1. Set AUDIT_PSEUDONYM_SALT env var (production REQUIRED):", + ' export AUDIT_PSEUDONYM_SALT="$(openssl rand -hex 32)"', + " Add to your deployment secrets manager.", + "", + "2. Mount the audit-logs Payload collection in packages/core-cms/src/payload.config.ts:", + ' import { auditLogsCollection } from "@repo/core-audit/collection";', + " // collections: [..., auditLogsCollection],", + "", + "3. Mount the admin tRPC router in packages/core-api/src/root.ts:", + ' import { createAuditRouter } from "@repo/core-audit/api";', + " // const { auditLog } = bindAudit(container, { payloadConfig, sinks: [\"payload\", \"stdout\"] });", + " // routers: { ..., audit: createAuditRouter(auditLog) },", + "", + "4. Bind audit in apps/web-next/src/server/bind-production.ts:", + ' const { bindAudit } = await import("@repo/core-audit/di");', + " const { auditLog } = bindAudit(sharedContainer, {", + " payloadConfig: resolvedConfig,", + ' sinks: ["payload", "stdout"],', + " });", + "", + "5. Install user-collection hooks (recommended for DPA compliance):", + " In packages/auth/src/di/bind-production.ts, gate on ctx.auditLog:", + " if (ctx.auditLog) {", + ' const { createAuditErasureHook, createAuditAfterReadHook } =', + ' await import("@repo/core-audit/hooks");', + " // wire onto users collection — see docs/guides/audit-and-compliance.md", + " }", + "", + "6. Set up a log shipper (Vector / Fluent Bit) to forward stdout JSON to", + " your aggregator. See docs/guides/audit-and-compliance.md for configs.", + "", + "7. Verify:", + " pnpm install", + " pnpm lint && pnpm typecheck && pnpm test", + " pnpm turbo boundaries", + "", + "See docs/guides/audit-and-compliance.md for the full guide.", + "─────────────────────────────────────────────────────────────", + ].join("\n"); +} + function coreUiComponentActions(a: { tier: "atom" | "molecule" | "organism"; name: string;