390 lines
17 KiB
JavaScript
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 })
|
|
}
|
|
})
|