fix(api-test): enforce shared database ownership in pg-security integration tests - #41
Conversation
The suite created users, credentials, roles, sessions and rate-limit buckets in the shared database and closed its pools without deleting any of them. Every identifier it mints is a fresh uuidv7, so a second run never collided and all 11 tests passed on a database they had already polluted -- while each run added 25 rows: 4 users, 4 credentials, 4 roles, 4 sessions and 9 buckets. Measured, not inferred: two runs against one PostgreSQL 16 database went 11/11 then 11/11, with the row counts doubling in between. Each test now runs inside withSharedDatabase from Increment 46 and names what it owns. Users are removed by id, which cascades to credentials, roles and sessions -- the only foreign keys to users without ON DELETE CASCADE are games.white_id and games.black_id, and this file creates no games. Buckets are removed by exact key, never by the shared "integration:" prefix, which is a naming convention other suites use rather than an ownership claim. Identifiers are recorded before the statement that creates the row. The reverse order loses exactly the rows worth cleaning: a create that commits and is then contradicted by a failing assertion never reaches the line that would have registered it. Reaching the helper needed a ./test-support/fixtures subpath export; withSharedDatabase was otherwise unreachable outside the persistence package. The API package already consumes ./test-support for withTestDatabase. Also fixes a hang the audit turned up: the bucket-creation race test read two backend pids between admin.connect() and the try that releases the client, so a failure there leaked the lease, and a pool with a client still checked out never finishes end(). Verified directly -- pool.end() does not settle while a client is outstanding. The new regression runs the real suite as a child process against a disposable database and compares row identity before and after, because the defect is invisible from inside: the suite's own assertions pass just as well on the hundredth run as on the first. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CUPqvu66J5ZVyiv4nJ797r
|
/review |
|
@coderabbitai full review |
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Team Run ID: 📒 Files selected for processing (5)
Included review availability: Your plan provides up to 8 included reviews per hour; 6 remain after this review. 📝 WalkthroughWalkthroughThe pull request adds PostgreSQL cleanup-ownership integration tests, migrates security tests to shared fixture cleanup, improves concurrency-test resource handling, and exports persistence test fixtures. ChangesPostgreSQL security test isolation
Estimated code review effort: 4 (Complex) | ~45 minutes Merge Risk: ⚪ Minimal · up to The updated PostgreSQL test fixtures and cleanup coverage do not introduce an actionable production or test reliability risk. Sequence Diagram(s)sequenceDiagram
participant OwnershipTest
participant ChildProcess
participant PostgreSQL
OwnershipTest->>PostgreSQL: Snapshot security rows and insert sentinels
OwnershipTest->>ChildProcess: Run compiled PostgreSQL security suite
ChildProcess->>PostgreSQL: Create and clean owned rows
ChildProcess-->>OwnershipTest: Return test result
OwnershipTest->>PostgreSQL: Compare snapshots and verify unrelated rows
Suggested reviewers: 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Comment |
PR Summary by QodoEnforce fixture ownership in PostgreSQL security integration tests
AI Description
Diagram
High-Level Assessment
Files changed (3)
|
Code Review by Qodo🐞 Bugs (0) 📘 Rule violations (0) 📎 Requirement gaps (0)
Great, no issues found!Qodo reviewed your code and found no material issues that require reviewTip of the day💡 Did you know, you can keep summaries lean with Finding overflow, which tucks the rest behind 'View more' |
|
CodeRabbit's pre-merge docstring check read 66.67% against an 80% threshold. `readOwnedState` was the function without one: the block above it documents the `OwnedState` shape, not the function that reads it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CUPqvu66J5ZVyiv4nJ797r
|
/review |
|
@coderabbitai full review |
|
Code review by qodo was updated up to the latest commit 22ee0d7 |
✅ Action performedFull review finished. |
Deferred while PR #35 held the milestone docs; that is merged, so the Increment 48 payload lands here rather than only in the PR body. PROJECT_STATE gains the increment ahead of 47: the pre-fix residue as measured (11/11 passing while leaking 25 rows a run, twice over), the single root cause, the ownership boundary and what was rejected, the `./test-support/fixtures` export, the leased-client release fix, the regression proven red then green, A/B/C on one database, and 8 of 9 mutations killed with the survivor named rather than rounded up. Two findings are recorded as OPEN rather than quietly carried: analysis-cache-durable never migrates and so depends on state another suite establishes (6 of 10 fail on a fresh database), and test:counts exits 1 because services/gateway sits outside the workspaces. Both are bounded follow-ups for their own PRs; neither is fixed here, and the gateway remedy is left to be established rather than assumed. Counts are labelled as readings, not invariants, and are given for both the implementation HEAD and after this branch was synchronized with main, since Increment 47's expanded correlator suite moved them. Increment 47 is left exactly as it was written, Signature B included: it remains UNRESOLVED, now with 0 occurrences observed during Increment 48, which bounds nothing more than the rate. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CUPqvu66J5ZVyiv4nJ797r
|
/review |
|
@coderabbitai full review |
|
Code review by qodo was updated up to the latest commit ba88113 |
✅ Action performedFull review finished. |
M15 Increment 48 — the API PostgreSQL security integration suite left every row it created in the shared database. This gives it ownership of that state and proves the ownership holds.
Original starting
origin/main:90211916fbdaca290e994fc7d556e7f320e4ddd4.mainmerged during this branch's continuation:df8e5105bae97ecc752cabf96b3e5d26af8c0359(PR #35 — Signature B correlator hardening). Brought in by a normal merge, no rebase and no force; zero conflicts, and no implementation-file overlap.Final HEAD after documentation synchronization:
ba88113af4ac90c54003efba0bf1b7c4f04d87dd.Changed files against current
main(5):packages/api/test/pg-security.integration.test.ts,packages/api/test/pg-security-ownership.integration.test.ts,packages/persistence/package.json,docs/PROJECT_STATE.md,docs/ROADMAP.md.The defect, reproduced before anything was edited
PostgreSQL 16.14 (
pgvector/pgvector:pg16), Node v24.15.0,pg8.22.0, dedicated database,DATABASE_URLset (no run was accepted without it).25 rows per run, accumulating linearly, with the suite green forever. That is the whole defect: not a failure, a leak. Every identifier the file mints is a fresh
uuidv7(), so a second run never collides and no assertion in the file can see the residue. Sentinel rows belonging to a notional other suite were planted before run 1 and were still present after run 2 — the file destroys nothing, it only accumulates.An independent read-only audit (delegation 1) derived the same 4/4/4/4/9 = 25 from the source without seeing these numbers.
Ownership ledger
uuidv7(), handleRace<suffix>ON DELETE CASCADE(0001_init.sql:91,127)uuidv7()sessions.user_idcascade (0001_init.sql:111);rotated_fromis not an FK (0001_init.sql:115)uuidv7()uuidv7()integration:<uuid>integration:full|roomy:<run>integration:a-short|z-long:<run>integration:refusal:<uuid>integration:create-race:<uuid>integration:dup:<uuid>integration:auser|zip:<run>Residue classes found: users; password credentials; roles; sessions (including a rotation child); rate-limit buckets. Non-row resources were audited too and were already sound — every pool closed and every transaction settled — with one exception noted below.
Root cause
The file violates the shared-database ownership contract: it creates uniquely identified rows through real repositories and closes its connection pools without deleting any of them. UUID uniqueness hides the defect by preventing collisions while residue accumulates. There is one mechanism, not several — every leaked row traces to the same missing cleanup, and nothing in the file ever issued a
DELETE.rate_limit_bucketsdeserves its own sentence because it has no owner in the graph: no foreign key references it, so no cascade can ever reach it, andPgRateLimiter.sweeponly evicts buckets that expired over an hour ago (and only once per thousand admissions). The only thing that removes such a row is naming it.Chosen boundary, and what was rejected
Scoped shared-database cleanup through Increment 46's
withSharedDatabase, not disposable databases. Evidence: no test here mutates schema, touches the append-onlygame_events, tampers withschema_migrations, or asserts on an unscoped count — the conditions that forcedwithTestDatabaseelsewhere. Disposable databases would mean 31 migrations per test, 11 times over, for rows a keyedDELETEremoves in milliseconds.Reusing the existing helper rather than writing a second one keeps a single failure-precedence contract in the monorepo. Reaching it required exporting
./test-support/fixturesfrom the persistence package;withSharedDatabasewas otherwise unreachable outside that package, while./test-support(forwithTestDatabase) is already consumed bypackages/api.Rejected, and why:
TRUNCATE,DELETE FROM users,DELETE FROM rate_limit_buckets— claims ownership of a database this suite does not own. Increment 46 closed exactly this bug; repeating it would destroy the 0021 bot seeds permanently, sincemigratehas already recorded 0021 as applied.LIKE 'integration:%'— the prefix is a naming convention, not an ownership claim. A sentinel bucket using that prefix is planted in the regression precisely to fail this.ON CONFLICT DO NOTHING— these prevent collisions, which was never the problem. Silent growth is.No production semantics changed. No migration, constraint, FK, unique index or repository behaviour was touched.
A second defect found by the adversarial pass
The bucket-creation race test read two backend pids between
admin.connect()and thetrywhosefinallyreleases the client. A failure in either read leaked the lease — andpool.end()never settles while a client is checked out (verified directly: it had not resolved after 3s). The file would hang instead of reporting the error that caused it. The two reads now sit inside the try.Concurrency preserved
packages/apialready runsnode --test --test-concurrency=1; that is a pre-existing repository invariant, not something this change adds or relies on. The cleanup is safe independently of it: every predicate names identifiers minted in that test, so it cannot reach another file's rows at any concurrency. Nothing was serialised, no assertion moved out of a concurrent path, no race was pre-empted, and no real-server behaviour was replaced by a fake. Pool sizes were deliberately left at the driver default so the 10- and 8-way concurrent admissions still overlap genuinely.Regression coverage
packages/api/test/pg-security-ownership.integration.test.ts(3 tests). The first runs the real compiled suite as a child process against a disposable database and compares row identity — not counts — before and after, because the defect cannot be seen from inside a suite whose every assertion passes on a polluted database. Sentinels standing in for another suite (including a bucket sharing theintegration:prefix) must survive untouched. The remaining two prove cleanup runs when the body throws with the body's own error still surfacing, and that cleanup takes the keys it owns and not the ones that merely look like them.Proven to fail before the fix: against the pre-fix suite it reported the exact expected/actual diff — 8 users vs 4, 5/5/5 credentials/roles/sessions vs 1, 10 buckets vs 1. After the fix, 3/3 pass.
One harness detail worth recording: the child must not inherit
NODE_TEST_CONTEXT. A child that does believes it is a nested runner, declines to execute the file, and exits 0 having produced nothing — which would have made this test pass for the worst possible reason.Repeated-database acceptance
One migrated PostgreSQL 16 database, sentinels planted, no reset between runs:
After C the survivors are exactly the three migration-seeded bots plus the four sentinel rows — including
integration:sentinel-other-suite, the bucket a prefix match would have taken. 0 leakedtest_db_*databases, 0 lingering backends.Falsification
9 mutations, applied to real source, each rebuilt and run, each restored and verified byte-identical by SHA-256.
integration:prefixpackages/persistence(see note)8 of 9 killed. Three notes, because the raw numbers would otherwise mislead:
npxcannot be spawned throughexecFileSyncon Windows, so every build failed. Reporting it as nine kills would have been a fabricated score. The harness now invokestscdirectly.packages/persistence. Re-run against that package's own suite it kills 6 tests. Not a coverage gap — a harness scoping artefact.backendPidto fail, which requires fault injection no passing suite performs. It is hardening for an error path, and the mechanism behind it was proven separately rather than asserted.Same-pattern audit of sibling API tests
Surveyed every API test touching PostgreSQL.
pg-observer.integration.test.tscreates probe tables but already drops them infinally;analysis-cache-compositionandbootstrap-analysis-cachenever connect.auth-signin-schemaandsemantic-pipelinealready carry cleanup or use disposable databases. No sibling file leaks rows —pg-securitywas the only one, and nothing outside it was modified.One adjacent defect found and recorded, not fixed.
packages/api/test/analysis-cache-durable.integration.test.tsnever callsmigrate(): it opens pools and assumes the schema is already there. Run inside the full suite it passes, becausepackages/persistencemigrates the database first; run asnpm test --workspace @chess-platform/apiagainst a fresh database it fails 6 of 10, expecting a cached row and getting a search. Verified by running that one file against three databases — fresh: 6 fail; two previously-migrated: 0 fail. It is the mirror image of the defect this PR fixes — a suite depending on state it does not establish, rather than leaking state it does not remove — and it belongs to the same ownership family. Last touched by #19; out of scope here, recorded for a follow-up.Validation
All re-run at the final HEAD
22ee0d7, against a fresh PostgreSQL 16 database:npm run build✅ ·npm run lint✅ (exit 0) · full repositorynpm test✅ exit 0, 19 packagesfail 0— and zero occurrences ofDATABASE_URL not set, so no database suite self-skipped.packages/apialone: 978 tests, 968 pass, 0 fail, 10 skipped (Fairy-Stockfish binary gates).check:ci-parity✅ ·check:variant-parity✅ ·check:adr-claims✅ ·check:engine-pin-parity✅ ·check:observability✅ ·test:scripts✅ ·git diff --checkclean.test:countsreported 3286 tests (28 skipped) at the implementation HEAD, up from 3283 — exactly the +3 this branch adds, measured on both sides under identical env and with stale build output removed. After mergingmainit reports 3301 (28 skipped), the extra 15 being Increment 47's expanded correlator suite. It exits 1 in both readings, and that needs stating plainly rather than as a tick:services/gatewayis not an npm workspace (workspacesis["packages/*"]), soioredisis never installed locally and itstscstep fails. Pre-existing, environmental, untouched by this diff, and CI skips that job for this PR. I did notnpm installthere, because that mutatesservices/gateway/package-lock.jsonoutside this PR's scope.An earlier pass of these checks reported all seven as exit 0. That reading was wrong: the exit code was taken after a pipe, so it measured
tailrather than the command. The figures above are measured without a pipe.Guards: clean-code-guard, test-guard, security-and-hardening. No credentials or connection strings committed.
docs-guardN/A — see below. Impeccable N/A.Signature B
UNRESOLVED, and untouched by this work. No Signature B occurrence during this task — every suite that ran reported its own tests, and no bare
'test failed'file-level failure appeared in the baseline, the A/B/C runs, the mutation rounds, or the full-repository validation.Remaining known defects — all OPEN, none fixed here
analysis-cache-durable.integration.test.tsdepends on schema it does not establish — OPEN. See the section above. A bounded follow-up for a separate PR after fix(api-test): enforce shared database ownership in pg-security integration tests #41.npm run test:countsexits 1 — OPEN.services/gatewaysits outside the root npm workspaces (packages/*), so itsioredisresolution fails. A separate bounded tooling/workspace investigation; the remedy is not assumed to be "make gateway a workspace". Not fixed here, and no gateway dependency was installed or mutated.Canonical documentation — SYNCHRONIZED
PR #35 is merged, so the deferral is over and the payload is now recorded in the canonical docs rather than only here:
docs/PROJECT_STATE.md— header moved to Increment 48; a new section inserted before Increment 47 covering the measured residue, the single root cause, the ownership boundary and what was rejected, the./test-support/fixturesexport, the leased-client fix, the red-then-green regression, A/B/C, and 8 of 9 mutations with the survivor named. Increment 47 is preserved verbatim — its 26 bounded runs with 0 captures, the four correlator defects, the three Increment 46 persistence observations, and Signature B UNRESOLVED.docs/ROADMAP.md— an Increment 48 entry beside Increment 46's, two new OPEN follow-up entries, and the existing Signature B entry extended with Increment 48's 0 occurrences.Test counts are written as readings taken during Increment 48, not standing invariants, and are given both at the implementation HEAD and after the merge.
Final gate at
ba88113npm testexit 0, 19 packagesfail 0, 0 database self-skips · API package 993 / 983 pass / 10 skipped / 0 fail on a fresh migrated database · six parity gates ✅ ·git diff --checkcleantest:countsservices/gateway/iorediscause as before the merge; only the total moved.test_db_*; 0 lingering backendsNOT MERGED. The repository owner merges manually.
🤖 Generated with Claude Code
https://claude.ai/code/session_01CUPqvu66J5ZVyiv4nJ797r
Summary by CodeRabbit
Tests
Documentation
Chores