Ctrl K
ComunidadGuías…

deploy/run-hook.test.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
/**
 * 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([])
      }
    }
  })
})