From b5d7b6b0cceb474496cecd9fadc80f7a790db11b Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 30 Sep 2026 17:29:19 +0200 Subject: [PATCH] A refused store closes its pool; pin the key's columns and uniqueness The startup check's refusal left the store's pool open: the process exits on it in production, but the connection outlived the refusal. createStore now closes a store it refuses. Two live cases the first run showed undefended (mutants D6 and D9): a unique key replaced by a plain index on the same columns, and by a unique index on other columns of the same count. The read-only-role case also checks that no session of the role outlives the refusal. The fixture's owner pool ignores the connection ends its own forced drop causes. Co-Authored-By: Claude Opus 5.5 --- src/store/index.ts | 3 +++ tests/store.test.ts | 28 ++++++++++++++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/src/store/index.ts b/src/store/index.ts index 32f6e8e..972bd8f 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -29,6 +29,9 @@ export async function createStore(config: HeimdallConfig): Promise undefined); throw new Error( `Postgres storage could not be prepared (${typeof code === "string" ? code : "error"}); check GC_ACCESS_DATABASE_URL_FILE.` ); diff --git a/tests/store.test.ts b/tests/store.test.ts index 0729ba0..b208956 100644 --- a/tests/store.test.ts +++ b/tests/store.test.ts @@ -311,6 +311,9 @@ describe.skipIf(!adminUrl)("startup against a live Postgres (HEIMDALL_TEST_PG_AD await admin.end(); }); const pool = new Pool({ connectionString: urlFor(name) }); + // The forced drop ends this fixture pool's connections, possibly while + // they are still closing; that is cleanup, not a finding. + pool.on("error", () => undefined); cleanup.push(async () => { await pool.end(); }); @@ -347,6 +350,15 @@ describe.skipIf(!adminUrl)("startup against a live Postgres (HEIMDALL_TEST_PG_AD const message = await start(urlFor(name, role, "CANARYrolepw"), false); expect(message).toBe(refusal("SCHEMA_PRIVILEGES")); expect(message).not.toContain("CANARY"); + + // The refused store closed its pool: no session of the role outlives the refusal. + let sessions = -1; + for (let attempt = 0; attempt < 20 && sessions !== 0; attempt += 1) { + if (attempt > 0) await new Promise((resolve) => setTimeout(resolve, 100)); + const result = await pool.query<{ n: number }>("SELECT count(*)::int AS n FROM pg_stat_activity WHERE usename = $1", [role]); + sessions = result.rows[0]!.n; + } + expect(sessions).toBe(0); }); it("refuses a table whose primary key was dropped, even after applying the schema", async () => { @@ -367,6 +379,22 @@ describe.skipIf(!adminUrl)("startup against a live Postgres (HEIMDALL_TEST_PG_AD expect(await start(urlFor(name), false)).toBe(refusal("SCHEMA_MISSING")); }); + it("refuses a unique key replaced by a plain index on the same columns", async () => { + const { name, pool } = await appliedDatabase(); + await pool.query("ALTER TABLE linked_identities DROP CONSTRAINT linked_identities_provider_provider_user_id_key"); + await pool.query("CREATE INDEX linked_identities_plain ON linked_identities(provider, provider_user_id)"); + + expect(await start(urlFor(name), false)).toBe(refusal("SCHEMA_MISSING")); + }); + + it("refuses a unique key replaced by one on other columns of the same count", async () => { + const { name, pool } = await appliedDatabase(); + await pool.query("ALTER TABLE linked_identities DROP CONSTRAINT linked_identities_provider_provider_user_id_key"); + await pool.query("CREATE UNIQUE INDEX linked_identities_other ON linked_identities(provider, username)"); + + expect(await start(urlFor(name), false)).toBe(refusal("SCHEMA_MISSING")); + }); + it("refuses a created table's dropped column", async () => { const { name, pool } = await appliedDatabase(); await pool.query("ALTER TABLE auth_completions DROP COLUMN mode");