diff --git a/.github/workflows/backup-checkpoint-qualification.yml b/.github/workflows/backup-checkpoint-qualification.yml index 6e0df6c2f7f..8da917ce4dc 100644 --- a/.github/workflows/backup-checkpoint-qualification.yml +++ b/.github/workflows/backup-checkpoint-qualification.yml @@ -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 diff --git a/doc/idle-sleep-safety.md b/doc/idle-sleep-safety.md index 9963bdc3468..09d4c670601 100644 --- a/doc/idle-sleep-safety.md +++ b/doc/idle-sleep-safety.md @@ -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 diff --git a/package.json b/package.json index bbbf2652985..9e2bb3714a6 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/server/scripts/lib/backup-qualification.ts b/server/scripts/lib/backup-qualification.ts index ee5e05cb2e8..e60cd0a4421 100644 --- a/server/scripts/lib/backup-qualification.ts +++ b/server/scripts/lib/backup-qualification.ts @@ -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"); diff --git a/server/src/__tests__/database-backup-checkpoint-qualification.integration.ts b/server/src/__tests__/database-backup-checkpoint-qualification.integration.ts index 886e93081fd..951150fb297 100644 --- a/server/src/__tests__/database-backup-checkpoint-qualification.integration.ts +++ b/server/src/__tests__/database-backup-checkpoint-qualification.integration.ts @@ -19,6 +19,7 @@ const pair = configuredPair; describe("real automatic backup checkpoint qualification", () => { let directory: string; let source: ReturnType; + 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 }); } diff --git a/server/src/__tests__/database-backup-restore-guard.integration.ts b/server/src/__tests__/database-backup-restore-guard.integration.ts new file mode 100644 index 00000000000..e02535edfa9 --- /dev/null +++ b/server/src/__tests__/database-backup-restore-guard.integration.ts @@ -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 }); } + } + }); +}); diff --git a/server/vitest.backup-qualification.config.ts b/server/vitest.backup-qualification.config.ts index 2fafb7c5099..518eb073539 100644 --- a/server/vitest.backup-qualification.config.ts +++ b/server/vitest.backup-qualification.config.ts @@ -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, },