/** * run_hook's exit status, and the two properties the tee exists for (task #1102). * * WHY THIS FILE EXISTS SEPARATELY FROM updater.sh.test.ts. The defect was in * one FUNCTION: `run_hook` could not report a failed hook, so the caller-side * guard `if ! run_hook pre-update` in update.sh could never fire. An * entry-point harness can only observe the far end of that (a swap that happened * when it should not have), and it cannot tell the two properties of the tee * apart from each other: a construct that dropped the capture entirely and one * that kept both would look the same unless a test asks about the transcript * on its own. So the function is tested directly, against the REAL shipped * source — the harness sources the actual `run_hook`/`collect_db_backups` * fragment out of deploy/update.sh rather than a copy of it, so a mutation of * update.sh is what turns these tests red. * * WHAT EACH TEST PINS. * 1. A hook exiting 7 reaches the caller as 7 (the status crosses the pipe). * Before the fix it arrived as 0: `{ "$hook" 2>&1 || rc=$?; } | tee "$out"` * assigns inside the pipeline's LEFT STAGE, and a stage is a subshell, so * the assignment died there and the parent only saw tee's status. * 2. The output is still STREAMED — the hook's own line reaches stdout, so a * long backup does not look hung. * 3. The output is still CAPTURED as data — `collect_db_backups` still finds * the backup path, so the recovery block can name it (task #1079 F6). * 4. The `mktemp_owned` FAILURE branch (no temp file available) carries the * status too, and creates nothing. * * WHY A FRAGMENT AND NOT `source update.sh`: update.sh ends with `main "$@"`, * so sourcing it would run a whole update against the live install layout. The * fragment is delimited by two markers that exist in the file, and the harness * FAILS LOUDLY when either marker is missing or the extracted text does not * define both functions — otherwise "the fragment was empty" would show up as a * green test that asserted nothing. * * No sudo, no systemctl, no service: run_hook only reads ${HOOKS_DIR} and writes * the transcript, and the temp root is a tree of this suite's own. */ import { describe, expect, test } from 'bun:test' import { spawnSync } from 'node:child_process' import { chmodSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs' import { join } from 'node:path' import { guardRealStateDir, installCleanup, isolatedEnv, mkTempTree } from './tmp-fixtures' // Temp-tree ownership: the scratch tree and the temp ROOT run_hook writes into // both come from mkTempTree, so the sweep removes them even after a failing // test. /tmp is the same filesystem as production's database (AGENTS.md §8). installCleanup() // Tripwire for #1101: this suite sources the real lib/common.sh, which carries // resolve_target_user()'s getent-first resolution — the mechanism that wrote // into the operator's real ~/.synaptomind. The tripwire fails the run if it moves. guardRealStateDir() const UPDATE_SH = join(import.meta.dir, 'update.sh') const COMMON_SH = join(import.meta.dir, 'lib', 'common.sh') /** * The shipped `DB_BACKUPS` / `collect_db_backups` / `run_hook` block, read out * of deploy/update.sh. * * Throws when a marker moved or a function disappeared, so a stale extraction * cannot pass as a working harness. */ function hookFragment(): string { const src = readFileSync(UPDATE_SH, 'utf8') const start = src.indexOf('DB_BACKUPS=()') const end = src.indexOf('# ── Rollback guidance') if (start < 0 || end < 0 || end <= start) { throw new Error( `cannot locate the hook block in ${UPDATE_SH}: DB_BACKUPS=() at ${start}, ` + `'# ── Rollback guidance' at ${end}` ) } const fragment = src.slice(start, end) for (const fn of ['collect_db_backups()', 'run_hook()']) { if (!fragment.includes(fn)) { throw new Error(`the extracted hook block does not define ${fn} — the extraction is stale`) } } return fragment } /** What run_hook reported, and what it left behind. */ type HookRun = { status: number | null stdout: string stderr: string rc: string backups: string[] } /** * Run the REAL run_hook against one hook script. * * `tempRoot` is exported as TMPDIR for the child, so the transcript lands in a * tree this suite owns and the leak assertion is exact rather than a count of * the host's shared /tmp. `tempRoot: null` points TMPDIR at a path that does not * exist, which is how mktemp_owned's failure branch is reached. */ function runHook(opts: { body: string code: number name?: 'pre-update' | 'post-update' tempRoot?: string | null }): HookRun { const root = mkTempTree('synapto-hook-') const hooks = join(root, 'hooks') const scratch = join(root, 'fragment.sh') // An EXISTING temp root this suite owns (the transcript is written there and // must be gone afterwards), or a path that does not exist (mktemp_owned fails, // which is the fallback branch). const ownedTmp = join(root, 'tmp') const noTempRoot = join(root, 'does-not-exist') if (opts.tempRoot === undefined) mkdirSync(ownedTmp) mkdirSync(hooks) const hook = join(hooks, opts.name ?? 'pre-update') writeFileSync(hook, ['#!/usr/bin/env bash', opts.body, `exit ${opts.code}`].join('\n')) chmodSync(hook, 0o755) writeFileSync(scratch, hookFragment()) const script = [ '#!/usr/bin/env bash', // The same prologue update.sh itself has, so run_hook runs under the same // `set -e` / `pipefail` it has in production. 'set -euo pipefail', 'APP_NAME="synaptomind"', `HOOKS_DIR="${hooks}"`, `. "${COMMON_SH}"`, `. "${scratch}"`, 'trap cleanup_run EXIT', 'rc=0', // The caller's shape, not a bare call: a bare `run_hook x` under `set -e` // would abort the harness and hide the status this suite is about. 'run_hook "$1" || rc=$?', 'printf \'RC=%s\\n\' "$rc"', // Biome's noTemplateCurlyInString flags the `${…}` below; it is bash, not a JS // template literal, and the advisory lint (AGENTS.md §3) is left as it lands. 'printf \'BACKUPS=%s\\n\' "${DB_BACKUPS[*]-}"' ].join('\n') const res = spawnSync('bash', ['-c', script, 'run-hook-harness', opts.name ?? 'pre-update'], { encoding: 'utf8', // isolatedEnv rather than a bare `...process.env` (task #1104). This harness // sources the REAL lib/common.sh, so it has resolve_target_user available and // HOOKS_DIR is set by the script itself — but pinning RUN_DIR keeps the // pin from depending on the script's own prologue staying as it is. env: isolatedEnv(root, { PATH: process.env.PATH!, TMPDIR: opts.tempRoot === undefined ? ownedTmp : (opts.tempRoot ?? noTempRoot) }), timeout: 30_000 }) const rc = /^RC=(\d+)$/m.exec(res.stdout)?.[1] ?? '' const backupsLine = /^BACKUPS=(.*)$/m.exec(res.stdout)?.[1] ?? '' return { status: res.status, stdout: res.stdout, stderr: res.stderr, rc, backups: backupsLine === '' ? [] : backupsLine.split(' ') } } const PRINTS_BACKUP = 'echo "Database backed up: /var/lib/synaptomind/synaptomind.db.backup/synaptomind.db.20260101-000000.bak"' describe('update.sh — run_hook carries the hook exit status out of the tee (task #1102)', () => { test('a hook that exits 7 reaches the caller as 7, and its output is still streamed', () => { const res = runHook({ body: PRINTS_BACKUP, code: 7 }) // The status crossed the pipe. This is the assertion the guard depends on: // before the fix this was RC=0, because `|| rc=$?` ran inside the pipeline's // left stage (a subshell) and the parent only saw tee. expect(res.rc, res.stdout + res.stderr).toBe('7') // Streaming survived: the hook's own line is on update.sh's stdout, so a long // backup still shows progress instead of looking hung. expect(res.stdout).toContain('Database backed up: /var/lib/synaptomind/') // The failure is reported, and the harness itself was not killed by `set -e` // on the non-zero pipeline (its own status is 0, the run finished). expect(res.stderr).toContain('pre-update hook failed (exit 7)') expect(res.status).toBe(0) }) test('a hook that exits 0 still reports 0, so the guard stays quiet on a good run', () => { const res = runHook({ body: PRINTS_BACKUP, code: 0 }) expect(res.rc, res.stdout + res.stderr).toBe('0') expect(res.stderr).not.toContain('hook failed') expect(res.status).toBe(0) }) test('the transcript is still CAPTURED, not only streamed: the backup path is collected', () => { // The half of the tee that a status fix must not cost. collect_db_backups // parses the transcript the hook's output was written to, and print_recovery // restores exactly those paths (task #1079 F6). Dropping the capture would // leave the recovery block naming no backup at all — silently, because the // streaming half would still look correct. const res = runHook({ body: PRINTS_BACKUP, code: 7 }) expect(res.backups, res.stdout + res.stderr).toEqual([ '/var/lib/synaptomind/synaptomind.db.backup/synaptomind.db.20260101-000000.bak' ]) }) test('the no-temp-file fallback branch carries the status too, and creates nothing', () => { // mktemp_owned fails when TMPDIR names a path that does not exist, which is // what this branch is for (a full or read-only /tmp). It runs the hook with no // pipe at all, so `$?` is the hook's own status — asserted here because the // two branches are the two ways the status has to survive, and a future edit // to one of them is not obliged to look at the other. const root = mkTempTree('synapto-hook-notmp-') const res = runHook({ body: PRINTS_BACKUP, code: 7, tempRoot: null }) expect(res.rc, res.stdout + res.stderr).toBe('7') expect(res.stderr).toContain('no temp file') expect(res.stderr).toContain('pre-update hook failed (exit 7)') // Nothing was created: the branch that cannot own a temp file owns none. expect(readdirSync(root)).toEqual([]) }) test('every branch leaves the temp root with no leftover entry', () => { // Task #1101 closed the mktemp-ownership leak class (94 entries per deploy // suite run, on production's filesystem). The status fix must not reopen it, // so the count under a temp root THIS suite owns is asserted for both // branches and both hook outcomes. for (const code of [0, 7]) { for (const tempRoot of ['owned', null] as const) { const root = mkTempTree(`synapto-hook-leak-${code}-${tempRoot === null ? 'notmp' : 'tmp'}-`) const res = runHook({ body: PRINTS_BACKUP, code, tempRoot: tempRoot === null ? null : root }) expect(res.rc, res.stdout + res.stderr).toBe(String(code)) // cleanup_run ran on the harness's EXIT, so the transcript is gone. const leftover = tempRoot === null ? [] : readdirSync(root) expect(leftover, `hook exit ${code}, temp ${tempRoot}`).toEqual([]) } } }) })