fix(test): reject custom pg-prefixed restore schemas

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-11-02 13:02:25 -06:00
1 parent 7a43aa31b9
commit 14ee3f02c4
7 files changed
+49 -8

No files matched your search

@@ -70,7 +70,7 @@ jobs:
DATABASE_URL: ${{ env.PAPERCLIP_BACKUP_CHECKPOINT_SOURCE_TEST_DATABASE_URL }}
run: pnpm --filter @paperclipai/db exec tsx src/migrate.ts
- name: Run required real checkpoint and restore proof
run: pnpm test:backup-checkpoint
run: pnpm --filter @paperclipai/server exec vitest run --config vitest.backup-qualification.config.ts
- name: Attach qualification receipt
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
+1 -1
View File
@@ -360,7 +360,7 @@ workflow after closing their clients.
The integration suite is
`server/src/__tests__/database-backup-checkpoint-qualification.integration.ts`. Local
runs use `pnpm test:backup-checkpoint`, require the same explicit URL pair, and use
runs use `pnpm --filter @paperclipai/server exec vitest run --config vitest.backup-qualification.config.ts`, require the same explicit URL pair, and use
bounded clients. The dedicated Backup checkpoint qualification CI job provisions
both fixtures and matching PostgreSQL clients. Missing URLs or clients fail this
required integration invocation; it is separate from the default unit suite. The suite
-1
View File
@@ -24,7 +24,6 @@
"typecheck": "pnpm run preflight:workspace-links && pnpm -r typecheck",
"typecheck:build-gaps": "pnpm run preflight:workspace-links && pnpm --filter @paperclipai/plugin-sdk ensure-build-deps && pnpm --filter @paperclipai/server build && node scripts/run-typecheck-build-gaps.mjs",
"test": "pnpm run test:run",
"test:backup-checkpoint": "pnpm --filter @paperclipai/server exec vitest run --config vitest.backup-qualification.config.ts",
"test:watch": "pnpm run preflight:workspace-links && vitest",
"test:run": "pnpm run preflight:workspace-links && node scripts/run-vitest-stable.mjs",
"test:runner-acceptance": "vitest run --config tests/runner-acceptance/vitest.config.ts",
+1 -1
View File
@@ -31,7 +31,7 @@ export async function assertDistinctEmptyRestore(pair: QualificationPair) {
const [result] = await target`SELECT pg_try_advisory_lock(${a}, ${b}) AS acquired`;
targetLocked = result!.acquired === true;
if (!targetLocked) throw new Error("Backup source and restore resolve to the same actual database");
const rows = await target`SELECT 1 FROM pg_namespace WHERE nspname NOT IN ('public', 'information_schema') AND nspname NOT LIKE 'pg_%'
const rows = await target`SELECT 1 FROM pg_namespace WHERE nspname NOT IN ('public', 'information_schema') AND nspname !~ '^pg_'
UNION ALL SELECT 1 FROM pg_depend
WHERE refclassid = 'pg_namespace'::regclass AND refobjid = 'public'::regnamespace LIMIT 1`;
if (rows.length) throw new Error("Backup restore target must be empty before restore");
@@ -19,6 +19,7 @@ const pair = configuredPair;
describe("real automatic backup checkpoint qualification", () => {
let directory: string;
let source: ReturnType<typeof createDb>;
let ownsRestoreFixture = false;
const logger = { info() {}, warn() {}, error() {} };
const receipts: unknown[] = [];
const owner = () => createDatabaseBackupOwner({ connectionString: pair.sourceUrl, backupDir: directory, getRetention: async () => retention, logger });
@@ -29,6 +30,7 @@ describe("real automatic backup checkpoint qualification", () => {
directory = await mkdtemp(path.join(parent, "backup-checkpoint-qualification-"));
source = createDb(pair.sourceUrl, { maxConnections: 1 });
await assertDistinctEmptyRestore(pair);
ownsRestoreFixture = true;
await seedBackupQualificationFixture(pair.sourceUrl);
}, 120_000);
afterAll(async () => {
@@ -37,7 +39,8 @@ describe("real automatic backup checkpoint qualification", () => {
if (receiptFile) await writeFile(receiptFile, JSON.stringify({ version: 1, cases: receipts, directory,
sourceDatabase: new URL(pair.sourceUrl).pathname.slice(1), restoreDatabase: new URL(pair.restoreUrl).pathname.slice(1) }, null, 2));
} finally {
await source?.$client.end({ timeout: 5 });
try { if (ownsRestoreFixture) await emptyRestore(); }
finally { await source?.$client.end({ timeout: 5 }); }
if (directory && !process.env.PAPERCLIP_BACKUP_QUALIFICATION_RECEIPT) await rm(directory, { recursive: true, force: true });
}
});
@@ -45,9 +48,9 @@ describe("real automatic backup checkpoint qualification", () => {
const target = createDb(pair.restoreUrl, { maxConnections: 1 }).$client;
try {
const tables = await target`SELECT n.nspname, c.relname FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE c.relkind IN ('r', 'p') AND n.nspname <> 'information_schema' AND n.nspname NOT LIKE 'pg_%'`;
WHERE c.relkind IN ('r', 'p') AND n.nspname <> 'information_schema' AND n.nspname !~ '^pg_'`;
for (const { nspname, relname } of tables) await target`DROP TABLE IF EXISTS ${target(nspname)}.${target(relname)} CASCADE`;
const schemas = await target`SELECT nspname FROM pg_namespace WHERE nspname NOT IN ('information_schema') AND nspname NOT LIKE 'pg_%'`;
const schemas = await target`SELECT nspname FROM pg_namespace WHERE nspname NOT IN ('information_schema') AND nspname !~ '^pg_'`;
for (const { nspname } of schemas) await target`DROP SCHEMA ${target(nspname)} CASCADE`;
await target`CREATE SCHEMA public`;
} finally { await target.end({ timeout: 5 }); }
@@ -0,0 +1,36 @@
import { mkdtemp, readdir, rm } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { createDb } from "@paperclipai/db";
import { describe, expect, it } from "vitest";
import { assertDistinctEmptyRestore, qualifyBackupRestore, resolveBackupQualificationPair } from "../../scripts/lib/backup-qualification.js";
const configuredPair = resolveBackupQualificationPair();
if (!configuredPair) throw new Error("Backup guard qualification requires an explicit dedicated source/restore test URL pair");
const pair = configuredPair;
describe("backup restore schema guard", () => {
it("refuses a populated pgdata schema without backup or restore and preserves its rows", async () => {
const target = createDb(pair.restoreUrl, { maxConnections: 1 }).$client;
const directory = await mkdtemp(path.join(os.tmpdir(), "backup-restore-guard-"));
let createdSchema = false;
try {
await assertDistinctEmptyRestore(pair);
await target`CREATE SCHEMA pgdata`;
createdSchema = true;
await target`CREATE TABLE pgdata.retained (id integer PRIMARY KEY, value text NOT NULL)`;
await target`INSERT INTO pgdata.retained VALUES (1, 'keep this existing target data')`;
const guardFailure = await assertDistinctEmptyRestore(pair).then(() => undefined, error => error);
expect(await target`SELECT id, value FROM pgdata.retained`).toEqual([{ id: 1, value: "keep this existing target data" }]);
expect(await readdir(directory)).toEqual([]);
expect(guardFailure).toBeInstanceOf(Error);
expect(guardFailure.message).toBe("Backup restore target must be empty before restore");
await expect(qualifyBackupRestore(pair, directory, "pg_dump")).rejects.toThrow("Backup restore target must be empty before restore");
expect(await target`SELECT id, value FROM pgdata.retained`).toEqual([{ id: 1, value: "keep this existing target data" }]);
expect(await readdir(directory)).toEqual([]);
} finally {
try { if (createdSchema) await target`DROP SCHEMA pgdata CASCADE`; }
finally { await target.end({ timeout: 5 }); await rm(directory, { recursive: true, force: true }); }
}
});
});
+4 -1
View File
@@ -4,7 +4,10 @@ export default defineConfig({
...base,
test: {
...base.test,
include: ["src/__tests__/database-backup-checkpoint-qualification.integration.ts"],
include: [
"src/__tests__/database-backup-checkpoint-qualification.integration.ts",
"src/__tests__/database-backup-restore-guard.integration.ts",
],
fileParallelism: false,
maxWorkers: 1,
},