Skip to content

fix(e2e): close the throwaway-customer leak at its source - #678

Merged
CybotTM merged 3 commits into
mainfrom
fix/e2e-throwaway-customer-leak
Aug 15, 2026
Merged

fix(e2e): close the throwaway-customer leak at its source#678
CybotTM merged 3 commits into
mainfrom
fix/e2e-throwaway-customer-leak

Conversation

@CybotTM

@CybotTM CybotTM commented Aug 15, 2026

Copy link
Copy Markdown
Member

What this closes

#675 diagnosed a scheduled-run failure down to a leaked throwaway customer and hardened the consumerscreateWorklogEntry now picks the seeded bookable customer by name instead of taking the first option. The leak itself was left open: e2e/admin-inline-edit.spec.ts creates a global, project-less E2EInline_<ts>_<rand> customer and deletes it in a finally that drives the row's Delete button and swallows every error. A test that dies mid-UI — with a modal covering that button, or the row detached — leaves the row behind, and because db-e2e is a persistent volume and the customer list is name-sorted, that row sorts ahead of Freizeit and pollutes every later spec in the shard that reads the list. This PR closes it at the source.

e2e/admin/admin-ui.spec.ts turned out to be the worse of the two: its UI delete is the test's final assertion and it had no finally at all, so any failure before that point leaked unconditionally.

Defense in depth

Per-test cleanup that survives a failed test. deleteThrowawayCustomers() resolves the row over GET /getAllCustomers and removes it with POST /customer/delete — the same JSON endpoint the admin UI uses. No page state is involved, so it works from a finally after any UI failure; an open modal can no longer block it, which is why the Escape-then-delete dance in the inline-edit spec could go away. It matches on the name prefix, so the renamed variants (-edited, -draft, _Renamed) are covered without the caller enumerating which name actually landed.

A cleanup failure is reported, not swallowed. A row that could not be removed produces a console.error line and a cleanup-failed TestInfo annotation, so CI surfaces it instead of it becoming the next shard's mystery flake. Nothing in the helper throws, so a finally calling it can never replace the test's own error with a teardown error. The verdict comes from re-reading the customer list rather than from the delete's status code: /customer/delete answers 422 both for a genuine failure and for a row the parallel worker already removed, and reporting on the status alone made every concurrent sweep cry leak (observed, then fixed).

A sweep so a leak cannot outlive the spec. sweepStaleThrowawayCustomers() runs in the beforeEach of both specs that create throwaway rows and drops ones left by an earlier run that crashed hard enough to skip its own finally, so a shard heals itself instead of needing a manual db-e2e cleanup. Only rows whose embedded timestamp is older than ten minutes are touched — fullyParallel is on with two workers per CI shard, so the sibling worker may have a fresh throwaway row live at that very moment, and the per-test timeout is 30s.

The UI delete in admin-ui.spec.ts deliberately stays a UI click: exercising it is that test's subject. The new finally is only a safety net around it.

Sibling check

.admin-crud-toolbar/Add-modal entity creation exists in exactly the two specs fixed here — no other spec creates an admin entity, so the customer-shaped leak has no further instances. The worklog specs (worklog-crud, worklog-grid-editing, date-format via cleanupWorklogEntries, and summary-refresh via cleanupStamped) create throwaway entries rather than customers. They share the best-effort shape, but the exposure differs in kind: entries are per-user, are matched by their own unique stamp rather than positionally, and are already cleaned by a prefix sweep rather than by exact identity — so a leaked entry cannot reorder a relation list the way a leaked customer does. cleanupWorklogEntries does still scrape the DOM rather than the API, which is the same root cause in a milder form; that is worth a separate change, and it is not folded in here because the specs it serves are the ones e2e/AGENTS.md marks as CI-authoritative and therefore not verifiable on this branch.

Verification

Ran against a local e2e stack (ghcr.io/netresearch/timetracker:e2e, compose profile e2e, own project namespace, E2E_BASE_URL=http://localhost:8768), asserting on the customers table directly after each run.

Baseline on unmodified origin/main (3e2a2fe): admin-inline-edit 13/13 pass; date-format 2/2 fail on getByLabel(/Date format|Datumsformat/i) never resolving — a pre-existing local-vs-CI divergence, identical before and after this change.

The leak reproduced and closed, same tree and containers, A/B via a temporary probe that opens the Edit modal and then throws: with the pre-fix cleanup the row survives in db-e2e (E2EInline_1786772591271_324639 left behind); with this one the table holds only the three seed rows, and the test still reports Error: simulated mid-test failure rather than a teardown error.

The sweep: a planted stale row (E2EInline_9999999999_999999-draft, plus an E2ECustomer_..._Renamed) is removed on the next run, while a row stamped with the current Date.now() is left untouched — so the age bound that protects a concurrent worker is doing real work rather than the sweep simply deleting everything.

Shard-1-like combination (admin-inline-edit + admin/admin-ui + date-format, --workers=2) with a stale row planted first: 18 passed, the 2 pre-existing date-format failures unchanged, the planted row swept, and the customers table back to exactly its three seed rows — no cross-spec residue.

The diff is confined to e2e/.

## The leak

The admin specs create a global, project-less customer to mutate so they
never touch the shared seed rows, then delete it in a `finally`. That
delete drove the row's Delete button and swallowed every error, so a
test that died mid-UI — with a modal covering the button, or the row
detached — left the row behind. `db-e2e` is persistent and the customer
list is name-sorted, so a leaked `E2E*` row sorts ahead of the seeded
bookable customer and derails every later spec in the shard that reads
that list. #675 hardened those consumers to pick by name; the leak
itself stayed open.

`e2e/admin/admin-ui.spec.ts` was the worse of the two: its UI delete is
the test's final assertion and there was no `finally` at all, so any
earlier failure leaked unconditionally.

## Two independent guards

`deleteThrowawayCustomers()` resolves the row over `/getAllCustomers`
and deletes it with `POST /customer/delete`. No page state is involved,
so it still works from a `finally` after any UI failure. It matches on
the name prefix, which covers the renamed variants (`-edited`, `-draft`,
`_Renamed`) the callers previously had to enumerate. A row it cannot
remove is reported as a TestInfo annotation plus a console line rather
than swallowed, and nothing in it throws, so it can never mask the
test's own failure. The verdict comes from re-reading the list, not from
the delete's status code: `/customer/delete` answers 422 both for a real
failure and for a row the parallel worker already removed.

`sweepStaleThrowawayCustomers()` runs in `beforeEach` and drops rows
left by an earlier run that crashed hard enough to skip its own cleanup,
so a shard heals itself instead of needing a manual DB cleanup. Only rows
whose embedded timestamp is older than ten minutes are touched: under
`fullyParallel` the sibling worker may have a fresh throwaway row live
at that very moment, and the per-test timeout is 30s.

The UI delete in `admin-ui.spec.ts` stays a UI click — it is that test's
subject. The new `finally` is only a safety net around it.

## Verified

Against a local e2e stack, same tree, same containers: with the pre-fix
cleanup, a test failing while the Edit modal is open leaves the row in
`db-e2e`; with this one the table is clean and the test still reports
its own error rather than a teardown error. A planted stale row is swept
while a freshly-created one is left untouched, so the age bound is doing
real work. `admin-inline-edit` (13) and `admin/admin-ui` (5) pass.

Claude-Session: https://claude.ai/code/session_01AcqcEjgwcQfp3vpnFa3gh6
Signed-off-by: Sebastian Mendel <github@sebastianmendel.de>
Comment thread e2e/helpers/admin-fixtures.ts Fixed
SonarCloud flags Math.random as a security hotspot
(typescript:S2245) in the new helper, blocking the PR quality gate.
Nothing here is security-relevant - the suffix only de-duplicates
names across parallel workers - but crypto.randomInt costs nothing
and clears the finding at the source instead of a Sonar UI review.

Claude-Session: https://claude.ai/code/session_01AcqcEjgwcQfp3vpnFa3gh6
Signed-off-by: Sebastian Mendel <github@sebastianmendel.de>
@sonarqubecloud

Copy link
Copy Markdown

@CybotTM

CybotTM commented Aug 15, 2026

Copy link
Copy Markdown
Member Author

Self-review (Copilot review unavailable — monthly quota exhausted; diff reviewed manually).

Checked on head 1cd4194: the leak is closed at the source with two independent guards — an API-based deleteThrowawayCustomers() that works from a finally regardless of UI state (open modal, detached row) and never throws over the test's own error, plus an age-bounded sweepStaleThrowawayCustomers() in the beforeEach of both creating specs so a hard-killed run cannot poison the persistent db-e2e for its successors. Three details are exactly right: the leak verdict comes from re-reading the customer list rather than the delete's status code (/customer/delete answers 422 both for a real failure and for a row the sibling worker already removed), failures surface as console.error + a cleanup-failed TestInfo annotation instead of being swallowed, and the ten-minute staleness bound protects the concurrent worker's live row under fullyParallel. The sibling check is measured, not asserted: admin/admin-ui.spec.ts was the worse instance (no finally at all) and is fixed; the worklog specs' best-effort cleanups differ in kind (per-user entries, stamp-matched) and are correctly left for a separate change. The UI delete in admin-ui stays a UI click because it is the test's subject — the finally is only the net.

Verification includes a genuine A/B repro (simulated mid-test failure leaks the row pre-fix, leaves the table at its three seed rows post-fix, with the test's own error preserved) and a planted-stale-row sweep test that also proves the age bound spares fresh rows. The two local date-format failures are the documented local-vs-CI divergence, identical on the unmodified base — CI is authoritative there and all four E2E shards are green on this head. Follow-up commit 33d0d6f swapped the name-suffix PRNG to crypto.randomInt to clear SonarCloud's S2245 hotspot (not security-relevant here, fixed at source anyway); the thread is answered and resolved, and all 17 checks are green after the base merge.

@CybotTM
CybotTM marked this pull request as ready for review August 15, 2026 05:56
Copilot AI lite review requested due to automatic review settings August 15, 2026 05:56
@CybotTM
CybotTM merged commit 2e01032 into main Aug 15, 2026
20 of 21 checks passed
@CybotTM
CybotTM deleted the fix/e2e-throwaway-customer-leak branch August 15, 2026 05:56

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants