Files
Marc-André Bombeck a10bebdc43 fix(db): give each database its own app role and keep the queue schema the owner's
Roles are cluster-wide. Two HealthLog databases on one server shared
healthlog_app: each start set its own password, locking the other
instance out, and the role held grants on both databases. The role is
now healthlog_app_<database>_<hash>, marked with a comment naming its
database, its password is derived per database, and a role holding an
explicit CONNECT on another database is not used.

The role owned the pgboss schema, so it could plant a trigger, a rule or
a function body that a later owner session (a rollback, DB_APP_ROLE=off,
a fallback start) would run with the owner's rights. pg-boss changes its
schema only on install, on migration and for partitioned queues, which
the app does not use. The start now installs and migrates the schema as
the owner with pg-boss's own plans, and the worker runs pg-boss with
migrate: false when it does not own the schema. The role gets rows only,
read-only on pgboss.bam (SQL the migration runner executes) and on the
version column. Every start, on every path, takes back what a
HealthLog role owns there, drops triggers and rules, and after a
take-back restores the queue functions to pg-boss's own definitions and
retires the legacy role. Bloated queue indexes, which pg-boss only
reports when it does not own them, are rebuilt at start.
2026-11-03 23:31:37 +01:00

650 lines
24 KiB
TypeScript

/**
* The app's restricted database role, against a real Postgres.
*
* `scripts/db-app-role.mjs` runs at boot as the owner and hands the app a URL
* for this database's own role (`healthlog_app_<database>_<hash>`). Proven
* here:
*
* - The role does what the app does: Prisma reads and writes, the queue
* lifecycle the worker runs (start with `migrate: false`, create a new
* queue, send, fetch, complete, schedule, delete a queue, a supervise
* pass), the measurement maintenance's VACUUM and concurrent rebuild.
* - It is refused what it must not do: DDL in either schema, triggers,
* functions, roles, schemas, server files and programs, the migration
* history, and the two queue tables whose contents pg-boss executes or
* migrates by.
* - It owns nothing in the queue schema, which the boot installs as the
* owner, and the boot takes back, from a pre-release state, a schema a
* HealthLog role owned, with a planted trigger and a replaced function.
* - Two databases on one server get two roles that do not lock each other
* out and cannot read each other's data; a role that holds CONNECT on
* another database is refused.
* - A second run is a no-op that keeps the password; an owner that may not
* create roles, and DB_APP_ROLE=off, leave the app on the owner.
*
* On Postgres 16 (the nightly `INTEGRATION_PG_IMAGE` run) the script stays
* on the owner by design; the file proves that instead.
*/
import { PrismaPg } from "@prisma/adapter-pg";
import { Client } from "pg";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import { PrismaClient } from "@/generated/prisma/client";
import { createWorkerBoss } from "@/lib/jobs/boss-instance";
import {
MAINTENANCE_SESSION_OPTIONS,
RETIRED_MEASUREMENT_INDEXES,
runMeasurementMaintenance,
} from "@/lib/jobs/measurement-maintenance";
import {
LEGACY_APP_ROLE,
MANAGED_MARK,
appRoleName,
appRolePassword,
ensureAppRole,
restoreQueueFunctions,
} from "../../scripts/db-app-role.mjs";
import { TEMPLATE_DATABASE } from "./worker-database";
const HMAC_KEY = "a".repeat(64);
const OTHER_HMAC_KEY = "b".repeat(64);
const PROBE_OWNER = "app_role_probe_owner";
const USER_ID = "user-app-role-probe";
const RETIRED = RETIRED_MEASUREMENT_INDEXES[0];
const OTHER_DB = `app_role_probe_other_${process.pid}`;
function ownerUrl(database?: string): string {
const url = process.env.DATABASE_URL;
if (!url) throw new Error("DATABASE_URL is not set for the integration run");
if (!database) return url;
const u = new URL(url);
u.pathname = `/${database}`;
return u.toString();
}
function databaseOf(url: string): string {
return decodeURIComponent(new URL(url).pathname.slice(1));
}
function withoutSchema(url: string): string {
const u = new URL(url);
u.searchParams.delete("schema");
u.searchParams.delete("pgbouncer");
return u.toString();
}
async function connect(url: string, options?: string): Promise<Client> {
const client = new Client({ connectionString: withoutSchema(url), options });
await client.connect();
return client;
}
/** Run the boot step and keep what it says on stderr. */
async function boot(
env: Record<string, string | undefined>,
): Promise<{ url: string | null; said: string }> {
const lines: string[] = [];
const spy = vi
.spyOn(process.stderr, "write")
.mockImplementation((chunk: string | Uint8Array) => {
lines.push(String(chunk));
return true;
});
try {
const url = await ensureAppRole(env as NodeJS.ProcessEnv);
return { url, said: lines.join("") };
} finally {
spy.mockRestore();
}
}
async function sqlState(db: Client, sql: string): Promise<string | null> {
try {
await db.query(sql);
return null;
} catch (err) {
return (err as { code?: string }).code ?? "unknown";
}
}
/** Objects in the queue schema not owned by `owner`. */
async function queueObjectsNotOwnedBy(
db: Client,
owner: string,
): Promise<number> {
const { rows } = await db.query<{ n: string }>(
`SELECT (
(SELECT count(*) FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE n.nspname = 'pgboss' AND pg_get_userbyid(c.relowner) <> $1)
+ (SELECT count(*) FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace
WHERE n.nspname = 'pgboss' AND pg_get_userbyid(p.proowner) <> $1)
+ (SELECT count(*) FROM pg_namespace WHERE nspname = 'pgboss'
AND pg_get_userbyid(nspowner) <> $1))::text AS n`,
[owner],
);
return Number(rows[0].n);
}
let owner: Client;
let ownerName = "";
let serverVersion = 0;
const roleA = () => appRoleName(databaseOf(ownerUrl()));
const roleB = appRoleName(OTHER_DB);
beforeAll(async () => {
owner = await connect(ownerUrl());
const { rows } = await owner.query<{ v: number; me: string }>(
`SELECT current_setting('server_version_num')::int AS v, current_user AS me`,
);
serverVersion = rows[0].v;
ownerName = rows[0].me;
// Each file starts from a worker database without the queue schema.
await owner.query(`DROP SCHEMA IF EXISTS pgboss CASCADE`);
});
afterAll(async () => {
await owner.query(`DROP DATABASE IF EXISTS "${OTHER_DB}" WITH (FORCE)`);
// Roles are cluster-wide: hand back what they hold here and drop them, so
// no other file or run sees them.
for (const role of [roleA(), roleB, LEGACY_APP_ROLE, PROBE_OWNER]) {
const { rows } = await owner.query(
`SELECT 1 FROM pg_roles WHERE rolname = $1`,
[role],
);
if (rows.length === 0) continue;
await owner.query(`REASSIGN OWNED BY "${role}" TO CURRENT_USER`);
await owner.query(`DROP OWNED BY "${role}"`);
await owner.query(`DROP ROLE "${role}"`);
}
await owner.query(`DROP SCHEMA IF EXISTS pgboss CASCADE`);
await owner.query(`DELETE FROM users WHERE id = $1`, [USER_ID]);
await owner.end();
});
describe("the role's name", () => {
it("is per database, under 63 characters, and stable", () => {
expect(appRoleName("healthlog")).toMatch(
/^healthlog_app_healthlog_[0-9a-f]{8}$/,
);
expect(appRoleName("healthlog")).toBe(appRoleName("healthlog"));
expect(appRoleName("healthlog")).not.toBe(appRoleName("healthlog-staging"));
// Names that clean up alike still differ by their hash.
expect(appRoleName("a-b")).not.toBe(appRoleName("a_b"));
expect(appRoleName("x".repeat(63)).length).toBeLessThanOrEqual(63);
expect(appRolePassword(HMAC_KEY, "a")).not.toBe(
appRolePassword(HMAC_KEY, "b"),
);
});
});
describe("DB_APP_ROLE=off", () => {
it("stays on the owner and creates no role", async () => {
const { url, said } = await boot({
DATABASE_URL: ownerUrl(),
API_TOKEN_HMAC_KEY: HMAC_KEY,
DB_APP_ROLE: "off",
});
expect(url).toBeNull();
expect(said).toContain("DB_APP_ROLE=off");
const { rows } = await owner.query(
`SELECT 1 FROM pg_roles WHERE rolname = $1`,
[roleA()],
);
expect(rows).toHaveLength(0);
});
});
describe("an owner that may not create roles", () => {
it("leaves the app on that user, with a notice and no secret in it", async () => {
await owner.query(
`CREATE ROLE ${PROBE_OWNER} LOGIN NOSUPERUSER NOCREATEROLE PASSWORD 'probe-owner-pw'`,
);
const url = new URL(ownerUrl());
url.username = PROBE_OWNER;
url.password = "probe-owner-pw";
const { url: handed, said } = await boot({
DATABASE_URL: url.toString(),
API_TOKEN_HMAC_KEY: HMAC_KEY,
});
expect(handed).toBeNull();
expect(said).toContain(`the app connects as ${PROBE_OWNER}`);
if (serverVersion >= 170000) {
expect(said).toContain("may not create roles");
}
expect(said).not.toContain("probe-owner-pw");
expect(said).not.toContain(
appRolePassword(HMAC_KEY, databaseOf(ownerUrl())),
);
});
});
describe("Postgres before 17", () => {
it("stays on the owner: no MAINTAIN privilege", async (ctx) => {
if (serverVersion >= 170000) ctx.skip();
const { url, said } = await boot({
DATABASE_URL: ownerUrl(),
API_TOKEN_HMAC_KEY: HMAC_KEY,
});
expect(url).toBeNull();
expect(said).toContain("no MAINTAIN privilege");
});
});
describe("the restricted role (Postgres 17+)", () => {
let appUrl: string;
let app: Client;
beforeAll(async () => {
if (serverVersion < 170000) return;
// A database first created before Postgres 15 still lets PUBLIC create
// objects in `public`; the boot must take that away from the role too.
await owner.query(`GRANT CREATE ON SCHEMA public TO PUBLIC`);
// An index the role may not drop, as an instance that has not run 0416.
await owner.query(
`CREATE INDEX IF NOT EXISTS "${RETIRED}" ON "measurements" ("user_id", "type", "measured_at")`,
);
const first = await boot({
DATABASE_URL: ownerUrl(),
API_TOKEN_HMAC_KEY: HMAC_KEY,
});
expect(first.url, first.said).not.toBeNull();
expect(first.said).toContain(`the app connects as ${roleA()}`);
expect(first.said).toContain("queue schema installed as the owner");
expect(first.said).not.toContain(
appRolePassword(HMAC_KEY, databaseOf(ownerUrl())),
);
// The first run drops it as the owner; put it back for the role's turn.
expect(first.said).toContain(`dropped index ${RETIRED}`);
await owner.query(
`CREATE INDEX IF NOT EXISTS "${RETIRED}" ON "measurements" ("user_id", "type", "measured_at")`,
);
appUrl = first.url!;
app = await connect(appUrl);
});
afterAll(async () => {
await app?.end();
});
it("keeps the owner's host, database and parameters", (ctx) => {
if (serverVersion < 170000) ctx.skip();
const owned = new URL(ownerUrl());
const handed = new URL(appUrl);
expect(handed.username).toBe(roleA());
expect(handed.password).toBe(
appRolePassword(HMAC_KEY, databaseOf(ownerUrl())),
);
expect(handed.host).toBe(owned.host);
expect(handed.pathname).toBe(owned.pathname);
expect(handed.search).toBe(owned.search);
});
it("reads and writes rows through Prisma", async (ctx) => {
if (serverVersion < 170000) ctx.skip();
const db = new PrismaClient({
adapter: new PrismaPg({ connectionString: withoutSchema(appUrl) }),
});
try {
await db.user.create({
data: {
id: USER_ID,
username: "app-role-probe",
email: "app-role-probe@example.test",
role: "USER",
},
});
await db.measurement.create({
data: {
userId: USER_ID,
type: "WEIGHT",
value: 70,
unit: "kg",
measuredAt: new Date("2026-01-01T08:00:00Z"),
},
});
await db.user.update({
where: { id: USER_ID },
data: { username: "app-role-probe-2" },
});
expect(await db.measurement.count({ where: { userId: USER_ID } })).toBe(
1,
);
await db.measurement.deleteMany({ where: { userId: USER_ID } });
const migrations = await db.$queryRaw<{ n: bigint }[]>`
SELECT count(*) AS n FROM _prisma_migrations`;
expect(Number(migrations[0].n)).toBeGreaterThan(100);
} finally {
await db.$disconnect();
}
});
it("runs the worker's queue lifecycle owning nothing in the queue schema", async (ctx) => {
if (serverVersion < 170000) ctx.skip();
expect(await queueObjectsNotOwnedBy(owner, ownerName)).toBe(0);
const errors: unknown[] = [];
// The options the worker passes when it does not own the schema.
const boss = createWorkerBoss(withoutSchema(appUrl), {
ownsQueueSchema: false,
});
boss.on("error", (err: unknown) => errors.push(err));
await boss.start();
try {
// A queue no earlier boot created: a row, no table.
const queue = `app-role-probe-${Date.now()}`;
await boss.createQueue(queue, { policy: "standard" });
await boss.createQueue(`${queue}-singleton`, { policy: "singleton" });
const id = await boss.send(queue, { n: 1 });
const [job] = await boss.fetch(queue);
expect(job?.id).toBe(id);
await boss.complete(queue, job.id);
await boss.schedule(queue, "0 3 * * *", {}, { tz: "Europe/Berlin" });
await boss.unschedule(queue);
await boss.supervise();
await boss.deleteQueue(`${queue}-singleton`);
} finally {
await boss.stop({ graceful: false });
}
expect(errors).toEqual([]);
// Still nothing of the role's in the schema.
expect(await queueObjectsNotOwnedBy(owner, ownerName)).toBe(0);
});
it("vacuums and rebuilds measurements, and leaves the drop to the owner", async (ctx) => {
if (serverVersion < 170000) ctx.skip();
const db = await connect(appUrl, MAINTENANCE_SESSION_OPTIONS);
try {
const summary = await runMeasurementMaintenance(db, {
vacuum: true,
reindex: true,
});
expect(summary.outcome).toBe("completed");
// A VACUUM without the privilege only warns and skips the table, so
// the step reads ok either way; the grant is what makes it real.
const { rows } = await db.query<{ maintain: boolean }>(
`SELECT has_table_privilege(current_user, 'measurements', 'MAINTAIN') AS maintain`,
);
expect(rows[0].maintain).toBe(true);
expect(summary.steps.find((s) => s.step === "vacuum")?.ok).toBe(true);
const rebuilt = summary.steps.filter((s) => s.step === "reindex");
expect(rebuilt.length).toBeGreaterThan(0);
expect(rebuilt.every((s) => s.ok)).toBe(true);
expect(summary.dropsLeftForBoot).toEqual([RETIRED]);
} finally {
await db.end();
}
});
it("is refused schema changes, triggers, functions, roles, server files and the queue's executed tables", async (ctx) => {
if (serverVersion < 170000) ctx.skip();
const refused = {
dropTable: await sqlState(app, `DROP TABLE "users"`),
alterTable: await sqlState(
app,
`ALTER TABLE "users" ADD COLUMN probe text`,
),
createTable: await sqlState(app, `CREATE TABLE public.probe (x int)`),
createFunction: await sqlState(
app,
`CREATE FUNCTION public.probe() RETURNS int LANGUAGE sql AS 'SELECT 1'`,
),
createRole: await sqlState(app, `CREATE ROLE probe_escalation`),
createSchema: await sqlState(app, `CREATE SCHEMA probe`),
readFile: await sqlState(app, `SELECT pg_read_file('postgresql.conf')`),
program: await sqlState(app, `COPY (SELECT 1) TO PROGRAM 'true'`),
dropIndex: await sqlState(app, `DROP INDEX "${RETIRED}"`),
writeMigrations: await sqlState(
app,
`DELETE FROM _prisma_migrations WHERE false`,
),
becomeOwner: await sqlState(app, `SET ROLE "${ownerName}"`),
queueTrigger: await sqlState(
app,
`CREATE TRIGGER probe BEFORE INSERT ON pgboss.job_common
FOR EACH ROW EXECUTE FUNCTION pgboss.job_now()`,
),
queueFunction: await sqlState(
app,
`CREATE FUNCTION pgboss.probe() RETURNS int LANGUAGE sql AS 'SELECT 1'`,
),
replaceQueueFunction: await sqlState(
app,
`CREATE OR REPLACE FUNCTION pgboss.job_now() RETURNS timestamptz LANGUAGE sql AS 'SELECT now()'`,
),
queueTable: await sqlState(app, `CREATE TABLE pgboss.probe (x int)`),
alterQueueTable: await sqlState(
app,
`ALTER TABLE pgboss.queue ADD COLUMN probe text`,
),
writeBam: await sqlState(
app,
`INSERT INTO pgboss.bam (name, version, status, command) VALUES ('probe', 1, 'pending', 'SELECT 1')`,
),
writeQueueVersion: await sqlState(
app,
`UPDATE pgboss.version SET version = version`,
),
};
expect(refused).toEqual({
dropTable: "42501",
alterTable: "42501",
createTable: "42501",
createFunction: "42501",
createRole: "42501",
createSchema: "42501",
readFile: "42501",
program: "42501",
dropIndex: "42501",
writeMigrations: "42501",
becomeOwner: "42501",
queueTrigger: "42501",
queueFunction: "42501",
replaceQueueFunction: "42501",
queueTable: "42501",
alterQueueTable: "42501",
writeBam: "42501",
writeQueueVersion: "42501",
});
// pg-boss's own maintenance stamps stay writable.
expect(
await sqlState(app, `UPDATE pgboss.version SET cron_on = cron_on`),
).toBeNull();
const { rows } = await app.query(
`SELECT rolsuper, rolcreaterole, rolcreatedb, rolinherit, rolreplication, rolbypassrls
FROM pg_roles WHERE rolname = current_user`,
);
expect(rows[0]).toEqual({
rolsuper: false,
rolcreaterole: false,
rolcreatedb: false,
rolinherit: false,
rolreplication: false,
rolbypassrls: false,
});
});
it("does not let the worker migrate the queue schema as the role", async (ctx) => {
if (serverVersion < 170000) ctx.skip();
const { rows } = await owner.query<{ v: number }>(
`SELECT max(version)::int AS v FROM pgboss.version`,
);
await owner.query(`UPDATE pgboss.version SET version = $1`, [
rows[0].v - 1,
]);
try {
const boss = createWorkerBoss(withoutSchema(appUrl), {
ownsQueueSchema: false,
});
boss.on("error", () => {});
await expect(boss.start()).rejects.toThrow(/requires migrations/);
await boss.stop({ graceful: false }).catch(() => {});
} finally {
await owner.query(`UPDATE pgboss.version SET version = $1`, [rows[0].v]);
}
});
it("leaves pg-boss's own functions alone on an untouched schema", async (ctx) => {
if (serverVersion < 170000) ctx.skip();
expect(await restoreQueueFunctions(owner)).toEqual([]);
});
it("gives a second database on the server its own role, without locking the first out", async (ctx) => {
if (serverVersion < 170000) ctx.skip();
await owner.query(
`SELECT pg_advisory_lock(hashtext('healthlog-integration-clone'))`,
);
try {
await owner.query(
`CREATE DATABASE "${OTHER_DB}" TEMPLATE "${TEMPLATE_DATABASE}"`,
);
} finally {
await owner.query(
`SELECT pg_advisory_unlock(hashtext('healthlog-integration-clone'))`,
);
}
const other = await boot({
DATABASE_URL: ownerUrl(OTHER_DB),
API_TOKEN_HMAC_KEY: OTHER_HMAC_KEY,
});
expect(other.url, other.said).not.toBeNull();
expect(new URL(other.url!).username).toBe(roleB);
expect(roleB).not.toBe(roleA());
// Both log in to their own database, the first after the second's boot.
for (const [url, role] of [
[appUrl, roleA()],
[other.url!, roleB],
] as const) {
const db = await connect(url);
const { rows } = await db.query(`SELECT current_user AS me`);
expect(rows[0].me).toBe(role);
await db.query(`SELECT count(*) FROM users`);
await db.end();
}
// Each is refused the other's data and queue.
for (const [url, database] of [
[appUrl, OTHER_DB],
[other.url!, databaseOf(ownerUrl())],
] as const) {
const u = new URL(url);
u.pathname = `/${database}`;
const db = await connect(u.toString());
expect(await sqlState(db, `SELECT count(*) FROM users`)).toBe("42501");
expect(await sqlState(db, `SELECT count(*) FROM pgboss.job`)).toBe(
"42501",
);
expect(await sqlState(db, `CREATE TABLE public.probe (x int)`)).toBe(
"42501",
);
await db.end();
}
// A role that holds CONNECT on another database is not used.
await owner.query(
`GRANT CONNECT ON DATABASE "${OTHER_DB}" TO "${roleA()}"`,
);
try {
const refused = await boot({
DATABASE_URL: ownerUrl(),
API_TOKEN_HMAC_KEY: HMAC_KEY,
});
expect(refused.url).toBeNull();
expect(refused.said).toContain(
`holds CONNECT on another database (${OTHER_DB})`,
);
} finally {
await owner.query(
`REVOKE CONNECT ON DATABASE "${OTHER_DB}" FROM "${roleA()}"`,
);
}
});
it("takes the queue schema back from a pre-release role, with what it planted", async (ctx) => {
if (serverVersion < 170000) ctx.skip();
const { rows: before } = await owner.query<{ src: string }>(
`SELECT prosrc AS src FROM pg_proc WHERE oid = 'pgboss.job_now()'::regprocedure`,
);
// The state a pre-release build left: the schema and everything in it
// owned by the cluster-wide role, which could then plant code.
await owner.query(
`CREATE ROLE "${LEGACY_APP_ROLE}" NOLOGIN;
COMMENT ON ROLE "${LEGACY_APP_ROLE}" IS '${MANAGED_MARK}: the app''s least-privilege login (scripts/db-app-role.mjs). Recreated and re-granted on every start.';
GRANT SELECT ON users TO "${LEGACY_APP_ROLE}";
ALTER SCHEMA pgboss OWNER TO "${LEGACY_APP_ROLE}";
ALTER TABLE pgboss.job_common OWNER TO "${LEGACY_APP_ROLE}";
ALTER FUNCTION pgboss.job_now() OWNER TO "${LEGACY_APP_ROLE}";
CREATE OR REPLACE FUNCTION pgboss.job_now() RETURNS timestamptz
LANGUAGE sql AS 'SELECT now() - interval ''1 day''';
CREATE FUNCTION pgboss.planted() RETURNS trigger LANGUAGE plpgsql
AS 'BEGIN RETURN NEW; END';
ALTER FUNCTION pgboss.planted() OWNER TO "${LEGACY_APP_ROLE}";
CREATE TRIGGER planted BEFORE INSERT ON pgboss.job_common
FOR EACH ROW EXECUTE FUNCTION pgboss.planted();
CREATE RULE planted AS ON DELETE TO pgboss.queue DO INSTEAD NOTHING;`,
);
const next = await boot({
DATABASE_URL: ownerUrl(),
API_TOKEN_HMAC_KEY: HMAC_KEY,
});
expect(next.url, next.said).toBe(appUrl);
expect(next.said).toContain("took back");
expect(next.said).toContain("trigger planted");
expect(next.said).toContain("rule planted");
expect(await queueObjectsNotOwnedBy(owner, ownerName)).toBe(0);
const { rows: after } = await owner.query<{ src: string }>(
`SELECT prosrc AS src FROM pg_proc WHERE oid = 'pgboss.job_now()'::regprocedure`,
);
expect(after[0].src).toBe(before[0].src);
const { rows: leftovers } = await owner.query(
`SELECT to_regprocedure('pgboss.planted()') AS planted,
(SELECT count(*)::int FROM pg_trigger t JOIN pg_class c ON c.oid = t.tgrelid
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE n.nspname = 'pgboss' AND NOT t.tgisinternal) AS triggers,
(SELECT count(*)::int FROM pg_roles WHERE rolname = $1) AS legacy`,
[LEGACY_APP_ROLE],
);
expect(leftovers[0]).toEqual({ planted: null, triggers: 0, legacy: 0 });
// Taken back, not dropped: the queue's tables are all still there.
const { rows: tables } = await owner.query(
`SELECT to_regclass('pgboss.job_common') IS NOT NULL AS common,
(SELECT count(*)::int FROM pgboss.queue) > 0 AS queues`,
);
expect(tables[0]).toEqual({ common: true, queues: true });
});
it("is idempotent: a later boot keeps the password and drops what the role could not", async (ctx) => {
if (serverVersion < 170000) ctx.skip();
// A table a later migration adds: the next boot grants it.
await owner.query(`CREATE TABLE public.app_role_probe_new (x int)`);
// The boot before this one dropped it; put it back once more.
await owner.query(
`CREATE INDEX IF NOT EXISTS "${RETIRED}" ON "measurements" ("user_id", "type", "measured_at")`,
);
try {
const second = await boot({
DATABASE_URL: ownerUrl(),
API_TOKEN_HMAC_KEY: HMAC_KEY,
});
expect(second.url).toBe(appUrl);
expect(second.said).toContain(`dropped index ${RETIRED}`);
// The connection opened before the second boot still works.
await app.query(`INSERT INTO public.app_role_probe_new VALUES (1)`);
const third = await boot({
DATABASE_URL: ownerUrl(),
API_TOKEN_HMAC_KEY: HMAC_KEY,
});
expect(third.url).toBe(appUrl);
expect(third.said).not.toContain("dropped index");
expect(third.said).not.toContain("took back");
const { rows } = await owner.query(
`SELECT to_regclass($1) IS NULL AS gone`,
[RETIRED],
);
expect(rows[0].gone).toBe(true);
} finally {
await owner.query(`DROP TABLE public.app_role_probe_new`);
}
});
});