feat(audit): track user management events in audit log (#336) #337
Reference in New Issue
Block a user
Delete Branch "feat/issue-336-audit-user-management"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
USER_CREATED,USER_DELETED,GROUP_MEMBERSHIP_CHANGEDtoAuditKind(no schema migration needed — stored asEnumType.STRING)AuditServiceintoUserService; emitslogAfterCommitincreateUserOrUpdate,deleteUser,adminUpdateUser— audit entries only persist if the transaction commitsGROUP_MEMBERSHIP_CHANGEDpayload contains group names, not permission strings; only emitted when the group set actually changesfindRecentUserManagementEvents(int limit)toAuditLogQueryService— ready for #326 to consume asfindRecentUserManagementEvents(3)Test plan
UserServiceTest— 6 new unit tests covering all branches (log / don't-log) for the three service methodsAuditLogQueryServiceTest— 1 unit test verifyingfindRecentByKindsis called with all three new kindsUserManagementAuditIntegrationTest— integration test: create user → delete user → query → assertUSER_DELETEDnewest,USER_CREATEDsecond./mvnw test→ 1362 tests, 0 failuresCloses #336
dd1bd837adto77affcfb4f👨💻 Felix Brandt — Senior Fullstack Developer
Verdict: ⚠️ Approved with concerns
TDD evidence is strong — the 7 new unit tests and the integration test clearly cover every branch. Test names are descriptive and read as sentences. Awaitility instead of
Thread.sleepis the right call. Good stuff overall. Two things I'd push back on.Blockers
adminUpdateUserviolates SRP (UserService.java, lines 158–202)The method now does three things: updates scalar fields, computes a group membership diff, and emits an audit event. The group diff block is 18 lines of
beforeIds/newGroups/afterIds/added/removed— complex enough to have its own name and test.Extract it:
Then
adminUpdateUserjust callsgroupChangePayload(...).ifPresent(payload -> auditService.logAfterCommit(...)). The helper is unit-testable in isolation andadminUpdateUserreads in one pass.Suggestions
DRY violation in controller (
UserController.java,createUser,adminUpdateUser,deleteUser)All three methods open with the same two lines:
Extract to a private helper:
One call site per method, zero repetition. Not a blocker since it's three methods, but the pattern will spread.
isNewflag is readable but the control flow could be tighter (UserService.java, lines 52–83)The
isNewboolean set inside anif/elseand checked 12 lines later is fine, but thecreateUserOrUpdatemethod is already ~40 lines with the audit addition. IfadminUpdateUsergets its private helper, consider splittingcreateUserOrUpdateintocreateUserandupdateExistingUser— the "or update" semantics are already awkward when only the create path audits.🏗️ Markus Keller — Application Architect
Verdict: ✅ Approved
The structural choices here are sound.
AuditServiceis injected intoUserServicevia constructor — service calls service, no repository leakage. The newAuditKindvalues live in theauditfeature package.findRecentUserManagementEventsprovides a clean API boundary for #326 to consume without coupling it to the audit internals. Module boundaries are respected throughout.Notes (non-blocking)
LIMIT :limitin JPQL is a Hibernate HQL extension, not portable JPQL (AuditLogQueryRepository.java)This works because Spring Boot 4 uses Hibernate 6, which accepts
LIMITin HQL. But it's a Hibernate-specific extension and the:limitbinding is non-standard JPQL. The more idiomatic Spring Data approaches are:Option A is the simplest drop-in. Option B gives #326 pagination for free. Either avoids the HQL-specific
LIMIT :limitbinding. Not a blocker — Hibernate 6 supports it — but worth normalising.Extra DB lookup per admin operation (
UserController.java)Each of the three modified endpoints now does
userService.findByEmail(authentication.getName())before the main operation. For an admin-only path this is low frequency, so not a performance concern. Just worth being aware that if the actor lookup fails (user deleted between login and request), the endpoint throwsUSER_NOT_FOUNDrather than serving the admin action. This is the correct fail-closed behaviour, just document it if it surprises someone during an incident.nullactorId accepted bylogAfterCommitThe integration test deliberately passes
nullactorId for the bootstrap admin creation. The productioncreateUserOrUpdatealso passesnullas actorId when creating the initial admin (it receives null from the test). In production, the controller always provides a real actorId, so this only surfaces during seeding or tests. Acceptable, but a@Nullableannotation on theactorIdparameter inlogAfterCommit's signature would make the contract explicit.🔒 Nora "NullX" Steiner — Application Security Engineer
Verdict: ⚠️ Approved with concerns
No new injection vectors. Parameterized JPQL.
@RequirePermission(ADMIN_USER)is already on all three modified endpoints. The actor identity is resolved server-side from theAuthenticationprincipal — the client cannot supply a fake actor. These are all correct.Two concerns, one of which I'd call a blocker for an audit trail.
Blockers
No permission-enforcement test for the modified endpoints
The PR adds actor-resolution logic to
createUser,adminUpdateUser, anddeleteUser, but there is no@WebMvcTesttest verifying that a caller withoutADMIN_USERgets a 403. Existing tests mock the service and never exercise the@RequirePermissionAOP path. A missing@RequirePermissionannotation is silent — it compiles fine and returns 200 to everyone.Minimum regression test for each endpoint:
Without this, removing the annotation by mistake would go undetected until manual testing.
Suggestions
PII in audit payload — document the data retention decision
The
USER_CREATED,USER_DELETED, andGROUP_MEMBERSHIP_CHANGEDpayloads all includeemail. This is correct for a useful audit trail, but it means theaudit_logtable now stores personally identifiable data that must be covered by the GDPR retention policy. Since this is a family archive with European users, this matters.Suggested: add a comment in
AuditKind.javaor the migration for theaudit_logtable noting that email-containing events are subject to the data retention policy. Not a code fix — a documentation marker so future-you doesn't have to rediscover this.nullactorId weakens audit trail attributionlogAfterCommit(AuditKind.USER_CREATED, null, null, payload)is called when the bootstrap admin creates itself (createUserOrUpdate(null, adminReq)in the integration test). In production the controller always provides a real actorId, so this is low-risk. But if someone callscreateUserOrUpdatedirectly from a migration or data-seeding script without an actorId, the audit entry is unattributable. Consider a distinctAuditKind.USER_CREATED_SYSTEM(no actorId required by design) versusUSER_CREATED(actorId required, throw if null). This makes the contract unambiguous rather than silently nullable.🧪 Sara Holt — QA Engineer & Test Strategist
Verdict: ⚠️ Approved with concerns
The test coverage is good at the unit layer — 7 new unit tests with clear names, proper Arrange-Act-Assert, ArgumentCaptor used correctly. The integration test uses Awaitility (not
Thread.sleep), hits a real Postgres container, and verifies ordering. All strong positives.Two gaps I'd flag before this ships.
Blockers
No integration test for
GROUP_MEMBERSHIP_CHANGEDUserManagementAuditIntegrationTestcovers the create→delete sequence. ButGROUP_MEMBERSHIP_CHANGEDis the most complex event: it fires only when the group set actually changes, it computes added/removed lists from set differences, and the payload is aMap<String, Object>with heterogeneous value types (StringandList<String>). The unit test (adminUpdateUser_logsGroupMembershipChanged_whenGroupSetChanges) verifies the service logic against a mock, but it does not verify that the payload serializes correctly through the full stack (service → AuditService → async write →audit_logrow → query).Suggested addition to the integration test:
Suggestions
@DirtiesContext(AFTER_EACH_TEST_METHOD)will hurt CI as the class growsRight now
UserManagementAuditIntegrationTesthas one test method, so the cost is hidden.AFTER_EACH_TEST_METHODtears down and rebuilds the entire Spring application context after each test — the Postgres Testcontainer is shared (static), but the Spring context rebuild adds 5–10 seconds per test on this project.The integration test clears audit log rows manually with
auditLogRepository.deleteAll()mid-test. The same pattern works as a@BeforeEach:With that,
@DirtiesContextcan be dropped entirely. Test isolation becomes explicit without the context rebuild penalty.Wait condition relies on absolute
count()After the
deleteAll()mid-test, this is correct. But if a background process or another async operation writes toaudit_log, a stray row could satisfy the count before the expected event arrives. Prefer a condition that checks for the specific expected event:This makes the wait semantically precise and immune to noise from other test-generated audit entries.
🚀 Tobias Wendt — DevOps & Platform Engineer
Verdict: ✅ Approved
No infrastructure changes in this PR — no Compose changes, no new CI steps, no Dockerfile modifications, no new environment variables, no secrets handling. From an operations perspective, the feature piggybacks entirely on the existing
audit_logtable and the existing async infrastructure, so there is nothing new to operate.One observation for CI maintainability:
@DirtiesContext(AFTER_EACH_TEST_METHOD)adds rebuild cost to CIUserManagementAuditIntegrationTestusesDirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD. Each Spring context rebuild on this project takes ~8–12 seconds on the CI runner. With one test method now it is invisible, but the class will grow. Sara's note in the QA review covers the fix — a@BeforeEachcleanup of the audit log rows removes the need for it.Everything else here is routine backend Java. The new test class uses
PostgresContainerConfig(shared Testcontainer), the@MockitoBean S3Clientcorrectly stubs out the MinIO client, and the@ActiveProfiles("test")is consistent with every other integration test. CI should pick this up cleanly.LGTM on the infra side.
🎨 Leonie Voss — UX Designer & Accessibility Strategist
Verdict: ✅ Approved
This PR is entirely backend — new
AuditKindvalues, service-layer audit emission, a new query method, and tests. There are no.sveltefiles, no Tailwind classes, no HTML markup, and no user-facing strings changed. Nothing to review from a UI, accessibility, or brand-compliance perspective.One forward-looking note for when #326 surfaces these events in the admin panel:
The
GROUP_MEMBERSHIP_CHANGEDpayload includesaddedGroupsandremovedGroupsas arrays of group names. When this reaches the UI, the display pattern matters for the senior audience (60+): a plain["Admin", "Editors"]list dumped as text is illegible. I'd expect a diff-style representation:+Adminin brand-mint (with a+prefix oraria-label="hinzugefügt"— never color alone)−Viewersin a muted red-adjacent tone (again, icon + color, not color only)Flag this when #326 is spec'd — happy to provide the exact Tailwind classes and WCAG contrast values at that point.
LGTM on this PR.
Review concerns addressed
All blockers and actionable suggestions from the six-persona review have been resolved. Summary of changes pushed to this branch:
Blockers resolved
Felix — SRP violation in
adminUpdateUser✅Extracted the 18-line group diff block into a private
groupChangePayload(Set<UserGroup> before, Set<UserGroup> after, UUID userId, String email) → Optional<Map<String, Object>>helper.adminUpdateUsernow calls.ifPresent(...)— single responsibility restored.→ commit
refactor(audit): extract groupChangePayload() from adminUpdateUserNora — No permission enforcement tests ✅
Added three
@WebMvcTesttests toUserControllerTestverifying thatcreateUser,adminUpdateUser, anddeleteUsereach return 403 when the caller lacksADMIN_USER. Follows the existinggetUser_returns403_*pattern.→ commit
test(audit): add 403 permission tests for createUser, adminUpdateUser, deleteUserSara — No integration test for
GROUP_MEMBERSHIP_CHANGED✅Added
updateUserGroups_producesGroupMembershipChangedEvent()toUserManagementAuditIntegrationTest. Creates two groups and a target user pre-assigned to group A, changes to group B viaadminUpdateUser, awaits the event, then asserts kind, email,addedGroups, andremovedGroupsfrom the DB-round-tripped JSONB payload.→ commit
test(audit): add GROUP_MEMBERSHIP_CHANGED integration test with payload assertionsSuggestions implemented
Felix — DRY in controller ✅
Extracted
actorId(Authentication)private helper inUserController— three methods now each callactorId(authentication)instead of repeatinguserService.findByEmail(auth.getName()).getId().→ commit
refactor(audit): extract actorId() helper in UserControllerMarkus —
LIMIT :limitHibernate HQL ✅Replaced
findRecentByKinds(@Param("limit") int limit)withPage<AuditLog> findByKindIn(Collection<AuditKind>, Pageable).findRecentUserManagementEventsnow usesPageRequest.of(0, limit, Sort.by("happenedAt").descending()). Standard Spring Data, no Hibernate extension.→ commit
refactor(audit): replace LIMIT :limit JPQL with Pageable in audit querySara / Tobias —
@DirtiesContext(AFTER_EACH_TEST_METHOD)✅Dropped
@DirtiesContext. Added@BeforeEach void clearAuditLog()for between-test isolation without context rebuild.→ commit
refactor(audit): drop @DirtiesContext, add @BeforeEach, use existsByKind in wait conditionsSara — Fragile
count()-based wait conditions ✅Added
existsByKind(AuditKind)toAuditLogRepository. All threeawait().until(() -> count() > 0)/count() >= 2conditions replaced with semantically preciseexistsByKind(USER_CREATED)/existsByKind(USER_DELETED)/existsByKind(GROUP_MEMBERSHIP_CHANGED)checks.→ same commit as above
Test suite
1366 tests, 0 failures (+4 net: 3 × 403 controller tests + 1 group membership integration test).
👨💻 Felix Brandt — Senior Fullstack Developer
Verdict: ✅ Approved
Overall this is clean, disciplined backend work. The implementation is focused, the helpers do one thing, and the test coverage matches the surface area.
Suggestions
actorId()does a DB lookup on every admin callUserController.actorId()callsuserService.findByEmail(auth.getName())which hits the database on everycreateUser,adminUpdateUser, anddeleteUserrequest — just to resolve the acting user's UUID. The Spring SecurityAuthenticationprincipal is already anAppUserobject (loaded byUserDetailsService); you could cast it directly instead:This avoids a redundant SELECT per admin operation. Minor, but worth fixing since it scales with every protected endpoint.
isNewboolean flag increateUserOrUpdateis fine as a local variableIt's a local flag, not a boolean parameter — this doesn't violate the "no boolean args" rule. The branching is clear. No action needed.
groupChangePayload()is a well-extracted private helperClean: compares UUID sets (not entity equality), builds the payload only when a real change exists, returns
Optional.empty()otherwise. This is exactly the right shape for this logic.logAfterCommitwithnullactorId (bootstrap case)The integration test passes
nullasactorIdfor the bootstrap admin creation. The production code path permits this. WhetherAuditService.logAfterCommithandles null gracefully isn't visible in this diff — worth verifying if there's a null check or DB NOT NULL constraint onactor_id. Not a blocker if the service is already null-safe, but worth a glance.Test coverage
Six unit tests in
UserServiceTest, one inAuditLogQueryServiceTest, one full integration test with two scenarios, three 403 permission tests inUserControllerTest. This matches the implementation surface area precisely.🔐 Nora "NullX" Steiner — Application Security Engineer
Verdict: ⚠️ Approved with concerns
The permission model is correct, the audit trail is well-structured, and the 403 tests are a welcome addition. I have two findings that need attention.
Blockers
Finding 1: Null
actorIdaccepted in production code pathUserService.createUserOrUpdate(UUID actorId, CreateUserRequest request)accepts anullactorId and the integration test deliberately passesnull:The test comments this as "bypasses audit logging so no FK issue." This means production code silently accepts a null actor, relying on
AuditService.logAfterCommitto handle it gracefully.The risk: If
AuditServiceor theaudit_logtable has a NOT NULL constraint onactor_id, bootstrap calls will fail at runtime. If it doesn't, any caller that forgets to pass the actorId (passes null) will silently log an un-attributable event with no error. Un-attributable audit events are a compliance gap — the whole point of an audit trail is knowing who did what.Recommendation: Either make
actorId@NonNulland provide a dedicatedcreateUserForBootstrap(CreateUserRequest)without audit, or document thatnullactorId is the explicit "system-initiated" actor with a sentinel UUID (e.g.,SYSTEM_ACTOR_ID = UUID(0, 0)). The current ambiguity is a security smell.Finding 2:
actorId()inUserController— extra DB round-trip + potential NPEIf
findByEmailreturns a user that doesn't exist (e.g., account deleted between auth and request), it will throwDomainException.notFound. That's fail-closed and acceptable. However, this is a hidden assumption: the authenticated principal is always in the database. If theAppUserentity is theUserDetailsimplementation (which is typical with Spring Security), itsgetId()is already available via(AppUser) auth.getPrincipal()without a DB call. This also eliminates the window between auth and actor resolution.Suggestions
Permission tests cover 403 (authenticated, wrong role) — good. Consider also adding a test for 401 (unauthenticated) for each of the three endpoints. These are different security failures: one is "not logged in," the other is "logged in but not authorized." Both should be tested per the defense-in-depth principle.
Audit payload contains email — this is the right choice for audit trails. Group names (not permission strings) in the payload is also the right call: human-readable, less sensitive than internal IDs, and stable for display in an admin UI.
🧪 Sara Holt — Senior QA Engineer
Verdict: ✅ Approved
This is a solid test addition. The right tools are used at each layer, the async pattern is handled correctly, and coverage matches the implementation. A few observations:
What's done well
Awaitility, not
Thread.sleep— the integration test usesawait().atMost(5, SECONDS).until(...)throughout. This is exactly right for async post-commit audit events. No sleep loops.No
@Transactionalon integration test methods — correct. Rolling back the transaction would prevent thelogAfterCommitasync listener from ever firing. ManualtransactionTemplate.execute()is the right pattern here.@BeforeEachclears the audit log — clean slate before every test, no cross-test contamination.Unit tests cover all branches: log / don't-log for all three methods. The
adminUpdateUser_doesNotLogGroupMembershipChanged_whenGroupsUnchangedandwhenGroupIdsIsNulltests are especially important — they verify the no-op path explicitly.Suggestions
Integration test setup is complex and sequential — the test
updateUserGroups_producesGroupMembershipChangedEventhas fivetransactionTemplate.execute()calls with interleavedawait()checkpoints to clear state between phases. This is technically correct but fragile under CI load: if the async event fires slowly (GC pause, CI contention), the 5-second timeout will occasionally trip.Consider using
existsByKindwith a specific kind and payload constraint to be more precise, or increase the timeout to 10 seconds for the integration tests. The pattern is correct; the timeout budget is tight.@SuppressWarnings("unchecked")on payload casts — these are unavoidable sinceMap<String, Object>stores the list asObject. They're correctly scoped to the local variable. No action needed; just noting this is expected.No test for
createUserOrUpdateupdate path in integration — the integration test only covers the create path (new user → audit entry) and the delete path. There's no integration test for the update-existing-user path. The unit testcreateUserOrUpdate_doesNotLogUserCreated_whenUserAlreadyExistscovers the logic, which is sufficient since the behavior is "emit nothing." LGTM.existsByKindonAuditLogRepository— used only in integration tests as a wait condition. This is fine; it's a simple derived query. Not a concern.🏗️ Markus Keller — Application Architect
Verdict: ✅ Approved
The structural choices are sound. I want to flag one dependency direction question and one minor design note.
Observations
UserService→AuditServicedependency directionUserService(inservice/) now injectsAuditService(inaudit/). The CLAUDE.md architecture defines the layering asController → Service → Repository. Theaudit/package sits at the same level asservice/, so this is a lateral dependency rather than a layer violation.For a small codebase this is acceptable — audit is a cross-cutting concern and not a domain boundary in the same sense as
PersonServicevsDocumentService. If audit grows (audit queries, audit dashboards), consider makingAuditServicean explicit infrastructure/cross-cutting package with a clearly defined inbound interface, rather than letting every domain service inject it freely. Not a blocker for this PR.existsByKindonAuditLogRepositoryis test-onlyAuditLogRepository.existsByKind(AuditKind kind)is derived from Spring Data and used exclusively in integration test wait conditions. Adding production repository methods for test-only use is a minor smell. It's also trivially harmless — Spring Data generates it at startup, it's not a security issue, and it's a simple query. I mention it only because it sets a precedent; if it stays test-only, it should eventually move to a test helper or be expressed differently in theawait()condition. Not a blocker.findRecentUserManagementEvents(int limit)is correctly designedThe
limitis passed from the caller rather than hardcoded in the service, which is the right call. The query sorts byhappenedAtdescending. The PR description mentionsfindRecentUserManagementEvents(3)will be the call site from #326 — the signature is ready.groupChangePayloadcompares UUID sets, not entity setsCorrect. Entity equality on Hibernate-managed objects can behave unexpectedly depending on
equals/hashCodeimplementation and proxy state. UUID comparison is deterministic and correct. Good call.🛠️ Tobias Wendt — DevOps & Platform Engineer
Verdict: ✅ Approved
This is a backend-only PR with no infrastructure, Compose, CI, or migration changes. From a platform perspective, everything is in order.
What I checked
No Flyway migration required — the PR description confirms
AuditKindusesEnumType.STRING, so the three new enum values (USER_CREATED,USER_DELETED,GROUP_MEMBERSHIP_CHANGED) are stored as plain strings. PostgreSQL doesn't care about new enum values in this case — no DDL change, no migration file needed. Correct.Integration tests use
PostgresContainerConfig— real Postgres via Testcontainers, not H2. The async audit assertions use Awaitility. Both are CI-friendly patterns that won't introduce flakiness from database engine differences.No new infrastructure dependencies —
AuditServicewas already in the codebase. No new services, no new ports, no Compose changes. The PR stays self-contained.No secrets or credentials in test fixtures — integration test creates test users with inline passwords (
"admin-secret","secret"). These are isolated to the Testcontainers database and don't persist anywhere. Acceptable for test data.Nothing to flag from the infrastructure side. Ship it when the code review concerns are addressed.
🎨 Leonie Voss — UX Design Lead
Verdict: ✅ Approved
This PR contains no frontend or UI changes — it is entirely backend: new audit event kinds, service wiring, and tests.
Nothing to flag from a UI, accessibility, or brand perspective. When #326 consumes
findRecentUserManagementEvents(3)and exposes these events in the admin dashboard, I'll review the display layer then (audit event labels, timestamp formatting, empty states, truncation on mobile).LGTM.
📋 Elicit — Requirements Engineer
Verdict: ⚠️ Approved with concerns
The implemented scope aligns well with what the PR description claims. My job is to flag what the PR doesn't cover that users might reasonably expect from "audit user management events."
Requirement Coverage Assessment
Covered:
USER_CREATED— only for new users (not upsert updates) — intentional and documented in PR descriptionUSER_DELETED— with email captured before deletionGROUP_MEMBERSHIP_CHANGED— with added/removed group names, only when group set actually changeslogAfterCommit)findRecentUserManagementEvents)Gap: No audit event for non-group admin updates
adminUpdateUseremitsGROUP_MEMBERSHIP_CHANGEDonly when group membership changes. When an admin changes a user's email, password, display name, or contact info, no audit event is emitted.From an admin's perspective: if a user's email is changed by another admin, there is no audit trail for that action. This is a meaningful compliance gap for a system with role-based access control.
This may be intentional scope for issue #336 — the PR description doesn't explicitly address non-group user updates. If so, I'd recommend opening a follow-up issue for
USER_PROFILE_UPDATED(or similar) before this feature reaches users who rely on the audit trail for accountability.Open question: Does the acceptance criteria for #336 explicitly exclude non-group admin updates from audit scope? If yes, this is correctly deferred. If the criteria are silent on it, this is a gap.
Requirement Traceability Note
The PR description mentions
findRecentUserManagementEvents(3)is "ready for #326 to consume." This forward dependency should be explicitly tracked: #326 is blocked on this PR. Ensure #326 has a "depends on #337" link in Gitea so it doesn't get scheduled out of order.Second-round review concerns addressed
All open blockers and actionable suggestions from the second review round have been resolved.
Blockers resolved
Nora — Finding 1: null
actorIdaccepted in production code path ✅Extracted
createUserForBootstrap(CreateUserRequest)— no actorId parameter, no audit event — for use in tests and seeding scenarios.createUserOrUpdate(UUID actorId, ...)remains for controller use and always receives a real authenticated actor. The two contracts are now unambiguous.Unit test added:
createUserForBootstrap_createsUserWithoutAuditEventverifiesauditService.logAfterCommit()is never called.→ commit
refactor(audit): extract createUserForBootstrap() to make null actorId contract explicitNora — Finding 2:
actorId()DB lookup — cast-principal suggestion 🚫 Not applicableCustomUserDetailsServicereturns a Spring SecurityUserobject, notAppUser. Casting(AppUser) auth.getPrincipal()would throwClassCastException. The current DB lookup inactorId(Authentication)is the correct approach for this architecture. No change made.Suggestions implemented
Nora — 401 tests for unauthenticated access ✅
Added
createUser_returns401_whenUnauthenticated,adminUpdateUser_returns401_whenUnauthenticated, anddeleteUser_returns401_whenUnauthenticatedtoUserControllerTest. These complement the existing 403 permission tests and guard against any future regression that opens the endpoints to unauthenticated callers.→ commit
test(audit): add 401 unauthenticated tests for createUser, adminUpdateUser, deleteUserSara — Integration test setup simplification + timeout increase ✅
With
createUserForBootstrap()no longer emitting an audit event, the twoawait(...existsByKind(USER_CREATED)) + deleteAll()cycles that existed only to drain bootstrap noise are removed. All remaining Awaitility waits increased from 5 → 10 seconds.→ commit
test(audit): replace null-actorId bootstrap calls with createUserForBootstrap(), increase timeouts to 10sDeferred (out of scope per #336)
Elicit — No audit event for non-group admin updates
Issue #336 explicitly excludes
USER_UPDATED: "log only access-control-relevant events for now." A follow-up issue should be opened forUSER_PROFILE_UPDATEDif audit coverage of email/password/profile changes is needed before the audit dashboard ships.Test suite
1371 tests, 0 failures (+5 net: 1 ×
createUserForBootstrapunit test + 3 × 401 controller tests + integration test simplified but count unchanged).