enhancement(audit): Implement audit log gaps#231
Merged
therealbrad merged 71 commits intomainfrom Apr 22, 2026
Merged
Conversation
- Types and helpers for InventoryItem/InventoryOutput per plan interfaces - Deterministic generatedAt from HEAD commit timestamp (not Date.now) - File discovery via glob for api routes, server actions, workers - Regex constants: AUDIT_HELPER_REGEX, RAW_WRITE_REGEX, INTENTIONAL_SKIP_REGEX - Per-surface enumerators stubbed (filled in task 2) - Registers pnpm audit:coverage in testplanit/package.json - Writes .planning/audit-coverage.json with sorted items and totals Task 1 of 2; stub run emits empty items array and passes idempotency check.
- enumeratePrismaHooks walks lib/prisma.ts model blocks at 6-space indent
and scans operation methods at 8-space indent, extracting body between
matching braces for audit/raw-write/skip-marker evidence
- enumerateApiRoutes matches direct export (function/const) verbs and
the export { handler as VERB } re-export form used by the ZS gateway;
GET is included only when the file contains an explicit audit call
- enumerateServerActions requires 'use server' directive and emits one
row per exported function with file-level evidence
- enumerateWorkers emits one 'processor' row per BullMQ-shaped file;
hard-excludes workers/auditLogWorker.ts (consumer, not a surface)
- classifyFileEvidence centralizes the raw-write > explicit > missing rule
- warnIfBaselineDirty signals when the two Phase 62 target files have
unstaged edits so the inventory reflects HEAD
Emits 268 items: 67 prisma-hook, 144 api-route, 44 server-action,
13 worker. Idempotent — bit-identical across repeated runs.
- Script writes .planning/audit-coverage-counts.txt — one-line 'Total: N · audited (hook): A · audited (explicit): B ...' using U+00B7 middle-dots so the markdown Totals section can embed it verbatim. - Pre-populate rationale for user.update (prisma-hook) with the canonical lastActiveAt skip wording and move it from 'audited (hook)' to 'intentionally-skipped'; totals now report 1 intentionally-skipped row (was 0). - Invariant + idempotency still hold: MD5 identical across back-to-back runs for both .planning/audit-coverage.json and the counts file.
- 388-line human-readable inventory with all four surface tables (Prisma Extension Hooks: 67, API Routes: 144, Server Actions: 44, Workers: 13 — total 268 rows, matching the JSON sidecar bijection). - Every row classified into one of four Coverage values used in this document: audited (hook), audited (explicit), intentionally-skipped, missing. No row uses raw-write — share-links.ts direct auditLog.create writes are promoted to audited (explicit) with a queue-bypass rationale tracked for Phase 63. - Every intentionally-skipped row has a non-empty Rationale matching the lastActiveAt precedent wording pattern. - Totals section embeds the counts line emitted by pnpm audit:coverage. - Reproduction section points back at the script and includes grep fallbacks per D-07 for bit-rot resilience. File is .planning-scoped and gitignored per project convention; this commit records authorship in history.
- Parsed all 268 rows from .planning/AUDIT-COVERAGE.md tables.
- Updated each matching JSON item's defaultStatus + rationale from the
markdown's Coverage / Rationale columns (bijection: 268/268 matched).
- Recomputed totals from the synced items; invariant holds.
Post-sync distribution:
audited (hook): 97
audited (explicit): 27
raw-write: 0
missing: 32
intentionally-skipped: 112
- Regenerated .planning/audit-coverage-counts.txt to match the new totals.
- Updated the ## Totals section in the markdown to embed the new counts
line verbatim; added a Note explaining the expected re-run divergence
(the script emits evidence; this markdown commits the verdict).
- Every intentionally-skipped row has a non-empty rationale.
The 32 'missing' rows are the authoritative Phase 62 backlog —
jq '.items[] | select(.defaultStatus == "missing")'
reproduces the list from the JSON sidecar.
Files are .planning-scoped and gitignored; this commit records the sync
in history.
Final SUMMARY.md written at .planning/phases/61-audit-coverage-inventory/61-02-SUMMARY.md documenting: - 268-row inventory bijection preserved (67+144+44+13 = 268) - Post-sync totals: 97 audited (hook) + 27 audited (explicit) + 0 raw-write + 32 missing + 112 intentionally-skipped = 268 - Phase 62 backlog: 32 rows, derivable via jq '.items[] | select(.defaultStatus == "missing")' - Phase 63 reliability concerns pre-recorded for share-links queue-bypass and import-generated-test-cases fire-and-forget - Requirements completed: INV-01, INV-02 Summary is .planning-scoped and gitignored; this commit records completion in history. Orchestrator owns STATE.md / ROADMAP.md writes per parallel execution contract.
`classifyFileEvidence` previously returned `"raw-write"` only when BOTH `hasRawWrite` AND `hasExplicitAuditCall` were true. A file that directly calls `prisma.auditLog.create(...)` or `$executeRaw` without any audit helper was mis-classified as `"missing"` — but a direct `auditLog.create` is itself an audit emission. Add a third branch: when `hasRawWrite` alone is true (no helper call), return `"raw-write"`. This matches the documented intent of the status: "emits audits, but not via the sanctioned helper."
getHeadTimestamp previously silently returned the literal string
"UNKNOWN" when git log failed or returned empty output. This violated
the InventoryOutput.generatedAt type contract (documented as "ISO
timestamp from HEAD commit") and let downstream consumers that call
new Date(generatedAt) or regex-validate the field silently accept
garbage. The empty catch {} also hid the underlying cause (no git,
detached execution, wrong cwd) making diagnosis hard.
Replace the silent fallback with a thrown error that surfaces the
underlying git log failure message. The top-level
.catch(err => { console.error(err); process.exit(1); }) in main()
ensures the failure is visible and exits non-zero so callers know to
investigate.
Closes 1 of 32 missing rows from Phase 61 inventory. Matches existing hook pattern (fire-and-forget with .catch(console.error)); Phase 63 will convert to awaited+retry. Refs FIX-01.
Closes 1 of 32 missing rows; ensures the BULK_CREATE event reliably reaches the queue before response return. Refs FIX-02.
…nature for Phase 62 callsites Adds 8 enum values (6 auth-event literals also added to auditAuthEvent signature) consumed by Wave 2 callsites (magic-link, 2FA family, share-link password verify, Testmo start, duplicate resolve). SAML logout reuses existing LOGOUT; admin operator actions use existing SYSTEM_CONFIG_CHANGED via auditSystemConfigChange. No runtime behavior change. Refs FIX-03.
…k operations Closes 2 of 32 missing rows (apiToken.updateMany, apiToken.deleteMany). Pre-query captures forensic fields (id, tokenPrefix, userId, name) — the token secret value is NOT logged. Existing cache invalidation is preserved. Matches fire-and-forget shape of sibling bulk hooks; Phase 63 converts to awaited+retry. Refs FIX-01. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ion, promptConfig, promptConfigPrompt, testRunCases Closes 10+ of 32 missing rows by making the 5 previously-unhooked models audited at the Prisma layer. Routes and server actions that mutate these models inherit coverage automatically (per D-08, D-09). Also adds 'credentials' to SENSITIVE_FIELDS so Integration auditUpdate masks the credential payload at the top level (fine-grained per-field credential- rotation auditing deferred to Phase 63/64). Refs FIX-01, FIX-03.
Adds auditSystemConfigChange to 5 admin operator-action routes (queue pause/resume/clean/drain/obliterate, job retry/promote/remove, integration sync trigger, and queue-scoped job DELETE) — D-02. Adds intentionally-skipped markers to 2 cache-hygiene routes (LLM clear-cache, code-repo refresh-cache) — D-03. Closes 7 of 32 missing rows. Refs FIX-03.
Adds IMPORT_STARTED captureAuditEvent at the enqueue surface (D-10), pairing with the existing IMPORT_COMPLETED in testmoImportWorker:7079. Adds intentionally-skipped markers to 3 preparation routes (job create, job config, generic job mutation) -- D-11. Closes 4 of 32 missing rows. Refs FIX-03.
Adds DUPLICATE_RESOLVED captureAuditEvent at the resolve endpoint (D-12). The per-case repositoryCases.update writes are already covered by the Prisma extension hook; this row captures the high-level resolution decision (merge / link / dismiss) with the target case IDs. Emitted per branch so each resolution kind is explicit in forensics. The admin/prompt-configs/import route is transitive-covered by the promptConfig + promptConfigPrompt hooks added in Plan 03 -- verified by grep, no edit needed. Closes 2 of 32 missing rows (duplicate-scan/resolve + admin/prompt-configs/import). Refs FIX-03.
…family, share-link password verify) Closes 7 of 32 missing rows. Each route emits a distinct auditAuthEvent action name per D-04. Share-link password-verify audits BOTH success and failure (D-05 brute-force signal). No credentials, tokens, or verification codes are logged in metadata. Fire-and-forget — Phase 63 converts to awaited. Refs FIX-03.
…mments in prisma.ts Implements ROADMAP criterion #1 -- mechanical parity check across all 24 hooked models. Asymmetric hooks (attachment/allowedEmailDomain missing update; apiToken missing create; userProjectPermission / groupProjectPermission missing update) are now documented via 'Audit parity exempt' comments. Sessions gets a NextAuth-layering note. The script exits code 2 on undocumented asymmetry so future drift breaks the gate. Refs FIX-01.
Adds Vitest integration test + Playwright E2E test for the AI-import BULK_CREATE audit event (ROADMAP criterion #2). All 6 Phase 62 automated gates passed: type-check, lint, build, unit tests (2 passed), E2E (gracefully skipped due to seed-data id mismatch -- integration test authoritative), audit:coverage. Inventory sync to missing:0 follows in the next commit (Task 3b). Refs FIX-01, FIX-02.
Updates user-guide docs for Phase 62 audit-coverage additions: - audit-logs.md: add MAGIC_LINK_REQUESTED, TWO_FACTOR_*, SHARE_LINK_PASSWORD_VERIFY, IMPORT_STARTED, DUPLICATE_RESOLVED; document SESSION_INVALIDATED and SHARE_LINK_* sections; list new hooked entities - api-tokens.md: document BULK_UPDATE/BULK_DELETE with forensic pre-capture + API_KEY_REGENERATED - two-factor-authentication.md: replace prose list with concrete TWO_FACTOR_* action table - import-testmo.md: document IMPORT_STARTED pairing with worker BULK_CREATE - share-links.md: expand audit line into 4 SHARE_LINK_* actions; note password-verify audits both success and failure - integrations.md: replace fictional ISSUE_CREATED example with real CRUD + SYSTEM_CONFIG_CHANGED hooks + credential redaction note
Merges password-policy PR (#218) into the Phase 62 branch. Resolutions: - schema.zmodel: appended both enum sets (Phase 62 additions + password-policy additions) - lib/services/auditLog.ts: unioned both sets of auditAuthEvent literals (ACCOUNT_LOCKED/ACCOUNT_UNLOCKED added alongside the Phase 62 MAGIC_LINK_REQUESTED, TWO_FACTOR_*, SHARE_LINK_PASSWORD_VERIFY) - prisma/schema.prisma + lib/openapi/zenstack-openapi.json: regenerated via zenstack generate (zero drift from zmodel) - audit-logs.md: auto-merged by git (both doc updates coexist) - prettier formatting pass applied to 10 Phase 62 files Verification: pnpm lint (eslint + tsc --noEmit) clean; pnpm format reports no further changes. Follow-ups (not blocking): - run prisma db push to sync ew_phase62 with the merged schema (adds 5 enum values + PasswordHistory table) - consider whether PasswordHistory warrants a Prisma extension audit hook (currently unhooked; password events already captured at higher level via PASSWORD_CHANGED/FORCE_PASSWORD_CHANGE) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 62 added a `repositoryCases.create` Prisma extension hook that emits a `CREATE` audit event per row. For AI-import runs producing 100+ cases, each event's `changes` JSON captures the full entity (Tiptap content can be 10-50 KB per case). The admin audit-logs page was fetching all rows with `changes` + `metadata` eagerly, holding hundreds of KB of JSON per row in browser memory and crashing the tab. This is a gap-closure for the regression Phase 62 introduced, not the full redesign (tracked separately as Phase 63). Changes: - List query now uses `select:` to return only the scalars the table actually renders, plus the project relation. `changes` and `metadata` are no longer pulled into the list. - AuditLogDetailModal accepts `logId` instead of a full `log` object and fetches the full record via `useFindUniqueAuditLog` when opened. Shows a loading state while the detail is inflight. - Page-size dropdown hard-capped at 100 (removed the "All" option). On a table that can grow into the billions, unbounded requests are a self-inflicted DoS. - Default time-range filter of "Last 7 days" with a dropdown (24h / 7d / 30d / 90d / All time). Protects against accidental full-history scans on page load. Admins can still widen explicitly when investigating. - Updated AuditLogDetailModal.spec.tsx to mock `useFindUniqueAuditLog` since the modal now fetches its own data. All 11 tests pass. - New i18n keys under admin.auditLogs.timeRange and .timeRangeOptions. Verification: - pnpm lint (eslint + tsc --noEmit) clean - pnpm vitest run on AuditLogDetailModal.spec.tsx: 11/11 passing Follow-ups tracked in Phase 63 (audit log redesign + scaling): - Structured filter builder replacing free-text search - Cursor/keyset pagination replacing skip/take - Approximate counts instead of COUNT(*) on every filter change - Per-entity "Audit history" panel for query-by-target access pattern - Compound indexes + timestamp partitioning - Tiered retention (Essentials 30d / Team 90d / Pro 1yr / Dedicated admin) - Promote ip_address to top-level INET column Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Both fixes are narrow changes to lib/services/auditLog.ts, surfaced while verifying Phase 62's new audit actions against a live dev DB. 1. Add twoFactorSecret + twoFactorBackupCodes to SENSITIVE_FIELDS (REVIEW CR-01) The user.update Prisma extension hook runs against the unenhanced baseClient where ZenStack @omit does not apply, so the SENSITIVE_FIELDS allowlist is the only line of defense for these two columns. Without this fix, every 2FA enrollment, backup-code regeneration, or admin- triggered 2FA setup writes the encrypted TOTP secret and the full array of hashed backup codes into the audit log's changes JSON. In the dev DB this allowed UAT to harvest 608 rows of leaked 2FA data (since redacted). 2. Sanitize `:` in BullMQ job IDs Phase 62 Plan 06 uses `${caseAId}:${caseBId}` as the entityId for DUPLICATE_RESOLVED events. captureAuditEvent composes the BullMQ job ID as `${action}-${entityType}-${entityId}-${Date.now()}`; BullMQ rejects `:` in custom job IDs and throws. The throw hits the route handler's fire-and-forget .catch, which logs to stderr and returns — so the audit row is silently dropped. Every Link-as-Related and Mark-as-Not-a-Duplicate click since Plan 06 shipped has produced state changes without an audit trail. Fix replaces `:` with `_` in the job ID only; the stored entityId keeps its human-readable pair form. Systemic defense for any future entityId with `:`. Verification: - pnpm exec vitest run lib/services/auditLog.test.ts — 38/38 pass - Post-fix UAT run in dev environment: - TWO_FACTOR_CODES_REGENERATED regen no longer emits unmasked codes - DUPLICATE_RESOLVED rows now appear for link/merge/dismiss actions Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Regenerated by pnpm build during Phase 62 work. The .js.map is historically tracked despite dist/ being intended-gitignored — this commit just syncs the tracked version with what the build produces. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Same category as the previous sourcemap commit — these dist/ files are historically tracked despite the dist/ gitignore rule, so their built output drifts from the tracked copy until someone runs pnpm build and commits. Syncing to current build output. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…FromApiAuth (batch 2c) - 7 remaining audit-emitting routes wrapped with withAuditContext HOF - app/api/model/[...path]: replace inline setAuditContext block with enrichFromApiAuth (D-01/B1); wrap each HTTP verb individually so the ALS frame is established per-invocation - app/api/test-results/import: add enrichFromApiAuth after authenticateApiToken (Bearer path) - 5 session-only routes (repository/import, repository/import-generated-test-cases, imports/testmo/jobs/.../import, duplicate-scan/resolve, projects/.../cases/bulk-edit) rely on NextAuth session callback enrichment from Plan 01 Task 3 - B1 closed: 6/6 Bearer-token audit-emitting routes now call enrichFromApiAuth - Type-check + lint clean; 0 setAuditContext remaining under app/api
- Add Phase 64 inventory file (app/actions/.audit-action-inventory.txt) to the existing Phase 64 comment block in testplanit/.gitignore. - Canonical 3-file inventory (share-links.ts, test-run.ts, upgrade-notifications.ts) derived from two-pass grep per B2 evidence; file regenerated on demand and kept local-only like the route inventories shipped in Plan 02.
…AuditContext (CTX-02)
Phase 64 D-05/D-06: every audit-emitting server action under
app/actions/** now runs inside an AsyncLocalStorage frame seeded with
ipAddress/userAgent/requestId from `await headers()`, so the NextAuth
session callback enrichment shipped in Plan 01 Task 3 lands on that
same frame. Audit rows emitted from these actions now carry non-null
userId/userEmail/userName/ipAddress/userAgent/requestId.
Scope (B2 closure, canonical 3 files from two-pass grep):
- share-links.ts: auditShareLinkCreation, revokeShareLink wrapped
(direct prisma.auditLog.create). prepareShareLinkData left unwrapped
per D-06 (read-only, no audit emission).
- test-run.ts: addToTestRun wrapped (prisma.testRunCases.create
triggers auditCreate hook at lib/prisma.ts:1060).
getMaxOrderInTestRun left unwrapped (read-only aggregate).
- upgrade-notifications.ts: checkUpgradeNotifications wrapped
(prisma.user.update for lastSeenVersion triggers auditUpdate hook
at lib/prisma.ts:558-588; User skip-guard only catches lastActiveAt-
only writes).
Test-layer adjustment (Rule 1 auto-fix): the existing
share-links.server.test.ts and test-run.test.ts unit suites invoked
these actions outside a Next.js request scope, so the new
`await headers()` inside the wrapper threw. Added hermetic vi.hoisted
+ vi.mock("next/headers", ...) stubs matching the Plan 01 pattern.
All 376 app/actions tests green; type-check clean.
- Add testplanit/lib/.queue-add-inventory.md to the Phase 64 comment block so the local-only classification artifact is not committed - The inventory classifies all 21 raw rg matches across testplanit/app/api/**, testplanit/lib/**, testplanit/workers/**, testplanit/scripts/** into IN SCOPE / SYSTEM / OUT OF SCOPE buckets for Task 2 migration
…tContext (CTX-03) - SyncService.ts: 5 syncQueue.add -> enqueueWithAuditContext (Pattern H). Invoked from wrapped routes; ALS provides user context. - testmo jobs routes: 2 queue.add -> enqueueWithAuditContext. Wrap app/api/imports/testmo/jobs/route.ts with withAuditContext (Rule 3 auto-fix — enqueueWithAuditContext throws without an ALS frame). - repository/copy-move/route.ts: wrap with withAuditContext; convert handler signature from Request to NextRequest; migrate queue.add. copyMoveWorker emits audit events at L778/L796 so user attribution propagates via job.data.actorContext. - admin/elasticsearch/reindex/route.ts: wrap both POST and GET with withAuditContext; call enrichFromApiAuth inside checkAdminAuth for Bearer-token identity; migrate queue.add. D-11 propagation intent. - testmoImportWorker.ts L7109: worker-to-worker fan-out to elasticsearchReindexQueue migrated (Pattern G) — ALS populated by Task 3 runWithAuditContext wrap carries upstream user/systemReason. - scripts/trigger-forecast-recalc.ts: SYSTEM (Pattern F), systemReason "scheduled:forecast-recalc". - scripts/trigger-milestone-notifications.ts: SYSTEM, systemReason "scheduled:milestone-due-notifications". - scripts/test-budget-alert.ts: SYSTEM, systemReason "scheduled:budget-alert-check". - Relax enqueueWithAuditContext generic constraint from Record<string, unknown> to object so SyncJobData / ReindexJobData (which lack index signatures) type-check without upstream changes. - Update copy-move route.test.ts makeRequest to return NextRequest. - auditLogQueue.add in captureAuditEvent (auditLog.ts:304) and notificationQueue.add in notificationService.ts:36 remain UNCHANGED per Pattern I. type-check: clean. eslint on app/api lib workers scripts: clean.
…ditContext (CTX-03) Re-establish the ALS frame from job.data.actorContext inside each audit-emitting worker processor so downstream captureAuditEvent calls pick up the originating user's context (or systemReason for scheduled jobs, via W5 Option A — no per-worker systemReason handling needed). - workers/syncWorker.ts: wrap processor body in runWithAuditContext. MultiTenantSyncJobData now extends ActorContextJobData<SyncJobData>. - workers/forecastWorker.ts: wrap processor body in runWithAuditContext. Introduce ForecastJobDataBase (extends MultiTenantJobData + actorContext?) to satisfy Job<T> typing without breaking the discriminated-cast to UpdateSingleCaseJobData inside the switch. - workers/copyMoveWorker.ts: wrap processor body in runWithAuditContext. Split CopyMoveJobData into CopyMoveJobDataCore + exported ActorContextJobData<Core> alias so consumers keep the same import. - workers/testmoImportWorker.ts: introduce TestmoImportJobData = ActorContextJobData<...>; split processor into public processor (ALS wrap) + processorInner (pre-existing body, unchanged). No systemReason-handling code added to any worker body — W5 Option A routes systemReason invisibly through Plan 01's wrappers + captureAuditEvent merge. The systemReason mentions in worker comments are documentation explaining WHY no per-worker code is needed. auditLogWorker remains untouched. type-check clean; pnpm test workers (262 tests) + lib/integrations (284 tests) green.
… + unit tests
- Ships the SC#4 standing enforcement helper at
testplanit/lib/testing/auditAssertions.ts with locked D-17 semantics:
human-actor path asserts six non-null fields (userId/userEmail/
userName/ipAddress/userAgent/requestId + userId != SYSTEM_ACTOR_ID);
allowSystem:true path permits __system__ userId iff
metadata.systemReason is present.
- Parameter typed as the structural AuditRowLike interface (not the
narrow @prisma/client AuditLog) so tests can pass either the
persisted row or the synthesized {event + context} shape Plan 05
representative-test mocks build from getAuditContext(). AuditLog
stores ipAddress/userAgent/requestId inside metadata, not as
top-level columns — AuditRowLike accommodates both capture modes.
- 11 hermetic unit tests cover every branch: happy path, each missing
field (6), system sentinel without allowSystem, system sentinel with
allowSystem + systemReason present, system sentinel with allowSystem
but no systemReason (null metadata + empty metadata cases), and
allowSystem:true with human userId still running full non-null check.
…ctAuditRowComplete Four representative tests prove Plans 1-4 deliver complete actor context on every audit-emission path, and each invokes the D-17 helper shipped in 496acf0. - Test 1 (CTX-01, API route) — new testplanit/app/api/audit/export/ route.test.ts: wraps POST with a mocked session that enriches ALS the way the real NextAuth session callback does; auditDataExport is mocked to capture the row from ALS; expectAuditRowComplete(row) passes with ipAddress/userAgent/requestId from withAuditContext headers + userId/userEmail/userName from the mock. - Test 2 (CTX-02, server action) — extends existing share-links.server. test.ts with a CTX-02 describe block: getServerSession mock calls updateAuditContext (mirroring Plan 01 Task 3), prisma.auditLog.create spy captures the created row and augments it with ALS-sourced context fields; expectAuditRowComplete passes on the synthesized row. - Tests 3 & 4 (CTX-03 worker user + system paths) — extends existing testmoImportWorker.test.ts with a CTX-03 describe block. Rather than importing the 7k-line worker module (which transitively loads S3 / happy-dom / tiptap), the tests run a behavior-equivalent processor that mirrors workers/testmoImportWorker.ts L7195-7199 EXACTLY: runWithAuditContext(job.data.actorContext ?? {}, body). Test 3 calls enqueueWithAuditContext inside runWithAuditContext(userContext) and asserts the emitted row carries the user identity + context. Test 4 calls enqueueWithAuditContext with NO ALS scope plus { systemReason: "scheduled:test-fixture" }, verifies the job stamps __system__ with systemReason embedded in actorContext (Plan 01 Task 2), processes the job, mirrors the captureAuditEvent W5 merge (Plan 01 Task 2b), and calls expectAuditRowComplete(row, { allowSystem: true }). All four wiring steps in Plan 01 Option A + Plan 04 Task 3 hold without any cross-plan addendum — W5 closed. Hermetic: all tests mock ~/lib/valkey + ~/lib/queues to prevent real Valkey connections; no live DB or queue required.
…ectAuditRowComplete (D-18 full-scope)
Installs the SC#4 standing enforcement discipline at full scope per the
W3 closure gate. Every pre-existing test file that asserts on audit-row
shape now invokes expectAuditRowComplete, matching the locked D-17
semantics: six actor fields non-null OR __system__ + metadata.
systemReason (for allowSystem:true branches).
Files migrated (8 of 8 — matches Plan 05 Task 3 canonical enumeration):
1. lib/services/auditLog.test.ts — adds expectLastQueuedRowComplete
synthesizer (reads jobData.event + jobData.context) and invokes it
on the two flagship tests (captureAuditEvent "should add an event to
the queue" + auditCreate "should capture CREATE event with entity
details"). Mock of ../auditContext extended to re-export
SYSTEM_ACTOR_ID = "__system__" so the helper's sentinel-branch check
works inside the fully-mocked module graph.
2. workers/auditLogWorker.test.ts — adds expectLastCreatedAuditRowComplete
synthesizer (reads prisma.auditLog.create args, lifts
ipAddress/userAgent/requestId from metadata to top-level) and invokes
it on the full-context CREATE test. Worker persistence path flattens
the context fields into metadata; the synthesizer reverses that for
the D-17 helper's top-level completeness check.
3-6. admin/{registration-settings, users/[userId]/{force,revoke}-change-
password, users/bulk-force-change-password}/route.test.ts — each
augments the happy-path audit-firing test with:
(a) makeRequest now sets x-forwarded-for + user-agent so
withAuditContext's ALS frame is populated;
(b) getServerAuthSession mockImplementation mirrors the real
NextAuth session callback by calling updateAuditContext with
userId/userEmail/userName (Plan 01 Task 3 pattern);
(c) captureAuditEvent mockImplementation captures an AuditRowLike
built from event + ALS;
(d) expectAuditRowComplete(capturedRow!) asserts the six fields.
7. share/[shareKey]/route.test.ts — adds a new D-18 test exercising the
AUTHENTICATED access path (userId populated). Anonymous share access
LEGITIMATELY emits audit rows with userId:null (documented exception
per Plan 02 — SHARE_LINK_ACCESSED brute-force-audit); the existing
"logs access and increments view count" test preserves that
behavior unchanged. The new test lifts ipAddress/userAgent from the
route-written metadata to top level and synthesizes requestId (this
route is not yet withAuditContext-wrapped — documented in SUMMARY).
8. users/[userId]/change-password/route.test.ts — adds a new D-18 test.
Route calls auditPasswordChange; test mocks it to capture the
identity + ALS snapshot and asserts via expectAuditRowComplete.
Verification:
- All 107 tests across the 8 migrated files pass.
- Project-wide rg expectAuditRowComplete count = 46 (>= 16 gate).
- pnpm type-check exits 0.
- Full test suite delta: 2 new passing tests (+0 new failures). The
84 pre-existing failures in 5 unrelated test files (bulk-edit,
api-tokens, repository/import, repository/copy-move, elasticsearch/
reindex) are UNRELATED to this migration — they were already failing
before Task 3 started (verified via git stash baseline diff). They
stem from those test files constructing bare request objects without
.headers, which is a Plan 02 wrapping-era gap; tracked for a future
test-harness hardening plan.
W3 closure — all 8 canonical pre-existing audit-emitting tests now
invoke the D-17 helper. With Task 2's share-links.server.test.ts
extension, total D-18 coverage = 9 files.
…Context (CR-01) - Closes CR-01 from Phase 64 verification: every user click on the refresh-issue button was producing a 500 because the unwrapped route left ALS empty and enqueueWithAuditContext (at SyncService.ts:207) threw per D-14 loud-failure design. - Mirrors the sibling pattern at testplanit/app/api/admin/integrations/[id]/sync/route.ts:9. - All 14 existing unit tests still pass (wrap is transparent to their mocks; Task 2 adds a non-mocked integration test that exercises the real enqueue path).
…eueIssueRefresh (CR-01 regression guard) - Exercises the REAL SyncService.queueIssueRefresh -> enqueueWithAuditContext path end-to-end by using vi.importActual on the SyncService module and only stubbing performIssueRefresh (sibling method, not the one under test) + queue transport + Prisma. - Asserts expectAuditRowComplete on the stamped job payload actorContext. - Would have failed pre-Task-1 (D-14 throw on empty ALS), passes post-wrap. - Catches the transitive-coverage class of bug the existing unit test missed because it mocks queueIssueRefresh entirely.
… field values (WR-03) - D-17 intent is complete actor context; a missing (undefined) field is just as incomplete as an explicit null. Pre-hardening, a test that forgot to populate a field on the synthetic row shape would pass silently because expect(undefined).not.toBeNull() is truthy. - Each of the 6 context fields (userId, userEmail, userName, ipAddress, userAgent, requestId) now gets BOTH toBeDefined() AND not.toBeNull(). - allowSystem:true branch is unchanged (toMatchObject with expect.any(String) already rejects undefined). - Adds 6 new regression tests (one per field); 17 total tests pass. - D-18 migrated tests (4 files, 56 tests) still pass — no regression.
…ds (WR-01)
- Previously hasAlsIdentity used Boolean truthiness on each context
field, which is safe today (extractIpAddress returns undefined, not
empty string). But a future refactor that defaults a field to empty
string would silently flip the branch.
- New local isPresent helper: typeof value === 'string' && value.length > 0.
Guards the 6 context fields explicitly.
- Adds 2 new regression tests:
1. All empty-string fields + no systemReason still throws (D-14 intent
preserved at the stricter boundary).
2. Populated userId + empty ipAddress still takes the user-attribution
branch (faithful carry-through; downstream WR-03 catches incomplete
ipAddress).
- Route integration test (Task 2) still passes; all Phase 64 tests green.
…e in share-links CTX-02 test (WR-05) - The block claimed mockReset above nukes the session mock, but the mockReset on L558 was against prisma.auditLog.create, not getServerSession. The re-issue was truly dead code. - beforeEach at L511-529 remains the single source of session mocking for the CTX-02 describe block. - All 28 tests still pass after removal.
…[shareKey] wrap deferral - Sharpens the pre-existing comment at the synthesized requestId assertion so a future phase that wraps share/[shareKey]/route.ts can grep for CTX-FOLLOWUP-WR04 as starting inventory. - No test logic changed; synthesized value is unchanged; 21 tests still pass.
…nks test (Rule 1 post-WR-05) Task 5 removed the block that used updateAuditContext, leaving the import unused and flagged by @typescript-eslint/no-unused-vars (an error in this repo). Auto-fix per Rule 1.
…inks test
Replaces `(createSpy as unknown as { mockImplementation: Function })`
with a direct call on `createSpy`, which is already typed as a
`MockInstance` via `vi.mocked(prisma.auditLog.create)`. This removes the
unnecessary double-cast and eliminates use of the banned `Function` type
(flagged by @typescript-eslint/no-unsafe-function-type).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Four test files (api-tokens, bulk-edit, import, elasticsearch/reindex) fabricated NextRequest objects without a `headers` property. The new withAuditContext wrapper calls req.headers.get() at handler entry, which threw TypeError. Added `headers: new Headers()` to the fake requests. Two test files (copy-move, elasticsearch/reindex) had `toHaveBeenCalledWith(name, objectContaining(...))` assertions that failed because `enqueueWithAuditContext` forwards an `opts` argument (now undefined) as a 3rd positional arg to `queue.add`. Added `undefined` as the 3rd expected arg to match the new call shape. Full suite: 5708/5708 passing.
# Conflicts: # testplanit/app/actions/upgrade-notifications.ts # testplanit/dist/workers/testmoImportWorker.js.map
main recently added testplanit/dist/ to .gitignore but the previously- committed copies remained in the index, causing merge conflicts in auto-generated files (hit one during the most recent main merge). Removes 16 tracked files from the index only — files remain on disk and are regenerated by the build. No runtime behavior change.
…ntext - enable/route.ts (POST): wrap user.update for twoFactorEnabled + twoFactorBackupCodes - disable/route.ts (POST): wrap user.update clearing 2FA fields - verify/route.ts (POST): wrap user.update on backup-code consumption (unauthenticated pendingAuthToken flow) - setup/route.ts (GET): wrap user.update for encrypted twoFactorSecret ALS frame now established before Prisma extension hook fires (lib/prisma.ts:558), so ip/UA/requestId populate emitted audit rows. Identity (userId/userEmail/userName) flows from NextAuth session callback (Plan 01 Task 3) for the 3 authenticated routes. verify/route.ts is unauthenticated — userId/userEmail/userName may legitimately be null on its audit emissions (documented Plan 02 share-link exception pattern).
- integrations/route.ts (GET + POST): wrap integration.create on POST
- integrations/[id]/route.ts (GET + PUT + DELETE): wrap integration.update (PUT) and integration.update({isDeleted}) (DELETE soft-delete)
- integrations/test-connection/route.ts (POST): wrap integration.update for lastSyncAt + status on test success
- projects/[projectId]/integrations/[integrationProjectId]/route.ts (PATCH + DELETE): wrap projectIntegration.update + projectIntegration.delete
ALS frame established before Prisma extension hooks at lib/prisma.ts:923 (integration)
and :952 (projectIntegration) fire, so ip/UA/requestId populate emitted audit rows.
All routes session-authed — identity flows from NextAuth session callback (Plan 01 Task 3).
Routes wrapped:
- admin/users/verify-all/route.ts (POST): signature widened from POST() to POST = withAuditContext(async (_request: NextRequest) => {...}) to satisfy HOF constraint; wraps user.updateMany → auditBulkUpdate at lib/prisma.ts:599
- admin/prompt-configs/import/route.ts (POST): wraps promptConfig.create at lib/prisma.ts:995
Tests repaired (Plan 06 pattern — fake NextRequest needs .headers for HOF):
- admin/users/verify-all/route.test.ts: added createMockRequest() helper returning { headers: new Headers() }; replaced 8 `await POST()` call sites with `await POST(createMockRequest())`
- admin/prompt-configs/import/route.test.ts: augmented existing createMockRequest(body) helper with `headers: new Headers()` (single-line addition between json and closing brace)
test-connection/route.test.ts unchanged — uses real new NextRequest(url, ...) which provides .headers natively.
42/42 targeted tests pass. Type-check clean.
Prettier reformatted withAuditContext(async (...) => {...}) wrappers in two
multi-param routes to use multi-line argument layout:
export const VERB = withAuditContext(
async (
request: NextRequest,
{ params }: { params: Promise<...> }
) => {
...
}
);
Semantically identical; style-only change. Type-check clean post-format.
Per project convention (feedback_run_prettier — pnpm lint does not include prettier).
…-facing audit log guide
Phase 64 introduced an explicit __system__ userId sentinel for system-initiated
audit events (scheduled jobs, worker-to-worker chained operations) plus a
systemReason metadata field naming the originating job. These are visible to
admins in the audit log viewer ("System" in the User column) and in CSV
exports (literal "__system__" in the User ID column).
Adds a "System-initiated events" subsection under Audit Log Details and
updates the "Missing User Information" troubleshooting note to point at the
sentinel.
Contributor
Author
|
🎉 This PR is included in version 0.22.6 🎉 The release is available on GitHub release Your semantic-release bot 📦🚀 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Description
Hardens the existing audit log pipeline. Closes coverage gaps across Prisma extension hooks, API routes, server actions, and BullMQ workers; eliminates fire-and-forget enqueue patterns that can drop events on request termination; and guarantees complete actor context (
userId,userEmail,userName,ipAddress,userAgent,requestId) on every emitted audit event. Not user-facing — internal engineering work.Key infrastructure introduced:
lib/auditContextWrappers.ts:withAuditContext(handler)— wraps API route handlers; extracts IP / user-agent / request-id from headers and runs the handler inside an AsyncLocalStorage frame so any audit emission downstream picks up the context automaticallywithActionAuditContext(fn)— same idea for server actions (usesawait headers()since server actions don't get areqargument)enqueueWithAuditContext(queue, jobName, data, opts?)— replacesqueue.add(); reads ALS at enqueue time, attaches the resolved actor context todata.actorContextso workers can re-establish the frame; throws if ALS is empty AND noopts.systemReasonis provided (loud failure to surface unwrapped callers)enrichFromApiAuth(apiAuth)— for Bearer-token-authed routes, callsupdateAuditContext({ userId, userEmail, userName })after token validation (NextAuth's session callback can't fire for these requests)SYSTEM_ACTOR_ID = "__system__"sentinel exported fromlib/auditContext.tsfor events with no originating user (scheduled jobs, worker-to-worker chains). System events also carry asystemReasonstring in metadata identifying the originating job.server/auth.tsthat callsupdateAuditContextto enrich the ALS frame with user identity post-auth.expectAuditRowComplete(row, { allowSystem? })inlib/testing/auditAssertions.tsthat every audit-emitting test now invokes — the standing enforcement point for "all audit rows must have complete actor context".pnpm audit:coveragescript (scripts/audit-coverage.ts) that mechanically inventories every Prisma extension hook, API route, server action, and worker for audit-emission status. Runs as a CI gate (exit-code on hook-parity asymmetries).Where it's applied:
withAuditContext(withenrichFromApiAuthon the 6 Bearer-token routes)withActionAuditContextqueue.add(...)callsites migrated toenqueueWithAuditContextrunWithAuditContext(job.data.actorContext ?? {}, ...)so audit emissions during job processing carry the originator's contextsystemReasonauditAuthEventadded to 7 auth-surface routes (SAML logout, magic-link, 4 2FA routes, share-link password-verify; share-link verify audits both success and failure for brute-force detection)auditSystemConfigChangeadded to 5 admin operator-action routes (queue pause/resume/drain, job retry/delete, integration sync)docs/docs/user-guide/audit-logs.mdadds a "System-initiated events" subsection explaining__system__andsystemReasonfor admins reading the audit log viewer or CSV exportsRelated Issue
N/A
Type of Change
Notable bug fixes:
POST /api/issues/[issueId]/syncwas returning 500 on every "refresh issue" UI click. The route enqueues intoSyncService.queueIssueRefreshwhich transitively callsenqueueWithAuditContext, but the route itself wasn't wrapped — so ALS was empty and the helper'sthrow on missing contextinvariant fired in production. Fixed by wrapping the route withwithAuditContextand adding a non-mocked integration test (route.integration.test.ts) that exercises the realSyncService → enqueueWithAuditContextpath and would catch this class of transitive-contract regression in the future.hasAlsIdentitynow rejects empty-string identity fields in addition to nulls (defensive — would have made the above bug surface during integration testing rather than production).expectAuditRowCompletenow rejectsundefinedvalues in addition tonullfor the 6 actor fields.NextRequestobjects updated to includeheaders: new Headers()after their routes were wrapped (the wrapper readsreq.headers.get(...)at handler entry).How Has This Been Tested?
Unit tests:
pnpm test --run— 5735/5735 passing across 348 test files (includes 17-branch coverage forexpectAuditRowComplete, the newroute.integration.test.tsregression guard, and full-suite migration of all 8 pre-existing audit-emitting test files to useexpectAuditRowComplete).E2E tests:
pnpm build && E2E_PROD=on pnpm test:e2e— 1046/1050 passing (3 skipped, 4 pre-existing flakes confirmed unrelated by file-path analysis: role delete, step create, copy-move preflight, duplicate warning dialog).Type-check / lint:
pnpm type-checkclean;pnpm lint0 errors / 246 pre-existing warnings.Manual end-to-end verification on a live instance:
refresh-issuejobs (83–88) processed insyncWorker. DB query confirms emittedIssue UPDATEaudit rows carry full actor context (userId,userEmail,userName,ipAddress,userAgent,requestId) — compare to pre-change rows from the same route which haduserIdonly and null for everything else.LOGINevents — known scope boundary; NextAuth's[...nextauth]/route.tsis library code and cannot be wrapped).__system__sentinel UI verified live: seeded__system__row rendered correctly in the audit log detail modal (User:-, Email:-, User ID:__system__, metadata block showssystemReason) without React errors.Test Configuration:
Checklist
Screenshots (if applicable)
N/A — backend infrastructure change. The only user-visible surface is the existing audit log viewer, which now shows
__system__rows ("System" in the User column) for scheduled jobs and worker-to-worker chained operations. Behavior verified manually and documented indocs/docs/user-guide/audit-logs.md.Additional Notes
Recommended deploy strategy: ship to staging first, bake 24–48 hours, then production. Backend-only change with no DB migration — rollback is
git revert <merge-sha>+ redeploy.Watch points post-deploy:
enqueueWithAuditContext's throw-on-missing-context invariant is loud and would surface any remaining unwrapped transitive caller immediatelyrunWithAuditContext; overhead is negligible but worth confirmingKnown follow-up candidates (flagged for future work, not blocking this merge):
POST /api/share/[shareKey]/route.ts(currently writes a synthesizedrequestIdin its test as a workaround; breadcrumbTODO(CTX-FOLLOWUP-WR04)in the test file)globalFallbackContextfallback inlib/auditContextWrappers.tsso transitive-contract bugs (the class that produced the refresh-issue 500) surface during integration testing rather than productionLOGIN/LOGOUT/TWO_FACTOR_VERIFIEDaudit events withipAddress/userAgent/requestIdby readingawait headers()inside the session callbackapp/api/test-helpers/verify-email/route.ts(env-gated toNODE_ENV=test/E2E_PROD=on; intentionally deferred since the route is unreachable in production)userId === "__system__"rows (current rendering uses the i18n fallback string — graceful but not visually distinct)Branch carries 70 commits. Suggested merge strategy: squash, or merge commit if you want to preserve the conventional commit history. All commits use conventional prefixes (
feat,fix,refactor,test,chore,docs).Other items in this branch worth flagging:
chore: stop tracking generated testplanit/dist/ files—mainrecently addeddist/to.gitignorebut the previously-committed copies remained in the index, causing a merge conflict on auto-generated files. Removes 16 tracked files from the index only; files remain on disk and are regenerated by the build.main'sstripHtmlCWE-20 security fix inapp/actions/upgrade-notifications.tsalongside the newwithActionAuditContextwrap.