Plan: Harden Bruno API tests to gate staging deploys with auto-rollback
Current status
Update this one line on every PR merge so anyone (including a fresh Claude session) can pick up the work without re-reading the whole plan.
Progress log
This plan is not tracked as BMad stories — it’s test infrastructure work + opportunistic bugfixes. The table below is the single source of truth for progress. Each PR’s last commit before merge updates its row in this table.
| PR # | Branch | Scope | Status | Merged | Findings / bugs discovered |
|---|---|---|---|---|---|
| 1 | test-enhancement/bruno-staging-hardening-infra |
Sections A + B: cleanup endpoints, runner flags, ECR staging-stable tag, deployment alarms, Bruno-failure rollback job, one-time legacy-junk cleanup script (17 rows). |
✅ merged | 2026-05-25 (#664, 5265f61a) |
A3 deployment alarms deferred (false-positive rollback risk vs marginal value over circuit breaker — revisit after empirical Container Insights data). Companies @Pattern validation deferred — see audit-bugs section. Discovered during impl: (1) ResponseStatusException → 500 swallowing in EMS + PCS GlobalExceptionHandlers (fixed in 5aaff459 + 3a398e80). (2) Gateway /api/v1/admin/* route fell through to EMS — fixed in c36bc744 with per-service path discriminator (/api/v1/admin/test-fixtures/{cums,ems,pcs}/cleanup). (3) @EnableMethodSecurity was @Profile("!local") — flipped in 46c5dc75. (4) CUMS Checkstyle indent on @ApiResponses — fixed in 7799b2ec. (5) Bruno CI step failed expected 401 to equal 403 on admin-cleanup-api auth-matrix tests — root cause was missing SPEAKER_AUTH_TOKEN / PARTNER_AUTH_TOKEN env vars in CI; resolved by out-of-band #667 (11.F.1 commit bce55b12 honoring pre-set role env vars). (6) Bruno runner aborted on first all-green run under set -e due to ((passed++)) returning 0 (PRE-increment value) — resolved by out-of-band #668. |
| 2 | test-enhancement/bruno-events-api-split (= PR 670, 10 commits) |
Section C mechanical-only: file moves + cleanup hooks for 4 active collections + 2 stubs (event-types-api, event-full-workflow-api gets seeded with tests 12/13/16). Net-new tests deferred to PR 2a + PR 2b. + bundled apex-domain hotfix from #669 fallout | ✅ merged | 2026-05-26 (#670, squash 57d80f14) |
events-api was 49/60 on staging pre-split — splitting is the unlock for PRs 6 (batched audit) + 11 + 12. Bundled hotfix (claude-review on #669, unfixed at merge): RateLimitingFilter.java:155 + TurnstileVerificationFilter.java:199 both ended up with origin.equals("https://www.batbern.ch") || origin.equals("https://www.batbern.ch") (apex https://batbern.ch dropped from the OR). Fix: both filters now delegate via corsHandler.isOriginAllowed(origin). Branch deleted post-merge (local + remote). |
| 2a | (merged in #670) | Net-new event-types-api tests: 9 Bruno tests (GET list + GET specific + GET invalid + idempotent PUT capture-then-replay + PUT validation 400 + POST/DELETE 404/405). + bundled backend hygiene fix: HttpRequestMethodNotSupportedException handler in all 4 services’ GlobalExceptionHandler (EMS, CUMS, PCS, SCS) — without it, POST/DELETE on a GET-only route returned 500 (same gotcha class as the MethodArgumentNotValidException rule in project-context.md). |
✅ merged | 2026-05-26 (#670, 57d80f14) |
19/19 tests green ×2 locally. |
| 2b | (merged in #670) | Net-new event-full-workflow-api tests: 7-step state-machine walk CREATED → ARCHIVED via PUT /events/{code}/workflow/transition with overrideValidation:true + 2 invalid-transition rejection tests + 3 fixture events + 2 cleanup hooks. Registrations CRUD against REGISTRATION_OPEN deferred to potential PR 2c — not blocking. |
✅ merged | 2026-05-26 (#670, 57d80f14) |
44/44 tests green ×2 locally. |
| 2c | (merged in #670) — DB cleanup + 4 local-dev test fixes | scripts/db/bruno-dev-pre-cleanup-2026-05-26.sql one-shot: deleted 88 user_profiles + 14 companies + 3 test events (cascading 14 sessions, 12 speaker_pool, 30 registrations, 18 session_content_history, 1 newsletter_send) + 1 partner. Preserved 11 real Bruno-named people + 2 real partner_meetings. Plus 4 test fixes: users-api/15 timestamp-collision subprefix, sessions-api/21 stale presentationTitle assertion (Story 11.E.8 V100 dropped the column), speaker-portal-api/03+04 magic-login deleted endpoints now accept 401-or-404. |
✅ merged | 2026-05-26 (#670, 57d80f14) |
Local Bruno: 11/13 collections green, 245/247 tests (99.2%). |
| 3 | test-enhancement/bruno-audit-file-upload-api |
D.1: light audit | ✅ merged | 2026-05-25 (bundled into #666, a05dce90) |
— |
| 4 | test-enhancement/bruno-audit-companies-api |
D.2: light audit | ✅ merged | 2026-05-25 (bundled into #666, a05dce90) |
— |
| 5 | test-enhancement/bruno-audit-users-api |
D.3: light audit + F1/F2/F3/F4 fixes (see below) | ✅ merged | 2026-05-25 (bundled into #666, a05dce90) |
F1 (var capture) + F2 (test 14 → 95-delete-test-user, regex tightened, lastName Test) + F3 (resolved via F2 + ordering) + F4 (CUMS ADDITIONAL_EMAILS entityType + 00/00a/99/99a cleanup hooks) all landed. Surprise dependency: F2’s encoded-email DELETE returning 401 turned out to be a gateway URI double-encoding bug (%40 → %2540 tripping Spring’s StrictHttpFirewall) — fixed in out-of-band #665, not strictly in PR 5 scope but blocked users-api from going green. Also: 7 .bru cleanup files added by PR 3/4/5 had # comments between meta { } and the next block; Bruno’s parser silently drops such files. Caught + fixed in #666’s 38fcbcea; CLAUDE.md guardrail in #665. |
| 6 | test-enhancement/bruno-audit-batched-closeout (= PR #671, bundled with PR 11) |
Batched audit-pass closeout — Section D steps 2 + 4 across tasks-api + events-crud-api + event-topics-api + sessions-api + event-full-workflow-api. tasks-api gets the canonical hook migration (00/00b/98/99); other four get DELETE-then-GET-404 verify stubs (29a, 67a, 11a, 97c). | 🔵 in review (PR #671 → develop) | — | Local Bruno green ×2 on all 5 collections (tasks-api 31/31, events-crud-api 30/30, event-topics-api 26/26, sessions-api 58/58, event-full-workflow-api 46/46; re-verified post-rebase). Step 2 finding (verified during impl): CreateEventRequest.java has no eventCode field and CreateSessionRequest (events-api.openapi.yml) has no sessionSlug field — server-generated for both. Prefix normalization is therefore inapplicable across all 5 collections; BRUNO-TEST-* cleanup-endpoint sweep is a no-op for today’s fixture events (cleaned up via explicit 98-delete-fixture-event.bru DELETE by eventCode instead). Filed in §“Risks to track” item 7. |
| 7 | absorbed into PR 6 (batched) | 🟢 absorbed | — | Step 3 (00/99 hooks) N/A — no mutable test data (capture-then-replay PUT). Step 2 + step 4 inapplicable (no DELETE endpoint exists by design). Effectively complete after PR 670 / PR 2a; row kept as historical numbering anchor. | |
| 8 | absorbed into PR 6 (batched) | 🟢 absorbed | — | Step 3 shipped in PR 670. Step 2 already canonical (bruno-test-topic-{ts}). Step 4 landed in PR 6 as 67a-verify-event-deleted.bru. |
|
| 9 | absorbed into PR 6 (batched) | 🟢 absorbed | — | Step 3 shipped in PR 670. Step 2 investigated in PR 6 — CreateEventRequest has no eventCode field (server generates BATbern{N}); filed in §“Risks to track” item 7. Step 4 landed in PR 6 as 29a-verify-event-deleted.bru. |
|
| 10 | absorbed into PR 6 (batched) | 🟢 absorbed | — | Step 3 shipped in PR 670. The 1 pre-existing failure (21-assign-speaker-to-session.bru presentationTitle assertion) was fixed in PR 670’s commit 753c1315. Step 2 investigated in PR 6 — CreateSessionRequest has no sessionSlug field; filed in §“Risks to track” item 7. Step 4 landed in PR 6 as 11a-verify-session-deleted.bru. |
|
| 11 | test-enhancement/bruno-audit-batched-closeout (= PR #671, bundled with PR 6) |
D.9: workflow-aware redesign per ADR-009 — speaker-pool-api restructure + speaker-portal-api cascade fixes + CUMS USERS regex widening | 🔵 in review (PR #671 → develop) | — | Speaker-pool-api: 61/78 → 78/78 ✓×2 locally. Test 38 rewritten as POST /promote (CONTACTED → READY, replacing PUT /status that now returns 400 READY_REQUIRES_PROMOTE_ENDPOINT). New 38a (READY → INVITED). 39 updated (previousStatus=INVITED, not READY). 40/51 switched from message-text assertions to error-code (InvalidStateTransitionException). 41 updated to expect 4 transitions, not 3. 51 swapped invalid enum (CONFIRMED removed by 11.B.1) for valid-enum-invalid-transition (QUALITY_REVIEWED from IDENTIFIED → 422). 47 flipped from 200-idempotent to 409 — the controller’s pre-check (InvalidPromotionStateException) tightens beyond the workflow service’s same-state branch. Tests 53/54 disabled with docs (5-speaker setup needed for slot-capacity gate; canonical coverage in SpeakerInvitationControllerIntegrationTest#should_return409_when_sendInvitationCalledAndSlotCapacityReached). 55 widened keyword set (“invalid request format” accepted). 45-47 migrated from promote.e2e.* to bruno.test.* naming for cross-service cleanup. CUMS USERS regex widened to ^bruno\.test\.?$ (optional trailing dot) — accepts both bruno.test (bare, first-promote collision-free) and bruno.test.N (suffixed) via single LIKE bruno.test% sweep. New 00a-/99a-pretest/posttest-cleanup-users.bru hooks. Speaker-portal-api: 4 failures → 37/37 ✓×2 locally. Tests 02 + 09 fixed via per-speaker promote bridge (01a/01b + 08a/08b — PUT to CONTACTED + POST /promote before /send-invitation). Test 27 fixed via 26b/26c (advance INVITED → ACCEPTED → CONTENT_SUBMITTED). Test 33 disabled with docs (speaker-token-bound seed requires 5+ setup .bru files; canonical coverage in SpeakerResponseControllerIntegrationTest). |
| 12 | absorbed into PR 6 (batched) | 🟢 absorbed | — | Step 3 + state-machine walk + invalid-transition tests shipped in PR 670 / PR 2b. Step 2 inapplicable (same eventCode finding as PR 9; §“Risks to track” item 7). Step 4 landed in PR 6 as 97c-verify-workflow-event-deleted.bru (conditional on prior DELETE status — 204/404 → assert 404, 409 → event still exists by design, swept later by Story 10.18 archival cleanup). Deferred “registrations CRUD against AGENDA_PUBLISHED” stays out of scope. |
|
| 13 | test-enhancement/bruno-audit-partners-api (= PR #672 → develop) |
D.11: light audit + PCS partner_meetings entityType extension + new partner-meetings-api collection (written from scratch) |
🔵 in review (PR #672 → develop); rebased to 1 clean commit; green ×2 locally | — | All affected collections green ×2 locally: partner-meetings-api 24/24 (15 req), partners-api 63/63 (20 req), admin-cleanup-api 11/11. PCS Java integration suite green (added 5 MEETINGS tests), Checkstyle clean. Deliverables: (1) PCS cleanup entityType=meetings via meetingIds UUID allowlist (§B2 option 1) — TestFixtureCleanupService/Request/Repository/Controller; deletes partner_meetings by id, cascading attendance + rsvps (V2/V9); empty/missing/over-cap (200) list → 400; bounded at MAX_MEETING_IDS=200. (2) New partner-meetings-api Bruno collection — the collection did not exist before (CLAUDE.md listed it but no dir on disk); covers create/get/404/list/patch/send-invite(202)/delete/verify-404 + 401/403, with a 98-posttest-cleanup-meetings.bru that posts the run’s captured IDs to the new endpoint (audit log confirmed entityType=MEETINGS meetingIds=2 counts={meetings=1}). (3) partners-api audit: added 99b-posttest-cleanup.bru (canonical brtest sweep) + 99-verify-partner-soft-deleted.bru. (4) admin-cleanup-api 24-pcs-200-meetings + 25-pcs-400-empty-meetings. (5) DELETE /partner-meetings/{id} added to the OpenAPI spec (was code-only). Findings: (a) partners DELETE is a soft delete (sets partnershipEndDate, isActive=false; row persists, GET still 200s) — audit-step-4 verify asserts isActive=false, not 404. (b) Meetings have no Bruno-identifying column, so there is no 00-pretest meetings sweep — the allowlist can only reach IDs from the current run (documented trade-off; teardown = explicit DELETE + posttest allowlist sweep). |
| 14 | test-enhancement/bruno-gate-flip (= PR #674 → develop) |
Section E: remove continue-on-error, prove rollback path |
🔵 in review (PR #674 → develop) | — | Workflow changes on deploy-staging.yml: removed step-level continue-on-error on the Bruno step (failure now fails the deploy); downstream jobs gate on needs.bruno-tests.result; rollback-on-bruno-failure gained always() + dropped the enable_bruno_rollback gate (fires on every Bruno failure); tag-stable-on-success simplified + outputs.outcome removed. Bonus: this also repairs the never-promoted staging-stable ECR tag (the old outputs.outcome gate never evaluated true through the continue-on-error step → promote always skipped; confirmed staging-stable absent in ECR). First green deploy under the new workflow populates it. Owed after merge (user-approved sequence): the F1 deliberate-fail drill (real production-account rollback — low-traffic window). |
Out-of-band PRs landed during this session (not in the original 1-14 enumeration but tightly coupled to the staging-hardening work — Bruno failures surfaced them):
| PR # | Branch | Scope | Status | Merged | Why it was needed |
|---|---|---|---|---|---|
| 665 | fix/gateway-path-encoding-and-cache-control |
Gateway URI verbatim forwarding (no double-encode of %40) + upstream Cache-Control passthrough + CLAUDE.md Bruno docs { } guardrail. |
✅ merged | 2026-05-25 (82ee9734 → 3d637915) |
Found by users-api/17 returning 401 on encoded-email DELETE (StrictHttpFirewall rejecting %2540) and users-api/20 failing Cache-Control assertion (gateway stripping upstream public, max-age=86400). Both blocked PR 5 from going green. |
| 668 | fix/bruno-runner-counter-set-e-trap |
Replace ((var++)) with var=$((var+1)) in Bruno runner counters. |
✅ merged | 2026-05-25 (62e77a13) |
After 11.F.1 fixed the role-token plumbing and admin-cleanup-api went 9/9 green for the first time, the runner aborted on ((passed++)) returning 0 under set -e BEFORE the summary block. GitHub recorded outcome=failure, tag-stable-on-success skipped, staging-stable never promoted. Invisible while admin-cleanup-api had real failures. |
| 669 | fix/remove-dead-staging-batbern-ch-domain |
Retire dead staging.batbern.ch / api.staging.batbern.ch / cdn.staging.batbern.ch domains from test scaffolding (73 files). |
✅ merged | 2026-05-25 (1a6e2ce2) |
Bruno’s collection.bru:10 set Origin: https://staging.batbern.ch for every request; the deployed gateway correctly rejected the defunct origin → 14 should have CORS headers assertions failed on staging (7 file-upload + 7 companies). Substitutes to www.batbern.ch / api.batbern.ch / cdn.batbern.ch; dedupes the resulting duplicate entries in gateway CORS allow-lists and CSP. Two apex-domain regressions slipped past auto-merge (claude-review flagged them; auto-merge fired anyway because PR-review-count is 0): RateLimitingFilter.java:155 + TurnstileVerificationFilter.java:199 ended up with origin.equals("https://www.batbern.ch") || origin.equals("https://www.batbern.ch") instead of ... || origin.equals("https://batbern.ch"). Browsers hitting the apex pass CORS (CorsHandler is correct) but get rejected by rate-limit + Turnstile filters. Hotfix bundled into PR 2. Minor: duplicate Swagger UI server entry (staging label points to same URL as production) not fixed — acceptable wording-stale post-consolidation. |
Plus PR #667 (feature/11-f-1-magic-link-teardown, Epic 11.F.1) merged 2026-05-25 (27a990ca) — separate epic but contained commit bce55b12 fix(ci): honor pre-set role env vars in run-bruno-tests.sh which unblocked the role-token plumbing in CI.
Status legend: ⬜ not started · 🟡 in progress · 🔵 in review · ✅ merged · 🔴 blocked
PR description convention: every PR includes Refs: docs/plans/bruno-staging-hardening.md PR #<n> and the final commit updates the row above (status, merged date, notable findings/bugs). When real bugs are discovered during a test audit, file them as separate fix-commits in the same PR and note the bug in “Findings / bugs discovered.”
Pre-PR-1 audit results — 2026-05-24
Query executed against staging RDS (batbern-staging-postgres.c7qauya0ie7a.eu-central-1.rds.amazonaws.com, db batbern) via SSM tunnel. Query: /tmp/bruno-audit-query.sql. Results: /tmp/bruno-audit-results.log + /tmp/bruno-audit-extra.log.
Critical finding — naming-collision near-miss
12 real users named “Bruno” (bruno.blumenthal@ruag.com, bruno.frey@astra.admin.ch, bruno.linder@sbb.ch, etc.) match LIKE 'bruno%'. A naïve prefix-cleanup would have deleted real people. The dot-anchored regex ^bruno\.test\.[0-9]+$ from B1 is the only safe pattern — confirmed binding, not optional. The same applies to companies: ^[A-Za-z0-9]+$ OpenAPI pattern is not enforced server-side (confirmed below: company names with spaces and 30 chars persist) — so the cleanup-prefix regex must do all the safety work.
Pre-existing test junk to remove on PR 1 deploy
| Table | Identifier | Count | Examples | Disposition |
|---|---|---|---|---|
companies |
name |
1 | bruno test company 1761143046 (2025-10-22) |
Disposable — Bruno residue |
companies |
name |
6 | E2E Test Company 1768749259021 … (Jan–Feb 2026) |
Disposable — Playwright E2E residue |
companies |
name |
3 | testag, testcompanya, testdeclinea (Jan–Mar 2026) |
Disposable — automated fixture residue |
partners |
company_name |
7 | brtest117, brtest150, brtest288, brtest424, brtest675, brtest861, brtest899 |
Disposable — current Bruno partner test residue (44% of partners table!) |
user_profiles |
username |
1 | user.eetest@batbern-test.ch |
KEEP — still in use (user confirmed 2026-05-24). Exclude from cleanup script and from cleanup-endpoint regex match list. |
events |
event_code |
0 | — | Clean |
sessions |
session_slug |
0 | (1 real session about Test Automation at SBB matches %test% — KEEP) |
Clean |
topics |
topic_code |
0 | — | Clean |
registrations |
registration_code |
0 | — | Clean |
logos |
s3_key |
0 | — | Clean (4 PENDING auto-purge in 24h) |
event_tasks |
(joined via events) |
0 | — | Clean |
speaker_pool |
(joined via events) |
0 | — | Clean |
user_profiles (email='%@e2e.batbern.invalid') |
reserved test domain | 0 | — | Clean — reservation works |
Total disposable rows: 17 (10 companies + 7 partners; user.eetest excluded — still in use). Partner cascade likely brings additional rows in partner_meeting_*, topic_votes, partner_notes etc. for the 7 brtest* partners — to be counted by the PR 1 cleanup script as a SELECT-then-DELETE before any destructive operation.
Bugs confirmed by audit — dispositions
companies.nameserver-side validation NOT enforcing the OpenAPI pattern^[A-Za-z0-9]+$— but the deeper finding is that the OpenAPI spec is wrong, not the validation. Initial reading suggested adding@Patternenforcement to the DTO. Closer look atservices/company-user-management-service/src/test/java/.../integration/CompanyControllerIntegrationTest.java:62,84reveals the existing integration test fixture uses"Test Company"and"New Company"with spaces and the tests pass.CreateCompanyRequest.javaDTO has only@Size(min=2, max=255). The DTO documentation example value is"Swisscom AG"(also with space). Production data shows 7+ rows with spaces are clearly real test/E2E residue but the broader pattern of “company name with space” is the documented intent of the code. Conclusion: the OpenAPI spec’s^[A-Za-z0-9]+$pattern was aspirational and doesn’t reflect reality. Adding@Patternenforcement would break the existing test suite plus reject most legitimate Swiss company names. Revised decision (2026-05-24): defer to a separate follow-up ticket. That ticket needs a product/architecture call: either (a) relax the OpenAPI spec to match code reality, allowing spaces/periods/hyphens/ampersands, OR (b) tighten code + tests + data to enforce strict alphanumeric per the spec, which is a much bigger change. Neither belongs in PR 1.partners.company_name VARCHAR(12)constraint confirmed enforced — all 7brtest*entries are ≤12 chars. Still the known mismatch withcompanies.name VARCHAR(255)from risk #7.1. Decision (user, 2026-05-24): separate Linear ticket, not in PR 1. Requires a migration to widen the column; out of scope for test infrastructure work.
Cleanup approach for the historical junk — decided
Decision (user, 2026-05-24): one-time SQL cleanup script in PR 1. Targets the 17 specific rows by exact-match list (10 companies + 7 partners), not by regex. The cleanup endpoint’s canonical regexes (B1) won’t match these legacy patterns (bruno test company 1761143046 has spaces; E2E Test Company is mixed case with spaces; testag/testcompanya/testdeclinea follow no canonical), and that’s fine — historical patterns aren’t worth supporting in the long-running endpoint.
PR 1 ships:
- The cleanup endpoint (for ongoing canonical patterns going forward).
- A one-shot SQL migration
V<n>__bruno_staging_pre_cleanup.sql(or a dedicatedscripts/db/bruno-staging-pre-cleanup.sql) that exact-match deletes the 17 rows + their cascade dependents (partner_meeting_*, topic_votes, partner_notes for the 7 partners). SELECT-then-DELETE pattern: SELECT first to log counts, then DELETE inside a transaction with a final COUNT verification. - Audit log entries in CloudWatch for each deletion.
The script runs exactly once when PR 1 deploys. Never re-runs.
Local pre-flight findings — 2026-05-25 (users-api)
Status (2026-05-25 end-of-session): ✅ ALL FOUR FINDINGS RESOLVED. F1, F2, F3, F4 fixes shipped in PR #666 (
a05dce90) — see PR 5 row in the progress table. F2’s encoded-URL 401 sub-cause turned out to be a separate gateway bug (URI double-encoding) and was fixed in out-of-band #665. users-api now reports 35/35 ✓ on staging post-#666 merge (verified). The detailed analysis below is retained as historical context for anyone debugging similar patterns in PR 2’s split events-api collections or PR 13’s partners-api.
Ran ./scripts/ci/run-bruno-tests.sh development --collection users-api after fix-commit 74b8cd8a (which moved 3 #-prefixed comment blocks into docs { ... } blocks so Bruno’s parser would stop skipping the files). Parser warnings are gone — but the now-parseable files surfaced 3 genuine test failures, plus the run surfaced 2 other pre-existing failures that the previously-skipped files were masking. Result: 31 requests, 28 passed, 3 failed. Full log: /tmp/bruno-users-api.log (locally — not committed).
These belong to PR 5 (test-enhancement/bruno-audit-users-api, D.3) and the auditor for that PR should fix them as part of the light audit pass.
F1 — users-api/18-add-additional-email-duplicate.bru returns 400 instead of 409 (exposed by 74b8cd8a)
Request body sent literally as {"email": "{{authUserEmail}}", ...} — Bruno is not interpolating authUserEmail because it’s never defined. bruno-tests/environments/development.bru:1-16 has no authUserEmail var; grep -rn "authUserEmail" bruno-tests/ returns only the consumer site. The server rejects the literal {{...}} string with Validation failed: email - must be a well-formed email address → 400, instead of exercising the 409 duplicate-error branch the test intends.
Why new: before 74b8cd8a, the parser skipped this file entirely. The docs-block fix made it parseable, which exposed the latent var gap. The test was effectively dead code.
Fix options for the auditor (cheapest first):
- Capture email from
01-get-current-uservia ascript:post-response { bru.setEnvVar("authUserEmail", res.getBody().email); }and let18-consume it. Lines added: 1 in01-, 0 in18-. - OR inline a known organizer email directly in the body (couples test to env’s auth user — brittle but simplest).
- OR add
authUserEmail: {{process.env.AUTH_USER_EMAIL}}to all three env files plus the corresponding GitHub secret (heaviest).
Recommended: the post-response capture in 01-. Same pattern is already used elsewhere in the collection (01- stores firstName/lastName/bio to env vars for 03b-restore-current-user).
F2 — users-api/17-delete-additional-email.bru returns 401 instead of 204 (pre-existing)
DELETE /users/me/additional-emails/{{encodedAdditionalEmail}} returns 401. The pre-request precondition check passed (so additionalEmail env var was set by test 15). The request is unauthenticated at the server.
Root cause: test-ordering bug. Bruno runs files alphanumerically. 14-delete-test-user.bru (bruno-tests/users-api/14-delete-test-user.bru:8) DELETEs {{testUsername}} which development.bru:8 defines as batbern.organizer — the current auth user. 14 returns 204 (assertion res.status: in [204, 403, 404] accepts all three). Tests 15/16 still pass because PreTokenGen + Pattern 3b silently re-hydrate the user on the next authed call (see docs/architecture/06b-user-lifecycle-sync.md §“Pattern 3b”). But by the time 17- (DELETE on encoded-email path) runs, the user’s user_additional_emails row is gone (cascade from the user delete in 14-), or auth-state is inconsistent enough that the gateway returns 401.
Independent of PR 1’s docs-block fix. This test has been broken for a while; nobody noticed because runs against staging silently no-op’d (A1’s broken baseUrl).
Fix options for the auditor:
- Point
14-delete-test-user.bru:8at a disposable username (e.g.bruno.test.<ts>created by04-create-user.bruearlier in the run) instead of{{testUsername}} = batbern.organizer. This is the right fix —14-is clearly meant to exercise GDPR-delete, not to nuke the auth user. - OR rename
14-to99-delete-test-user.bruso it runs after every other test in the collection. Quick but doesn’t fix the underlying “tests should not destroy their own auth user” problem.
This also implies updating B1’s canonical-naming rules to make explicit that testUsername in development.bru must NEVER be the same user whose JWT is in AUTH_TOKEN — a footgun worth pinning down before PR 1 ships its 3 cleanup endpoints.
F3 — users-api/20-public-user-by-username.bru returns 404 instead of 200 (pre-existing, cascades from F2)
Anonymous GET /public/users/batbern.organizer returns 404 from CUMS. Same root cause: 14-delete-test-user.bru deleted batbern.organizer earlier in the run, and the public projection endpoint legitimately can’t find them. The 404 is correct given the state — the test is wrong about the precondition.
Fix: falls out automatically once F2 is fixed (auth user no longer being deleted mid-run). No separate change needed.
F4 — users-api/15-add-additional-email.bru accumulates rows; 6th run+ blocks at 422 (discovered 2026-05-25, second run)
Re-running the collection after the first round of findings surfaced a new failure mode on test 15:
status: 422
errorCode: ADDITIONAL_EMAIL_LIMIT_REACHED
message: "Additional email limit reached: maximum 5 per user"
Local DB snapshot confirms: batbern.organizer (the auth user, since development.bru:3 authToken: {{process.env.AUTH_TOKEN}} resolves to that identity) has 5 leftover rows in user_additional_emails matching bruno-additional-%@example.com, dating from earlier today’s runs.
Root cause is a pairing-without-isolation bug, not a state-cleanup miss in the cleanup endpoint:
15-add-additional-email.bruPOSTsbruno-additional-{{$randomInt}}@example.comto/users/me/additional-emails(against the auth user, not a disposable test user).17-delete-additional-email.bruis supposed to delete it, but has been failing for the F2 reasons (auth user deleted mid-run by test 14, encoded-URL 401, precondition-trip when 15 itself fails).- Each leaked row stays forever. After 5 leaks the cap is hit and every future test 15 immediately 422s before it can even try to write — making the leak permanent until a manual
DELETE.
No single commit caused this. It’s an accumulation pattern: Story 10.32 (9c35bf11) introduced both the cap and the test, but the test “worked” as long as 15→17 always paired up cleanly. Recent PR-1 changes that exposed the previously-skipped tests (my docs-block fix at 74b8cd8a) and changes that altered the auth user’s behavior (46c5dc75 flipping method security on in local; the staging ORGANIZER role re-grant earlier this session making test 14 actually destructive) all raised the failure rate on test 17, accelerating the leak — but the leak vector existed already.
Fix for the PR 5 auditor — three pieces, do all three:
- Extend the CUMS
TestFixtureCleanupControllerwith anADDITIONAL_EMAILSentityType. Same shape as the partner_meetings TODO in PR 13. Prefix regex along the lines of^bruno-(test|additional)-[0-9]+@example\.com$(or normalize to canonical^bruno-test-[0-9]+@e2e\.batbern\.invalid$and migrate the test). Single DELETE onuser_additional_emailsbyLOWER(email) LIKE LOWER(?) || '%'. Add the regex to the controller’s boundMap<CleanupEntityType, Pattern>per B2’s controller-layer enforcement. ~50 lines + 1 integration test. - Normalize the test prefix to a canonical pattern per B1. Today’s
bruno-additional-{{$randomInt}}@example.comdoesn’t match any canonical regex from B1’s table — it’s a one-off prefix invented inside this test file. Either (a) widen B1 to recognizebruno-additional-as a second canonical prefix for additional-email fixtures, OR (b) change the test tobruno-test-{{$timestamp}}@e2e.batbern.invalidand reuse the existing email canonical. (b) is preferable — it consolidates one less regex. - Add
00-pretest-cleanup.bru+99-posttest-cleanup.brutousers-api/per B3. Both POST to the cleanup endpoint withentityType=ADDITIONAL_EMAILS+ the canonical prefix. Status assertiononeOf([200, 204, 404])so they never fail the run. This breaks the create/delete coupling entirely — even if test 17 fails, the next run starts clean.
One-time local-DB unblock before PR 5 can run green locally:
DELETE FROM user_additional_emails
WHERE email LIKE 'bruno-additional-%@example.com';
Same shape as PR 13’s TODO. The two share a structural pattern: an entity that lives on a real parent (additional_emails on real users; partner_meetings as a standalone table) and that the existing cleanup entityTypes can’t sweep because they only delete the parent or only follow FK cascades from a test-prefixed parent. Worth pulling out as a B2 framing note: “cleanup endpoint entityTypes must enumerate every table that tests write to, not just the entity owners.”
Update 2026-06-06 — two more applications of this framing note landed (branch fix/bruno-cleanup-topics-notifications):
- PCS
entityType=topics—topic_suggestions(+topic_votescascade) referencecompany_nameas an ADR-003 string, NOT a partner FK, so theentityType=partnerssweep never reached them and orphans survived aborted runs. New prefix sweep ontitle LIKE 'Bruno Test Topic%'(regex-locked literalBruno Test Topic). Pretest00-pretest-cleanup-topics.bru+ posttest99c-posttest-cleanup-topics.bruinpartners-api/. - EMS
entityType=notifications—notificationsare created as SIDE EFFECTS of entity/state changes (workflow transitions, publishes, registrations) and carry no FK to events; uncleaned, BRUNO-TEST notifications land in REAL organizers’ in-app lists on the production account. Composite sweep:event_code LIKE 'BRUNO-TEST-%'OR reserved-rangeBATbern{N}(N ≥ 10000) ORrecipient_username LIKE 'bruno.test.%'ORsubject LIKE '%BRUNO-TEST-%'ORbody LIKE '%BRUNO-TEST-%'(regex-locked sentinelBRUNO-TEST-). The subject/body markers catch test notifications that carry a NULLevent_code(e.g. a workflow notification whose only test trace is theBRUNO-TEST-Xsentinel rendered into its text) — rows the event_code/recipient discriminators alone would miss. The reserved-range regex is bounded to^BATbern[0-9]{1,9}$(≤ 9 digits) so a pathological 10±digit tail can never overflow theCAST(... AS INTEGER)and 500 the whole sweep — such a row simply fails the numeric branch and is left untouched. Pretest + posttest…-cleanup-notifications.bruwired into all 6 EMS-writing collections (event-full-workflow-api, events-crud-api, sessions-api, tasks-api, event-topics-api, partner-meetings-api). admin-cleanup-api gained16/17(EMS notifications 200/400) and26/27(PCS topics 200/400) verification cases. run_cleanup_for_collectionnow glob-discovers cleanup files — the helper inscripts/ci/run-bruno-tests.shpreviously hardcoded("00-pretest-cleanup.bru" "99-posttest-cleanup.bru"), so--cleanup-onlymode and the post-failure defensive sweep silently skipped every suffixed cleanup file (the pre-existing99b-/99c-sweeps AND the new…-cleanup-topics/…-cleanup-notificationsfiles). It now globs every*pretest-cleanup*.bru+*posttest-cleanup*.bruin the collection dir and runs them in filename (lexical) order, preserving the non-fatal semantics.
Cross-cutting note for the PR 5 auditor
The four failures together demonstrate a class of test-design bug this plan should explicitly track: implicit dependencies on test order + shared mutable state in dev DB. Section D.5 already mandates “run twice” — but that catches idempotency bugs, not self-destruction bugs like 14- deleting its own auth user, or accumulation bugs like 15- leaking rows to a real user every time 17- fails. Three adjustments worth considering for the D template:
- Add a
00-pre-flight that asserts the auth user exists — fail-fast if a previous run left the user deleted, instead of cascading into 5 confusing failures. - Forbid tests that mutate
{{testUsername}}when it equals the auth user. Either route them through a disposablebruno.test.<ts>created earlier in the run, OR move them to the final 99-block. - Forbid tests that write rows to a real user’s tables (e.g. additional emails, profile pictures) without an unconditional posttest cleanup. Pair every such create with a
99-posttest sweep OR rewrite to use a disposable test user.
Neither (1) nor (2) is mandatory for PR 5 — they’re nice-to-have hardening for D. (3) is now binding for PR 5 specifically because F4 demonstrates the exact failure mode it would have prevented. Captured here so the PR 5 auditor (or whoever later notices similar patterns in another collection) doesn’t have to re-derive it.
Local pre-flight findings — 2026-05-26 (speaker-pool-api) — PR 11 redesign scope
Status (2026-05-26): Discovered during PR 2 local verification —
speaker-pool-apilands at 61/78 tests locally (17 failing). The failures are not test-infrastructure bugs; they’re the consequence of the Unified Speaker Workflow refactor (Epic 11 / ADR-009) that landed across PRs 11.B.1 → 11.F.1 and changed the API contract underneath these tests. The tests were written against the old Story 5.4 + 6.0a contract and have been silently red on staging since Epic 11 merged. PR 11’s “light audit” framing in §D is therefore insufficient for speaker-pool-api specifically — it needs a workflow-aware redesign of the 35–66 test chain. Estimated effort: 2–3× a normal audit-pass PR.
Root cause — the new state machine (ADR-009 §0.1)
The legacy workflow accessed via PUT /events/{code}/speakers/{id}/status looked like:
IDENTIFIED → CONTACTED → READY → ACCEPTED → (CONTENT_SUBMITTED → QUALITY_REVIEWED)
↘ OVERFLOW (excess speakers)
↘ WITHDREW (dropouts)
ADR-009 replaces it with:
IDENTIFIED → CONTACTED → READY → INVITED → ACCEPTED → CONTENT_SUBMITTED → QUALITY_REVIEWED
↑ ↓
│ ↓
POST /promote (any state) → DECLINED
(User provisioning gate)
Key changes that break the existing tests:
READYis now a provisioning gate. Transition INTOREADYis unreachable viaPUT /status— only viaPOST /api/v1/events/{code}/speakers/{speakerId}/promote(Story 11.D.1). The PUT endpoint explicitly throwsReadyRequiresPromoteException→ 400 with codeREADY_REQUIRES_PROMOTE_ENDPOINT. Seeservices/event-management-service/src/main/java/ch/batbern/events/exception/ReadyRequiresPromoteException.java.INVITEDis a new state betweenREADYandACCEPTED. Formal invitation step.READY → ACCEPTEDdirectly is no longer a valid transition.OVERFLOWis removed. Slot capacity is enforced at theREADY → INVITEDgate (ADR-009 §0.7) —count(ACCEPTED) + count(INVITED) >= max_slotsblocks the transition. Excess speakers stay inREADYindefinitely.WITHDREWis removed. Replaced byDECLINED, the single terminal state, reachable from any non-terminal state.
Failure categorisation
The 17 failing assertions in speaker-pool-api fall into four categories:
| # | Category | Example test(s) | Why it fails post-Epic-11 | Fix for PR 11 |
|---|---|---|---|---|
| A | PUT /status → READY blocked by design | 38-update-speaker-status-to-ready.bru |
Returns 400 with READY_REQUIRES_PROMOTE_ENDPOINT instead of the old 200. Test asserts currentStatus: READY, gets undefined. |
Delete the test OR rewrite to assert the 400 + error code (documents the design constraint). The promote path is already covered by tests 45–47. |
| B | Chained tests assume sequential PUT advancement | 39-update-speaker-status-to-accepted (and 41 / 42 which read history) |
Speaker is stuck at CONTACTED because test 38 failed. PUT CONTACTED → ACCEPTED is invalid (must traverse READY → INVITED → ACCEPTED). | Restructure the 35–42 chain to use the modern path: PUT to CONTACTED, then POST /promote to READY, then PUT INVITED → ACCEPTED → CONTENT_SUBMITTED → QUALITY_REVIEWED. |
| C | Slot capacity tests assume OVERFLOW | 53-speaker-workflow-slot-capacity-409, 54-send-invitation-slot-capacity-409 |
OVERFLOW state was removed. The new gate fires at READY → INVITED. Tests may be asserting the wrong transition point or the wrong exception class. | Verify the test exercises the capacity check at READY → INVITED and that the error code matches the current SlotCapacityReachedException shape. |
| D | Workflow exception messages drifted | Tests asserting 'Invalid state transition for speaker …' to include 'ACCEPTED' |
The state machine emits different messages now. Asserting on text is brittle. | Switch assertions to error codes (machine-readable) rather than message text. |
Estimated rework
| Action | Test files | LOC |
|---|---|---|
| Delete or rewrite category-A test (PUT → READY) | 1 (38-) |
~20 |
Restructure 35–42 to thread through POST /promote |
8 (35- through 42-) |
~150 |
| Verify slot-capacity tests against new gate | 2 (53-, 54-) |
~30 |
| Update message-based assertions to code-based | 2–3 (across category D) | ~30 |
| Total redesign | ~12 files touched | ~230 LOC |
This is 2–3× a normal audit-pass PR. PR 11 should be planned with that in mind — and a heads-up to whoever picks it up that this is a redesign, not a tune-up.
Recommended PR 11 sequencing
- Fix the fixture chain first — currently 35 (add to pool) → 36 (list) → 37 (PUT CONTACTED) is solid. 38 is the breakage point. Build forward from 37 by inserting a new
38-promote-speaker-to-ready.bruthat callsPOST /promote(replacing the broken PUT /status pattern). - Renumber from there — old 38 becomes 38a (deleted) or 38b (rewritten as a negative test asserting the 400).
- Insert new invitation step — between READY and ACCEPTED add
39-invite-speaker.bruthat calls the appropriate state-transition endpoint. Old test 39 (PUT to ACCEPTED) becomes 40, asserting INVITED → ACCEPTED instead of READY → ACCEPTED. - Verify slot capacity at the new gate — old tests 53 / 54 already point at slot capacity but may exercise the wrong transition; verify and adjust.
- Run locally green ×2 per Section D.5, then PR.
The 11.D.1 + 11.B.2 references in the test names indicate parts of this work were already started during Epic 11 — but only the POST /promote happy-path tests (45–47) were added; the legacy PUT /status chain wasn’t pruned. PR 11 closes that loop.
Next: batched audit-pass closeout (PR 6) — for the fresh-session pickup
Status (2026-05-26): scoped, ready to start. Estimated ~2 hours. Six collections in one PR. Branch name:
test-enhancement/bruno-audit-batched-closeout. OffdevelopAFTER PR 670 merges (do not stack on PR 670 — wait for clean baseline). All file paths and Story IDs in this section are post-PR-670 layout.
Why one batched PR instead of 5 separate ones
The original §D rollout had one audit-pass PR per collection (PRs 6, 7, 8, 9, 10, 12). PR 670 ended up shipping the heaviest item from each (step 3: canonical 00-pretest-cleanup.bru + 99-posttest-cleanup.bru hooks) plus fixture-event setup where needed. That left only steps 2 + 4 from the §D checklist as actual audit work — and those are 1–5-line edits per collection. Six tiny PRs would be ceremony; one batched PR is cleaner.
speaker-pool-api is NOT in this batch — it’s PR 11’s redesign. speaker-portal-api isn’t in the original §D rollout (dev-only) and its 4 cascade failures are the same Epic 11 root cause as PR 11, so PR 11 fixes them too.
Per-collection action list
tasks-api (was PR 6; this is the only collection that genuinely needs new hook files):
- Replace
00-create-test-event.bru+99-cleanup-test-event.bruwith canonical00-pretest-cleanup.bru+99-posttest-cleanup.brucallingPOST {{baseUrl}}/admin/test-fixtures/ems/cleanupwithentityType=events, prefix=BRUNO-TEST-(status assertiononeOf([200, 204, 404])per §B3). - Add
00b-fixture-event.bru+98-delete-fixture-event.brumirroring PR 670’s sessions-api pattern. - Audit-step 2 — normalize fixture event_code to
BRUNO-TEST-{ts}if the create-event endpoint accepts a client-suppliedeventCodefield (investigate; if not, document the gap and leave server-generatedBATbern{N}). - Audit-step 4 — for any DELETE-tests, add an immediate GET that asserts 404.
events-crud-api (was PR 9):
- Audit-step 2 — same investigation as tasks-api above. Today
03-create-event.brusendseventNumberonly; server generatesBATbern{N}. Determine whether the API acceptseventCodein the body; if yes, normalize toBRUNO-TEST-{ts}so the §B3 cleanup-prefix sweep stops being a no-op. If no, file as plan §“Risks to track” item and skip the normalization for events. - Audit-step 4 —
29-delete-event.bruasserts 204. Append29a-verify-event-deleted.brudoingGET /events/{{createdEventCode}}→ 404.
event-topics-api (was PR 8):
- Audit-step 2 — topics already canonical (
bruno-test-topic-{ts}) ✓. Event fixture (30a-) uses the sameBATbern{N}shape — see the events-crud item above. - Audit-step 4 —
67-cleanup-topic-test-event.brudoes DELETE. Append67a-verify-event-deleted.bru→ 404. Topics aren’t currently deleted-then-verified — consider adding34a-verify-topic-still-attached.bruif the topic-selection flow leaves residue worth checking.
sessions-api (was PR 10):
- Audit-step 2 —
session_slugis server-generated from session title? Verify in01-create-session.bru(search for anyslugfield in request vs response). If client can supply, normalize tobruno-test-session-{ts}. - Audit-step 4 —
11-delete-session.bruasserts 204. Append11a-verify-session-deleted.bru→ 404. (Already-fixed by PR 670: test 21presentationTitlestale assertion.)
event-full-workflow-api (was PR 12):
- Audit-step 2 — fixture events use
BATbern{N}— same as events-crud item. - Audit-step 4 —
97a-delete-workflow-fixture-event.bruaccepts[204, 404, 409]. Append97c-verify-workflow-event-deleted.bru→ 404 (only when previous DELETE returned 204; skip when 409 because the event still exists by design). - Optional out-of-scope: registrations CRUD against an event in
REGISTRATION_OPENstate — keep deferred to a hypothetical PR 2c. Not blocking PR 14.
Verification
Run each collection twice locally per §D.5:
make dev-native-up # ensure all services running (EMS may need flywayRepair on V86)
for c in tasks-api events-crud-api event-topics-api sessions-api event-full-workflow-api; do
./scripts/ci/run-bruno-tests.sh development --collection "$c"
./scripts/ci/run-bruno-tests.sh development --collection "$c" # second run = idempotency check
done
Expected: all five green ×2 (pre-existing local-dev baseline post-PR-670: events-crud 28/28, event-topics 24/24, sessions 57/57, event-full-workflow 44/44; tasks-api 25/25). Don’t open the PR if any drift.
What’s NOT in PR 6 scope (explicit non-goals)
- speaker-pool-api — PR 11 redesign (see §“Local pre-flight findings — 2026-05-26 (speaker-pool-api)” addendum).
- speaker-portal-api — fixes ride with PR 11 (same Epic 11 root cause).
- partners-api — PR 13 (PCS
partner_meetingsentityType extension). - The Section D step 1 “verbs vs OpenAPI” gap-documentation — already covered by the PR 2a tests 08/09 + the existing per-collection inline comments. No separate sweep needed.
- Cross-collection prefix normalization on event_code — IF the API doesn’t accept client-supplied eventCode, this entire item drops to a “plan §Risks to track” entry (not a PR 6 blocker).
Definition of done for PR 6
- ~10 small
.brufiles added (~7 verify-deleted-404 stubs + tasks-api hook rewrite). - Each of the 5 collections green ×2 locally.
- Plan-doc rows for PRs 6, 7, 8, 9, 10, 12 updated to ✅ merged with this PR’s SHA.
- Section §D step 2 “Normalize prefix” item gets a finding-summary line noting which collections could and couldn’t normalize event_code.
- Open PR with
Refs: docs/plans/bruno-staging-hardening.md PR #6per convention.
After PR 6 merges, only PR 11 (speaker-pool redesign) + PR 13 (partner_meetings entityType) + PR 14 (gate flip + F1 deliberate-fail exercise) remain before tag-stable-on-success can fire.
Context
Staging deploys at https://api.batbern.ch (production account 188701360969 — “staging” is a CDK envName, not a separate environment) keep breaking after Epic 11 despite all CI tests being green. Root cause: the Bruno API contract tests in bruno-tests/ are wired into .github/workflows/deploy-staging.yml as post-deploy warnings only (continue-on-error: true at line 1226). They don’t gate the deploy, they don’t trigger rollback, and bruno-tests/environments/staging.bru even points to a nonexistent host (api.staging.batbern.ch instead of api.batbern.ch) so most of them haven’t actually executed against real staging in months.
The existing per-entity collections are already there and mostly fine. file-upload-api, companies-api, users-api, tasks-api, partners-api, speaker-portal-api already follow per-entity boundaries with timestamp-suffixed test data and inline cleanup tests. They need a light audit + small targeted patches — not a redesign. The one collection that genuinely needs splitting is the monolithic 67-test events-api, which today bundles events + topics + sessions + speakers + speaker-pool + reminders.
The actual fix is three things:
- New infra: per-service cleanup endpoint, runner flags to iterate one folder at a time, Bruno-failure → rollback workflow, ECR
staging-stabletag, fix the broken staging baseUrl. - One structural change: split
events-apiinto 6 smaller collections in dependency order. - Light audit pass over every collection: standardize on canonical per-entity prefixes (see B1) where inconsistent, add a 2-file unconditional cleanup hook (
00-pretest-cleanup.bru+99-posttest-cleanup.bru), tighten DELETE-then-GET-404 assertions. Most collections need 5-30 minutes of work, not a rewrite.
Branch strategy: Parent test-enhancement branch off develop (test-enhancement/bruno-staging-hardening), with stacked PRs: one infra PR, then 11 small per-entity PRs in dependency order, then a final gate-flip PR. The plan document itself lands as docs/plans/bruno-staging-hardening.md in the infra PR.
Risk callout — this is production data. Cleanup endpoint role-checked + prefix-regex-locked + audit-logged.
Reality-check on the starting frame
Validated during exploration — important corrections:
circuitBreaker: { rollback: true }already exists atinfrastructure/lib/constructs/domain-service-construct.ts:243-246. It catches startup failures only — not “starts but returns 500s.” Bruno fills that gap.deploymentAlarmsare missing (line 299 has a circular-dep comment). Solvable.- Existing collections already use timestamp suffixes (
bruno-test-{{$timestamp}}), so they’re already mostly idempotent. The gap is inconsistent prefixes (bruno-test-,brtest, none) and conditional cleanup (final99-cleanup-*.bruonly runs if earlier tests passed). That’s what the audit pass fixes. - The legacy
deploy-production.yml:362-429rollback job references the decommissioned account 422940799530. Dead code. Fix in same PR. run-bruno-tests.sh:120references aspeakers-apicollection that doesn’t exist on disk. Silently skipped today.speaker-portal-apicorrectly stays dev-only — it depends onE2ETestTokenControllerwhich is gated by@Profile({"dev","local","test","development"})and is inactive in production-account services. Don’t touch it.
Branching & rollout
Parent branch: test-enhancement/bruno-staging-hardening off develop.
| # | Branch | Scope | Bruno gate state after merge |
|---|---|---|---|
| 1 ✅ | test-enhancement/bruno-staging-hardening-infra |
A + B: cleanup endpoints, runner flags, ECR staging-stable tag, deployment alarms (deferred), Bruno-failure rollback job (still continue-on-error: true). One-time SQL pre-cleanup script. Plan doc lands here. |
warning |
| 2 + 2a + 2b + 2c ✅ | test-enhancement/bruno-events-api-split (PR 670, 10 commits) |
C mechanical-only + event-types-api tests + event-full-workflow state-machine walk + apex-domain CORS hotfix + 405 cross-service handler + dev-DB cleanup SQL + 4 local-dev test fixes. Squash-merged 2026-05-26 (57d80f14); branch deleted. |
warning |
| 3 + 4 + 5 ✅ | test-enhancement/bruno-audit-{file-upload,companies,users}-api |
D.1 + D.2 + D.3 bundled into #666 with F4 fixes. | warning |
| 6 + 11 🔵 | test-enhancement/bruno-audit-batched-closeout (= PR #671 → develop) |
Bundled PR 6 + PR 11. PR 6: §D steps 2+4 across tasks-api + 4 PR-670 collections. PR 11: speaker-pool-api ADR-009 redesign + speaker-portal-api cascade fixes + CUMS USERS regex widening + cross-service cleanup hooks. Rebased clean onto squashed develop; all 7 collections green ×2 locally. | warning |
| 7-10 + 12 | absorbed into PR 6 (batched) — see those rows in the Progress log table | warning | |
| 13 🔵 | test-enhancement/bruno-audit-partners-api (= PR #672 → develop) |
D.11 light audit + PCS entityType=meetings id-allowlist cleanup + brand-new partner-meetings-api collection + admin-cleanup-api meetings cases + OpenAPI DELETE. Rebased to 1 clean commit; green ×2 locally. |
warning |
| 14 🔵 | test-enhancement/bruno-gate-flip (= PR #674 → develop) |
E: removed continue-on-error; downstream jobs gate on needs.bruno-tests.result; rollback gained always() + always-on; also repairs the never-promoted staging-stable tag. Owed after merge: F1 deliberate-fail drill. |
blocking + auto-rollback |
A. Infrastructure foundation (PR 1)
A1. Fix broken staging URL (one-line change, do first)
bruno-tests/environments/staging.bru line 2: change https://api.staging.batbern.ch/api/v1 → https://api.batbern.ch/api/v1. Until this is fixed, every “staging” Bruno run silently hits DNS-NXDOMAIN and continue-on-error: true swallows it. Pair this fix with B’s cleanup endpoint so the first real staging run after the fix doesn’t leave junk.
A2. Runner enhancements (scripts/ci/run-bruno-tests.sh)
--collection <name>flag — iterate one folder at a time (./run-bruno-tests.sh staging --collection companies-api).--cleanup-onlymode — runs only99-posttest-cleanup.brufiles. Belt-and-suspenders after a failed collection.--no-bail— relaxset -earound the collection loop so a failed test inside a collection doesn’t orphan that collection’s cleanup file. Track failure in a flag; still exit non-zero at the end.- Remove the dead
speakers-apireference at line 120.
A3. ECS deployment alarms (resolves circular-dep at domain-service-construct.ts:299)
Before creating the FargateService, compute const alarmName = \${envName}-${serviceName}-5xx-rate`` (string only, no service-attribute dependency). Create:
- CloudWatch alarm on API Gateway
5XXErrorfiltered to this service’s route, > 5% over 2 min. - Alarm on
RunningTaskCount < desiredCountover 3 min.
Pass deploymentAlarms: { alarmNames: [...], rollback: true } to the service. Skip target-group alarms (Service Connect topology, no ALBs). Don’t pursue CodeDeploy blue/green — circuit breaker + alarms is the right tool.
A4. ECR last-known-good tag strategy
Three tags per ECR repo (batbern/staging/{service}):
<sha7>— immutable; task definitions pin to this.staging-current— what was just deployed.staging-stable— last-known-good (last Bruno-green deploy).
Rollback resolves staging-stable → SHA → registers a task definition with that SHA. Mutable tags alone don’t trigger ECS redeploys; pin to SHAs.
A5. Bruno-failure rollback job (workflow refactor)
Split monolithic deploy-staging.yml into 4 jobs:
deploy(current CDK steps).bruno-tests(needsdeploy).tag-stable-on-success(needs[deploy, bruno-tests], runs on Bruno success). Promotesstaging-current→staging-stablefor all 5 ECR repos.rollback-on-bruno-failure(needs[deploy, bruno-tests], runs on Bruno failure). Invokesscripts/ci/rollback-deployment.sh staging --yes. Posts Slack notification.
scripts/ci/rollback-deployment.sh: replace interactive sleep 10 at line 33 with [ "$2" = "--yes" ] || sleep 10. Switch image-tag lookup from deployments[1] to staging-stable ECR resolution. Add --dry-run.
While in this file, fix deploy-production.yml:362-429 (decommissioned account 422940799530 reference) — same shape of fix, same PR.
B. Test data isolation + cleanup (PR 1)
B1. Canonical naming convention — must fit real PK/format constraints
Constraints verified against Flyway migrations + JPA + OpenAPI specs. Several of the “obvious” patterns are illegal under these constraints — the table below is binding, not aspirational.
| Entity | Source of constraint | Constraint | Canonical pattern | Filled length |
|---|---|---|---|---|
| companyName | V3__Create_companies_schema.sql:7 + companies-api.openapi.yml |
VARCHAR(255), ^[A-Za-z0-9]+$ (existing tests use hyphens though — verify hyphen acceptance in PR 1) |
BRUNOTESTCO<13-digit-ts> (alphanumeric, safest) OR BRUNO-TEST-CO-<ts> if hyphens confirmed |
24 / 30 chars |
| username | V4__Create_user_profiles_table.sql:13,59-60 |
VARCHAR(100), DB regex ^[a-z]+\.[a-z]+(\.[0-9]+)?$ — dots required, no underscores, no hyphens |
bruno.test.<13-digit-ts> |
24 chars |
V4__Create_user_profiles_table.sql |
VARCHAR(255), RFC 5321 | bruno-test-<ts>@e2e.batbern.invalid (existing convention, reserved domain) |
36 chars | |
| eventCode | V3__Add_event_code_and_organizer_username.sql:6 |
VARCHAR(50), @Size(max=50) |
BRUNO-TEST-<13-digit-ts> |
24 chars |
| sessionSlug | sessions.session_slug |
VARCHAR(200) | bruno-test-session-<ts> |
33 chars |
| topicCode | V24__add_topic_code_column.sql:6 |
VARCHAR(255), lowercase-hyphens convention | bruno-test-topic-<ts> |
31 chars |
| partnerCompanyName | V2__create_partner_coordination_schema.sql:9 ⚠️ known bug |
VARCHAR(12) — extremely tight | brtest<6-digit-id> (matches existing brtest{{$randomInt}}) |
≤12 chars |
partnerTopicTitle (PCS entityType=topics) |
V4__update_topic_tables_for_story_8_2.sql:18 |
VARCHAR(255) topic_suggestions.title |
Bruno Test Topic - <free text> (matches 10-suggest-topic-as-partner); cleanup prefix literal Bruno Test Topic, regex ^Bruno Test Topic$; topic_votes cascade via FK |
≤255 chars |
notificationMarker (EMS entityType=notifications) |
V33__Create_notifications_table.sql:9,11 |
event_code VARCHAR(50) nullable + recipient_username VARCHAR(100) + subject/body |
composite sweep: event_code LIKE 'BRUNO-TEST-%' OR reserved-range BATbern{N} (N ≥ 10000, regex bounded ^BATbern[0-9]{1,9}$ to prevent CAST overflow) OR recipient_username LIKE 'bruno.test.%' OR subject LIKE '%BRUNO-TEST-%' OR body LIKE '%BRUNO-TEST-%' (subject/body markers catch NULL-event_code rows); cleanup prefix literal BRUNO-TEST-, regex ^BRUNO-TEST-$ |
n/a |
| filename (file-upload) | file-upload-api.openapi.yml |
maxLength 255 | bruno-test-<ts>.png |
30 chars |
| registrationCode | registrations.registration_code |
VARCHAR(100) | BRUNO-<ts>-<6char-rand> |
≤28 chars |
Prefix-detection regexes (for cleanup endpoint validation — locked in service code):
- Uppercase alphanumeric (companies):
^BRUNOTESTCO[0-9]+$and/or^BRUNO-TEST-[A-Z0-9-]+$ - Lowercase-dotted (usernames):
^bruno\.test\.[0-9]+$ - Hyphenated lowercase (sessions, topics, files):
^bruno-test-[a-z0-9-]+$ - Hyphenated mixed-case (eventCode, registrationCode):
^BRUNO-(TEST-)?[A-Z0-9-]+$ - Short partner:
^brtest[0-9]+$
Cleanup endpoint accepts a single canonical prefix string per call (one per entity type), validates against the entity-specific regex above before issuing DELETE. The “single BRUNO_TEST_ prefix everywhere” idea from the previous draft is incompatible with reality — entities use different conventions; cleanup must match each.
Document the full table once in new bruno-tests/README.md. PR 1 must verify whether company name hyphens are accepted server-side (the OpenAPI pattern says alphanumeric-only but existing tests use hyphens — one of those is wrong, and we need to know which before locking the pattern).
Most existing collections already use compatible prefixes (e.g. companies-api uses bruno-test-company-*, partners use brtest*). The audit pass (D) normalizes to the canonical patterns above and updates any prefix-detection that diverges.
B2. Per-service cleanup endpoint
Gating (per decision: ROLE_ORGANIZER + regex prefix):
POST /api/v1/admin/test-fixtures/{cums|ems|pcs}/cleanup— one path per owning service. The per-service path discriminator is required becauseapi-gateway’sDomainRouterfalls through/api/v1/admin/*→ EMS by default (line ~103); a single shared path would make CUMS and PCS cleanup endpoints unreachable via the gateway. Each path prefix is matched explicitly inDomainRouter.determineTargetServiceBEFORE the generic/adminfallback. Discovered 2026-05-25 during F2 (admin-cleanup-api Bruno collection) authoring.@PreAuthorize("hasRole('ORGANIZER')").- Body specifies
entityType(companies | users | events | sessions | topics | tasks | partners | registrations | speaker_pool | uploads | notifications [EMS, added 2026-06-06] | topics [PCS partner topic_suggestions, added 2026-06-06]) andprefix. Note:topicsis overloaded — EMStopicscleanstopics/topic_code, PCStopicscleanstopic_suggestions/title; each service binds its own regex. - Server-side constant
Map<EntityType, Pattern>validates the prefix against the entity-specific regex from B1 — request body cannot supply the pattern, only the literal prefix value to match. Reject anything that doesn’t match the bound pattern for the given entity type. This preventsprefix=*/prefix=BAT/ SQL-injection-shaped inputs at the controller layer. - Audit-log every invocation (caller username, entityType, prefix, deletion counts).
- Per-service (not gateway fan-out) for blast-radius control.
Cleanup order inside the endpoint (respects FK constraints):
- registrations → 2. session-speakers → 3. sessions → 4. speaker-pool entries → 5. event-topics → 6. tasks → 7. events → 8. topics → 9. partner-topic-votes/contacts/meetings → 10. partners → 11. users (matching
bruno.test.*) → 12. companies → 13. file uploads (S3 + DB metadata).
Event state machine: dedicated TestFixtureRepository.hardDeleteByPrefix(...) issues raw DELETE FROM events WHERE event_code LIKE 'BRUNO-TEST-%' with cascade. Bypasses state guards, only reachable from this endpoint.
Startup invariant check: at boot, count real entities matching the canonical patterns. Log WARN if > 0 — don’t fail-fast (might be junk from a previous failed run that this cleanup will sweep), but operators should see it.
✅ RESOLVED in PR 13 — partner_meetings cleanup coverage (discovered 2026-05-25): shipped option 1 below — a meetingIds UUID allowlist on the PCS cleanup endpoint (entityType=meetings), consumed by the new partner-meetings-api collection’s 98-posttest-cleanup-meetings.bru. The original analysis is retained below for context.
PCS owns partner_meetings but the table has no FK to partners — meetings are standalone events that link to partners only via partner_meeting_attendance / partner_meeting_rsvps (which cascade from partners, not from the meeting itself). The PR 1 PCS cleanup endpoint only handled entityType=partners, so:
- Deleting a
brtest*partner removes its attendance + RSVP rows but leaves the underlyingpartner_meetingsrows in place. - Bruno’s
partner-meetings-apicollection creates ad-hoc meetings (meeting_type='ad_hoc',scheduled_date=…) with no Bruno-identifying column — there’s nobruno-test-prefix on a meeting row to match against.
Options for PR 13 (partners-api audit) or a sibling PR (partner-meetings-api audit, currently not in the rollout — would need to be added):
- Add a
meeting_idallowlist parameter to the cleanup endpoint — Bruno collects IDs from create-meeting tests viabru.setVarand posts them back to cleanup. Tighter than a prefix; safe even though meetings lack a Bruno-prefix column. Recommended. - Add a marker column to
partner_meetings(e.g.created_by_test_fixture BOOLEAN) — schema change, larger blast radius, only do this if the allowlist proves too clunky. - Constrain by
created_atwindow — Bruno cleanup deletes meetings created in the last N minutes by the cleanup caller. Risky on a shared staging account (could nuke a concurrent organizer’s meeting).
Recommendation: option 1, scoped to a new meetings entityType on the PCS cleanup endpoint. Not blocking PR 1 because the staging audit (2026-05-24) found 0 stray partner_meetings rows — Bruno meetings-api isn’t run in CI today against staging (deferred to its first audit).
B3. Per-collection cleanup .bru files
Each collection gets:
00-pretest-cleanup.bru— POST to cleanup endpoint with that entity’s prefix. Status assertiononeOf([200, 204, 404])— never fails the run.99-posttest-cleanup.bru— same.
Bruno orders by filename → 00- first, 99- last. Two-layer defense: run-bruno-tests.sh ALSO invokes --cleanup-only after each collection regardless of pass/fail.
C. events-api decomposition proposal (PR 2)
The only collection that genuinely needs splitting. The pre-PR-2 bruno-tests/events-api/ was 60 active tests (+ 3 disabled) covering 6 conceptually-distinct entity domains. Proposed split:
| New collection | Source tests | New tests needed | Auth |
|---|---|---|---|
event-types-api |
(none — currently untested) | All new: GET /types, idempotent PUT. No create/delete — API gap, not a test bug. Document the gap. | organizer |
event-topics-api |
30, 30a, 31, 32, 33, 34, 67 | 00-/99- cleanup hooks. 30a (fixture event) becomes 00a-fixture-event.bru |
organizer |
events-crud-api |
01, 03–07, 19, 29 | 00-/99- cleanup, DELETE-then-GET-404 | organizer |
sessions-api |
08–11, 20–28 | 00-/99- cleanup, fixture event setup | organizer |
speaker-pool-api |
35–56, 60–66 | 00-/99- cleanup. Promote tests (43–48) provision real Users in CUMS — cleanup must wipe bruno.test.* users via CUMS endpoint (cross-service) |
organizer |
event-full-workflow-api |
12, 13, 16 | Net-new tests advancing through all 8 states: CREATED → TOPIC_SELECTION → SPEAKER_IDENTIFICATION → SLOT_ASSIGNMENT → AGENDA_PUBLISHED → EVENT_LIVE → EVENT_COMPLETED → ARCHIVED + registrations CRUD | organizer |
speaker-portal-api stays unchanged and dev-only. After the split, bruno-tests/events-api/ directory ceases to exist (no overlap with the new collections).
PR 2 actual scope — mechanical-only (decided 2026-05-26)
PR 2 ships only the mechanical move + cleanup hooks, not the net-new tests for event-types-api and event-full-workflow-api. Two follow-up PRs land the net-new content:
| Sub-PR | Scope | Status |
|---|---|---|
| PR 2 (this one) | Move all 60 active .bru files + 3 .disabled files into 4 real collections (events-crud, event-topics, sessions, speaker-pool). Seed event-full-workflow-api with tests 12/13/16 (+ 13a.disabled). Add 00-pretest-cleanup.bru + 99-posttest-cleanup.bru (plus 00a-/99a- topic sweeps for event-topics-api) for all 5 active collections. Create empty event-types-api/ (README only — no .bru files yet). Update runner’s collections=() array. Remove bruno-tests/events-api/. |
🟡 in progress |
| PR 2a | event-types-api net-new tests: GET /event-types + idempotent PUT + documenting the absent POST/DELETE as 405 or contract-absent. ~3–5 tests. | ⬜ not started |
| PR 2b | event-full-workflow-api net-new tests: advance through all 8 workflow states + registrations CRUD against a published event. ~10+ tests, longest collection. | ⬜ not started |
Why the split: writing the 8-state advancement test set is its own design problem (state-machine ordering, valid-transition matrix, invalid-transition rejections, post-publish-only registration windows) — pulling it out of PR 2 lets the mechanical move land fast and unblock PRs 7–12 audit work on the 4 collections that did get full content.
The speaker-pool-api collection is the hairy one — its cleanup spans events service (pool entries) + CUMS (provisioned Users). PR 2 ships only the events-side hook (BRUNO-TEST-* cascade clears speaker_pool); the CUMS side is gated on a small backend extension that’s now a documented TODO in speaker-pool-api/99-posttest-cleanup.bru’s docs block. Two paths for PR 11 (D.9 speaker-pool audit) — widen CUMS USERS regex to accept promote.e2e., OR change tests 45–47 to use firstName=Bruno, lastName=Test so the auto-generated username matches the existing bruno.test. prefix. Either is ~5–10 LOC.
D. Light audit pass on each collection (PRs 3–13)
Most collections are already structurally fine — this is a tune-up, not a rewrite. Per-collection checklist:
- Glance through verbs vs OpenAPI — note any missing CRUD operations as known gaps (e.g., event-types has no DELETE). Don’t try to add backend endpoints in this scope.
- Normalize prefix — search-and-replace
bruno-test-/brtest/ inconsistent variants → canonical patterns per B1. Usually 1–5 line changes per collection. - Add
00-pretest-cleanup.bru+99-posttest-cleanup.brucalling the cleanup endpoint with that entity’s prefix. - Audit DELETE assertions — every DELETE test followed by a GET that asserts 404. Today most just trust the 204.
- Run twice —
./scripts/ci/run-bruno-tests.sh staging --collection <name>twice in a row. First run cleans + creates + cleans. Second run starts dirty (intentionally — to verify pretest-cleanup is real), then green. If green ×2 → commit, open PR.
Order (dependency-first, lowest-coupling-first):
file-upload-api— leaf, no cross-entity couplingcompanies-apiusers-api— depends on companies (optional companyName ref)tasks-api— depends on eventsevent-types-api(from C split) — GET-mostly, lightevent-topics-api(from C split)events-crud-api(from C split)sessions-api(from C split) — needs event + speakerspeaker-pool-api(from C split) — cross-service cleanup, highest riskevent-full-workflow-api(from C split) — longest collectionpartners-api— depends on companies + users
speaker-portal-api: skip (dev-only, unchanged).
Expected size: 6 of these PRs probably 5-50 line diffs (existing collections are mostly fine). The 5 new-from-events-split PRs are larger.
E. Gate flip (PR 14)
Only after every PR 2-13 has been merged AND passed staging CI green twice in a row:
- Remove
continue-on-error: trueatdeploy-staging.yml:1226. - Confirm the
rollback-on-bruno-failurejob from A5 is wired (if: needs.bruno-tests.result == 'failure'). - Run F1’s deliberate-fail exercise (below) to prove the rollback path on a feature branch before merging this PR.
F. Verification
F1. Test the rollback path without breaking real users
Layered, increasingly real:
- Dry-run mode —
scripts/ci/rollback-deployment.sh staging --dry-runprints what it would do without callingaws ecs update-service. Verifies permissions, credentials, image-tag lookup. - Deliberate-fail on a branch — add one Bruno test asserting
expect(res.getStatus()).to.equal(999). Push branch → runworkflow_dispatchof staging deploy from that branch → confirm: deploy succeeds → bruno-tests fails → rollback-on-bruno-failure runs → ECS task defs pin tostaging-stableSHA → close branch and letdevelop’s normal deploy restore current. Pick a low-traffic window (BATbern ~3 events/year). - Quarterly disaster drill — separately, push a container that exits on startup; confirm circuit breaker rolls in-place within 5 min.
F2. Verify cleanup endpoint authorization
Before PR 1 merges, exercise the endpoint in development for every entity type:
- Non-organizer JWT + any payload → 403
- Organizer +
entityType=companies+prefix=BRUNOTESTCO→ 200 with deletion count - Organizer +
entityType=users+prefix=bruno.test.→ 200 with deletion count - Organizer +
entityType=users+prefix=BRUNOTESTCO→ 400 (prefix doesn’t match the username regex) - Organizer +
entityType=companies+prefix=BAT→ 400 (would match real entities) - Organizer +
entityType=companies+prefix=(empty) → 400 - Organizer +
entityType=companies+prefix=BRUNOTESTCO'; DROP TABLE...→ 400 (regex enforcement at controller layer) - Organizer +
entityType=foo_bar(unknown) → 400 - Real-data tables: query rows matching each canonical regex before/after; confirm 0 unintended deletions.
Add as a new bruno-tests/admin-cleanup-api/ collection that runs FIRST in run-bruno-tests.sh, so authorization regressions are caught before any test creates state to clean.
F3. End-to-end on staging after PR 14
- Push trivial change to
develop→ deploy-staging.yml runs. - Confirm
bruno-testsjob is now blocking. - Check ECR:
staging-stable= previous SHA,staging-current= new SHA. - Trigger a deliberate-fail again on a branch → confirm rollback runs and Slack notification arrives.
- Cleanup audit logs in CloudWatch show no anomalies.
G. Local pre-flight (every PR)
bruno-tests/environments/development.bru already targets http://localhost:8000/api/v1, and scripts/ci/run-bruno-tests.sh accepts development as its env arg. Every PR in this plan should run its Bruno tests against local dev once before pushing. Same code, same Spring Boot config, same Postgres, same staging Cognito for JWT — local catches regex / route / config drift that Java integration tests miss (because they bypass the gateway and call the service directly), and surfaces wiring issues seconds after a save instead of minutes after a CI run.
Coverage matrix — what local dev validates ahead of staging
| PR scope | Local-dev coverage |
|---|---|
| B2 cleanup endpoints (CUMS done; EMS + PCS upcoming) | ✅ Full — regex map, ROLE_ORGANIZER gate, cascade order, audit logging |
B3 per-collection 00-/99- cleanup hooks |
✅ Full — HTTP-level, env-agnostic |
F2 admin-cleanup-api auth matrix |
✅ Full — develop the 8 cases locally first; same collection then runs against staging |
| C events-api decomposition + 6 new collections | ✅ Full — Bruno collection structure is env-agnostic |
| D per-collection audit pass (PRs 3-13) | ✅ Full — every D.5 “run twice” step runs locally first |
| Cross-service cleanup order (PCS → EMS → CUMS) | ✅ Full — end-to-end via Bruno |
A2 runner flags (--collection, --cleanup-only, --no-bail) |
✅ Full — already exercised against development |
A5 rollback-deployment.sh |
🟡 Partial — --dry-run verifies arg parsing + ECR tag-lookup; AWS calls skipped |
| A3 ECS deployment alarms | ❌ Staging-only — CloudWatch + ECS deployment controller has no local analogue |
A4 ECR staging-stable tag promotion |
❌ Staging-only — ECR is AWS |
A5 rollback-on-bruno-failure workflow job |
❌ Staging-only — fires only in GitHub Actions |
One-time bruno-staging-pre-cleanup.sql |
❌ Staging-only — the 17 target rows only exist in the staging DB |
| Real auto-rollback ECS task-def swap | ❌ Staging-only — deferred to F1’s deliberate-fail exercise |
Prerequisite: Pattern 3b user mirroring
Staging-issued JWTs (from ~/.batbern/staging-organizer.json) work locally because shared-kernel/.../security/JwtRolesConverter falls back to the local DB when custom:role is empty (Epic 11.E.7 Pattern 3b). The user_profiles row must exist in the local DB. If cleanup-endpoint calls return 403 Forbidden locally but pass in staging:
./scripts/dev/sync-users-from-cognito.sh # mirror staging users → local DB
./scripts/ci/run-bruno-tests.sh development --collection admin-cleanup-api
See docs/architecture/06b-user-lifecycle-sync.md §“Pattern 3b” for the full pattern.
Per-PR workflow
- Code + Java unit/integration tests green (TDD red-green-refactor).
- Local pre-flight:
make dev-native-up+ the relevant./scripts/ci/run-bruno-tests.sh development --collection <name>run. - Commit + push.
- Staging CI re-runs the same Bruno tests (still
continue-on-error: trueuntil PR 14). - After PR 14: staging Bruno is the deploy gate; local pre-flight remains the first signal.
For PR 1 specifically, the EMS and PCS cleanup-endpoint work is fully exercisable locally (Section B is ~90% of remaining PR 1 scope by line count) — only the workflow / alarm / ECR pieces of Section A fall through to staging-only verification.
Critical files
| Path | Change |
|---|---|
bruno-tests/environments/staging.bru |
Fix baseUrl line 2 |
bruno-tests/README.md (new) |
Document canonical naming convention |
bruno-tests/admin-cleanup-api/ (new) |
Authorization test collection (F2) |
bruno-tests/{file-upload,companies,users,tasks,partners}-api/00-pretest-cleanup.bru + 99-posttest-cleanup.bru (new) |
Per-collection cleanup hooks for existing collections |
bruno-tests/{event-types,event-topics,events-crud,sessions,speaker-pool,event-full-workflow}-api/ (new, ×6) |
Split from events-api |
bruno-tests/events-api/ |
Removed after PR 2 |
scripts/ci/run-bruno-tests.sh |
Add --collection, --cleanup-only, --no-bail; remove speakers-api ghost; relax set -e around collection loop |
scripts/ci/rollback-deployment.sh |
--yes, --dry-run, switch to staging-stable ECR tag |
.github/workflows/deploy-staging.yml |
Split into 4 jobs; ECR retag steps; flip continue-on-error in PR 14 |
.github/workflows/deploy-production.yml:362-429 |
Fix dead account-422940799530 reference |
infrastructure/lib/constructs/domain-service-construct.ts:299 |
Add deploymentAlarms with statically-named alarms |
services/*/src/main/java/.../controller/TestFixtureCleanupController.java (new, ×5) |
Per-service cleanup endpoint |
scripts/db/bruno-staging-pre-cleanup.sql (new) |
One-time exact-match deletion of 17 legacy test rows (10 companies + 7 partners, with cascade dependents). Runs once when PR 1 deploys. |
docs/plans/bruno-staging-hardening.md (this file) |
Checked-in plan artifact |
Service ownership mapping (discovered 2026-05-24)
All 5 microservices share the public schema. Only 3 services own entity tables:
| Service | Owns tables |
|---|---|
| CUMS (company-user-management-service) | companies, user_profiles, role_assignments, user_additional_emails, logos |
| EMS (event-management-service) | events, event_types, event_tasks, event_photos, event_teaser_images, topics, topic_usage_history, sessions, session_users, session_materials, session_content_history, session_timing_history, registrations, speaker_pool, speaker_invitation_tokens, speaker_outreach_history, speaker_arrivals, speaker_slot_preferences, speaker_reminder_log, speaker_status_history |
| PCS (partner-coordination-service) | partners, partner_meetings, partner_meeting_attendance, partner_meeting_rsvps, partner_notes, topic_suggestions, topic_votes |
Speaker-coordination-service and attendee-experience-service own NO tables (still foundation phase) — no TestFixtureCleanupController needed there. PR 1 ships 3 cleanup controllers, not 5.
Cross-service cleanup order (Bruno cleanup .bru files orchestrate):
- PCS (deletes partner-side rows first — they reference companies + users by name)
- EMS (deletes events + cascades — they reference users by name)
- CUMS (foundation — last to delete, deletes companies + users + logos)
Reuse — existing functions/utilities
scripts/auth/get-token.sh+refresh-token.sh— already handle multi-role staging tokens.AbstractIntegrationTest(shared-kernel) — base for Java tests covering the cleanup controller.E2ETestTokenController.java— pattern reference forTestFixtureCleanupController.java, but use@ConditionalOnProperty+@PreAuthorizeinstead of@Profile.- Bruno’s existing
bru.setEnvVar()/bru.setVar()pattern — keep for identifier handoff within collections. - Existing
circuitBreaker: { rollback: true }atdomain-service-construct.ts:243-246— stays; A3 only ADDSdeploymentAlarms. - Existing per-collection
99-cleanup-test-*.brufiles — keep them, the new99-posttest-cleanup.bruruns after them as a final defense.
Risks to track
- Staging account = production. Every “test against staging” is “test against production.” Cleanup endpoint is the only thing standing between Bruno and real data — per-entity regex prefix lock + role check + audit log + startup invariant warn are all required.
- First real cleanup run after A1’s URL fix will likely delete pre-existing test junk in the production-account DB (months of accumulated leftovers from runs that hit DNS-NXDOMAIN). Pre-audit: query rows matching each entity’s canonical regex from B1 before merging PR 1 and confirm with user that everything matched is disposable. (Pre-audit results table at the top of this file.)
deploy-production.yml:362-429references decommissioned account 422940799530. Same-PR fix in A5.scripts/ci/run-bruno-tests.sh:4 set -esilently masks token-loading failures. Audit before relying on the script as a deploy gate.- Circuit breaker only catches startup failures. Until A3’s deployment alarms ship, Bruno is the only safety net for “container starts but serves 500s.” Ship A3 in PR 1 so both arrive together.
speaker-pool-apicleanup is cross-service (events + CUMS). PR 11 (its audit) needs PR 1’s CUMS cleanup endpoint to exist — sequencing is built in already, but flag if anything slips.eventCode+sessionSlugare server-generated, not client-suppliable (verified 2026-05-26 during PR 6).CreateEventRequest.javahas noeventCodefield — server derivesBATbern{eventNumber}.CreateSessionRequest(events-api.openapi.yml) has nosessionSlugfield — server slugifies fromtitle. Consequence: the00-/99-cleanup hooks’entityType=events, prefix=BRUNO-TEST-sweep is a no-op for today’s fixture events (they’re namedBATbern{N}); per-run teardown happens via explicit98-delete-fixture-event.bruDELETE by eventCode instead. The canonical prefix sweep becomes load-bearing only if the API later accepts client-suppliedeventCode/sessionSlug. Not a bug — a constraint to remember when reasoning about staging cleanup blast radius.- Constraint surprises to verify in PR 1 — these are likely bugs the test work uncovers:
partners.company_name VARCHAR(12)vscompanies.name VARCHAR(255)is a real bug (partners can only reference companies whose names are ≤12 chars). Don’t fix in this plan’s scope, but file as a separate ticket on first hit. Leave a comment inV2__create_partner_coordination_schema.sql:9.companies-api.openapi.ymlsays company name regex is^[A-Za-z0-9]+$(alphanumeric only) butcompanies-api/01-create-company.bruuses hyphens and tests pass. Either the OpenAPI is wrong or server validation isn’t enforced — PR 1 reproduces and reports. Whichever is wrong is a bug-fix commit in PR 1 or follow-up.users-apitest data may not match the DB regex^[a-z]+\.[a-z]+(\.[0-9]+)?$(existing tests use hyphens — verify in PR 5’s audit). If existing tests pass despite hyphens, the regex isn’t enforced server-side: another bug to file.- Any other “test passes but shouldn’t” mismatches discovered during the audit pass land as inline fix-commits in the relevant PR (per user: “fixing some bugs we prob. find on the way”).