gongxiangp2/scripts/create-verified-production-backup.test.mjs

390 lines
17 KiB
JavaScript

import assert from 'node:assert/strict'
import { createHash } from 'node:crypto'
import {
cpSync,
mkdirSync,
mkdtempSync,
readFileSync,
readdirSync,
rmSync,
writeFileSync,
} from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { spawnSync } from 'node:child_process'
import test from 'node:test'
import {
assertProductionAndIsolationAreDistinct,
assertPurgeRestoreFunctions,
} from './create-verified-production-backup.mjs'
import {
assertBackupReceiptDocument,
databaseFingerprintHash,
} from './safe-production-migrate.mjs'
const COMMIT = 'a'.repeat(40)
const PASSWORDS = ['production-password-must-not-leak', 'isolated-password-must-not-leak']
const FAKE_DRIVER = String.raw`
import { appendFileSync, existsSync, writeFileSync } from 'node:fs'
const [tool, ...args] = process.argv.slice(2)
const failTool = process.env.FAKE_FAIL_TOOL || ''
const secret = process.env.PGPASSWORD || ''
if (process.env.FAKE_EMIT_SECRET === '1' && secret) process.stderr.write(secret + '\n')
if (process.env.FAKE_TOOL_TRACE_FILE) appendFileSync(process.env.FAKE_TOOL_TRACE_FILE, JSON.stringify({ tool, args }) + '\n')
if (args.includes('--version')) {
process.stdout.write(tool + ' (PostgreSQL) 15.14\n')
process.exit(0)
}
if (failTool === tool) process.exit(42)
if (tool === 'pg_dump') {
const outputIndex = args.indexOf('--file')
if (outputIndex < 0 || !args[outputIndex + 1]) process.exit(43)
writeFileSync(args[outputIndex + 1], Buffer.from('PGDMP-fake-custom-backup-fixture'))
process.exit(0)
}
if (tool === 'pg_restore') {
if (process.env.FAKE_FAIL_RESTORE_SECTION && args.includes('--section=' + process.env.FAKE_FAIL_RESTORE_SECTION)) process.exit(42)
const artifact = args.at(-1)
if (!artifact || !existsSync(artifact)) process.exit(44)
process.exit(0)
}
if (tool !== 'psql') process.exit(45)
const commandIndex = args.indexOf('--command')
const sql = commandIndex >= 0 ? args[commandIndex + 1] || '' : ''
const isolated = String(process.env.PGDATABASE || '').startsWith('gongxiangp2_restore_')
if (sql.includes('verified-backup-identity-v1')) {
process.stdout.write(JSON.stringify({
database: process.env.PGDATABASE,
user: process.env.PGUSER,
serverAddress: isolated ? process.env.FAKE_ISOLATED_SERVER_ADDRESS : process.env.FAKE_PRODUCTION_SERVER_ADDRESS,
serverPort: process.env.PGPORT,
}) + '\n')
process.exit(0)
}
if (sql.includes('verified-backup-summary-v1')) {
const mismatch = isolated && process.env.FAKE_SUMMARY_MODE === 'mismatch'
process.stdout.write(JSON.stringify({
schemaVersion: 1,
sampleLimit: 32,
materials: { count: mismatch ? '1000' : '999', sampleSha256InputMd5: 'a'.repeat(32) },
users: { count: '12', sampleSha256InputMd5: 'b'.repeat(32) },
systemSettings: { count: '1', sampleSha256InputMd5: 'c'.repeat(32) },
prismaMigrations: { count: '24', sampleSha256InputMd5: 'd'.repeat(32) },
productPackageUploadJobs: { count: '7', sampleSha256InputMd5: 'e'.repeat(32) },
}) + '\n')
process.exit(0)
}
if (sql.includes('verified-backup-reset-isolated-v1')) process.exit(0)
if (sql.includes('verified-backup-purge-set-v1') || sql.includes('verified-backup-purge-reset-v1')) process.exit(0)
if (sql.includes('verified-backup-purge-functions-v1')) {
const names = ['public.purge_json_reference_strings(jsonb)', 'public.purge_reference_keys(text[],boolean)'];
const metadata = names.map(signature => ({ signature, definition: 'CREATE OR REPLACE FUNCTION public.' + signature.slice(7) + ' legacy-body', config: null, language: 'plpgsql', returnType: 'text[]', volatility: 'i', strict: true, securityDefiner: false, parallel: 'u' }));
if (process.env.FAKE_PURGE_METADATA_MODE === 'missing') metadata.pop();
if (process.env.FAKE_PURGE_METADATA_MODE === 'config') metadata[0].config = ['search_path=public'];
if (process.env.FAKE_PURGE_METADATA_MODE === 'changed') {
if (existsSync(process.env.FAKE_PURGE_STATE_FILE)) metadata[0].definition += '-changed';
else writeFileSync(process.env.FAKE_PURGE_STATE_FILE, 'captured');
}
process.stdout.write(JSON.stringify(metadata) + '\n'); process.exit(0);
}
process.exit(46)
`
function createFixture() {
const root = mkdtempSync(path.join(os.tmpdir(), 'gx-verified-backup-'))
const repo = path.join(root, 'repo')
const scripts = path.join(repo, 'scripts')
const output = path.join(root, 'output')
mkdirSync(scripts, { recursive: true })
mkdirSync(output)
cpSync(new URL('./create-verified-production-backup.mjs', import.meta.url), path.join(scripts, 'create-verified-production-backup.mjs'))
cpSync(new URL('./safe-production-migrate.mjs', import.meta.url), path.join(scripts, 'safe-production-migrate.mjs'))
writeFileSync(path.join(repo, 'DEPLOYMENT_COMMIT'), `${COMMIT}\n`)
const driver = path.join(root, 'fake-pg-driver.mjs')
writeFileSync(driver, FAKE_DRIVER)
return { root, repo, output, driver }
}
function baseEnvironment(fixture) {
return {
...process.env,
NODE_ENV: 'test',
VERIFIED_BACKUP_TEST_PG_DRIVER: fixture.driver,
EXPECTED_COMMIT_SHA: COMMIT,
BACKUP_OUTPUT_DIR: fixture.output,
PRODUCTION_PGHOST: 'production.service',
PRODUCTION_PGPORT: '5432',
PRODUCTION_PGDATABASE: 'lobechat',
PRODUCTION_PGUSER: 'gongxiangpan',
PRODUCTION_PGPASSWORD: PASSWORDS[0],
ISOLATED_PGHOST: 'restore.service',
ISOLATED_PGPORT: '5433',
ISOLATED_PGDATABASE: 'gongxiangp2_restore_fixture01',
ISOLATED_PGUSER: 'restore_operator',
ISOLATED_PGPASSWORD: PASSWORDS[1],
EXPECTED_DATABASE_HOST: 'production.service',
EXPECTED_DATABASE_PORT: '5432',
EXPECTED_DATABASE_NAME: 'lobechat',
EXPECTED_DATABASE_USER: 'gongxiangpan',
EXPECTED_DATABASE_SERVER_ADDRESS: '10.10.0.10',
EXPECTED_DATABASE_SERVER_PORT: '5432',
EXPECTED_ISOLATED_DATABASE_HOST: 'restore.service',
EXPECTED_ISOLATED_DATABASE_PORT: '5433',
EXPECTED_ISOLATED_DATABASE_NAME: 'gongxiangp2_restore_fixture01',
EXPECTED_ISOLATED_DATABASE_USER: 'restore_operator',
EXPECTED_ISOLATED_DATABASE_SERVER_ADDRESS: '10.20.0.20',
EXPECTED_ISOLATED_DATABASE_SERVER_PORT: '5433',
FAKE_PRODUCTION_SERVER_ADDRESS: '10.10.0.10',
FAKE_ISOLATED_SERVER_ADDRESS: '10.20.0.20',
FAKE_EMIT_SECRET: '1',
}
}
function runFixture(fixture, overrides = {}, args = ['--create-and-restore']) {
return spawnSync(
process.execPath,
[path.join(fixture.repo, 'scripts', 'create-verified-production-backup.mjs'), ...args],
{
cwd: fixture.repo,
env: { ...baseEnvironment(fixture), ...overrides },
encoding: 'utf8',
timeout: 20_000,
},
)
}
function authorize() {
return {
ALLOW_VERIFIED_BACKUP: 'CREATE_AND_RESTORE',
ALLOW_ISOLATED_DATABASE_RESET: 'RESET_VERIFIED_ISOLATED_TARGET',
CONFIRM_MAINTENANCE_WINDOW: 'NO_WRITES_DURING_SNAPSHOT',
}
}
function outputFiles(fixture) {
return readdirSync(fixture.output).sort()
}
function assertNoReceipt(fixture) {
assert.equal(outputFiles(fixture).some((name) => name.endsWith('.receipt.json')), false)
assert.equal(outputFiles(fixture).some((name) => name.includes('.partial-')), false)
}
function assertSecretsRedacted(result) {
for (const password of PASSWORDS) {
assert.equal(result.stdout.includes(password), false)
assert.equal(result.stderr.includes(password), false)
}
}
test('rejects a production endpoint or actual server reused as the isolated target', () => {
const production = {
configuredHost: 'production.service', configuredPort: '5432', configuredDatabase: 'lobechat',
configuredUser: 'app', serverAddress: '10.0.0.10', serverPort: '5432',
}
assert.throws(() => assertProductionAndIsolationAreDistinct(production, {
...production,
configuredDatabase: 'gongxiangp2_restore_fixture01',
}), /different PostgreSQL server/)
assert.throws(() => assertProductionAndIsolationAreDistinct(production, {
...production,
configuredHost: 'restore.service',
configuredPort: '5433',
configuredDatabase: 'lobechat',
serverAddress: '10.20.0.20',
serverPort: '5433',
}), /database name/)
})
test('subprocess workflow rejects a production endpoint reused as the isolated target', () => {
const fixture = createFixture()
try {
const result = runFixture(fixture, {
...authorize(),
ISOLATED_PGHOST: 'production.service',
ISOLATED_PGPORT: '5432',
EXPECTED_ISOLATED_DATABASE_HOST: 'production.service',
EXPECTED_ISOLATED_DATABASE_PORT: '5432',
})
assert.notEqual(result.status, 0)
assert.match(result.stderr, /different PostgreSQL server/)
assert.deepEqual(outputFiles(fixture), [])
assertSecretsRedacted(result)
} finally {
rmSync(fixture.root, { recursive: true, force: true })
}
})
test('write mode refuses before any PostgreSQL subprocess when explicit authorization is absent', () => {
const fixture = createFixture()
try {
const result = runFixture(fixture, {
ALLOW_VERIFIED_BACKUP: '',
FAKE_FAIL_TOOL: 'psql',
})
assert.notEqual(result.status, 0)
assert.match(result.stderr, /ALLOW_VERIFIED_BACKUP=CREATE_AND_RESTORE/)
assert.deepEqual(outputFiles(fixture), [])
assertSecretsRedacted(result)
} finally {
rmSync(fixture.root, { recursive: true, force: true })
}
})
for (const failedTool of ['pg_dump', 'pg_restore']) {
test(`${failedTool} failure never creates a receipt or leaves a partial file`, () => {
const fixture = createFixture()
try {
const result = runFixture(fixture, { ...authorize(), FAKE_FAIL_TOOL: failedTool })
assert.notEqual(result.status, 0)
assert.match(result.stderr, new RegExp(`${failedTool} failed with exit code 42`))
assertNoReceipt(fixture)
assertSecretsRedacted(result)
} finally {
rmSync(fixture.root, { recursive: true, force: true })
}
})
}
test('restore covers all three sections and resets only the two scoped legacy functions before receipt', () => {
const fixture = createFixture()
try {
const trace = path.join(fixture.root, 'tools.jsonl')
const result = runFixture(fixture, { ...authorize(), FAKE_TOOL_TRACE_FILE: trace })
assert.equal(result.status, 0, result.stderr)
const calls = readFileSync(trace, 'utf8').trim().split('\n').map(line => JSON.parse(line))
const restores = calls.filter(call => call.tool === 'pg_restore' && !call.args.includes('--version'))
assert.equal(restores.length, 2)
assert.ok(restores[0].args.includes('--section=pre-data'))
assert.ok(restores[1].args.includes('--section=data') && restores[1].args.includes('--section=post-data'))
assert.ok(restores.every(call => call.args.includes('--exit-on-error')))
const sql = calls.filter(call => call.tool === 'psql').map(call => call.args.at(-1))
assert.ok(sql.some(value => value.includes('ALTER FUNCTION public.purge_reference_keys(text[],boolean) SET search_path TO pg_catalog, public')))
assert.ok(sql.some(value => value.includes('ALTER FUNCTION public.purge_json_reference_strings(jsonb) RESET search_path')))
assert.equal(sql.filter(value => value.includes('verified-backup-purge-functions-v1')).length, 2)
} finally { rmSync(fixture.root, { recursive: true, force: true }) }
})
for (const mode of ['missing', 'config', 'changed']) {
test(`unsupported or changed purge metadata (${mode}) never produces a backup receipt`, () => {
const fixture = createFixture()
try {
const result = runFixture(fixture, { ...authorize(), FAKE_PURGE_METADATA_MODE: mode, FAKE_PURGE_STATE_FILE: path.join(fixture.root, 'purge-state') })
assert.notEqual(result.status, 0)
assert.match(result.stderr, mode === 'changed' ? /definitions or configurations changed/ : /metadata is unsupported/)
assert.equal(readdirSync(fixture.output).some(name => name.endsWith('.receipt.json')), false)
} finally { rmSync(fixture.root, { recursive: true, force: true }) }
})
}
test('post-data index restore failure remains fatal and cannot issue a receipt', () => {
const fixture = createFixture()
try {
const result = runFixture(fixture, { ...authorize(), FAKE_FAIL_RESTORE_SECTION: 'post-data' })
assert.notEqual(result.status, 0)
assert.match(result.stderr, /pg_restore failed with exit code 42/)
assert.equal(readdirSync(fixture.output).some(name => name.endsWith('.receipt.json')), false)
} finally { rmSync(fixture.root, { recursive: true, force: true }) }
})
test('legacy resolver metadata requires exact signatures and excludes privileged or mutable functions', () => {
const rows = ['public.purge_json_reference_strings(jsonb)', 'public.purge_reference_keys(text[],boolean)'].map(signature => ({ signature, definition: 'CREATE OR REPLACE FUNCTION public.' + signature.slice(7), config: null, language: 'plpgsql', returnType: 'text[]', volatility: 'i', strict: true, securityDefiner: false, parallel: 'u' }))
assert.doesNotThrow(() => assertPurgeRestoreFunctions(rows))
for (const change of [{ securityDefiner: true }, { volatility: 'v' }, { strict: false }, { signature: 'public.other_function(jsonb)' }]) assert.throws(() => assertPurgeRestoreFunctions([{ ...rows[0], ...change }, rows[1]]), /metadata is unsupported/)
})
test('restored bounded summary mismatch refuses the receipt', () => {
const fixture = createFixture()
try {
const result = runFixture(fixture, { ...authorize(), FAKE_SUMMARY_MODE: 'mismatch' })
assert.notEqual(result.status, 0)
assert.match(result.stderr, /Restored database summary does not match/)
assertNoReceipt(fixture)
assertSecretsRedacted(result)
} finally {
rmSync(fixture.root, { recursive: true, force: true })
}
})
test('write mode refuses a backup directory containing any existing file', () => {
const fixture = createFixture()
try {
writeFileSync(path.join(fixture.output, 'old-evidence.txt'), 'do not overwrite\n')
const result = runFixture(fixture, authorize())
assert.notEqual(result.status, 0)
assert.match(result.stderr, /BACKUP_OUTPUT_DIR must be empty/)
assert.deepEqual(outputFiles(fixture), ['old-evidence.txt'])
assertSecretsRedacted(result)
} finally {
rmSync(fixture.root, { recursive: true, force: true })
}
})
test('success atomically creates a custom dump and a machine-generated compatible receipt', () => {
const fixture = createFixture()
try {
const result = runFixture(fixture, authorize())
assert.equal(result.status, 0, result.stderr)
assertSecretsRedacted(result)
const files = outputFiles(fixture)
assert.equal(files.some((name) => name.includes('.partial-')), false)
assert.equal(files.filter((name) => name.endsWith('.dump')).length, 1)
assert.equal(files.filter((name) => name.endsWith('.receipt.json')).length, 1)
const artifactPath = path.join(fixture.output, files.find((name) => name.endsWith('.dump')))
const receiptPath = path.join(fixture.output, files.find((name) => name.endsWith('.receipt.json')))
const artifact = readFileSync(artifactPath)
const receiptBytes = readFileSync(receiptPath)
const receipt = JSON.parse(receiptBytes.toString('utf8'))
const expectedArtifactHash = createHash('sha256').update(artifact).digest('hex')
assert.equal(receipt.generatedBy, 'scripts/create-verified-production-backup.mjs')
assert.equal(receipt.sourceCommit, COMMIT)
assert.equal(receipt.backup.artifactPath, artifactPath)
assert.equal(receipt.backup.artifactSize, artifact.length)
assert.equal(receipt.backup.artifactSha256, expectedArtifactHash)
assert.equal(receipt.restoreValidation.status, 'passed')
assert.equal(receipt.restoreValidation.artifactSha256, expectedArtifactHash)
assert.equal(
receipt.restoreValidation.databaseFingerprint,
databaseFingerprintHash(receipt.database),
)
assert.notEqual(
receipt.restoreValidation.isolatedDatabaseFingerprint,
receipt.restoreValidation.databaseFingerprint,
)
assert.equal(
receipt.restoreValidation.verificationSummary.sourceSummarySha256,
receipt.restoreValidation.verificationSummary.restoredSummarySha256,
)
assert.doesNotThrow(() => assertBackupReceiptDocument(receipt, {
expectedCommit: COMMIT,
database: receipt.database,
maxAgeMinutes: 240,
}))
const receiptHash = createHash('sha256').update(receiptBytes).digest('hex')
assert.ok(result.stdout.includes(receiptHash))
} finally {
rmSync(fixture.root, { recursive: true, force: true })
}
})
test('default audit mode is read-only and produces no backup files', () => {
const fixture = createFixture()
try {
const result = runFixture(fixture, {}, [])
assert.equal(result.status, 0, result.stderr)
assert.match(result.stdout, /Read-only verified backup audit completed/)
assert.deepEqual(outputFiles(fixture), [])
assertSecretsRedacted(result)
} finally {
rmSync(fixture.root, { recursive: true, force: true })
}
})