79edee659f
docs/SOVEREIGN.md (run Accounted on Swedish infrastructure: providers, self-hosted Supabase gotchas, backup/restore runbook, honest dependency list), scripts/self-host/backup.sh + restore.sh (pg_dump custom format, storage tar, SHA-256 manifest, S3-compatible upload; ACLs are preserved through the restore and re-verified against an acl-manifest including sequences; the resume hook always runs after a failed quiesce and the hooks must be configured as a pair), Vercel Speed Insights gated off for self-hosted, and stale self-host docs corrected (assistant Q&A and categorization run on BYO OpenAI-compatible models; SMTP via EMAIL_PROVIDER=smtp after #1746; connector subscription described as proposed only).
224 lines
10 KiB
TypeScript
224 lines
10 KiB
TypeScript
import { describe, it, expect } from 'vitest'
|
|
import { execFileSync, type ExecFileSyncOptions } from 'node:child_process'
|
|
import { chmodSync, existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
|
import { tmpdir } from 'node:os'
|
|
import { join } from 'node:path'
|
|
|
|
// The self-host backup/restore scripts are shipped product (docs/SOVEREIGN.md
|
|
// tells operators to run them on a schedule), so at least their syntax and
|
|
// their refusal paths are checked in CI. Behaviour against a real bucket and
|
|
// database is exercised by the operator's first dry run per the runbook.
|
|
const DIR = join(__dirname, '..')
|
|
const SCRIPTS = ['backup.sh', 'restore.sh']
|
|
// A clean environment: none of the BACKUP_* / RESTORE_* variables, so the
|
|
// scripts' own guards are what runs. Typed as ProcessEnv (the repo's
|
|
// augmentation makes NODE_ENV a required key) so it can be spread into the
|
|
// per-test environments below without a cast.
|
|
const BARE_PROCESS_ENV: NodeJS.ProcessEnv = { PATH: process.env.PATH ?? '', NODE_ENV: 'test' }
|
|
const BARE_ENV: ExecFileSyncOptions = { env: BARE_PROCESS_ENV, stdio: 'pipe' }
|
|
// Enough environment for backup.sh to get past its required-variable guards;
|
|
// nothing here is ever contacted (the tests below stop before any tool runs).
|
|
const BACKUP_REQUIRED_ENV: NodeJS.ProcessEnv = {
|
|
...BARE_PROCESS_ENV,
|
|
BACKUP_DATABASE_URL: 'postgresql://postgres:x@127.0.0.1:1/postgres',
|
|
BACKUP_S3_ENDPOINT: 'https://s3.invalid',
|
|
BACKUP_S3_BUCKET: 'bucket',
|
|
AWS_ACCESS_KEY_ID: 'key',
|
|
AWS_SECRET_ACCESS_KEY: 'secret',
|
|
}
|
|
|
|
function runBash(args: string[], env: NodeJS.ProcessEnv): { status: number; stderr: string } {
|
|
try {
|
|
execFileSync('bash', args, { env, stdio: 'pipe' })
|
|
return { status: 0, stderr: '' }
|
|
} catch (err) {
|
|
return {
|
|
status: (err as { status?: number }).status ?? -1,
|
|
stderr: String((err as { stderr?: Buffer }).stderr ?? ''),
|
|
}
|
|
}
|
|
}
|
|
|
|
describe('self-host shell scripts', () => {
|
|
for (const name of SCRIPTS) {
|
|
it(`${name} parses under bash -n`, () => {
|
|
expect(() => execFileSync('bash', ['-n', join(DIR, name)])).not.toThrow()
|
|
})
|
|
|
|
it(`${name} sets strict mode and a private umask`, () => {
|
|
const src = readFileSync(join(DIR, name), 'utf8')
|
|
expect(src).toContain('set -euo pipefail')
|
|
expect(src).toContain('umask 077')
|
|
})
|
|
|
|
it(`${name} keeps ACLs in the dump (no --no-privileges), drops ownership (--no-owner), uses the ACL manifest`, () => {
|
|
// The migrations REVOKE hardened SECURITY DEFINER RPCs from anon and
|
|
// authenticated (REVOKE ... FROM PUBLIC, anon; GRANT ... TO service_role).
|
|
// --no-privileges would strip that from the dump and a restored self-host
|
|
// would re-expose those RPCs over PostgREST; --no-owner is what lets the
|
|
// dump land in a stack whose roles were created by that stack.
|
|
const src = readFileSync(join(DIR, name), 'utf8')
|
|
// The invocation lines, not the comments that explain them.
|
|
const commands = src.split('\n').filter((line) => /^\s*pg_(dump|restore) /.test(line))
|
|
expect(commands.length).toBeGreaterThan(0)
|
|
for (const command of commands) {
|
|
expect(command).toContain('--no-owner')
|
|
expect(command).not.toContain('--no-privileges')
|
|
}
|
|
expect(src).toContain('acl-manifest.sql')
|
|
})
|
|
}
|
|
|
|
it('restore.sh neutralizes the restoring role default privileges before pg_restore and diffs the ACL manifest after it', () => {
|
|
// pg_dump writes ACLs as a diff against acldefault(), so the dump never
|
|
// says "REVOKE FROM anon"; a Supabase target's ALTER DEFAULT PRIVILEGES
|
|
// would hand anon EXECUTE back to every restored function unless those
|
|
// defaults are removed first. The manifest diff is what makes a restore
|
|
// that lost the hardening fail instead of pass silently.
|
|
const src = readFileSync(join(DIR, 'restore.sh'), 'utf8')
|
|
const neutralize = src.indexOf('ALTER DEFAULT PRIVILEGES')
|
|
const restore = src.indexOf('pg_restore --clean')
|
|
const check = src.lastIndexOf('acl-manifest.sql')
|
|
expect(neutralize).toBeGreaterThan(-1)
|
|
expect(restore).toBeGreaterThan(neutralize)
|
|
expect(check).toBeGreaterThan(restore)
|
|
// Exactly the built-in default, not "no grants at all": a global entry
|
|
// that took EXECUTE on functions away from PUBLIC gets it back, otherwise
|
|
// a function the source never touched (NULL ACL, PUBLIC-executable)
|
|
// restores as non-executable.
|
|
expect(src).toContain('TO PUBLIC')
|
|
expect(src).toContain('ACL MISMATCH')
|
|
})
|
|
|
|
it('acl-manifest.sql states every PostgREST role for functions, relations and sequences in public', () => {
|
|
const sql = readFileSync(join(DIR, 'acl-manifest.sql'), 'utf8')
|
|
for (const role of ['anon', 'authenticated', 'service_role']) {
|
|
expect(sql).toContain(`has_function_privilege('${role}'`)
|
|
expect(sql).toContain(`has_table_privilege('${role}'`)
|
|
// Sequences have their own privilege set (USAGE, SELECT, UPDATE) that
|
|
// has_table_privilege does not see; a restore that hands anon nextval
|
|
// on an id sequence must show up in the diff like any other object.
|
|
for (const privilege of ['USAGE', 'SELECT', 'UPDATE']) {
|
|
expect(sql).toContain(`has_sequence_privilege('${role}', c.oid, '${privilege}')`)
|
|
}
|
|
}
|
|
expect(sql).toMatch(/relkind = 'S'/)
|
|
expect(sql).toContain("'public'::regnamespace")
|
|
// Byte-identical output on both sides regardless of database collation.
|
|
expect(sql).toContain('collate "C"')
|
|
// restore.sh shows the mismatching lines by object kind; a sequence line
|
|
// must not be filtered out of that excerpt.
|
|
const restore = readFileSync(join(DIR, 'restore.sh'), 'utf8')
|
|
expect(restore).toContain('(function|relation|sequence) ')
|
|
})
|
|
|
|
it('restore.sh does not require RESTORE_DATABASE_URL for the db-config pass (RESTORE_SKIP_DATABASE=1)', () => {
|
|
let status = 0
|
|
let stderr = ''
|
|
try {
|
|
execFileSync('bash', [join(DIR, 'restore.sh'), 'nightly-20260101T000000Z', '--yes'], {
|
|
...BARE_ENV,
|
|
env: { ...BARE_PROCESS_ENV, RESTORE_SKIP_DATABASE: '1' },
|
|
})
|
|
} catch (err) {
|
|
status = (err as { status?: number }).status ?? -1
|
|
stderr = String((err as { stderr?: Buffer }).stderr ?? '')
|
|
}
|
|
expect(status).not.toBe(0)
|
|
expect(stderr).not.toContain('RESTORE_DATABASE_URL')
|
|
expect(stderr).toContain('BACKUP_S3_ENDPOINT is required')
|
|
})
|
|
|
|
it('backup.sh refuses to run without the required environment', () => {
|
|
let status = 0
|
|
try {
|
|
execFileSync('bash', [join(DIR, 'backup.sh')], BARE_ENV)
|
|
} catch (err) {
|
|
status = (err as { status?: number }).status ?? -1
|
|
}
|
|
expect(status).not.toBe(0)
|
|
})
|
|
|
|
it('backup.sh refuses a quiesce hook without its resume hook, and the reverse, before running anything', () => {
|
|
// A quiesce command with no resume command would leave the operator's app
|
|
// stopped after every run; refusing up front is the only safe answer.
|
|
const quiesceOnly = runBash([join(DIR, 'backup.sh')], { ...BACKUP_REQUIRED_ENV, BACKUP_QUIESCE_CMD: 'true' })
|
|
expect(quiesceOnly.status).toBe(2)
|
|
expect(quiesceOnly.stderr).toContain('BACKUP_QUIESCE_CMD is set but BACKUP_RESUME_CMD is not')
|
|
const resumeOnly = runBash([join(DIR, 'backup.sh')], { ...BACKUP_REQUIRED_ENV, BACKUP_RESUME_CMD: 'true' })
|
|
expect(resumeOnly.status).toBe(2)
|
|
expect(resumeOnly.stderr).toContain('BACKUP_RESUME_CMD is set but BACKUP_QUIESCE_CMD is not')
|
|
})
|
|
|
|
it('backup.sh runs the resume hook when the quiesce hook fails part-way', () => {
|
|
// `docker compose stop app cron` can stop `app` and then fail on `cron`;
|
|
// set -e ends the script right there, and the EXIT trap must still run
|
|
// the resume hook or the operator's app stays down after a failed backup.
|
|
// The required tools are stubbed on PATH so the script reaches the hook;
|
|
// none of them is ever executed because the hook fails first.
|
|
const stubs = mkdtempSync(join(tmpdir(), 'accounted-backup-stubs-'))
|
|
try {
|
|
for (const tool of ['pg_dump', 'psql', 'tar', 'gzip', 'aws']) {
|
|
const stub = join(stubs, tool)
|
|
writeFileSync(stub, '#!/bin/sh\nexit 0\n')
|
|
chmodSync(stub, 0o755)
|
|
}
|
|
const marker = join(stubs, 'resumed')
|
|
const result = runBash([join(DIR, 'backup.sh')], {
|
|
...BACKUP_REQUIRED_ENV,
|
|
PATH: `${stubs}:${process.env.PATH ?? ''}`,
|
|
BACKUP_QUIESCE_CMD: 'exit 3',
|
|
BACKUP_RESUME_CMD: `touch "${marker}"`,
|
|
})
|
|
expect(result.status).not.toBe(0)
|
|
expect(existsSync(marker)).toBe(true)
|
|
} finally {
|
|
rmSync(stubs, { recursive: true, force: true })
|
|
}
|
|
})
|
|
|
|
it('backup.sh marks the quiesce attempt before running the hook, not after it succeeds', () => {
|
|
const src = readFileSync(join(DIR, 'backup.sh'), 'utf8')
|
|
const attempted = src.indexOf('QUIESCE_ATTEMPTED=1')
|
|
const hook = src.indexOf('bash -c "$BACKUP_QUIESCE_CMD"')
|
|
expect(attempted).toBeGreaterThan(-1)
|
|
expect(hook).toBeGreaterThan(attempted)
|
|
})
|
|
|
|
it('restore.sh rejects a backup name with shell metacharacters before doing anything', () => {
|
|
let status = 0
|
|
let stderr = ''
|
|
try {
|
|
execFileSync('bash', [join(DIR, 'restore.sh'), 'nightly-2026; rm -rf /', '--yes'], BARE_ENV)
|
|
} catch (err) {
|
|
status = (err as { status?: number }).status ?? -1
|
|
stderr = String((err as { stderr?: Buffer }).stderr ?? '')
|
|
}
|
|
expect(status).toBe(2)
|
|
expect(stderr).toContain('invalid backup name')
|
|
})
|
|
|
|
it('neither script runs a shell inside the docker helper container', () => {
|
|
// `docker run ... sh -c "<string with ${NAME}>"` would let a crafted
|
|
// backup name execute inside the container; tar must get the path as a
|
|
// direct argument. (The operator-supplied quiesce/resume hooks in
|
|
// backup.sh run through bash on the host by design; they are config.)
|
|
const dockerShell = /docker run[^\n]*(\\\n[^\n]*)*?\bsh -c/
|
|
expect(readFileSync(join(DIR, 'restore.sh'), 'utf8')).not.toMatch(dockerShell)
|
|
expect(readFileSync(join(DIR, 'backup.sh'), 'utf8')).not.toMatch(dockerShell)
|
|
})
|
|
|
|
it('restore.sh refuses to run without --yes', () => {
|
|
let status = 0
|
|
let stderr = ''
|
|
try {
|
|
execFileSync('bash', [join(DIR, 'restore.sh'), 'nightly-20260101T000000Z'], BARE_ENV)
|
|
} catch (err) {
|
|
status = (err as { status?: number }).status ?? -1
|
|
stderr = String((err as { stderr?: Buffer }).stderr ?? '')
|
|
}
|
|
expect(status).toBe(2)
|
|
expect(stderr).toContain('--yes')
|
|
})
|
|
})
|