From 6b8c4a9c63540775d4aa3a4cfba1f4f2f592554c Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Mon, 19 Oct 2026 11:47:37 +0200 Subject: [PATCH] Tests: pin the refresh fast paths, boundaries and connection hygiene Added after a diff-scoped StrykerJS run left survivors: no-lock paths, the skew boundary, decision kinds, a vanished row, open-transaction hygiene on the live store, and the token request's method, signal, token type and scope handling. Co-Authored-By: Claude Opus 5.5 --- tests/managed-credential.test.ts | 171 +++++++++++++++++++++++++++ tests/provider-token-request.test.ts | 48 ++++++++ 2 files changed, 219 insertions(+) diff --git a/tests/managed-credential.test.ts b/tests/managed-credential.test.ts index 427b2c8..a3b6c75 100644 --- a/tests/managed-credential.test.ts +++ b/tests/managed-credential.test.ts @@ -125,6 +125,7 @@ interface Rig { custody: ReturnType; config: HeimdallConfig; provider: Provider; + databaseUrl: string | undefined; row(): Promise; audit(): Promise>; resolve(appIndex?: number): Promise<{ statusCode: number; json: Record; body: string }>; @@ -179,6 +180,7 @@ for (const { kind, live } of rigKinds) describe.skipIf(live && !adminUrl)(`manag const custody = createTokenCustody(config); const stores: HeimdallStore[] = []; let adminForDb: Pool | undefined; + let databaseUrl: string | undefined; if (!live) { stores.push(new InMemoryStore()); } else { @@ -190,6 +192,7 @@ for (const { kind, live } of rigKinds) describe.skipIf(live && !adminUrl)(`manag pool.on("error", () => undefined); stores.push(new PostgresStore(pool)); } + databaseUrl = urlFor(name); adminForDb = new Pool({ connectionString: urlFor(name), max: 1 }); adminForDb.on("error", () => undefined); cleanup.push(async () => { @@ -235,6 +238,7 @@ for (const { kind, live } of rigKinds) describe.skipIf(live && !adminUrl)(`manag custody, config, provider, + databaseUrl, async row() { const row = await stores[0]!.findStoredLinkedIdentityForAccount(account.id, provider); return row!; @@ -504,6 +508,37 @@ for (const { kind, live } of rigKinds) describe.skipIf(live && !adminUrl)(`manag expect(await rig.row()).toEqual(revoked); }); + it.runIf(live)("leaves no transaction open after a missing row or a throwing decide", timeout, async () => { + const rig = await makeRig("twitch"); + const pool = new Pool({ connectionString: rig.databaseUrl, max: 1 }); + pool.on("error", () => undefined); + try { + const store = new PostgresStore(pool); + const idle = async () => { + await sleep(10); + const result = await pool.query<{ fresh: boolean }>("SELECT now() = statement_timestamp() AS fresh"); + return result.rows[0]!.fresh; + }; + expect(await store.updateLinkedIdentityCredential(randomUUID(), async () => ({ kind: "keep" }))).toBeNull(); + expect(await idle()).toBe(true); + await expect( + store.updateLinkedIdentityCredential(rig.identityId, async () => { + throw new Error("boom"); + }) + ).rejects.toThrow("boom"); + expect(await idle()).toBe(true); + } finally { + await pool.end(); + } + }); + + it.runIf(!live)("drops a drained row's chain entry", async () => { + const rig = await makeRig("twitch"); + await Promise.all([1, 2, 3].map(() => rig.stores[0]!.updateLinkedIdentityCredential(rig.identityId, async () => ({ kind: "keep" })))); + await sleep(5); + expect((rig.stores[0] as unknown as { credentialChains: Map }).credentialChains.size).toBe(0); + }); + it("serialises deciders on one row: each sees the previous commit", timeout, async () => { const rig = await makeRig("twitch"); const order: string[] = []; @@ -531,6 +566,142 @@ for (const { kind, live } of rigKinds) describe.skipIf(live && !adminUrl)(`manag }); }); +describe("refreshManagedCredential without the lock", () => { + const config = testConfig(); + const custody = createTokenCustody(config); + const now = Date.parse("2026-10-19T12:00:00.000Z"); + + function identity(overrides: Partial = {}, provider: Provider = "twitch"): StoredLinkedIdentity { + return { + id: "id-1", + accountId: "acct-1", + provider, + providerUserId: "u1", + scopes: ["s"], + profileJson: {}, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + accessTokenEncrypted: custody.encrypt("access-0"), + refreshTokenEncrypted: custody.encrypt("refresh-0"), + tokenExpiresAt: new Date(now + 60 * 60 * 1000).toISOString(), + ...overrides, + }; + } + + function countingStore(locked: StoredLinkedIdentity | null) { + const log = { locks: 0, decisions: [] as unknown[], audits: [] as unknown[] }; + const store = { + async updateLinkedIdentityCredential(_id: string, decide: (row: StoredLinkedIdentity) => Promise) { + log.locks += 1; + if (locked === null) return null; + log.decisions.push(await decide(locked)); + return locked; + }, + async createAuditEvent(event: unknown) { + log.audits.push(event); + }, + }; + return { store: store as unknown as HeimdallStore, log }; + } + + const ctxFor = (store: HeimdallStore) => ({ config, store, tokenCustody: custody, oauthRuntimes: createOAuthRuntimeRegistry(), clock: () => now }); + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + it("a snapshot with no access token is revoked without a lock or a provider call", async () => { + const { store, log } = countingStore(null); + const endpoint = installTokenEndpoint("twitch", "refresh-0"); + const outcome = await refreshManagedCredential(ctxFor(store), identity({ accessTokenEncrypted: undefined as never })); + expect(outcome.kind).toBe("revoked"); + expect(log.locks).toBe(0); + expect(endpoint.calls).toBe(0); + }); + + it.each([ + ["outside the skew", {}, "twitch"], + ["without an expiry", { tokenExpiresAt: undefined as never }, "twitch"], + ["without a refresh token", { refreshTokenEncrypted: undefined as never, tokenExpiresAt: new Date(now).toISOString() }, "twitch"], + ["for a provider whose runtime cannot refresh", { tokenExpiresAt: new Date(now).toISOString() }, "github"], + ] as const)("a credential %s is current with no lock and no provider call", async (_name, overrides, provider) => { + const { store, log } = countingStore(null); + const endpoint = installTokenEndpoint("twitch", "refresh-0"); + const snapshot = identity(overrides as Partial, provider); + const outcome = await refreshManagedCredential(ctxFor(store), snapshot); + expect(outcome).toEqual({ kind: "current", identity: snapshot }); + expect(log.locks).toBe(0); + expect(endpoint.calls).toBe(0); + }); + + it("an expiry exactly one skew away is due; one millisecond further is not", async () => { + const due = identity({ tokenExpiresAt: new Date(now + MANAGED_CREDENTIAL_REFRESH_SKEW_MS).toISOString() }); + const notDue = identity({ tokenExpiresAt: new Date(now + MANAGED_CREDENTIAL_REFRESH_SKEW_MS + 1).toISOString() }); + const a = countingStore(due); + installTokenEndpoint("twitch", "refresh-0"); + await refreshManagedCredential(ctxFor(a.store), due); + expect(a.log.locks).toBe(1); + const b = countingStore(notDue); + await refreshManagedCredential(ctxFor(b.store), notDue); + expect(b.log.locks).toBe(0); + }); + + it("decides revoke for a grant verdict and for a missing rotated token, rotate otherwise", async () => { + const due = identity({ tokenExpiresAt: new Date(now).toISOString() }); + const grant = countingStore(due); + installTokenEndpoint("twitch", "refresh-0").mode = "invalid_grant"; + await refreshManagedCredential(ctxFor(grant.store), due); + expect(grant.log.decisions).toEqual([{ kind: "revoke", updatedAt: new Date(now).toISOString() }]); + expect(grant.log.audits).toEqual([ + expect.objectContaining({ eventType: "managed_credential_revoked", eventPayloadJson: { provider: "twitch", reason: "provider_revoked" } }), + ]); + + const rotating = identity({ tokenExpiresAt: new Date(now).toISOString() }, "joystick"); + const missing = countingStore(rotating); + installTokenEndpoint("joystick", "refresh-0").mode = "no_refresh_token"; + await refreshManagedCredential(ctxFor(missing.store), rotating); + expect(missing.log.decisions).toEqual([{ kind: "revoke", updatedAt: new Date(now).toISOString() }]); + + const fine = countingStore(due); + installTokenEndpoint("twitch", "refresh-0"); + await refreshManagedCredential(ctxFor(fine.store), due); + expect(fine.log.decisions).toEqual([expect.objectContaining({ kind: "rotate", refreshTokenEncrypted: expect.any(String), scopes: ["s"] })]); + expect(fine.log.audits).toEqual([]); + }); + + it("a row that vanished is revoked with its tokens cleared", async () => { + const due = identity({ tokenExpiresAt: new Date(now).toISOString() }); + const { store } = countingStore(null); + installTokenEndpoint("twitch", "refresh-0"); + const outcome = await refreshManagedCredential(ctxFor(store), due); + expect(outcome.kind).toBe("revoked"); + expect(outcome.identity.accessTokenEncrypted).toBeUndefined(); + expect(outcome.identity.refreshTokenEncrypted).toBeUndefined(); + expect(outcome.identity.tokenExpiresAt).toBeUndefined(); + expect(outcome.identity.providerUserId).toBe("u1"); + }); + + it("a row revoked by someone else between the snapshot and the lock is revoked, with no provider call", async () => { + const due = identity({ tokenExpiresAt: new Date(now).toISOString() }); + const gone = identity({ accessTokenEncrypted: undefined as never, refreshTokenEncrypted: undefined as never, tokenExpiresAt: undefined as never }); + const { store } = countingStore(gone); + const endpoint = installTokenEndpoint("twitch", "refresh-0"); + const outcome = await refreshManagedCredential(ctxFor(store), due); + expect(outcome.kind).toBe("revoked"); + expect(endpoint.calls).toBe(0); + }); + + it("names the provider in the failure and carries nothing the provider said", async () => { + const due = identity({ tokenExpiresAt: new Date(now).toISOString() }); + const { store } = countingStore(due); + installTokenEndpoint("twitch", "refresh-0").mode = "server_error"; + const failure = await refreshManagedCredential(ctxFor(store), due).catch((error: unknown) => error as ManagedCredentialRefreshFailed); + expect(failure).toBeInstanceOf(ManagedCredentialRefreshFailed); + expect((failure as Error).message).toBe("Twitch managed credential refresh failed."); + expect((failure as ManagedCredentialRefreshFailed).code).toBe("managed_credential_refresh_failed"); + }); +}); + describe("live Postgres availability", () => { it.skipIf(!!adminUrl)("is present whenever the job requires it", () => { // Local workstation runs skip the live suites; the verification job sets diff --git a/tests/provider-token-request.test.ts b/tests/provider-token-request.test.ts index ff13d72..875598e 100644 --- a/tests/provider-token-request.test.ts +++ b/tests/provider-token-request.test.ts @@ -18,6 +18,7 @@ function configFor(): HeimdallConfig { interface Captured { url: string; + method: string | undefined; headers: Record; body: URLSearchParams; } @@ -27,6 +28,7 @@ function capture(answer: () => Response): { calls: Captured[] } { globalThis.fetch = (async (input: string | URL, init?: RequestInit) => { calls.push({ url: String(input), + method: init?.method, headers: init?.headers as Record, body: init?.body as URLSearchParams, }); @@ -58,6 +60,7 @@ describe("requestProviderTokens follows the provider descriptor", () => { expect(calls).toHaveLength(1); const call = calls[0]!; expect(call.url).toBe(descriptor.tokenEndpoint); + expect(call.method).toBe("POST"); expect(call.headers["Content-Type"]).toBe("application/x-www-form-urlencoded"); expect(call.headers.Accept).toBe("application/json"); expect(call.body.get("grant_type")).toBe(grantType); @@ -108,6 +111,51 @@ describe("requestProviderTokens follows the provider descriptor", () => { ); }); + it("passes the abort signal to the provider call", async () => { + let seen: AbortSignal | null | undefined; + globalThis.fetch = (async (_input: string | URL, init?: RequestInit) => { + seen = init?.signal; + return ok(); + }) as typeof fetch; + const controller = new AbortController(); + await requestProviderTokens("twitch", configFor(), { grant_type: "refresh_token", refresh_token: "r" }, controller.signal); + expect(seen).toBe(controller.signal); + }); + + it("keeps the provider's token type and defaults a missing one to Bearer", async () => { + capture(() => new Response(JSON.stringify({ access_token: "a", token_type: "bearer" }), { status: 200 })); + expect((await requestProviderTokens("twitch", configFor(), { grant_type: "refresh_token", refresh_token: "r" })).tokenType).toBe("bearer"); + capture(() => new Response(JSON.stringify({ access_token: "a" }), { status: 200 })); + expect((await requestProviderTokens("twitch", configFor(), { grant_type: "refresh_token", refresh_token: "r" })).tokenType).toBe("Bearer"); + }); + + it("trims scope entries and drops empty ones", async () => { + capture(() => new Response(JSON.stringify({ access_token: "a", scope: [" a ", "", "b"] }), { status: 200 })); + expect((await requestProviderTokens("twitch", configFor(), { grant_type: "refresh_token", refresh_token: "r" })).scope).toEqual(["a", "b"]); + }); + + it("treats an empty 2xx body as an empty answer, not a malformed one", async () => { + capture(() => new Response("", { status: 200 })); + const tokens = await requestProviderTokens("twitch", configFor(), { grant_type: "refresh_token", refresh_token: "r" }); + expect(tokens.accessToken).toBeUndefined(); + }); + + it("sets no refreshToken key when the provider sent none", async () => { + capture(() => new Response(JSON.stringify({ access_token: "a" }), { status: 200 })); + const tokens = await requestProviderTokens("twitch", configFor(), { grant_type: "refresh_token", refresh_token: "r" }); + expect("refreshToken" in tokens).toBe(false); + }); + + it.each([ + ["only the secret missing", { clientId: "id" }], + ["only the id missing", { clientSecret: "secret" }], + ])("refuses a provider with %s", async (_n, partial) => { + const config = { providers: { twitch: partial } } as unknown as HeimdallConfig; + await expect(requestProviderTokens("twitch", config, { grant_type: "refresh_token", refresh_token: "r" })).rejects.toThrow( + "Twitch OAuth is not fully configured." + ); + }); + it("refuses an unconfigured provider by display name", async () => { const config = { providers: { patreon: {} } } as unknown as HeimdallConfig; await expect(