tests: roundtrip and fail-closed suites over a hostile corpus
commit 94fd195743f8a7135ebdd235ab431e180a7bcaa1 · parent 8b39158c08
· browse this tree
Ximianth, 2026-10-18 11:31:42 UTC
tests/lib.sh carries the corpus builder and the pinned-identity, tick-clock git environment -- fixed identities belong in the tests, never in the tool. The corpus keeps every hostile case: a subject-slug collision, a rename, an in-range merge, a jj-style change-id header, an empty commit, a body with a blank line, and a message without a final newline. test-roundtrip.sh runs both sigil dialects through: regeneration-stable export, verify, import into a fresh upstream clone reproducing exact tips, and a strict fsck. It also greps the emitted framing, so the dialect loop can never silently degrade into testing quote twice. test-failclosed.sh checks each refusal by its specific message, not just the exit code, tying every case to the guard it exercises. Each guard was observed to be load-bearing by removing it and watching the matching test fail (tip comparison, duplicate-series, orphan flagging, the export STACK guard, and the sigil flag were probed concretely).
diff --git a/tests/lib.sh b/tests/lib.sh new file mode 100644 index 0000000..d8a8501 --- /dev/null +++ b/tests/lib.sh @@ -0,0 +1,184 @@ +#!/bin/bash +## Shared helpers for the patchette tests. Sourced, not executed. +## Provides a hermetic, fully-pinned git environment (fixed identities and a +## tick clock belong HERE, not in the tool: tests author commits, patchette +## never does), the test corpus builder, and PASS/FAIL reporting helpers. + +set -euo pipefail + +export GIT_CONFIG_GLOBAL=/dev/null +export GIT_CONFIG_SYSTEM=/dev/null +export GIT_CONFIG_NOSYSTEM=1 +export GIT_ATTR_NOSYSTEM=1 +export LC_ALL=C.UTF-8 +export TZ=UTC + +export GIT_AUTHOR_NAME="Ada Downstream" +export GIT_AUTHOR_EMAIL="ada@downstream.example" +export GIT_COMMITTER_NAME="Ada Downstream" +export GIT_COMMITTER_EMAIL="ada@downstream.example" + +PATCHETTE=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/bin/patchette + +RESULT=0 + +## Report one passing check. +## +## - @param $@ description +pass() { + echo "PASS $*" +} + +## Report one failing check and mark the test script failed. +## +## - @param $@ description +## - @state RESULT -- set to 1 +fail() { + echo "FAIL $*" + RESULT=1 +} + +## Run a command expected to refuse, and check it does so for the right reason. +## +## PASSes only when the command exits nonzero AND its output contains the +## expected message fragment: an exit code alone could come from any guard +## (or any crash), and we want each test tied to its specific refusal. +## +## - @param $1 check description +## - @param $2 message fragment the combined output must contain +## - @param $@ the command +expect_refusal() { + local desc=$1 want=$2 out rc=0 + shift 2 + out=$("$@" 2>&1) || rc=$? + if [[ $rc -eq 0 ]]; then + fail "$desc: expected a refusal, but the command succeeded" + elif [[ $out != *"$want"* ]]; then + fail "$desc: refused, but not with the expected message '$want'; got: $out" + else + pass "$desc" + fi +} + +_TICK=1700000000 + +## Advance the fixed clock by one minute, for the next commit. +## +## - @state GIT_AUTHOR_DATE, GIT_COMMITTER_DATE -- set to the new clock value. +tick() { + _TICK=$((_TICK + 60)) + export GIT_AUTHOR_DATE="@${_TICK} +0000" + export GIT_COMMITTER_DATE="@${_TICK} +0000" +} + +## Build the test corpus: an upstream repo, and a dev clone carrying two +## to-be-tracked branches. Every commit is hostile on purpose. +## +## `topic/fancy` exercises: linear commits, a subject-slug collision, a rename, +## a merge (with an in-range side branch), an extra commit header in the +## jj `change-id` style, an empty commit, a body with a blank line, and a +## message without a final newline. +## `docs-polish` exists to make the layout's branch mapping visible. +## +## - @param $1 output directory; created. Gets `upstream/`, `dev/`, and a +## sourceable `FACTS` file recording the base SHA and branch tips. +make_corpus() ( + local out=$1 base first raw new + mkdir -p "$out" + out=$(cd "$out" && pwd) + + # --- upstream --- + git init -q -b main "$out/upstream" + cd "$out/upstream" + { + printf 'The frobnicator.\n' + for i in 1 2 3 4 5 6 7 8; do printf 'gear %s turns smoothly\n' "$i"; done + } >frobnicator.txt + mkdir widgets + printf 'widget: mk1\nstate: dusty\n' >widgets/widget.txt + printf '# Machinery\n\nUpstream docs.\n' >README.md + git add -A + tick + git commit -q -m "Initial machinery" + printf '\nMore upstream docs.\n' >>README.md + git add -A + tick + git commit -q -m "Document the machinery" + base=$(git rev-parse HEAD) + + # --- dev clone, branch topic/fancy --- + git clone -q "$out/upstream" "$out/dev" + cd "$out/dev" + git switch -qc topic/fancy + + # plain linear change + sed -i 's/gear 3 turns smoothly/gear 3 turns BETTER/' frobnicator.txt + git add -A + tick + git commit -q -m "Fix the frobnicator" + first=$(git rev-parse HEAD) + + # same subject again: forces the slug collision rule to show itself + sed -i 's/gear 5 turns smoothly/gear 5 turns BETTER/' frobnicator.txt + git add -A + tick + git commit -q -m "Fix the frobnicator" + + # rename plus edit + mkdir gizmos + git mv widgets/widget.txt gizmos/widget.txt + sed -i 's/state: dusty/state: polished/' gizmos/widget.txt + git add -A + tick + git commit -q -m "Move widget into place" + + # a side branch forked in-range, then merged: exercises parents-as-slugs + git switch -qc sprocket-work "$first" + printf '# Sprockets\n\nThey spin.\n' >SPROCKETS.md + git add -A + tick + git commit -q -m "Add sprocket docs" + git switch -q topic/fancy + tick + git merge -q --no-ff -m "Merge sprocket work" sprocket-work + git branch -qD sprocket-work + + # extra commit header, jj change-id style; handcrafted via raw object rewrite. + # The body (with its blank line) exercises message framing in the exports. + sed -i 's/gear 7 turns smoothly/gear 7 hums quietly/' frobnicator.txt + git add -A + tick + git commit -q -m "Teach gizmo to hum" -m $'The hum is load-bearing.\nDo not ask why.' + raw=$(mktemp) + git cat-file commit HEAD >"$raw" + new=$(sed '/^committer /a change-id kpqxwvuzyntsmrlokqjihvgzfyexwdcu' "$raw" | + git hash-object -t commit -w --stdin) + rm "$raw" + git update-ref refs/heads/topic/fancy "$new" + + # empty commit + tick + git commit -q --allow-empty -m "Note to self" + + # message without a final newline: porcelain never makes one, but the object + # model allows it, and total fidelity means total + sed -i 's/gear 8 turns smoothly/gear 8 clicks twice/' frobnicator.txt + git add -A + tick + git commit -q -m "Scribble scribble" + new=$(git cat-file commit HEAD | head -c -1 | git hash-object -t commit -w --stdin) + git update-ref refs/heads/topic/fancy "$new" + + # --- second branch, small on purpose --- + git switch -qc docs-polish main + printf '\nDownstream notes: be kind to the machinery.\n' >>README.md + git add -A + tick + git commit -q -m "Polish the readme" + + { + printf 'base=%s\n' "$base" + printf 'tip_topic_fancy=%s\n' "$(git rev-parse topic/fancy)" + printf 'tip_docs_polish=%s\n' "$(git rev-parse docs-polish)" + } >"$out/FACTS" +) diff --git a/tests/run.sh b/tests/run.sh new file mode 100755 index 0000000..9247a77 --- /dev/null +++ b/tests/run.sh @@ -0,0 +1,24 @@ +#!/bin/bash +## Run every tests/test-*.sh script. Each prints PASS/FAIL lines and exits +## nonzero on failure; this driver reports per-script status and fails if any did. +set -uo pipefail +cd "$(dirname "$0")" + +fail=0 +for t in test-*.sh; do + echo "=== $t ===" + if "./$t"; then + echo "ok $t" + else + echo "FAIL $t (exit $?)" + fail=1 + fi +done + +echo +if [[ $fail -eq 0 ]]; then + echo "all test scripts passed" +else + echo "TEST FAILURES" +fi +exit "$fail" diff --git a/tests/test-failclosed.sh b/tests/test-failclosed.sh new file mode 100755 index 0000000..7b0c805 --- /dev/null +++ b/tests/test-failclosed.sh @@ -0,0 +1,81 @@ +#!/bin/bash +## Fail-closed behavior: every tampered or conflicting input is refused, +## loudly, and for its own specific reason (we check the message, not just +## the exit code, so each case stays tied to the guard it exercises). +set -euo pipefail +cd "$(dirname "$0")" +source ./lib.sh + +work=$(mktemp -d) +trap 'rm -rf "$work"' EXIT + +make_corpus "$work/corpus" +source "$work/corpus/FACTS" + +"$PATCHETTE" export "$work/corpus/dev" "$work/pristine" "$base" topic/fancy docs-polish >/dev/null +fancy=$'topic\u00a6fancy' + +## Set up a fresh tampering workspace from the pristine export. +## +## - @param $1 case name; becomes the folder name under $work +## - @state TAMPER -- path to the fresh copy +clone_layout() { + TAMPER="$work/$1" + cp -r "$work/pristine" "$TAMPER" +} + +# A tampered patch must make verify fail. Editing message content keeps the +# diff applying cleanly, so the only witness left is the tip comparison -- +# exactly the guard this case is about. +clone_layout tampered +sed -i 's/> The hum is load-bearing./> The hum is decorative./' "$TAMPER/$fancy/teach-gizmo-to-hum.patch" +expect_refusal "verify fails on a tampered patch" "MISMATCH" \ + "$PATCHETTE" verify "$work/corpus/dev" "$TAMPER" + +# Import onto an existing branch is refused (before any replay work). +git clone -q "$work/corpus/upstream" "$work/occupied" +git -C "$work/occupied" branch docs-polish main +expect_refusal "import onto an existing branch is refused" "already exists" \ + "$PATCHETTE" import "$work/occupied" "$work/pristine" + +# A malformed message line is a parse error, never a guess. +clone_layout badmsg +sed -i 's/> Do not ask why./| Do not ask why./' "$TAMPER/$fancy/teach-gizmo-to-hum.patch" +expect_refusal "a malformed message line is a parse error" "malformed message line" \ + "$PATCHETTE" verify "$work/corpus/dev" "$TAMPER" + +# An unknown parent reference is an error. +clone_layout badparent +sed -i '1s/.*/parents no-such-slug/' "$TAMPER/$fancy/add-sprocket-docs.patch" +expect_refusal "an unknown parent reference is an error" "not BASE or an earlier slug" \ + "$PATCHETTE" verify "$work/corpus/dev" "$TAMPER" + +# A duplicate series entry is an error. +clone_layout dupseries +echo "note-to-self" >>"$TAMPER/$fancy/STACK" +expect_refusal "a duplicate series entry is an error" "duplicate series entry" \ + "$PATCHETTE" verify "$work/corpus/dev" "$TAMPER" + +# An orphan patch file (nothing would ever read it) is flagged by verify. +clone_layout orphan +cp "$TAMPER/$fancy/note-to-self.patch" "$TAMPER/$fancy/stray.patch" +expect_refusal "an orphan patch file is flagged by verify" "orphan" \ + "$PATCHETTE" verify "$work/corpus/dev" "$TAMPER" + +# Export never deletes a folder it didn't write. +mkdir -p "$work/notours/$fancy" +touch "$work/notours/$fancy/precious.txt" +expect_refusal "export into a non-patchette folder is refused" "no STACK file" \ + "$PATCHETTE" export "$work/corpus/dev" "$work/notours" "$base" topic/fancy +if [[ -f "$work/notours/$fancy/precious.txt" ]]; then + pass "export left the non-patchette folder untouched" +else + fail "export deleted contents of a non-patchette folder" +fi + +# Importing where the recorded base is absent tells the user what to do. +git init -q -b main "$work/empty" +expect_refusal "import without the recorded base points at fetching" "fetch from upstream first" \ + "$PATCHETTE" import "$work/empty" "$work/pristine" + +exit "$RESULT" diff --git a/tests/test-roundtrip.sh b/tests/test-roundtrip.sh new file mode 100755 index 0000000..eec7766 --- /dev/null +++ b/tests/test-roundtrip.sh @@ -0,0 +1,64 @@ +#!/bin/bash +## Roundtrip, for both sigil dialects: export is regeneration-stable, +## verify passes, and import into a fresh clone of upstream reproduces the +## exact branch tips, fsck-clean. +set -euo pipefail +cd "$(dirname "$0")" +source ./lib.sh + +work=$(mktemp -d) +trap 'rm -rf "$work"' EXIT + +make_corpus "$work/corpus" +source "$work/corpus/FACTS" + +for sigil in quote tab; do + p="$work/$sigil" + + "$PATCHETTE" export "--sigil=$sigil" "$work/corpus/dev" "$p/patches" "$base" topic/fancy docs-polish >/dev/null + "$PATCHETTE" export "--sigil=$sigil" "$work/corpus/dev" "$p/again" "$base" topic/fancy docs-polish >/dev/null + if diff -r "$p/patches" "$p/again" >/dev/null; then + pass "$sigil: export is regeneration-stable (two runs byte-identical)" + else + fail "$sigil: export is not regeneration-stable" + fi + + # Guard the guard: confirm the flag actually switched the emitted framing, + # or this loop would just test the quote dialect twice. + sample="$p/patches/topic¦fancy/teach-gizmo-to-hum.patch" + case $sigil in + quote) want=$'> The hum is load-bearing.' ;; + tab) want=$'\tThe hum is load-bearing.' ;; + esac + if grep -qxF "$want" "$sample"; then + pass "$sigil: emitted message framing matches the dialect" + else + fail "$sigil: emitted message framing does not match the dialect" + fi + + if "$PATCHETTE" verify "$work/corpus/dev" "$p/patches" >/dev/null; then + pass "$sigil: verify passes on a fresh export" + else + fail "$sigil: verify rejected a fresh export" + fi + + # The recreate story: upstream has none of our branches; the patches repo + # alone must bring them back, byte-exact. + git clone -q "$work/corpus/upstream" "$p/fresh" + "$PATCHETTE" import "$p/fresh" "$p/patches" >/dev/null + got_fancy=$(git -C "$p/fresh" rev-parse refs/heads/topic/fancy) + got_docs=$(git -C "$p/fresh" rev-parse refs/heads/docs-polish) + if [[ $got_fancy == "$tip_topic_fancy" && $got_docs == "$tip_docs_polish" ]]; then + pass "$sigil: import reproduces exact tips" + else + fail "$sigil: import tips differ: topic/fancy $got_fancy (want $tip_topic_fancy), docs-polish $got_docs (want $tip_docs_polish)" + fi + + if git -C "$p/fresh" fsck --strict >/dev/null 2>&1; then + pass "$sigil: fsck --strict clean on the replayed repo" + else + fail "$sigil: fsck --strict rejected the replayed repo" + fi +done + +exit "$RESULT"