Skip to content

test(install): the guard that stops an upgrade eating secret.key had no test (#348) - #353

Merged
rainmanjam merged 2 commits into
mainfrom
test/upgrade-guard
Aug 14, 2026
Merged

test(install): the guard that stops an upgrade eating secret.key had no test (#348)#353
rainmanjam merged 2 commits into
mainfrom
test/upgrade-guard

Conversation

@rainmanjam

Copy link
Copy Markdown
Owner

What an operator loses if this regresses

#348 gave the binary install an update.sh with five refusals in it, and a test for none of them. The one that matters is secret.key.

Since 0.7.0 seals destination stream keys at rest, a database restored without secret.key comes back with every destination disabled — correctly, because a key that will not open disables its destination rather than failing open. That is exactly what makes it dangerous: nothing about the restore looks wrong. The server starts. The database loads. Every destination is still there, with its name and its URL. The operator finds out what the backup was missing when they go live and nothing publishes — and by then the pre-upgrade data directory has been replaced.

If the guard regresses, the upgrade that ate the key still exits 0 and still prints backup verified. There is no second signal. That is what these cases exist to hold.

What is here

Sections 7 and 8 of scripts/acceptance-install.sh:

  • The script is generated, never transcribed. install.sh is sourced (with its main "$@" invocation stripped, and the strip proved before eval — the failure mode of assuming it is a developer's laptop growing a polyemesis user and a unit file), then write_binary_update_script / write_helper_scripts are called with DATA_DIR, INSTALL_DIR, BIN_PATH and SERVICE_NAME pointed at a temp directory. A test holding its own copy of update.sh would keep passing for years after install.sh stopped writing the check.
  • Each refusal is staged and driven: no data directory, an empty one, a database with no key beside it, a key with nothing to unseal, both files present. Every refusal case asserts the message names the actual problem, not just that the exit code was non-zero — and the secret.key case additionally requires the message to say the restore comes back disabled, because an operator told only that a file is missing restores anyway.
  • The happy path asserts the reported backup path is real and holds both files, since that path is the operator's only way back.
  • Section 8 does the same to the docker branch, whose archive check is equally untested, driven with a stub docker that understands the two invocations the generated script makes. No daemon.

Nothing touches a real /var/lib/polyemesis. Runs in about a second.

Mutation-tested

Not "the assertions look right" — each was watched fail, by reading the output rather than the exit code, with install.sh restored from a file copy afterwards (shasum matched, git diff clean):

mutation what the suite said
delete the secret.key branch FAIL a backup with a database but NO secret.key is refused: update.sh exited 0 — the upgrade would have gone ahead, plus the message-consequence assertion
delete the docker archive check the same pair in section 8
delete the [ -e "$dest" ] refusal FAIL the second run nested the copy: …/data.bak-2026-08-14-1217/data exists, so the checks read the wrong directory

That last one reproduces the bug refusal 3 was written for, which is the point of driving it as a repeat run.

Repeat invocation

The nesting bug was found by running the generated script twice, which is what an operator does after a failed upgrade. That is how it is driven here — the same script, twice, not a pre-created collision — so it retries if the clock crosses a minute mid-case rather than reporting a pass it did not earn.

One repeat-invocation case is left uncovered on purpose and named in the file: a second docker run inside the same minute rewrites backup-STAMP.tar.gz, and tar truncates. The binary branch refuses that collision; the docker branch overwrites the pre-upgrade backup with whatever the half-migrated volume holds now — at the moment an operator has least to spare. Asserting the current behaviour would pin a bug as correct, so it is a comment and a follow-up, not a test. The fix belongs in install.sh.

Also

The suite is now run by the installer workflow. It had only ever run on a laptop, which is most of how five untested refusals shipped.

Verification

  • scripts/acceptance-install.sh: 38 passed, 0 failed — twice
  • bash -n scripts/install.sh clean; scripts/install.sh byte-identical to main
  • shellcheck --severity=warning clean on the changed file
  • go test ./internal/testenv/ -count=1 ok — no skip sites added, testdata/skips.json untouched
  • ./scripts/termination-guard.sh ok

https://claude.ai/code/session_01HeLrWaDmsNeeNSbHQfEofX

…no test (#348)

#348 gave the binary install an update.sh with five refusals in it. None of
them had a test, on any machine, and the suite that would have been their home
was not wired into CI either.

The refusal that matters is secret.key. Since 0.7.0 seals destination stream
keys at rest, a database restored WITHOUT secret.key comes back with every
destination DISABLED -- correctly, because a key that will not open disables
its destination rather than failing open. That is the whole problem: nothing
about the restore looks wrong. The server starts, the database loads, every
destination is still listed with its name and its URL. The operator finds out
what the backup was missing at the moment they go live and nothing publishes,
by which point the pre-upgrade data directory is gone. If this guard regresses,
the upgrade that ate the key still exits 0 and still prints "backup verified".

So sections 7 and 8 GENERATE update.sh by sourcing install.sh -- both branches,
binary and docker -- and run it against a temporary directory staged into each
failing shape: no data directory, an empty one, a database with no key beside
it, a key with nothing to unseal, and both files present. A test carrying its
own transcription of update.sh would keep passing after install.sh stopped
writing the check, which is the failure this is meant to catch.

Mutation-tested rather than asserted: deleting the secret.key branch from
install.sh turns "a backup with a database but NO secret.key is refused" into
"update.sh exited 0 -- the upgrade would have gone ahead". Deleting the docker
branch's archive check does the same to section 8. Deleting the dest-exists
refusal reproduces the nesting bug it was written for: the second run reports
data.bak-STAMP/data, a directory the checks would then have passed against.

The repeat-run case is the one an operator reaches by hand, after an upgrade
has already gone wrong, and it is why the nesting refusal exists at all. It is
driven the way they drive it -- the same script, twice -- rather than by
pre-creating the collision, so it retries if the clock crosses a minute
mid-case instead of quietly testing nothing.

The suite now runs in the installer workflow. It had only ever run on a laptop,
which is most of how five untested refusals shipped.

Claude-Session: https://claude.ai/code/session_01HeLrWaDmsNeeNSbHQfEofX
Copilot AI lite review requested due to automatic review settings August 14, 2026 19:19

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR expands the installer acceptance coverage to include the generated update.sh upgrade guard logic (both binary/systemd and docker modes), ensuring the critical secret.key refusal behavior is exercised and preventing regressions that would allow “backup verified” on an unrecoverable backup.

Changes:

  • Add end-to-end acceptance cases that generate update.sh from install.sh and drive each refusal path, including repeat-invocation behavior for the binary branch.
  • Add docker-mode guard coverage using a stub docker implementation (no daemon required) to validate archive checks and key presence messaging.
  • Update the installer GitHub Actions workflow to run scripts/acceptance-install.sh in CI.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.

File Description
scripts/acceptance-install.sh Adds acceptance coverage for generated update.sh guard rails (binary + docker), including key-missing refusals and repeat-run behavior.
.github/workflows/installer.yml Runs the installer acceptance suite in CI and includes it in path filters.
Suppressed comments (1)

scripts/acceptance-install.sh:328

  • Same subshell trap-inheritance issue as gen_binary_update: if load_install_defs returns before it disarms traps, the inherited $work cleanup trap can run on subshell exit and remove the workspace while the suite continues.
  ( load_install_defs || exit 1
    INSTALL_DIR="$1"
    MODE=docker
    COMPOSE_CMD="echo [stub compose]"
    write_helper_scripts >/dev/null )

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +150 to +155
( load_install_defs || exit 1
INSTALL_DIR="$1"
DATA_DIR="$2"
BIN_PATH="$1/polyemesis"
SERVICE_NAME="polyemesis-acceptance"
write_binary_update_script )
@sonarqubecloud

Copy link
Copy Markdown

@rainmanjam
rainmanjam merged commit 544d876 into main Aug 14, 2026
35 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants