App secret plus the member's live bearer for that app, a per-profile grant and method allowlist (bifrost only), a nonce spent once per session through the existing oauth_state_uses claim (no schema change), an in-process fixed-window rate per account and per app, and an audit row per assertion or post-secret refusal. Provider step-up sign-ins now label their proof provider:<name>. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
1130 lines
59 KiB
TypeScript
1130 lines
59 KiB
TypeScript
import { randomUUID } from "node:crypto";
|
|
import { Pool } from "pg";
|
|
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
|
|
import { newDb } from "pg-mem";
|
|
import { type HeimdallConfig } from "../src/config.js";
|
|
import { createStore, IdentityHeldElsewhereError, InMemoryStore, type HeimdallStore } from "../src/store/index.js";
|
|
import {
|
|
createPostgresStore,
|
|
PostgresStore,
|
|
checkSchemaRequirements,
|
|
HEIMDALL_SCHEMA_REQUIREMENTS,
|
|
predicateConditions,
|
|
printedDeclaredPredicates,
|
|
samePredicate,
|
|
STORE_TABLE_PRIVILEGES,
|
|
} from "../src/store/postgres.js";
|
|
import {
|
|
OAUTH_STATE_PRUNE_BATCH,
|
|
OAUTH_STATE_PRUNE_MARGIN_MS,
|
|
OAUTH_STATE_SKEW_MS,
|
|
} from "../src/store/types.js";
|
|
import { CREATE_SCHEMA_SQL, REQUIRED_COLUMNS, REQUIRED_KEYS, REQUIRED_TABLES } from "../src/store/schema.js";
|
|
|
|
describe("PostgresStore", () => {
|
|
it("round-trips core auth records through postgres", async () => {
|
|
const db = newDb();
|
|
const adapter = db.adapters.createPg();
|
|
const pool = new adapter.Pool();
|
|
const store = new PostgresStore(pool);
|
|
await store.ensureSchema();
|
|
|
|
const now = "2026-04-26T12:00:00.000Z";
|
|
const account = await store.createAccount({
|
|
createdAt: now,
|
|
lastSeenAt: now,
|
|
displayName: "Meta",
|
|
primaryEmail: "meta@gamecult.org",
|
|
});
|
|
|
|
await store.upsertLinkedIdentity({
|
|
accountId: account.id,
|
|
provider: "discord",
|
|
providerUserId: "discord-user-123",
|
|
username: "meta",
|
|
displayName: "Meta",
|
|
primaryEmail: "meta@gamecult.org",
|
|
accessTokenEncrypted: "encrypted-access-token",
|
|
refreshTokenEncrypted: "encrypted-refresh-token",
|
|
tokenExpiresAt: "2026-04-26T13:00:00.000Z",
|
|
scopes: ["identify", "email"],
|
|
profileJson: { id: "discord-user-123", username: "meta" },
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
});
|
|
|
|
const found = await store.findAccountByLinkedIdentity("discord", "discord-user-123");
|
|
expect(found).toEqual(
|
|
expect.objectContaining({
|
|
id: account.id,
|
|
displayName: "Meta",
|
|
primaryEmail: "meta@gamecult.org",
|
|
})
|
|
);
|
|
|
|
const linkedIdentities = await store.listLinkedIdentitiesForAccount(account.id);
|
|
expect(linkedIdentities).toEqual([
|
|
{
|
|
provider: "discord",
|
|
providerUserId: "discord-user-123",
|
|
username: "meta",
|
|
displayName: "Meta",
|
|
},
|
|
]);
|
|
|
|
const storedIdentity = await store.findStoredLinkedIdentity("discord", "discord-user-123");
|
|
expect(storedIdentity).toEqual(
|
|
expect.objectContaining({
|
|
provider: "discord",
|
|
accessTokenEncrypted: "encrypted-access-token",
|
|
refreshTokenEncrypted: "encrypted-refresh-token",
|
|
})
|
|
);
|
|
|
|
await store.createCapabilityGrant({
|
|
accountId: account.id,
|
|
scopeType: "app",
|
|
scopeId: "repixelizer",
|
|
capability: "app_access",
|
|
source: "manual",
|
|
status: "active",
|
|
note: "Test grant",
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
});
|
|
|
|
const grants = await store.listActiveGrants(account.id, "repixelizer", "2026-04-26T12:01:00.000Z");
|
|
expect(grants).toEqual([
|
|
expect.objectContaining({
|
|
capability: "app_access",
|
|
scopeType: "app",
|
|
scopeId: "repixelizer",
|
|
}),
|
|
]);
|
|
|
|
await store.createSession({
|
|
id: "session-123",
|
|
accountId: account.id,
|
|
appSlug: "repixelizer",
|
|
createdAt: now,
|
|
lastSeenAt: now,
|
|
expiresAt: "2026-04-26T13:00:00.000Z",
|
|
accessRevision: 1,
|
|
claimsJson: {
|
|
iss: "https://heimdall.gamecult.org",
|
|
aud: "repixelizer",
|
|
},
|
|
});
|
|
|
|
await store.upsertEntitlementSnapshot({
|
|
accountId: account.id,
|
|
provider: "discord",
|
|
scope: "repixelizer:discord_role_access:gamecult-guild",
|
|
evaluatedAt: now,
|
|
isAllowed: true,
|
|
reasonCode: "matched_role",
|
|
reasonDetail: "Matched test role",
|
|
rawSummaryJson: { matchedRoles: ["role-repixelizer"] },
|
|
});
|
|
|
|
await store.createAuditEvent({
|
|
accountId: account.id,
|
|
sessionId: "session-123",
|
|
appSlug: "repixelizer",
|
|
eventType: "oauth_callback_succeeded",
|
|
eventPayloadJson: { provider: "discord" },
|
|
createdAt: now,
|
|
});
|
|
|
|
const completion = await store.createAuthCompletion({
|
|
appSlug: "repixelizer",
|
|
provider: "discord",
|
|
mode: "sign_in",
|
|
accountId: account.id,
|
|
sessionId: "session-123",
|
|
returnTo: "https://repixelizer.gamecult.org/app/",
|
|
createdAt: now,
|
|
expiresAt: "2026-04-26T12:05:00.000Z",
|
|
payloadJson: {
|
|
status: "success",
|
|
provider: "discord",
|
|
appSlug: "repixelizer",
|
|
},
|
|
});
|
|
|
|
const consumed = await store.consumeAuthCompletion("repixelizer", completion.code, "2026-04-26T12:01:00.000Z");
|
|
expect(consumed).toEqual(
|
|
expect.objectContaining({
|
|
code: completion.code,
|
|
appSlug: "repixelizer",
|
|
consumedAt: "2026-04-26T12:01:00.000Z",
|
|
})
|
|
);
|
|
|
|
const consumedAgain = await store.consumeAuthCompletion("repixelizer", completion.code, "2026-04-26T12:02:00.000Z");
|
|
expect(consumedAgain).toBeNull();
|
|
|
|
const attempt = await store.createAuthAttempt({
|
|
handle: "attempt-123",
|
|
appSlug: "ghostlight",
|
|
provider: "discord",
|
|
mode: "sign_in",
|
|
returnTo: "https://yggdrasil.gamecult.org/ghostlight/",
|
|
createdAt: now,
|
|
expiresAt: "2026-04-26T12:05:00.000Z",
|
|
});
|
|
expect(attempt.status).toBe("pending");
|
|
expect((await store.updateAuthAttempt("ghostlight", attempt.handle, {
|
|
status: "completed",
|
|
at: "2026-04-26T12:01:00.000Z",
|
|
}))?.status).toBe("completed");
|
|
expect((await store.updateAuthAttempt("ghostlight", attempt.handle, {
|
|
status: "pending",
|
|
at: "2026-04-26T12:02:00.000Z",
|
|
}))?.status).toBe("completed");
|
|
|
|
const privateReceipt = await store.createPrivateCommandReceipt({
|
|
appSlug: "ghostlight",
|
|
idempotencyKey: "idem-123",
|
|
requestFingerprint: "fingerprint-123",
|
|
status: "accepted",
|
|
contentSchema: "heimdall.auth_begin_receipt.v1",
|
|
envelopeBase64: "encrypted-messagepack",
|
|
createdAt: now,
|
|
expiresAt: "2026-04-26T12:01:00.000Z",
|
|
});
|
|
expect(await store.findPrivateCommandReceipt("ghostlight", "idem-123")).toEqual(privateReceipt);
|
|
await expect(store.createPrivateCommandReceipt({
|
|
...privateReceipt,
|
|
requestFingerprint: "different-command",
|
|
})).rejects.toThrow("Idempotency key was reused");
|
|
|
|
await store.close();
|
|
});
|
|
});
|
|
|
|
// linked_identities has one writer that can move a row between accounts: none.
|
|
// upsert refreshes the holder's own row and refuses another account's; unlink is
|
|
// the only delete, and it never takes an account's last identity.
|
|
const unlinkStores: Array<[string, () => Promise<HeimdallStore>]> = [
|
|
["InMemoryStore", async () => new InMemoryStore()],
|
|
[
|
|
"PostgresStore on pg-mem",
|
|
async () => {
|
|
const store = new PostgresStore(new (newDb().adapters.createPg().Pool)());
|
|
await store.ensureSchema();
|
|
return store;
|
|
},
|
|
],
|
|
];
|
|
describe.each(unlinkStores)("linked_identities writes, %s", (_name, make) => {
|
|
const at = "2026-11-13T12:00:00.000Z";
|
|
const input = (accountId: string, providerUserId: string, extra: Record<string, unknown> = {}) => ({
|
|
accountId,
|
|
provider: "discord" as const,
|
|
providerUserId,
|
|
scopes: ["identify"],
|
|
profileJson: {},
|
|
createdAt: at,
|
|
updatedAt: at,
|
|
...extra,
|
|
});
|
|
async function twoAccounts() {
|
|
const store = await make();
|
|
const a = await store.createAccount({ createdAt: at, lastSeenAt: at });
|
|
const b = await store.createAccount({ createdAt: at, lastSeenAt: at });
|
|
return { store, a, b };
|
|
}
|
|
|
|
// pg-mem has neither ON CONFLICT ... WHERE nor row locks: these two run on real Postgres below.
|
|
const realPostgresOnly = _name !== "InMemoryStore";
|
|
|
|
it.skipIf(realPostgresOnly)("upsert on an identity another account holds throws IdentityHeldElsewhereError and leaves the row untouched", async () => {
|
|
const { store, a, b } = await twoAccounts();
|
|
const held = await store.upsertLinkedIdentity(input(a.id, "u1", { username: "original", accessTokenEncrypted: "keep" }));
|
|
await expect(
|
|
store.upsertLinkedIdentity(input(b.id, "u1", { username: "thief", accessTokenEncrypted: "stolen", updatedAt: "2027-01-01T00:00:00.000Z" }))
|
|
).rejects.toBeInstanceOf(IdentityHeldElsewhereError);
|
|
expect(await store.findStoredLinkedIdentity("discord", "u1")).toEqual(held);
|
|
expect(await store.listStoredLinkedIdentitiesForAccount(b.id)).toEqual([]);
|
|
});
|
|
|
|
it("upsert by the holder still refreshes its own row, keeping its id", async () => {
|
|
const { store, a } = await twoAccounts();
|
|
const first = await store.upsertLinkedIdentity(input(a.id, "u1", { username: "one" }));
|
|
const again = await store.upsertLinkedIdentity(input(a.id, "u1", { username: "two", updatedAt: "2026-11-14T00:00:00.000Z" }));
|
|
expect([again.id, again.accountId, again.username]).toEqual([first.id, a.id, "two"]);
|
|
});
|
|
|
|
it("unlink: deletes one of two, refuses the last, reports another account's or a missing id as not_found", async () => {
|
|
const { store, a, b } = await twoAccounts();
|
|
const one = await store.upsertLinkedIdentity(input(a.id, "u1"));
|
|
const two = await store.upsertLinkedIdentity(input(a.id, "u2"));
|
|
const theirs = await store.upsertLinkedIdentity(input(b.id, "u3"));
|
|
expect(await store.unlinkLinkedIdentity(a.id, theirs.id)).toEqual({ kind: "not_found" });
|
|
expect(await store.unlinkLinkedIdentity(a.id, "no-such")).toEqual({ kind: "not_found" });
|
|
expect(await store.unlinkLinkedIdentity(b.id, theirs.id)).toEqual({ kind: "last_identity" });
|
|
const done = await store.unlinkLinkedIdentity(a.id, one.id);
|
|
expect(done).toMatchObject({ kind: "unlinked", identity: { id: one.id, provider: "discord", providerUserId: "u1" } });
|
|
expect(await store.unlinkLinkedIdentity(a.id, two.id)).toEqual({ kind: "last_identity" });
|
|
expect((await store.listStoredLinkedIdentitiesForAccount(a.id)).map((i) => i.id)).toEqual([two.id]);
|
|
expect(await store.findStoredLinkedIdentity("discord", "u3")).toMatchObject({ id: theirs.id, accountId: b.id });
|
|
});
|
|
|
|
it.skipIf(realPostgresOnly)("two concurrent unlinks of an account's last two identities leave exactly one", async () => {
|
|
const { store, a } = await twoAccounts();
|
|
const one = await store.upsertLinkedIdentity(input(a.id, "u1"));
|
|
const two = await store.upsertLinkedIdentity(input(a.id, "u2"));
|
|
const results = await Promise.all([store.unlinkLinkedIdentity(a.id, one.id), store.unlinkLinkedIdentity(a.id, two.id)]);
|
|
expect(results.map((r) => r.kind).sort()).toEqual(["last_identity", "unlinked"]);
|
|
expect(await store.listStoredLinkedIdentitiesForAccount(a.id)).toHaveLength(1);
|
|
});
|
|
});
|
|
|
|
describe("consent_receipts foreign key", () => {
|
|
it("a revocation names a receipt of its own account: the schema says so and the memory store enforces it", async () => {
|
|
expect(CREATE_SCHEMA_SQL).toContain("FOREIGN KEY (revokes_receipt_id, account_id) REFERENCES consent_receipts(id, account_id)");
|
|
const store = new InMemoryStore();
|
|
const base = { appSlug: "streampixels" as const, provider: "joystick" as const, createdAt: "2026-11-08T00:00:00.000Z" };
|
|
const { receipt: grant } = await store.createConsentReceipt({ ...base, accountId: "a", kind: "grant", scopesAsked: [], scopesGranted: [], consentPageDigest: "d", consentText: "t" });
|
|
await expect(store.createConsentReceipt({ ...base, accountId: "b", kind: "revocation", revokesReceiptId: grant.id })).rejects.toThrow(/own account/);
|
|
await expect(store.createConsentReceipt({ ...base, accountId: "a", kind: "revocation", revokesReceiptId: "no-such" })).rejects.toThrow(/own account/);
|
|
expect(await store.listConsentReceipts("a")).toHaveLength(1);
|
|
});
|
|
});
|
|
|
|
describe("PostgresStore.createConsentReceipt revocation conflict", () => {
|
|
async function granted() {
|
|
const adapter = newDb().adapters.createPg();
|
|
const store = new PostgresStore(new adapter.Pool());
|
|
await store.ensureSchema();
|
|
const now = "2026-11-08T00:00:00.000Z";
|
|
const account = await store.createAccount({ createdAt: now, lastSeenAt: now, displayName: "Probe" });
|
|
const base = { accountId: account.id, appSlug: "streampixels" as const, provider: "joystick" as const };
|
|
const { receipt: grant } = await store.createConsentReceipt({ ...base, kind: "grant", createdAt: now, scopesAsked: ["a"], scopesGranted: ["a"], consentPageDigest: "d", consentText: "t" });
|
|
return { store, base, grant };
|
|
}
|
|
|
|
it("a second revocation of one grant inserts nothing: created:false, the first row's id", async () => {
|
|
const { store, base, grant } = await granted();
|
|
const first = await store.createConsentReceipt({ ...base, kind: "revocation", createdAt: "2026-11-08T01:00:00.000Z", revokesReceiptId: grant.id });
|
|
const second = await store.createConsentReceipt({ ...base, kind: "revocation", createdAt: "2026-11-08T02:00:00.000Z", revokesReceiptId: grant.id });
|
|
expect(first.created).toBe(true);
|
|
expect(second.created).toBe(false);
|
|
expect(second.receipt.id).toBe(first.receipt.id);
|
|
expect(second.receipt.createdAt).toBe("2026-11-08T01:00:00.000Z");
|
|
expect((await store.listConsentReceipts(base.accountId)).filter((r) => r.kind === "revocation")).toHaveLength(1);
|
|
});
|
|
|
|
it("the read-back returns the revocation of this grant, not another's or the grant", async () => {
|
|
const { store, base, grant } = await granted();
|
|
const other = (await store.createConsentReceipt({ ...base, kind: "grant", createdAt: "2026-11-08T00:30:00.000Z", scopesAsked: [], scopesGranted: [], consentPageDigest: "d2", consentText: "t2" })).receipt;
|
|
const otherRevocation = await store.createConsentReceipt({ ...base, kind: "revocation", createdAt: "2026-11-08T01:00:00.000Z", revokesReceiptId: other.id });
|
|
const first = await store.createConsentReceipt({ ...base, kind: "revocation", createdAt: "2026-11-08T01:10:00.000Z", revokesReceiptId: grant.id });
|
|
const second = await store.createConsentReceipt({ ...base, kind: "revocation", createdAt: "2026-11-08T02:00:00.000Z", revokesReceiptId: grant.id });
|
|
expect(second.receipt.id).toBe(first.receipt.id);
|
|
expect(second.receipt.id).not.toBe(otherRevocation.receipt.id);
|
|
expect(second.receipt).toMatchObject({ kind: "revocation", revokesReceiptId: grant.id });
|
|
});
|
|
});
|
|
|
|
describe("InMemoryStore.createConsentReceipt", () => {
|
|
it("writes one revocation per grant: the second names the first and is not created", async () => {
|
|
const store = new InMemoryStore();
|
|
const base = { accountId: "a", appSlug: "streampixels" as const, provider: "joystick" as const, createdAt: "2026-11-08T00:00:00.000Z" };
|
|
const { receipt: grant } = await store.createConsentReceipt({ ...base, kind: "grant", scopesAsked: [], scopesGranted: [], consentPageDigest: "d", consentText: "t" });
|
|
const first = await store.createConsentReceipt({ ...base, kind: "revocation", revokesReceiptId: grant.id });
|
|
const second = await store.createConsentReceipt({ ...base, createdAt: "2026-11-09T00:00:00.000Z", kind: "revocation", revokesReceiptId: grant.id });
|
|
expect([first.created, second.created]).toEqual([true, false]);
|
|
expect(second.receipt).toEqual(first.receipt);
|
|
expect(await store.listConsentReceipts("a")).toHaveLength(2);
|
|
});
|
|
});
|
|
|
|
describe("InMemoryStore.claimOAuthStateUse", () => {
|
|
const use = {
|
|
jti: "state-1",
|
|
phase: "launch" as const,
|
|
appSlug: "repixelizer" as const,
|
|
usedAt: "2026-04-26T12:00:00.000Z",
|
|
expiresAt: "2026-04-26T12:10:00.000Z",
|
|
};
|
|
|
|
it("is true once per (jti, phase)", async () => {
|
|
const store = new InMemoryStore();
|
|
expect(await store.claimOAuthStateUse(use)).toBe(true);
|
|
expect(await store.claimOAuthStateUse(use)).toBe(false);
|
|
expect(await store.claimOAuthStateUse({ ...use, phase: "callback" })).toBe(true);
|
|
expect(await store.claimOAuthStateUse({ ...use, phase: "callback" })).toBe(false);
|
|
expect(await store.claimOAuthStateUse({ ...use, jti: "state-2" })).toBe(true);
|
|
});
|
|
|
|
it("drops a record only once its state's exp + skew + margin has passed", async () => {
|
|
const store = new InMemoryStore();
|
|
await store.claimOAuthStateUse(use);
|
|
const expMs = Date.parse(use.expiresAt);
|
|
const at = (ms: number) => new Date(ms).toISOString();
|
|
// Inside the skew past expiry the state still verifies, and the margin keeps the record beyond that.
|
|
await store.claimOAuthStateUse({ ...use, jti: "state-2", usedAt: at(expMs + OAUTH_STATE_SKEW_MS) });
|
|
expect(await store.claimOAuthStateUse(use)).toBe(false);
|
|
const boundary = expMs + OAUTH_STATE_SKEW_MS + OAUTH_STATE_PRUNE_MARGIN_MS;
|
|
await store.claimOAuthStateUse({ ...use, jti: "state-3", usedAt: at(boundary - 1) });
|
|
expect(await store.claimOAuthStateUse(use)).toBe(false);
|
|
// At exactly exp + skew + margin the record is prunable (the rule is <=).
|
|
await store.claimOAuthStateUse({ ...use, jti: "state-4", usedAt: at(boundary) });
|
|
expect(await store.claimOAuthStateUse(use)).toBe(true);
|
|
});
|
|
|
|
it("removes at most one batch of prunable records per successful claim", async () => {
|
|
const store = new InMemoryStore();
|
|
const expMs = Date.parse(use.expiresAt);
|
|
const total = OAUTH_STATE_PRUNE_BATCH * 2 + 7;
|
|
for (let i = 0; i < total; i += 1) await store.claimOAuthStateUse({ ...use, jti: `old-${i}` });
|
|
const size = () => (store as unknown as { oauthStateUses: Map<string, string> }).oauthStateUses.size;
|
|
expect(size()).toBe(total);
|
|
const later = new Date(expMs + OAUTH_STATE_SKEW_MS + OAUTH_STATE_PRUNE_MARGIN_MS).toISOString();
|
|
await store.claimOAuthStateUse({ ...use, jti: "fresh-1", usedAt: later, expiresAt: later });
|
|
expect(size()).toBe(total + 1 - OAUTH_STATE_PRUNE_BATCH);
|
|
// A refused (replayed) claim prunes nothing, as on Postgres where the delete follows a new row.
|
|
await store.claimOAuthStateUse({ ...use, jti: "fresh-1", usedAt: later, expiresAt: later });
|
|
expect(size()).toBe(total + 1 - OAUTH_STATE_PRUNE_BATCH);
|
|
});
|
|
});
|
|
|
|
describe("PostgresStore.claimOAuthStateUse retention", () => {
|
|
const use = {
|
|
jti: "state-1",
|
|
phase: "launch" as const,
|
|
appSlug: "repixelizer" as const,
|
|
usedAt: "2026-04-26T12:00:00.000Z",
|
|
expiresAt: "2026-04-26T12:10:00.000Z",
|
|
};
|
|
|
|
function doublePool(prune: "ok" | "throws", inserted = 1) {
|
|
const calls: Array<{ sql: string; params: unknown[] }> = [];
|
|
const pool = {
|
|
query: async (sql: string, params: unknown[]) => {
|
|
calls.push({ sql, params });
|
|
if (calls.length === 1) return { rowCount: inserted, rows: [] };
|
|
if (prune === "throws") throw Object.assign(new Error("permission denied for table oauth_state_uses"), { code: "42501" });
|
|
return { rowCount: 0, rows: [] };
|
|
},
|
|
};
|
|
return { calls, store: new PostgresStore(pool as unknown as Pool) };
|
|
}
|
|
|
|
it("runs one bounded delete after a new row, at the shared cutoff and batch", async () => {
|
|
const { calls, store } = doublePool("ok");
|
|
expect(await store.claimOAuthStateUse(use)).toBe(true);
|
|
expect(calls).toHaveLength(2);
|
|
expect(calls[1]!.sql).toMatch(/DELETE FROM oauth_state_uses/);
|
|
expect(calls[1]!.params).toEqual([
|
|
new Date(Date.parse(use.usedAt) - OAUTH_STATE_SKEW_MS - OAUTH_STATE_PRUNE_MARGIN_MS),
|
|
OAUTH_STATE_PRUNE_BATCH,
|
|
]);
|
|
});
|
|
|
|
it("prunes nothing when the claim is a replay", async () => {
|
|
const { calls, store } = doublePool("ok", 0);
|
|
expect(await store.claimOAuthStateUse(use)).toBe(false);
|
|
expect(calls).toHaveLength(1);
|
|
});
|
|
|
|
it("a failing prune does not change the claim's result", async () => {
|
|
const { calls, store } = doublePool("throws");
|
|
expect(await store.claimOAuthStateUse(use)).toBe(true);
|
|
expect(calls).toHaveLength(2);
|
|
});
|
|
});
|
|
|
|
describe("createPostgresStore", () => {
|
|
// pg emits an idle client's connection loss on the pool. With no listener
|
|
// that event kills the process, and Node prints the pg Client with its
|
|
// connection parameters.
|
|
it("survives an idle connection's loss and says so by code alone", async () => {
|
|
const store = createPostgresStore("postgres://heimdall:CANARYpw@127.0.0.1:1/heimdall");
|
|
const pool = (store as unknown as { pool: { emit(event: string, ...args: unknown[]): boolean } }).pool;
|
|
const logged: string[] = [];
|
|
const originalError = console.error;
|
|
console.error = (...args: unknown[]) => void logged.push(args.map(String).join(" "));
|
|
try {
|
|
const terminated = Object.assign(new Error("terminating connection due to administrator command CANARY"), {
|
|
code: "57P01",
|
|
client: { connectionParameters: { password: "CANARYpw" } },
|
|
});
|
|
expect(() => pool.emit("error", terminated, terminated.client)).not.toThrow();
|
|
} finally {
|
|
console.error = originalError;
|
|
await store.close();
|
|
}
|
|
expect(logged).toEqual([
|
|
"Heimdall lost an idle Postgres connection (57P01); the pool reconnects on the next query.",
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe("PostgresStore.checkSchema", () => {
|
|
it("derives every table, typed column and unique key from the schema", () => {
|
|
expect(REQUIRED_TABLES).toEqual([
|
|
"accounts",
|
|
"linked_identities",
|
|
"sessions",
|
|
"auth_attempts",
|
|
"private_command_receipts",
|
|
"pending_links",
|
|
"auth_completions",
|
|
"oauth_state_uses",
|
|
"capability_grants",
|
|
"entitlement_snapshots",
|
|
"consent_receipts",
|
|
"audit_events",
|
|
]);
|
|
expect(REQUIRED_COLUMNS).toHaveLength(113);
|
|
expect(REQUIRED_COLUMNS).toEqual(
|
|
expect.arrayContaining([
|
|
{ table: "auth_completions", column: "code", type: "TEXT", nullable: false },
|
|
{ table: "sessions", column: "access_revision", type: "INTEGER", nullable: false },
|
|
{ table: "linked_identities", column: "profile_json", type: "JSONB", nullable: false },
|
|
{ table: "linked_identities", column: "username", type: "TEXT", nullable: true },
|
|
{ table: "entitlement_snapshots", column: "is_allowed", type: "BOOLEAN", nullable: false },
|
|
{ table: "auth_completions", column: "attempt_id", type: "TEXT", nullable: true },
|
|
])
|
|
);
|
|
expect(REQUIRED_KEYS).toHaveLength(17);
|
|
expect(REQUIRED_KEYS).toEqual(
|
|
expect.arrayContaining([
|
|
{ table: "linked_identities", columns: "provider,provider_user_id", predicate: "" },
|
|
{ table: "private_command_receipts", columns: "app_slug,idempotency_key", predicate: "" },
|
|
{ table: "oauth_state_uses", columns: "jti,phase", predicate: "" },
|
|
{ table: "consent_receipts", columns: "revokes_receipt_id", predicate: "revokes_receipt_id IS NOT NULL" },
|
|
{ table: "consent_receipts", columns: "account_id,id", predicate: "" },
|
|
{ table: "entitlement_snapshots", columns: "account_id,provider,scope", predicate: "" },
|
|
{ table: "auth_completions", columns: "app_slug,attempt_id", predicate: "attempt_id IS NOT NULL AND consumed_at IS NULL" },
|
|
])
|
|
);
|
|
});
|
|
|
|
// Soul pass 6: stripping parentheses and case merged predicates of
|
|
// different meaning. These are Postgres's own printed forms.
|
|
it("splits a printed predicate only at its top-level ANDs", () => {
|
|
expect(predicateConditions("((a OR b) AND c)")).toEqual(["a OR b", "c"]);
|
|
expect(predicateConditions("(a OR (b AND c))")).toEqual(["a OR (b AND c)"]);
|
|
expect(predicateConditions("((attempt_id IS NOT NULL) AND (consumed_at IS NULL))")).toEqual([
|
|
"attempt_id IS NOT NULL",
|
|
"consumed_at IS NULL",
|
|
]);
|
|
expect(predicateConditions("(s = 'x AND (y'::text)")).toEqual(["s = 'x AND (y'::text"]);
|
|
expect(predicateConditions('(("odd AND (name" IS NULL) AND b)')).toEqual(['"odd AND (name" IS NULL', "b"]);
|
|
expect(predicateConditions("")).toEqual([]);
|
|
});
|
|
|
|
it("matches predicates by their conditions, never by case or by dropping structure", () => {
|
|
const keys = ["app_slug", "attempt_id"];
|
|
expect(samePredicate("((a OR b) AND c)", "(a OR (b AND c))", keys, false)).toBe(false);
|
|
expect(samePredicate("(s = 'Pending'::text)", "(s = 'pending'::text)", keys, false)).toBe(false);
|
|
expect(samePredicate("((a) AND (b))", "((b) AND (a))", keys, false)).toBe(true);
|
|
expect(samePredicate("((a) AND (b))", "((a) AND (b) AND (c))", keys, false)).toBe(false);
|
|
});
|
|
|
|
it("lets a key column's IS NOT NULL be absent only where NULL keys cannot collide", () => {
|
|
const keys = ["app_slug", "attempt_id"];
|
|
const declared = "((attempt_id IS NOT NULL) AND (consumed_at IS NULL))";
|
|
expect(samePredicate(declared, "(consumed_at IS NULL)", keys, false)).toBe(true);
|
|
expect(samePredicate(declared, "(consumed_at IS NULL)", keys, true)).toBe(false);
|
|
expect(samePredicate(declared, "((consumed_at IS NULL) AND (other IS NOT NULL))", keys, false)).toBe(false);
|
|
});
|
|
|
|
it("requires a key without a predicate to have none", () => {
|
|
expect(samePredicate("", "", ["id"], false)).toBe(true);
|
|
expect(samePredicate("", "(id IS NOT NULL)", ["id"], false)).toBe(false);
|
|
});
|
|
});
|
|
|
|
/**
|
|
* Every operation the store offers, once, in an order that makes each one
|
|
* meaningful. Returns the names it called, so a test can prove it covered the
|
|
* store. Throws on the first operation the database refuses.
|
|
*/
|
|
async function exerciseStore(store: PostgresStore): Promise<Set<string>> {
|
|
const called = new Set<string>();
|
|
const s = new Proxy(store, {
|
|
get(target, name, receiver) {
|
|
const value = Reflect.get(target, name, receiver) as unknown;
|
|
if (typeof value !== "function") return value;
|
|
return (...args: unknown[]) => {
|
|
called.add(String(name));
|
|
return (value as (...a: unknown[]) => unknown).apply(target, args);
|
|
};
|
|
},
|
|
});
|
|
const now = "2026-04-26T12:00:00.000Z";
|
|
const later = "2999-01-01T00:00:00.000Z";
|
|
const account = await s.createAccount({ createdAt: now, lastSeenAt: now, displayName: "Probe" });
|
|
await s.touchAccount(account.id, now, { displayName: "Probe 2" });
|
|
await s.findAccountById(account.id);
|
|
const identity = {
|
|
accountId: account.id,
|
|
provider: "discord" as const,
|
|
providerUserId: "u1",
|
|
scopes: ["identify"],
|
|
profileJson: {},
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
};
|
|
const linked = await s.upsertLinkedIdentity(identity);
|
|
await s.updateLinkedIdentityCredential(linked.id, async () => ({ kind: "keep" }));
|
|
await s.upsertLinkedIdentity({ ...identity, username: "again" });
|
|
const spare = await s.upsertLinkedIdentity({ ...identity, providerUserId: "u-spare" });
|
|
expect((await s.unlinkLinkedIdentity(account.id, spare.id)).kind).toBe("unlinked");
|
|
await s.findAccountByLinkedIdentity("discord", "u1");
|
|
await s.findStoredLinkedIdentity("discord", "u1");
|
|
await s.findStoredLinkedIdentityForAccount(account.id, "discord");
|
|
await s.listStoredLinkedIdentitiesForAccount(account.id);
|
|
await s.listLinkedIdentitiesForAccount(account.id);
|
|
await s.createCapabilityGrant({
|
|
accountId: account.id,
|
|
scopeType: "app",
|
|
scopeId: "ghostlight",
|
|
capability: "app_access",
|
|
source: "probe",
|
|
status: "active",
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
});
|
|
await s.listActiveGrants(account.id, "ghostlight", now);
|
|
const session = { id: randomUUID(), accountId: account.id, appSlug: "ghostlight" as const, createdAt: now, lastSeenAt: now, expiresAt: later, accessRevision: 1, claimsJson: {} };
|
|
await s.createSession(session);
|
|
await s.createSession({ ...session, lastSeenAt: later });
|
|
await s.findSession("ghostlight", session.id);
|
|
await s.revokeSession("ghostlight", session.id, account.id, 1, now);
|
|
const attempt = await s.createAuthAttempt({ appSlug: "ghostlight", provider: "discord", mode: "sign_in", returnTo: "https://x.test/", createdAt: now, expiresAt: later });
|
|
await s.findAuthAttempt("ghostlight", attempt.handle);
|
|
await s.updateAuthAttempt("ghostlight", attempt.handle, { status: "completed", at: now });
|
|
const receipt = { appSlug: "ghostlight" as const, idempotencyKey: "k", requestFingerprint: "f", status: "accepted", contentSchema: "c", envelopeBase64: "e", createdAt: now, expiresAt: later };
|
|
await s.createPrivateCommandReceipt(receipt);
|
|
await s.createPrivateCommandReceipt(receipt);
|
|
await s.findPrivateCommandReceipt("ghostlight", "k");
|
|
const completion = { appSlug: "ghostlight" as const, provider: "discord" as const, mode: "sign_in" as const, accountId: account.id, sessionId: session.id, returnTo: "https://x.test/", createdAt: now, expiresAt: later, payloadJson: {} };
|
|
const byCode = await s.createAuthCompletion(completion);
|
|
await s.consumeAuthCompletion("ghostlight", byCode.code, now);
|
|
await s.createAuthCompletion({ ...completion, attemptId: attempt.handle });
|
|
await s.consumeAuthCompletionByAttempt("ghostlight", attempt.handle, now);
|
|
const found = await s.createAuthCompletion(completion);
|
|
await s.findAuthCompletion("ghostlight", found.code);
|
|
const pending = await s.createPendingLink({
|
|
appSlug: "ghostlight",
|
|
accountId: account.id,
|
|
provider: "discord",
|
|
providerUserId: "u2",
|
|
identity: { ...identity, providerUserId: "u2" },
|
|
returnTo: "https://x.test/",
|
|
createdAt: now,
|
|
expiresAt: later,
|
|
});
|
|
await s.consumePendingLink("ghostlight", pending.id, account.id, now);
|
|
const snapshot = { accountId: account.id, provider: "discord" as const, scope: "ghostlight:probe", evaluatedAt: now, isAllowed: true, reasonCode: "ok", rawSummaryJson: {} };
|
|
await s.upsertEntitlementSnapshot(snapshot);
|
|
await s.upsertEntitlementSnapshot({ ...snapshot, isAllowed: false });
|
|
const stateUse = { jti: randomUUID(), phase: "launch" as const, appSlug: "ghostlight" as const, usedAt: now, expiresAt: later };
|
|
expect(await s.claimOAuthStateUse(stateUse)).toBe(true);
|
|
expect(await s.claimOAuthStateUse(stateUse)).toBe(false);
|
|
expect(await s.claimOAuthStateUse({ ...stateUse, phase: "callback" })).toBe(true);
|
|
// The prune is swallowed when the role may not run it, so the exercise proves it ran: a long-expired
|
|
// record is gone after the next new claim. A missing prune stands for the privilege refusal behind it.
|
|
const stale = { ...stateUse, jti: randomUUID(), usedAt: "2000-01-01T00:00:00.000Z", expiresAt: "2000-01-01T00:10:00.000Z" };
|
|
expect(await s.claimOAuthStateUse(stale)).toBe(true);
|
|
expect(await s.claimOAuthStateUse({ ...stateUse, jti: randomUUID() })).toBe(true);
|
|
const retained = await s.claimOAuthStateUse(stale);
|
|
if (!retained) {
|
|
throw Object.assign(new Error("oauth_state_uses was not pruned"), { code: "42501" });
|
|
}
|
|
const { receipt: grant } = await s.createConsentReceipt({ kind: "grant", accountId: account.id, appSlug: "streampixels", provider: "joystick", createdAt: now, scopesAsked: ["a", "b"], scopesGranted: ["a"], consentPageDigest: "d", consentText: "t" });
|
|
const revocation = { kind: "revocation" as const, accountId: account.id, appSlug: "streampixels" as const, provider: "joystick" as const, createdAt: later, revokesReceiptId: grant.id };
|
|
const first = await s.createConsentReceipt(revocation);
|
|
// The second revocation of one grant hits ON CONFLICT DO NOTHING and reads the first back.
|
|
const second = await s.createConsentReceipt(revocation);
|
|
expect(first.created).toBe(true);
|
|
expect(second.created).toBe(false);
|
|
expect(second.receipt.id).toBe(first.receipt.id);
|
|
expect((await s.listConsentReceipts(account.id)).map((r) => r.kind)).toEqual(["grant", "revocation"]);
|
|
await s.createAuditEvent({ eventType: "probe", eventPayloadJson: {}, createdAt: now });
|
|
return called;
|
|
}
|
|
|
|
// Against a real Postgres, through the startup path itself: createStore, with
|
|
// and without applying the schema. HEIMDALL_TEST_PG_ADMIN_URL names a role
|
|
// that may create databases and roles. Each case clones a template database
|
|
// that already holds the schema, and runs as its own login role.
|
|
const adminUrl = process.env.HEIMDALL_TEST_PG_ADMIN_URL;
|
|
describe.skipIf(!adminUrl)("startup against a live Postgres (HEIMDALL_TEST_PG_ADMIN_URL)", { timeout: 120_000 }, () => {
|
|
const refusal = (code: string) => `Postgres storage could not be prepared (${code}); check GC_ACCESS_DATABASE_URL_FILE.`;
|
|
const suffix = randomUUID().replace(/-/g, "").slice(0, 12);
|
|
const template = `heimdall_template_${suffix}`;
|
|
const role = `heimdall_app_${suffix}`;
|
|
const rolePassword = "CANARYrolepw";
|
|
const admin = adminUrl ? new Pool({ connectionString: adminUrl, max: 2 }) : undefined;
|
|
const cleanup: Array<() => Promise<void>> = [];
|
|
|
|
/**
|
|
* A pool the test itself uses. The forced drops end its connections,
|
|
* possibly while pg is still closing them after end() resolved; that is
|
|
* cleanup, not a finding, so it is not left to become an unhandled error.
|
|
*/
|
|
function fixturePool(connectionString: string): Pool {
|
|
const pool = new Pool({ connectionString, max: 2 });
|
|
pool.on("error", () => undefined);
|
|
return pool;
|
|
}
|
|
|
|
function urlFor(database: string, user?: string, password?: string): string {
|
|
const url = new URL(adminUrl!);
|
|
url.pathname = `/${database}`;
|
|
if (user) url.username = user;
|
|
if (password) url.password = password;
|
|
return url.toString();
|
|
}
|
|
|
|
beforeAll(async () => {
|
|
await admin!.query(`CREATE DATABASE ${template}`);
|
|
const pool = fixturePool(urlFor(template));
|
|
await new PostgresStore(pool).ensureSchema();
|
|
await pool.end();
|
|
await admin!.query(`CREATE ROLE ${role} LOGIN PASSWORD '${rolePassword}'`);
|
|
}, 120_000);
|
|
|
|
afterEach(async () => {
|
|
for (const step of cleanup.splice(0).reverse()) await step();
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await admin!.query(`DROP DATABASE IF EXISTS ${template}`);
|
|
await admin!.query(`DROP ROLE IF EXISTS ${role}`);
|
|
await admin!.end();
|
|
}, 120_000);
|
|
|
|
/** A clone of the applied template, and a pool on it as its owner (the admin role). */
|
|
async function appliedDatabase(): Promise<{ name: string; owner: Pool }> {
|
|
const name = `heimdall_check_${randomUUID().replace(/-/g, "")}`;
|
|
await admin!.query(`CREATE DATABASE ${name} TEMPLATE ${template}`);
|
|
const owner = fixturePool(urlFor(name));
|
|
cleanup.push(async () => {
|
|
await owner.end();
|
|
await admin!.query(`DROP DATABASE IF EXISTS ${name} WITH (FORCE)`);
|
|
});
|
|
return { name, owner };
|
|
}
|
|
|
|
/** Grants the app role exactly `privileges`, per table, and nothing else. */
|
|
async function grant(owner: Pool, privileges: Readonly<Record<string, readonly string[]>>): Promise<void> {
|
|
await owner.query(`GRANT USAGE ON SCHEMA public TO ${role}`);
|
|
for (const [table, wanted] of Object.entries(privileges)) {
|
|
if (wanted.length > 0) await owner.query(`GRANT ${wanted.join(", ")} ON ${table} TO ${role}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Starts Heimdall's store on `url` and reports "started" or the refusal's
|
|
* message. A refusal must also leave no session of its own behind: the
|
|
* refused store's pool is closed before the error is thrown.
|
|
*/
|
|
async function start(url: string, applySchemaOnStartup: boolean): Promise<string> {
|
|
const tagged = new URL(url);
|
|
const tag = `heimdall_start_${randomUUID().replace(/-/g, "").slice(0, 16)}`;
|
|
tagged.searchParams.set("application_name", tag);
|
|
try {
|
|
const store = await createStore({
|
|
storage: { backend: "postgres", databaseUrl: tagged.toString(), applySchemaOnStartup },
|
|
} as HeimdallConfig);
|
|
await store.close();
|
|
return "started";
|
|
} catch (error) {
|
|
let sessions = -1;
|
|
for (let attempt = 0; attempt < 50 && sessions !== 0; attempt += 1) {
|
|
if (attempt > 0) await new Promise((resolve) => setTimeout(resolve, 100));
|
|
const result = await admin!.query<{ n: number }>(
|
|
"SELECT count(*)::int AS n FROM pg_stat_activity WHERE application_name = $1",
|
|
[tag]
|
|
);
|
|
sessions = result.rows[0]!.n;
|
|
}
|
|
expect(sessions, "a refused store left its connection open").toBe(0);
|
|
return (error as Error).message;
|
|
}
|
|
}
|
|
|
|
it("starts on an applied database as its owner, applying or not", async () => {
|
|
const { name } = await appliedDatabase();
|
|
expect(await start(urlFor(name), false)).toBe("started");
|
|
expect(await start(urlFor(name), true)).toBe("started");
|
|
});
|
|
|
|
it("serves every store operation with exactly the privileges it checks for", async () => {
|
|
const { name, owner } = await appliedDatabase();
|
|
await grant(owner, STORE_TABLE_PRIVILEGES);
|
|
|
|
expect(await start(urlFor(name, role, rolePassword), false)).toBe("started");
|
|
const pool = fixturePool(urlFor(name, role, rolePassword));
|
|
cleanup.push(async () => {
|
|
await pool.end();
|
|
});
|
|
const called = await exerciseStore(new PostgresStore(pool));
|
|
const offered = Object.getOwnPropertyNames(PostgresStore.prototype).filter(
|
|
(method) => !["constructor", "ensureSchema", "checkSchema", "close"].includes(method)
|
|
);
|
|
expect([...called].sort()).toEqual(offered.sort());
|
|
});
|
|
|
|
it("refuses each privilege taken away, and the store needs each one", async () => {
|
|
const pairs = Object.entries(STORE_TABLE_PRIVILEGES).flatMap(([table, wanted]) => wanted.map((privilege) => ({ table, privilege })));
|
|
expect(Object.keys(STORE_TABLE_PRIVILEGES).sort()).toEqual([...REQUIRED_TABLES].sort());
|
|
for (const { table, privilege } of pairs) {
|
|
const { name, owner } = await appliedDatabase();
|
|
await grant(owner, { ...STORE_TABLE_PRIVILEGES, [table]: STORE_TABLE_PRIVILEGES[table]!.filter((p) => p !== privilege) });
|
|
|
|
const message = await start(urlFor(name, role, rolePassword), false);
|
|
expect(message, `${table} without ${privilege}`).toBe(refusal("SCHEMA_PRIVILEGES"));
|
|
expect(message).not.toContain("CANARY");
|
|
|
|
const pool = fixturePool(urlFor(name, role, rolePassword));
|
|
const failure = await exerciseStore(new PostgresStore(pool)).then(
|
|
() => undefined,
|
|
(error: unknown) => (error as { code?: string }).code
|
|
);
|
|
await pool.end();
|
|
expect(failure, `the store runs without ${privilege} on ${table}, so the check need not ask for it`).toBe("42501");
|
|
for (const step of cleanup.splice(0).reverse()) await step();
|
|
}
|
|
});
|
|
|
|
it("startup refuses a role that lacks DELETE on linked_identities", async () => {
|
|
expect(STORE_TABLE_PRIVILEGES.linked_identities).toContain("DELETE");
|
|
const { name, owner } = await appliedDatabase();
|
|
await grant(owner, { ...STORE_TABLE_PRIVILEGES, linked_identities: ["SELECT", "INSERT", "UPDATE"] });
|
|
expect(await start(urlFor(name, role, rolePassword), false)).toBe(refusal("SCHEMA_PRIVILEGES"));
|
|
});
|
|
|
|
it("on real Postgres: an upsert never moves an identity, and concurrent unlinks of the last two leave one", async () => {
|
|
const { name, owner } = await appliedDatabase();
|
|
const clientA = new PostgresStore(owner);
|
|
const at = "2026-11-13T12:00:00.000Z";
|
|
const a = await clientA.createAccount({ createdAt: at, lastSeenAt: at });
|
|
const b = await clientA.createAccount({ createdAt: at, lastSeenAt: at });
|
|
const base = { provider: "discord" as const, scopes: [], profileJson: {}, createdAt: at, updatedAt: at };
|
|
const held = await clientA.upsertLinkedIdentity({ ...base, accountId: a.id, providerUserId: "u1", username: "original" });
|
|
await expect(clientA.upsertLinkedIdentity({ ...base, accountId: b.id, providerUserId: "u1", username: "thief" })).rejects.toBeInstanceOf(IdentityHeldElsewhereError);
|
|
expect(await clientA.findStoredLinkedIdentity("discord", "u1")).toEqual(held);
|
|
|
|
await clientA.upsertLinkedIdentity({ ...base, accountId: a.id, providerUserId: "u2" });
|
|
// Two separate pools, as two Heimdall processes would be, race for the last two.
|
|
const other = fixturePool(urlFor(name));
|
|
cleanup.push(async () => {
|
|
await other.end();
|
|
});
|
|
const storeB = new PostgresStore(other);
|
|
for (let round = 0; round < 10; round += 1) {
|
|
const [first, second] = await clientA.listStoredLinkedIdentitiesForAccount(a.id);
|
|
expect(first && second, `round ${round} starts with two`).toBeTruthy();
|
|
const results = await Promise.all([clientA.unlinkLinkedIdentity(a.id, first!.id), storeB.unlinkLinkedIdentity(a.id, second!.id)]);
|
|
expect(results.map((r) => r.kind).sort(), `round ${round}`).toEqual(["last_identity", "unlinked"]);
|
|
expect(await clientA.listStoredLinkedIdentitiesForAccount(a.id)).toHaveLength(1);
|
|
await clientA.upsertLinkedIdentity({ ...base, accountId: a.id, providerUserId: `u-next-${round}` });
|
|
}
|
|
});
|
|
|
|
describe("oauth_state_uses retention", () => {
|
|
const expMs = Date.parse("2026-04-26T12:10:00.000Z");
|
|
const boundary = expMs + OAUTH_STATE_SKEW_MS + OAUTH_STATE_PRUNE_MARGIN_MS;
|
|
const use = (jti: string, usedAtMs: number, expiresAtMs = expMs) => ({
|
|
jti,
|
|
phase: "launch" as const,
|
|
appSlug: "repixelizer" as const,
|
|
usedAt: new Date(usedAtMs).toISOString(),
|
|
expiresAt: new Date(expiresAtMs).toISOString(),
|
|
});
|
|
const count = async (owner: Pool) => Number((await owner.query("SELECT count(*) AS n FROM oauth_state_uses")).rows[0].n);
|
|
const seed = async (owner: Pool, jti: string) => {
|
|
await owner.query(
|
|
"INSERT INTO oauth_state_uses (jti, phase, app_slug, used_at, expires_at) VALUES ($1, 'launch', 'repixelizer', $2, $2)",
|
|
[jti, new Date(expMs)]
|
|
);
|
|
};
|
|
|
|
it.each([
|
|
[boundary - 1, true],
|
|
[boundary, false],
|
|
[boundary + 1, false],
|
|
])("a claim at exp + skew + margin %i ms keeps the record: %s", async (usedAtMs, kept) => {
|
|
const { owner } = await appliedDatabase();
|
|
const store = new PostgresStore(owner);
|
|
expect(await store.claimOAuthStateUse(use("old", expMs - 1000))).toBe(true);
|
|
expect(await store.claimOAuthStateUse(use("other", usedAtMs, usedAtMs + 600_000))).toBe(true);
|
|
expect(await store.claimOAuthStateUse(use("old", usedAtMs))).toBe(!kept);
|
|
});
|
|
|
|
it("removes at most one batch per claim and never the live record", async () => {
|
|
const { owner } = await appliedDatabase();
|
|
const store = new PostgresStore(owner);
|
|
const total = OAUTH_STATE_PRUNE_BATCH * 2 + 7;
|
|
for (let i = 0; i < total; i += 1) await seed(owner, `old-${i}`);
|
|
const fresh = use("fresh", boundary + 5, boundary + 600_000);
|
|
expect(await store.claimOAuthStateUse(fresh)).toBe(true);
|
|
expect(await count(owner)).toBe(total + 1 - OAUTH_STATE_PRUNE_BATCH);
|
|
expect(await store.claimOAuthStateUse(fresh)).toBe(false);
|
|
expect(await count(owner)).toBe(total + 1 - OAUTH_STATE_PRUNE_BATCH);
|
|
});
|
|
|
|
it("keeps its expires_at index", async () => {
|
|
const { owner } = await appliedDatabase();
|
|
const found = await owner.query("SELECT 1 FROM pg_indexes WHERE indexname = 'oauth_state_uses_expires_at_idx'");
|
|
expect(found.rowCount).toBe(1);
|
|
});
|
|
|
|
it("the step_up_assert nonce claim is single-use under the store's own role, and independent of the other phases", async () => {
|
|
const { name, owner } = await appliedDatabase();
|
|
await grant(owner, STORE_TABLE_PRIVILEGES);
|
|
const pool = fixturePool(urlFor(name, role, rolePassword));
|
|
cleanup.push(async () => {
|
|
await pool.end();
|
|
});
|
|
const store = new PostgresStore(pool);
|
|
const claim = { ...use("digest", boundary, boundary + 600_000), phase: "step_up_assert" as const, appSlug: "bifrost" };
|
|
expect(await store.claimOAuthStateUse(claim)).toBe(true);
|
|
expect(await store.claimOAuthStateUse(claim)).toBe(false);
|
|
expect(await store.claimOAuthStateUse({ ...claim, phase: "callback" })).toBe(true);
|
|
});
|
|
|
|
it("a role without DELETE still claims, and leaves the old records", async () => {
|
|
const { name, owner } = await appliedDatabase();
|
|
await grant(owner, { ...STORE_TABLE_PRIVILEGES, oauth_state_uses: ["SELECT", "INSERT"] });
|
|
await seed(owner, "old");
|
|
const pool = fixturePool(urlFor(name, role, rolePassword));
|
|
cleanup.push(async () => {
|
|
await pool.end();
|
|
});
|
|
const store = new PostgresStore(pool);
|
|
expect(await store.claimOAuthStateUse(use("fresh", boundary + 5, boundary + 600_000))).toBe(true);
|
|
expect(await count(owner)).toBe(2);
|
|
});
|
|
});
|
|
|
|
it("refuses to apply the schema as a role that may not, and closes the store", async () => {
|
|
const { name, owner } = await appliedDatabase();
|
|
await grant(owner, STORE_TABLE_PRIVILEGES);
|
|
|
|
expect(await start(urlFor(name, role, rolePassword), true)).toBe(refusal("42501"));
|
|
});
|
|
|
|
it("prints each declared predicate exactly as Postgres prints the applied index's", async () => {
|
|
const { owner } = await appliedDatabase();
|
|
const printed = await printedDeclaredPredicates(owner, HEIMDALL_SCHEMA_REQUIREMENTS);
|
|
const appliedByIndex = {
|
|
auth_completions_attempt_unconsumed_unique_idx: "auth_completions",
|
|
consent_receipts_revokes_unique_idx: "consent_receipts",
|
|
};
|
|
const partialKeys = REQUIRED_KEYS.flatMap((key, index) => (key.predicate === "" ? [] : [{ key, index }]));
|
|
expect(partialKeys.map(({ key }) => key.table).sort()).toEqual(Object.values(appliedByIndex).sort());
|
|
for (const [indexName, table] of Object.entries(appliedByIndex)) {
|
|
const applied = await owner.query<{ predicate: string }>(
|
|
"SELECT pg_get_expr(indpred, indrelid) AS predicate FROM pg_index WHERE indexrelid = $1::regclass",
|
|
[indexName]
|
|
);
|
|
const { index } = partialKeys.find(({ key }) => key.table === table)!;
|
|
expect(printed[index], indexName).toBe(applied.rows[0]!.predicate);
|
|
}
|
|
expect(printed.filter((predicate) => predicate !== "")).toHaveLength(partialKeys.length);
|
|
});
|
|
|
|
it("refuses to boot with consent_receipts missing when the schema is not applied on startup", async () => {
|
|
const { name, owner } = await appliedDatabase();
|
|
await owner.query("DROP TABLE consent_receipts");
|
|
|
|
expect(await start(urlFor(name), false)).toBe(refusal("SCHEMA_MISSING"));
|
|
});
|
|
|
|
it("refuses a table whose primary key was dropped, even after applying the schema", async () => {
|
|
const { name, owner } = await appliedDatabase();
|
|
await owner.query("ALTER TABLE auth_completions DROP CONSTRAINT auth_completions_pkey");
|
|
|
|
expect(await start(urlFor(name), false)).toBe(refusal("SCHEMA_MISSING"));
|
|
expect(await start(urlFor(name), true)).toBe(refusal("SCHEMA_MISSING"));
|
|
});
|
|
|
|
it("serves every store operation with each privilege granted column by column", async () => {
|
|
const { name, owner } = await appliedDatabase();
|
|
await owner.query(`GRANT USAGE ON SCHEMA public TO ${role}`);
|
|
for (const [table, wanted] of Object.entries(STORE_TABLE_PRIVILEGES)) {
|
|
const columns = REQUIRED_COLUMNS.filter((column) => column.table === table).map((column) => column.column).join(", ");
|
|
for (const privilege of wanted) {
|
|
// DELETE removes rows and cannot be granted per column.
|
|
await owner.query(privilege === "DELETE" ? `GRANT DELETE ON ${table} TO ${role}` : `GRANT ${privilege} (${columns}) ON ${table} TO ${role}`);
|
|
}
|
|
}
|
|
|
|
expect(await start(urlFor(name, role, rolePassword), false)).toBe("started");
|
|
const pool = fixturePool(urlFor(name, role, rolePassword));
|
|
cleanup.push(async () => {
|
|
await pool.end();
|
|
});
|
|
await exerciseStore(new PostgresStore(pool));
|
|
});
|
|
|
|
it("refuses a privilege missing on one column of another table", async () => {
|
|
const { name, owner } = await appliedDatabase();
|
|
await grant(owner, { ...STORE_TABLE_PRIVILEGES, auth_completions: [] });
|
|
const columns = REQUIRED_COLUMNS.filter((column) => column.table === "auth_completions" && column.column !== "attempt_id")
|
|
.map((column) => column.column)
|
|
.join(", ");
|
|
for (const privilege of STORE_TABLE_PRIVILEGES.auth_completions!) {
|
|
await owner.query(`GRANT ${privilege} (${columns}) ON auth_completions TO ${role}`);
|
|
}
|
|
|
|
expect(await start(urlFor(name, role, rolePassword), false)).toBe(refusal("SCHEMA_PRIVILEGES"));
|
|
});
|
|
|
|
it("names the TEMPORARY privilege when the role may not create temporary tables", async () => {
|
|
const { name, owner } = await appliedDatabase();
|
|
await grant(owner, STORE_TABLE_PRIVILEGES);
|
|
await owner.query(`REVOKE TEMPORARY ON DATABASE ${name} FROM PUBLIC`);
|
|
|
|
expect(await start(urlFor(name, role, rolePassword), false)).toBe(
|
|
"Postgres storage could not be prepared (SCHEMA_TEMP_PRIVILEGE); grant the database role TEMPORARY on its database."
|
|
);
|
|
});
|
|
|
|
// The schema's own predicate splits into the same conditions raw or
|
|
// printed, so only a predicate whose printed form differs (a literal gains
|
|
// its cast) shows that the check compares Postgres's printing.
|
|
it("matches a declared predicate that Postgres prints differently from how it is written", async () => {
|
|
const { owner } = await appliedDatabase();
|
|
await owner.query("CREATE TABLE predicate_probe (id INTEGER NOT NULL, state TEXT, attempts INTEGER)");
|
|
await owner.query("CREATE UNIQUE INDEX predicate_probe_live ON predicate_probe (id) WHERE state = 'live' AND attempts > 0");
|
|
const requirements = {
|
|
tables: ["predicate_probe"],
|
|
columns: [
|
|
{ table: "predicate_probe", column: "id", type: "INTEGER", nullable: false },
|
|
{ table: "predicate_probe", column: "state", type: "TEXT", nullable: true },
|
|
{ table: "predicate_probe", column: "attempts", type: "INTEGER", nullable: true },
|
|
],
|
|
keys: [{ table: "predicate_probe", columns: "id", predicate: "state = 'live' AND attempts > 0" }],
|
|
privileges: {},
|
|
};
|
|
|
|
expect((await printedDeclaredPredicates(owner, requirements))[0]).toBe("((state = 'live'::text) AND (attempts > 0))");
|
|
await expect(checkSchemaRequirements(owner, requirements)).resolves.toBeUndefined();
|
|
});
|
|
|
|
it("refuses a privilege held on only some of a table's columns", async () => {
|
|
const { name, owner } = await appliedDatabase();
|
|
await grant(owner, { ...STORE_TABLE_PRIVILEGES, audit_events: [] });
|
|
await owner.query(`GRANT INSERT (id) ON audit_events TO ${role}`);
|
|
|
|
expect(await start(urlFor(name, role, rolePassword), false)).toBe(refusal("SCHEMA_PRIVILEGES"));
|
|
});
|
|
|
|
// Each damage below leaves a database whose shape still resembles the
|
|
// schema but on which a store write fails or R21.1 no longer holds. A step
|
|
// starting with "!" is expected to fail and leave its mark (a failed
|
|
// CREATE INDEX CONCURRENTLY leaves an invalid index behind).
|
|
const seedDuplicates = [
|
|
"INSERT INTO accounts VALUES ('acc', now(), now(), null, null)",
|
|
"INSERT INTO sessions VALUES ('s', 'acc', 'ghostlight', now(), now(), now() + interval '1 hour', '{}', 1)",
|
|
];
|
|
it.each([
|
|
["a key made DEFERRABLE (ON CONFLICT refuses it as an arbiter)",
|
|
["ALTER TABLE private_command_receipts DROP CONSTRAINT private_command_receipts_pkey", "ALTER TABLE private_command_receipts ADD PRIMARY KEY (app_slug, idempotency_key) DEFERRABLE INITIALLY IMMEDIATE"]],
|
|
["a key narrowed to one column, the other only INCLUDEd",
|
|
["ALTER TABLE linked_identities DROP CONSTRAINT linked_identities_provider_provider_user_id_key", "CREATE UNIQUE INDEX li_include ON linked_identities(provider) INCLUDE (provider_user_id)"]],
|
|
["a key widened by an expression column",
|
|
["ALTER TABLE linked_identities DROP CONSTRAINT linked_identities_provider_provider_user_id_key", "CREATE UNIQUE INDEX li_expr ON linked_identities(provider, provider_user_id, lower(username))"]],
|
|
["a key replaced by a partial one",
|
|
["ALTER TABLE entitlement_snapshots DROP CONSTRAINT entitlement_snapshots_account_id_provider_scope_key", "CREATE UNIQUE INDEX es_partial ON entitlement_snapshots(account_id, provider, scope) WHERE is_allowed"]],
|
|
["a key replaced by a plain index on the same columns",
|
|
["ALTER TABLE linked_identities DROP CONSTRAINT linked_identities_provider_provider_user_id_key", "CREATE INDEX li_plain ON linked_identities(provider, provider_user_id)"]],
|
|
["a key replaced by one on other columns of the same count",
|
|
["ALTER TABLE linked_identities DROP CONSTRAINT linked_identities_provider_provider_user_id_key", "CREATE UNIQUE INDEX li_other ON linked_identities(provider, username)"]],
|
|
["a key left only as an invalid index by a failed concurrent build",
|
|
[...seedDuplicates,
|
|
"ALTER TABLE linked_identities DROP CONSTRAINT linked_identities_provider_provider_user_id_key",
|
|
"INSERT INTO linked_identities (id, account_id, provider, provider_user_id, created_at, updated_at) VALUES ('l1', 'acc', 'youtube', 'dup', now(), now()), ('l2', 'acc', 'youtube', 'dup', now(), now())",
|
|
"!CREATE UNIQUE INDEX CONCURRENTLY li_ccnew ON linked_identities(provider, provider_user_id)",
|
|
"DELETE FROM linked_identities WHERE id = 'l2'"]],
|
|
["the attempt index left invalid over duplicate attempts by a failed concurrent build",
|
|
[...seedDuplicates,
|
|
"DROP INDEX auth_completions_attempt_unconsumed_unique_idx",
|
|
"INSERT INTO auth_completions (code, attempt_id, app_slug, provider, mode, account_id, session_id, return_to, payload_json, created_at, expires_at) VALUES ('c1', 'att', 'ghostlight', 'discord', 'sign_in', 'acc', 's', 'https://x/', '{}', now(), now() + interval '1 hour'), ('c2', 'att', 'ghostlight', 'discord', 'sign_in', 'acc', 's', 'https://x/', '{}', now(), now() + interval '1 hour')",
|
|
"!CREATE UNIQUE INDEX CONCURRENTLY auth_completions_attempt_unconsumed_unique_idx ON auth_completions(app_slug, attempt_id) WHERE attempt_id IS NOT NULL AND consumed_at IS NULL"]],
|
|
// An interrupted DROP INDEX CONCURRENTLY leaves an index ready but not
|
|
// valid; the fixture sets that state directly (the admin role may).
|
|
["a key left only as an index that is ready but not valid",
|
|
["ALTER TABLE linked_identities DROP CONSTRAINT linked_identities_provider_provider_user_id_key",
|
|
"CREATE UNIQUE INDEX li_half_dropped ON linked_identities(provider, provider_user_id)",
|
|
"UPDATE pg_index SET indisvalid = false WHERE indexrelid = 'li_half_dropped'::regclass"]],
|
|
["a key's first column compared under a case-insensitive collation",
|
|
["CREATE COLLATION heimdall_ci (provider = icu, locale = 'und-u-ks-level2', deterministic = false)",
|
|
"ALTER TABLE linked_identities DROP CONSTRAINT linked_identities_provider_provider_user_id_key",
|
|
"CREATE UNIQUE INDEX li_ci_first ON linked_identities(provider COLLATE heimdall_ci, provider_user_id)"]],
|
|
// Soul pass 7: the key index compares under "C", but the column itself is
|
|
// case-insensitive, so the store's own lookups by provider_user_id match
|
|
// other users' ids.
|
|
["a column declared under a case-insensitive collation, its key index under C",
|
|
["CREATE COLLATION heimdall_ci (provider = icu, locale = 'und-u-ks-level2', deterministic = false)",
|
|
"ALTER TABLE linked_identities DROP CONSTRAINT linked_identities_provider_provider_user_id_key",
|
|
"ALTER TABLE linked_identities ALTER COLUMN provider_user_id TYPE text COLLATE heimdall_ci",
|
|
'CREATE UNIQUE INDEX li_c ON linked_identities(provider, provider_user_id COLLATE "C")']],
|
|
["a key compared under a case-insensitive collation (two users' ids become one row)",
|
|
["CREATE COLLATION heimdall_ci (provider = icu, locale = 'und-u-ks-level2', deterministic = false)",
|
|
"ALTER TABLE linked_identities DROP CONSTRAINT linked_identities_provider_provider_user_id_key",
|
|
"CREATE UNIQUE INDEX li_ci ON linked_identities(provider, provider_user_id COLLATE heimdall_ci)"]],
|
|
["the attempt index replaced by a plain one of the same name",
|
|
["DROP INDEX auth_completions_attempt_unconsumed_unique_idx", "CREATE INDEX auth_completions_attempt_unconsumed_unique_idx ON auth_completions(app_slug, attempt_id)"]],
|
|
["the attempt index made unique on every row (no predicate)",
|
|
["DROP INDEX auth_completions_attempt_unconsumed_unique_idx", "CREATE UNIQUE INDEX auth_completions_attempt_unconsumed_unique_idx ON auth_completions(app_slug, attempt_id)"]],
|
|
["the attempt index given another predicate",
|
|
["DROP INDEX auth_completions_attempt_unconsumed_unique_idx", "CREATE UNIQUE INDEX auth_completions_attempt_unconsumed_unique_idx ON auth_completions(app_slug, attempt_id) WHERE attempt_id IS NOT NULL"]],
|
|
["the attempt index narrowed by an extra condition",
|
|
["DROP INDEX auth_completions_attempt_unconsumed_unique_idx", "CREATE UNIQUE INDEX auth_completions_attempt_unconsumed_unique_idx ON auth_completions(app_slug, attempt_id) WHERE attempt_id IS NOT NULL AND consumed_at IS NULL AND app_slug <> 'ghostlight'"]],
|
|
["the attempt index with a condition ORed into its predicate",
|
|
["DROP INDEX auth_completions_attempt_unconsumed_unique_idx", "CREATE UNIQUE INDEX auth_completions_attempt_unconsumed_unique_idx ON auth_completions(app_slug, attempt_id) WHERE (attempt_id IS NOT NULL OR app_slug = 'x') AND consumed_at IS NULL"]],
|
|
["the attempt index made NULLS NOT DISTINCT without excluding NULL attempts",
|
|
["DROP INDEX auth_completions_attempt_unconsumed_unique_idx", "CREATE UNIQUE INDEX auth_completions_attempt_unconsumed_unique_idx ON auth_completions(app_slug, attempt_id) NULLS NOT DISTINCT WHERE consumed_at IS NULL"]],
|
|
["a column retyped", ["ALTER TABLE sessions ALTER COLUMN access_revision TYPE TEXT"]],
|
|
["a created table's column dropped", ["ALTER TABLE auth_completions DROP COLUMN mode"]],
|
|
["NOT NULL added to a column the store leaves null", ["ALTER TABLE auth_completions ALTER COLUMN attempt_id SET NOT NULL"]],
|
|
["NOT NULL added to a nullable column of another table",
|
|
["UPDATE linked_identities SET username = ''", "ALTER TABLE linked_identities ALTER COLUMN username SET NOT NULL"]],
|
|
])("refuses %s", async (_damage, steps) => {
|
|
const { name, owner } = await appliedDatabase();
|
|
for (const step of steps) {
|
|
if (step.startsWith("!")) await expect(owner.query(step.slice(1))).rejects.toBeDefined();
|
|
else await owner.query(step);
|
|
}
|
|
|
|
expect(await start(urlFor(name), false)).toBe(refusal("SCHEMA_MISSING"));
|
|
});
|
|
|
|
// Each change below leaves a database the store serves exactly as before.
|
|
it.each([
|
|
["a key index that carries extra INCLUDE columns",
|
|
["ALTER TABLE linked_identities DROP CONSTRAINT linked_identities_provider_provider_user_id_key", "CREATE UNIQUE INDEX li_covering ON linked_identities(provider, provider_user_id) INCLUDE (username)"]],
|
|
["the attempt index under another name", ["ALTER INDEX auth_completions_attempt_unconsumed_unique_idx RENAME TO attempt_idx_renamed"]],
|
|
["the attempt index's conditions in the other order",
|
|
["DROP INDEX auth_completions_attempt_unconsumed_unique_idx", "CREATE UNIQUE INDEX attempt_reordered ON auth_completions(app_slug, attempt_id) WHERE consumed_at IS NULL AND attempt_id IS NOT NULL"]],
|
|
// NULL attempt ids never collide in a unique index that treats NULLs as
|
|
// distinct (the default), so dropping that condition keeps the same rows unique.
|
|
["the attempt index without its redundant attempt_id IS NOT NULL",
|
|
["DROP INDEX auth_completions_attempt_unconsumed_unique_idx", "CREATE UNIQUE INDEX attempt_unconsumed ON auth_completions(app_slug, attempt_id) WHERE consumed_at IS NULL"]],
|
|
["a lookup index dropped", ["DROP INDEX audit_events_lookup_idx", "DROP INDEX auth_completions_lookup_idx"]],
|
|
// Only a key's own columns decide which rows collide; an INCLUDEd column's
|
|
// collation does not, whatever it is.
|
|
["a key index that INCLUDEs an undeclared case-insensitive column",
|
|
["CREATE COLLATION heimdall_ci (provider = icu, locale = 'und-u-ks-level2', deterministic = false)",
|
|
"ALTER TABLE linked_identities ADD COLUMN operator_note text COLLATE heimdall_ci",
|
|
"ALTER TABLE linked_identities DROP CONSTRAINT linked_identities_provider_provider_user_id_key",
|
|
"CREATE UNIQUE INDEX li_note ON linked_identities(provider, provider_user_id) INCLUDE (operator_note)"]],
|
|
])("accepts %s", async (_change, steps) => {
|
|
const { name, owner } = await appliedDatabase();
|
|
for (const step of steps) await owner.query(step);
|
|
|
|
expect(await start(urlFor(name), false)).toBe("started");
|
|
});
|
|
});
|