deploy/backup-multi-user.test.ts
Rama main · réplica de lectura del repositorio original.
import { Database } from 'bun:sqlite'
import { describe, expect, test } from 'bun:test'
import { spawnSync } from 'node:child_process'
import { chmodSync, copyFileSync, existsSync, mkdirSync, writeFileSync } from 'node:fs'
import { join, dirname } from 'node:path'
import { guardRealStateDir, installCleanup, isolatedEnv, mkTempTree } from './tmp-fixtures'
// Task #2299: the operator path that matters after a multi-user cutover is not
// one database but N. The single-DB suites already prove the WAL-safe snapshot
// and the fail-closed abort; what is unpinned here is that `backup_databases`
// enumerates EVERY user database from the custom config and that each snapshot
// restores on its own, without reading a sibling's rows.
//
// The fixtures are real SQLite files written by bun:sqlite, and the writer
// handles stay OPEN across the backup: that is what puts the committed rows in
// the WAL instead of the main file, so a plain `cp` would lose them. The backup
// itself runs the installed script with a stubbed systemctl (never the host's)
// and the host's real sqlite3, inside a tree this process owns.
installCleanup()
guardRealStateDir()
const LIB = join(import.meta.dir, 'lib', 'common.sh')
function quote(v: string): string {
return `'${v.replace(/'/g, `'\\''`)}'`
}
function makeStubs(root: string): string {
const stubs = join(root, 'stubs')
mkdirSync(stubs, { recursive: true })
writeFileSync(
join(stubs, 'systemctl'),
'#!/usr/bin/env bash\ncase "$*" in\n *"is-active"*) exit 3 ;;\n *) printf "\\n"; exit 0 ;;\nesac\n'
)
chmodSync(join(stubs, 'systemctl'), 0o755)
writeFileSync(join(stubs, 'sqlite3'), '#!/usr/bin/env bash\nexec /usr/bin/sqlite3 "$@"\n')
chmodSync(join(stubs, 'sqlite3'), 0o755)
return stubs
}
function runCommon(root: string, script: string, envExtra: Record<string, string> = {}) {
const env = isolatedEnv(root, {
PATH: `${join(root, 'stubs')}:${process.env.PATH ?? ''}`,
APP_NAME: 'synaptomind',
...envExtra
})
const res = spawnSync('bash', ['-c', script], { encoding: 'utf8', env, timeout: 60_000 })
return { status: res.status, stdout: res.stdout ?? '', stderr: res.stderr ?? '' }
}
function sqlite(db: string, sql: string): string {
const r = spawnSync('sqlite3', [db, sql], { encoding: 'utf8' })
if (r.status !== 0) throw new Error(`sqlite3 failed for ${db}: ${r.stderr}`)
return r.stdout.trim()
}
interface SeededDb {
id: string
path: string
marker: string
writer: Database
}
/** Create a WAL-mode DB and keep the writer open so the row stays in the WAL. */
function seedWalDb(path: string, marker: string, id: string): SeededDb {
mkdirSync(dirname(path), { recursive: true })
const writer = new Database(path)
writer.query('PRAGMA journal_mode = WAL').get()
writer.run('CREATE TABLE private (x TEXT)')
writer.run('INSERT INTO private (x) VALUES (?)', [marker])
return { id, path, marker, writer }
}
function backupPaths(stdout: string): string[] {
const m = /BACKUPS=(.*)/.exec(stdout)
expect(m).not.toBeNull()
return m![1].trim().split(/\s+/).filter(Boolean)
}
describe('deploy backup (task #2299) - all user databases', () => {
test('snapshots every configured user DB in WAL state and restores each in isolation', () => {
const root = mkTempTree('synapto-bk-users-')
const app = join(root, 'app')
mkdirSync(join(app, 'data'), { recursive: true })
const dbs: SeededDb[] = [
seedWalDb(join(app, 'data', 'main.db'), 'main-private-marker', 'main'),
// User DBs live OUTSIDE the install/data tree on purpose: the deploy
// resolver's discovery net only walks data dirs, so a snapshot of these
// can only come from the custom config's `users[].dbPath`.
seedWalDb(join(root, 'volumes', 'alice', 'alice.db'), 'alice-private-marker', 'alice'),
seedWalDb(join(root, 'volumes', 'bob', 'bob.db'), 'bob-private-marker', 'bob')
]
try {
// The open writer keeps the row in the WAL: the snapshot has to come from
// SQLite, not from a byte copy of the main file.
for (const db of dbs) {
expect(existsSync(`${db.path}-wal`), `expected a WAL beside ${db.path}`).toBe(true)
expect(sqlite(db.path, 'SELECT x FROM private;')).toBe(db.marker)
}
const cfg = join(app, 'config.json')
writeFileSync(
cfg,
JSON.stringify({
db: { path: './data/main.db' },
users: [
{ id: 'alice', dbPath: dbs[1].path, tokenSha256: 'a'.repeat(64) },
{ id: 'bob', dbPath: dbs[2].path, tokenSha256: 'b'.repeat(64) }
]
})
)
makeStubs(root)
const script = [
'APP_NAME=synaptomind',
`INSTALL_DIR=${quote(app)}`,
`export SYNAPTOMIND_CONFIG=${quote(cfg)}`,
'export BACKUP_KEEP=5',
`. ${quote(LIB)}`,
'backup_databases',
'RC=$?',
'echo "RC=$RC"',
'echo "BACKUPS=${DB_BACKUPS[*]}"'
].join('\n')
const res = runCommon(root, script, { INSTALL_DIR: app, SYNAPTOMIND_CONFIG: cfg })
expect(res.stderr).not.toContain('ERROR')
const backups = backupPaths(res.stdout)
// One snapshot per configured DB, never one shared or one skipped.
expect(backups).toHaveLength(3)
const byId = new Map<string, string>()
for (const db of dbs) {
const match = backups.filter(b => b.includes(`/${db.id === 'main' ? 'main' : db.id}.db.`))
expect(match, `no snapshot for ${db.id}`).toHaveLength(1)
byId.set(db.id, match[0])
}
for (const db of dbs) {
const backup = byId.get(db.id)!
// Snapshot is self-contained: no WAL/SHM sidecars to forget on restore.
expect(existsSync(`${backup}-wal`)).toBe(false)
expect(existsSync(`${backup}-shm`)).toBe(false)
expect(sqlite(backup, 'PRAGMA quick_check;')).toBe('ok')
expect(sqlite(backup, 'PRAGMA user_version;')).toBe(sqlite(db.path, 'PRAGMA user_version;'))
// Only this user's row, captured from the WAL.
expect(sqlite(backup, 'SELECT count(*) FROM private;')).toBe('1')
expect(sqlite(backup, 'SELECT x FROM private;')).toBe(db.marker)
}
// Restore each snapshot into its own directory. A restore is isolated:
// the restored copy carries that user's rows and no sibling's marker.
const restoreRoot = join(root, 'restore')
for (const db of dbs) {
const dir = join(restoreRoot, db.id)
mkdirSync(dir, { recursive: true })
const restored = join(dir, 'restored.db')
copyFileSync(byId.get(db.id)!, restored)
expect(sqlite(restored, 'PRAGMA quick_check;')).toBe('ok')
expect(sqlite(restored, 'SELECT x FROM private;')).toBe(db.marker)
for (const other of dbs) {
if (other.id === db.id) continue
expect(
sqlite(restored, `SELECT count(*) FROM private WHERE x = '${other.marker}';`)
).toBe('0')
}
}
// Snapshot isolation: rows written after the backup must not appear in
// it, and must not disturb any sibling's snapshot.
for (const db of dbs)
db.writer.run('INSERT INTO private (x) VALUES (?)', [`${db.marker}-after`])
for (const db of dbs) {
expect(sqlite(byId.get(db.id)!, 'SELECT count(*) FROM private;')).toBe('1')
expect(sqlite(db.path, 'SELECT count(*) FROM private;')).toBe('2')
}
} finally {
for (const db of dbs) db.writer.close()
}
})
})