Confirm recent review rows and job state are present.
+
Verify a backup is restorable
+
+ The backup profile ships verify-backup.sh, which checks the newest
+ backup without touching the live database: Postgres .dump archives with{" "}
+ pg_restore --list, and SQLite .sqlite.gz backups with a gzip and{" "}
+ integrity_check pass. Run it against the newest backup, or a specific file:
+
+ .dump`}
+ />
+
+ A healthy run ends with [verify] postgres archive OK: … (N TOC entries) (or{" "}
+ [verify] sqlite backup OK), then [verify] complete, and exits 0.
+ Corruption, a missing backup, or an empty archive exits non-zero with a{" "}
+ [verify] reason.
+
+
+ To prove a dump actually restores, opt into a scratch restore into a throwaway{" "}
+ database — never the live one:
+
+
+
+ The scratch restore runs pg_restore --clean against{" "}
+ GITTENSORY_VERIFY_SCRATCH_DATABASE_URL, so point it at a dedicated database you
+ can afford to drop. The script refuses to run when that URL equals the live backup source.
+
+
After scaling, revisit Operations and{" "}
Security because network and credential
diff --git a/docker-compose.yml b/docker-compose.yml
index cebc8d1d1f..7ccbe0eeaf 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -509,6 +509,8 @@ services:
# Active database backup (Postgres pg_dump or WAL-safe SQLite online backup) + a Qdrant snapshot, on a loop
# (default daily), kept in the gittensory-backups volume with retention. Run on demand:
# docker compose --profile backup run --rm backup sh /backup.sh
+ # Verify the newest backup is restorable (pg_restore --list / SQLite integrity_check; opt-in scratch restore):
+ # docker compose --profile backup run --rm backup sh /verify-backup.sh
backup:
image: alpine:3.20
restart: unless-stopped
@@ -519,14 +521,32 @@ services:
GITTENSORY_BACKUP_SOURCE_DATABASE_URL: "${GITTENSORY_BACKUP_SOURCE_DATABASE_URL:-}"
QDRANT_URL: ${QDRANT_URL:-http://qdrant:6333}
BACKUP_RETAIN: ${BACKUP_RETAIN:-7}
+ # Opt-in restore drill (verify-backup.sh): restore the newest dump into a THROWAWAY database, never the
+ # live one. Both must be set, and the scratch URL must differ from the backup source.
+ VERIFY_RESTORE_SCRATCH: "${VERIFY_RESTORE_SCRATCH:-}"
+ GITTENSORY_VERIFY_SCRATCH_DATABASE_URL: "${GITTENSORY_VERIFY_SCRATCH_DATABASE_URL:-}"
volumes:
# RW (not :ro) so SQLite's online-backup can use the WAL index; the script only ever reads the DB.
- gittensory-data:/data
- gittensory-backups:/backups
- ./scripts/backup.sh:/backup.sh:ro
- entrypoint: ["/bin/sh", "-c"]
+ - ./scripts/verify-backup.sh:/verify-backup.sh:ro
+ # `docker compose run --rm backup sh /backup.sh` (or /verify-backup.sh) REPLACES `command:`, not
+ # `entrypoint:`, so the package install must live in the entrypoint or an on-demand run gets a bare
+ # container with no pg_restore/sqlite3/psql. The entrypoint installs packages once, then `exec "$@"`
+ # runs whatever command is in effect — the default loop below, or a `run` override. The trailing `sh`
+ # entrypoint arg is a placeholder $0 so the real payload lands in "$@" starting at $1, not $0.
+ entrypoint:
+ - /bin/sh
+ - -ec
+ - |
+ apk add --no-cache sqlite postgresql16-client curl >/dev/null 2>&1
+ exec "$@"
+ - sh
command:
- - "apk add --no-cache sqlite postgresql16-client curl >/dev/null 2>&1 && while true; do sh /backup.sh || echo '[backup] run failed'; sleep ${BACKUP_INTERVAL_SECONDS:-86400}; done"
+ - sh
+ - -c
+ - "while true; do sh /backup.sh || echo '[backup] run failed'; sleep ${BACKUP_INTERVAL_SECONDS:-86400}; done"
volumes:
gittensory-data:
diff --git a/scripts/verify-backup.sh b/scripts/verify-backup.sh
new file mode 100644
index 0000000000..4c4b85d705
--- /dev/null
+++ b/scripts/verify-backup.sh
@@ -0,0 +1,168 @@
+#!/bin/sh
+# Self-host backup verification: check the newest backup produced by backup.sh WITHOUT touching the live
+# database. Postgres `.dump` archives are validated with `pg_restore --list` (a readable custom-format dump
+# whose table of contents is non-empty); SQLite `.sqlite.gz` backups are gzip- and integrity-checked in a
+# temp copy. An OPT-IN scratch restore (VERIFY_RESTORE_SCRATCH=1 + a dedicated scratch DB URL) additionally
+# restores the Postgres dump into a throwaway database and runs a sanity query — it refuses to touch the live
+# database. Run on demand (newest backup, or a specific file):
+# docker compose --profile backup run --rm backup sh /verify-backup.sh
+# docker compose --profile backup run --rm backup sh /verify-backup.sh /backups/postgres/gittensory-.dump
+set -eu
+
+OUT=${BACKUP_OUT_DIR:-/backups}
+PG_DB="${GITTENSORY_BACKUP_SOURCE_DATABASE_URL:-${DATABASE_URL:-}}"
+TARGET="${1:-}"
+
+verify_postgres() {
+ dump="$1"
+ if [ ! -s "$dump" ]; then
+ echo "[verify] missing or empty Postgres dump: $dump" >&2
+ return 1
+ fi
+ if ! command -v pg_restore >/dev/null 2>&1; then
+ echo "[verify] pg_restore not found; cannot verify Postgres backup" >&2
+ return 1
+ fi
+ # 1) Structural validation (non-destructive): the archive must be a readable custom-format dump whose table
+ # of contents holds at least one entry. A truncated or corrupt dump fails here.
+ toc="$(pg_restore --list "$dump" 2>&1)" || {
+ echo "[verify] pg_restore --list failed for $dump:" >&2
+ printf '%s\n' "$toc" | head -3 >&2
+ return 1
+ }
+ entries="$(printf '%s\n' "$toc" | grep -cvE '^;|^[[:space:]]*$' || true)"
+ if [ "${entries:-0}" -lt 1 ]; then
+ echo "[verify] $dump has an empty table of contents" >&2
+ return 1
+ fi
+ echo "[verify] postgres archive OK: $dump ($entries TOC entries)"
+
+ # 2) Optional scratch restore smoke (opt-in, guarded): restore into a THROWAWAY database and sanity-check.
+ # Never runs against the live database — the scratch URL must be set explicitly and differ from the source.
+ [ "${VERIFY_RESTORE_SCRATCH:-}" = "1" ] || return 0
+ scratch="${GITTENSORY_VERIFY_SCRATCH_DATABASE_URL:-}"
+ case "$scratch" in
+ postgres://* | postgresql://*) : ;;
+ *)
+ echo "[verify] VERIFY_RESTORE_SCRATCH=1 needs GITTENSORY_VERIFY_SCRATCH_DATABASE_URL=postgres://… (a dedicated scratch database, never the live one)" >&2
+ return 1
+ ;;
+ esac
+ if ! command -v psql >/dev/null 2>&1; then
+ echo "[verify] psql not found; cannot run the scratch restore smoke" >&2
+ return 1
+ fi
+ # Identity check, NOT a string comparison: a differently-spelled URL (postgres:// vs postgresql://, a host
+ # alias, an explicit vs default port) can still point at the SAME database, and a naive `[ "$scratch" =
+ # "$PG_DB" ]` misses that — letting `pg_restore --clean` drop live objects. Ask Postgres itself for the
+ # connection's actual identity instead of comparing the raw strings: `pg_control_system()`'s system_identifier
+ # is a random 64-bit value fixed for the life of that specific cluster's data directory (independent of how
+ # the connection was dialed — unlike a network-address fingerprint, e.g. inet_server_addr(), which can
+ # legitimately differ for the SAME server across connections over different address families, such as an IPv4
+ # vs IPv6 loopback — a false "these differ" that would defeat the guard). Combined with current_database(),
+ # this correctly matches "same server AND same database" while still treating a different database name on
+ # the same cluster as distinct (a legitimate, common scratch-DB setup). No special privilege is required:
+ # PUBLIC has EXECUTE on pg_control_system() by default. Any failure to fingerprint EITHER side aborts (fail
+ # closed) rather than assuming the databases differ.
+ db_identity() {
+ psql "$1" -X -q -t -A -v ON_ERROR_STOP=1 \
+ -c "SELECT current_database() || '@' || (SELECT system_identifier FROM pg_control_system())::text" \
+ 2>/dev/null
+ }
+ scratch_identity="$(db_identity "$scratch")" || scratch_identity=""
+ if [ -z "$scratch_identity" ]; then
+ echo "[verify] could not connect to the scratch database to verify its identity; refusing to proceed" >&2
+ return 1
+ fi
+ case "$PG_DB" in
+ postgres://* | postgresql://*)
+ live_identity="$(db_identity "$PG_DB")" || live_identity=""
+ if [ -z "$live_identity" ]; then
+ echo "[verify] could not connect to the live backup source to verify its identity; refusing to proceed" >&2
+ return 1
+ fi
+ if [ "$scratch_identity" = "$live_identity" ]; then
+ echo "[verify] refusing scratch restore: the scratch URL resolves to the SAME database as the live backup source ($scratch_identity)" >&2
+ return 1
+ fi
+ ;;
+ esac
+ echo "[verify] restoring $dump into the scratch database…"
+ if ! pg_restore --clean --if-exists --no-owner --no-privileges --dbname "$scratch" "$dump" >/dev/null 2>&1; then
+ echo "[verify] scratch restore failed for $dump" >&2
+ return 1
+ fi
+ tables="$(psql "$scratch" -X -q -t -A -v ON_ERROR_STOP=1 -c "SELECT count(*) FROM information_schema.tables WHERE table_schema = 'public'")" || {
+ echo "[verify] scratch sanity query failed" >&2
+ return 1
+ }
+ if [ "${tables:-0}" -lt 1 ]; then
+ echo "[verify] scratch restore produced no tables" >&2
+ return 1
+ fi
+ echo "[verify] scratch restore OK: $tables tables restored"
+}
+
+verify_sqlite() {
+ gz="$1"
+ if [ ! -s "$gz" ]; then
+ echo "[verify] missing or empty SQLite backup: $gz" >&2
+ return 1
+ fi
+ if ! gzip -t "$gz" 2>/dev/null; then
+ echo "[verify] gzip integrity check failed for $gz" >&2
+ return 1
+ fi
+ if ! command -v sqlite3 >/dev/null 2>&1; then
+ echo "[verify] sqlite3 not found; verified gzip integrity only for $gz"
+ return 0
+ fi
+ tmp="$(mktemp)"
+ if ! gzip -dc "$gz" >"$tmp" 2>/dev/null; then
+ rm -f "$tmp"
+ echo "[verify] failed to decompress $gz" >&2
+ return 1
+ fi
+ result="$(sqlite3 "$tmp" 'PRAGMA integrity_check;' 2>/dev/null | head -1 || true)"
+ rm -f "$tmp"
+ if [ "$result" != "ok" ]; then
+ echo "[verify] sqlite integrity_check failed for $gz (${result:-no output})" >&2
+ return 1
+ fi
+ echo "[verify] sqlite backup OK: $gz"
+}
+
+# An explicit file argument wins; otherwise verify the newest backup for the active database type.
+if [ -n "$TARGET" ]; then
+ case "$TARGET" in
+ *.dump) verify_postgres "$TARGET" ;;
+ *.sqlite.gz) verify_sqlite "$TARGET" ;;
+ *)
+ echo "[verify] unrecognized backup file: $TARGET (expected *.dump or *.sqlite.gz)" >&2
+ exit 1
+ ;;
+ esac
+ echo "[verify] complete"
+ exit 0
+fi
+
+case "$PG_DB" in
+ postgres://* | postgresql://*)
+ dump="$(ls -1t "$OUT"/postgres/*.dump 2>/dev/null | head -1 || true)"
+ if [ -z "$dump" ]; then
+ echo "[verify] no Postgres .dump found in $OUT/postgres" >&2
+ exit 1
+ fi
+ verify_postgres "$dump"
+ ;;
+ *)
+ gz="$(ls -1t "$OUT"/sqlite/*.sqlite.gz 2>/dev/null | head -1 || true)"
+ if [ -z "$gz" ]; then
+ echo "[verify] no SQLite backup found in $OUT/sqlite" >&2
+ exit 1
+ fi
+ verify_sqlite "$gz"
+ ;;
+esac
+
+echo "[verify] complete"
diff --git a/test/unit/selfhost-verify-backup-script.test.ts b/test/unit/selfhost-verify-backup-script.test.ts
new file mode 100644
index 0000000000..325555e1a5
--- /dev/null
+++ b/test/unit/selfhost-verify-backup-script.test.ts
@@ -0,0 +1,341 @@
+import { execFileSync } from "node:child_process";
+import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { gzipSync } from "node:zlib";
+import { afterEach, describe, expect, it } from "vitest";
+
+const tmpRoots: string[] = [];
+
+function tmpRoot(): string {
+ const dir = mkdtempSync(join(tmpdir(), "gittensory-verify-"));
+ tmpRoots.push(dir);
+ return dir;
+}
+
+afterEach(() => {
+ for (const dir of tmpRoots.splice(0)) rmSync(dir, { force: true, recursive: true });
+});
+
+// A well-formed dump contains GOODDUMP; anything else makes the fake pg_restore fail as if the archive were
+// truncated. `--list` prints a header (`;`-prefixed) plus two TOC entry lines; a restore just exits 0.
+const PG_RESTORE = `#!/bin/sh
+mode=list
+dump=
+while [ "$#" -gt 0 ]; do
+ case "$1" in
+ --list) mode=list; shift ;;
+ --dbname) mode=restore; shift 2 ;;
+ --clean|--if-exists|--no-owner|--no-privileges) shift ;;
+ -*) shift ;;
+ *) dump="$1"; shift ;;
+ esac
+done
+if ! grep -q GOODDUMP "$dump" 2>/dev/null; then
+ echo "pg_restore: error: could not read from input file: end of file" >&2
+ exit 1
+fi
+if [ "$mode" = list ]; then
+ printf ';\\n; Archive created\\n;\\n215; 1259 16385 TABLE public pull_requests owner\\n216; 1259 16400 TABLE public advisories owner\\n'
+fi
+exit 0
+`;
+const SQLITE3 = `#!/bin/sh
+echo "\${FAKE_SQLITE_INTEGRITY:-ok}"
+`;
+
+// Builds a fake `psql` that distinguishes the scratch-restore guard's identity query (`current_database()`)
+// from the post-restore table-count sanity query, and returns a caller-mapped identity per connection URL —
+// lets tests simulate two DIFFERENTLY-SPELLED URLs resolving to the SAME actual database (or genuinely
+// different ones), which is exactly the distinction the real db_identity() guard has to get right. A URL with
+// no entry in `identities` makes the identity query fail (exit 1), modeling "could not connect/fingerprint".
+function fakePsql(identities: Record, tableCount = "3"): string {
+ const cases = Object.entries(identities)
+ .map(([url, identity]) => ` "${url}") printf '%s\\n' "${identity}" ;;`)
+ .join("\n");
+ return `#!/bin/sh
+url="$1"
+shift
+sql=""
+while [ "$#" -gt 0 ]; do
+ case "$1" in
+ -c) sql="$2"; shift 2 ;;
+ *) shift ;;
+ esac
+done
+case "$sql" in
+ *current_database*)
+ case "$url" in
+${cases}
+ *) exit 1 ;;
+ esac
+ ;;
+ *)
+ printf '%s\\n' "${tableCount}"
+ ;;
+esac
+`;
+}
+
+function fakeBin(root: string, bins: Record): string {
+ const bin = join(root, "bin");
+ mkdirSync(bin, { recursive: true });
+ for (const [name, body] of Object.entries(bins)) {
+ const path = join(bin, name);
+ writeFileSync(path, body);
+ chmodSync(path, 0o755);
+ }
+ return bin;
+}
+
+function writePgDump(root: string, name: string, valid = true): string {
+ const dir = join(root, "backups", "postgres");
+ mkdirSync(dir, { recursive: true });
+ const path = join(dir, name);
+ writeFileSync(path, valid ? "PGDMP GOODDUMP payload" : "truncated garbage");
+ return path;
+}
+
+function writeSqliteGz(root: string, name: string, body = "fake sqlite db", gzip = true): string {
+ const dir = join(root, "backups", "sqlite");
+ mkdirSync(dir, { recursive: true });
+ const path = join(dir, name);
+ writeFileSync(path, gzip ? gzipSync(Buffer.from(body)) : Buffer.from(body));
+ return path;
+}
+
+function runVerify(
+ root: string,
+ args: string[],
+ env: Record,
+ bins: Record,
+): { status: number; out: string } {
+ const bin = fakeBin(root, bins);
+ try {
+ const stdout = execFileSync("sh", ["scripts/verify-backup.sh", ...args], {
+ cwd: process.cwd(),
+ encoding: "utf8",
+ env: {
+ ...process.env,
+ PATH: `${bin}:${process.env.PATH ?? ""}`,
+ BACKUP_OUT_DIR: join(root, "backups"),
+ GITTENSORY_BACKUP_SOURCE_DATABASE_URL: "",
+ DATABASE_URL: "",
+ VERIFY_RESTORE_SCRATCH: "",
+ GITTENSORY_VERIFY_SCRATCH_DATABASE_URL: "",
+ ...env,
+ },
+ });
+ return { status: 0, out: stdout };
+ } catch (err) {
+ const e = err as { status?: number; stdout?: string; stderr?: string };
+ return { status: e.status ?? 1, out: `${e.stdout ?? ""}${e.stderr ?? ""}` };
+ }
+}
+
+describe("self-host verify-backup script", () => {
+ it("validates the newest Postgres dump with pg_restore --list", () => {
+ const root = tmpRoot();
+ writePgDump(root, "gittensory-20240101T000000Z.dump", true);
+
+ const r = runVerify(root, [], { GITTENSORY_BACKUP_SOURCE_DATABASE_URL: "postgres://u:p@h/db" }, { pg_restore: PG_RESTORE });
+
+ expect(r.status).toBe(0);
+ expect(r.out).toContain("postgres archive OK");
+ expect(r.out).toContain("2 TOC entries");
+ expect(r.out).toContain("[verify] complete");
+ });
+
+ it("fails when the Postgres dump is unreadable", () => {
+ const root = tmpRoot();
+ writePgDump(root, "gittensory-bad.dump", false);
+
+ const r = runVerify(root, [], { GITTENSORY_BACKUP_SOURCE_DATABASE_URL: "postgres://u:p@h/db" }, { pg_restore: PG_RESTORE });
+
+ expect(r.status).toBe(1);
+ expect(r.out).toContain("pg_restore --list failed");
+ });
+
+ it("fails when no Postgres dump is present", () => {
+ const root = tmpRoot();
+ mkdirSync(join(root, "backups", "postgres"), { recursive: true });
+
+ const r = runVerify(root, [], { GITTENSORY_BACKUP_SOURCE_DATABASE_URL: "postgres://u:p@h/db" }, { pg_restore: PG_RESTORE });
+
+ expect(r.status).toBe(1);
+ expect(r.out).toContain("no Postgres .dump found");
+ });
+
+ it("refuses the opt-in scratch restore when no scratch URL is configured", () => {
+ const root = tmpRoot();
+ writePgDump(root, "gittensory-a.dump", true);
+
+ const r = runVerify(
+ root,
+ [],
+ { GITTENSORY_BACKUP_SOURCE_DATABASE_URL: "postgres://u:p@h/live", VERIFY_RESTORE_SCRATCH: "1" },
+ { pg_restore: PG_RESTORE, psql: fakePsql({}) },
+ );
+
+ expect(r.status).toBe(1);
+ expect(r.out).toContain("needs GITTENSORY_VERIFY_SCRATCH_DATABASE_URL");
+ });
+
+ it("refuses the scratch restore when the scratch URL is byte-for-byte the live database", () => {
+ const root = tmpRoot();
+ writePgDump(root, "gittensory-a.dump", true);
+ const live = "postgres://u:p@h/live";
+
+ const r = runVerify(
+ root,
+ [],
+ {
+ GITTENSORY_BACKUP_SOURCE_DATABASE_URL: live,
+ VERIFY_RESTORE_SCRATCH: "1",
+ GITTENSORY_VERIFY_SCRATCH_DATABASE_URL: live,
+ },
+ { pg_restore: PG_RESTORE, psql: fakePsql({ [live]: "same-cluster@10.0.0.5:5432/live" }) },
+ );
+
+ expect(r.status).toBe(1);
+ expect(r.out).toContain("SAME database as the live backup source");
+ });
+
+ it("refuses the scratch restore when a DIFFERENTLY-SPELLED URL resolves to the SAME database (regression: naive string compare bypass)", () => {
+ const root = tmpRoot();
+ writePgDump(root, "gittensory-a.dump", true);
+ // Same database, deliberately spelled differently: scheme (postgres vs postgresql) AND an explicit vs
+ // default port — a `[ "$scratch" = "$PG_DB" ]` string compare would wrongly treat these as distinct.
+ const live = "postgres://gittensory:pw@postgres/gittensory";
+ const scratch = "postgresql://gittensory:pw@postgres:5432/gittensory";
+ expect(scratch).not.toBe(live);
+
+ const r = runVerify(
+ root,
+ [],
+ {
+ GITTENSORY_BACKUP_SOURCE_DATABASE_URL: live,
+ VERIFY_RESTORE_SCRATCH: "1",
+ GITTENSORY_VERIFY_SCRATCH_DATABASE_URL: scratch,
+ },
+ {
+ pg_restore: PG_RESTORE,
+ // Both URLs resolve to the identical real connection identity, exactly as they would in production
+ // if they point at the same Postgres server/database despite the different spelling.
+ psql: fakePsql({
+ [live]: "gittensory@10.0.0.5:5432",
+ [scratch]: "gittensory@10.0.0.5:5432",
+ }),
+ },
+ );
+
+ expect(r.status).toBe(1);
+ expect(r.out).toContain("SAME database as the live backup source");
+ });
+
+ it("refuses (fails closed) when the scratch database's identity cannot be determined", () => {
+ const root = tmpRoot();
+ writePgDump(root, "gittensory-a.dump", true);
+
+ const r = runVerify(
+ root,
+ [],
+ {
+ GITTENSORY_BACKUP_SOURCE_DATABASE_URL: "postgres://u:p@h/live",
+ VERIFY_RESTORE_SCRATCH: "1",
+ GITTENSORY_VERIFY_SCRATCH_DATABASE_URL: "postgres://u:p@h/scratch",
+ },
+ // No identity entries at all: the scratch identity query fails, so the guard must abort rather than
+ // silently assume the databases differ.
+ { pg_restore: PG_RESTORE, psql: fakePsql({}) },
+ );
+
+ expect(r.status).toBe(1);
+ expect(r.out).toContain("could not connect to the scratch database");
+ });
+
+ it("refuses (fails closed) when the live database's identity cannot be determined", () => {
+ const root = tmpRoot();
+ writePgDump(root, "gittensory-a.dump", true);
+ const scratch = "postgres://u:p@h/scratch";
+
+ const r = runVerify(
+ root,
+ [],
+ {
+ GITTENSORY_BACKUP_SOURCE_DATABASE_URL: "postgres://u:p@h/live",
+ VERIFY_RESTORE_SCRATCH: "1",
+ GITTENSORY_VERIFY_SCRATCH_DATABASE_URL: scratch,
+ },
+ // Scratch resolves fine, but the live URL has no mapping — its identity query fails.
+ { pg_restore: PG_RESTORE, psql: fakePsql({ [scratch]: "gittensory@10.0.0.9:5432/scratch" }) },
+ );
+
+ expect(r.status).toBe(1);
+ expect(r.out).toContain("could not connect to the live backup source");
+ });
+
+ it("runs the guarded scratch restore into a throwaway database and sanity-checks it", () => {
+ const root = tmpRoot();
+ writePgDump(root, "gittensory-a.dump", true);
+ const live = "postgres://u:p@h/live";
+ const scratch = "postgres://u:p@h/scratch";
+
+ const r = runVerify(
+ root,
+ [],
+ {
+ GITTENSORY_BACKUP_SOURCE_DATABASE_URL: live,
+ VERIFY_RESTORE_SCRATCH: "1",
+ GITTENSORY_VERIFY_SCRATCH_DATABASE_URL: scratch,
+ },
+ {
+ pg_restore: PG_RESTORE,
+ psql: fakePsql({ [live]: "gittensory@10.0.0.5:5432/live", [scratch]: "gittensory@10.0.0.5:5432/scratch" }, "42"),
+ },
+ );
+
+ expect(r.status).toBe(0);
+ expect(r.out).toContain("scratch restore OK: 42 tables");
+ });
+
+ it("verifies an explicit dump path argument", () => {
+ const root = tmpRoot();
+ const target = writePgDump(root, "chosen.dump", true);
+
+ const r = runVerify(root, [target], {}, { pg_restore: PG_RESTORE });
+
+ expect(r.status).toBe(0);
+ expect(r.out).toContain("postgres archive OK");
+ });
+
+ it("validates the newest SQLite backup with an integrity check", () => {
+ const root = tmpRoot();
+ writeSqliteGz(root, "gittensory-20240101T000000Z.sqlite.gz");
+
+ const r = runVerify(root, [], {}, { sqlite3: SQLITE3 });
+
+ expect(r.status).toBe(0);
+ expect(r.out).toContain("sqlite backup OK");
+ });
+
+ it("fails when the SQLite backup fails its integrity check", () => {
+ const root = tmpRoot();
+ writeSqliteGz(root, "gittensory-a.sqlite.gz");
+
+ const r = runVerify(root, [], { FAKE_SQLITE_INTEGRITY: "malformed database disk image" }, { sqlite3: SQLITE3 });
+
+ expect(r.status).toBe(1);
+ expect(r.out).toContain("sqlite integrity_check failed");
+ });
+
+ it("fails when the SQLite backup is not valid gzip", () => {
+ const root = tmpRoot();
+ writeSqliteGz(root, "gittensory-a.sqlite.gz", "not gzip at all", false);
+
+ const r = runVerify(root, [], {}, { sqlite3: SQLITE3 });
+
+ expect(r.status).toBe(1);
+ expect(r.out).toContain("gzip integrity check failed");
+ });
+});