Heimdall/tests/store.test.ts
Meta Vens c81f923890 Refresh managed credentials under a row lock, with typed revocation
refreshLinkedIdentityCredentialIfNeeded is deleted. refreshManagedCredential
owns the policy; HeimdallStore.updateLinkedIdentityCredential owns the row
(SELECT ... FOR UPDATE in Postgres, a per-id chain in memory), so a refresh
token is presented once across concurrent requests and processes. invalid_grant
clears the tokens (410 managed_credential_revoked); any other failure keeps
them (502 managed_credential_refresh_failed). The "?? refreshToken" keep-old
fallback is gone; keeping it is an explicit branch for non-rotating providers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 11:11:34 +02:00

718 lines
36 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 } from "../src/store/index.js";
import {
createPostgresStore,
PostgresStore,
checkSchemaRequirements,
HEIMDALL_SCHEMA_REQUIREMENTS,
predicateConditions,
printedDeclaredPredicates,
samePredicate,
STORE_TABLE_PRIVILEGES,
} from "../src/store/postgres.js";
import { 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();
});
});
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",
"auth_completions",
"capability_grants",
"entitlement_snapshots",
"audit_events",
]);
expect(REQUIRED_COLUMNS).toHaveLength(85);
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(12);
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: "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" });
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 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 });
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("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 applied = await owner.query<{ predicate: string }>(
"SELECT pg_get_expr(indpred, indrelid) AS predicate FROM pg_index WHERE indexrelid = 'auth_completions_attempt_unconsumed_unique_idx'::regclass"
);
const attemptKey = REQUIRED_KEYS.findIndex((key) => key.predicate !== "");
expect(printed[attemptKey]).toBe(applied.rows[0]!.predicate);
expect(printed.filter((predicate) => predicate !== "")).toHaveLength(1);
});
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) await owner.query(`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");
});
});