mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-04 19:28:46 +01:00
fix(test): reject custom pg-prefixed restore schemas
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,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,
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user