#!/usr/bin/env bash # Hermetic tests for the autoupdater. # # usage: ./test.sh [name-filter] # # Runs against local bare repos standing in for origin and the AUR, with a # stubbed version lookup, so nothing here touches the network or the real AUR. # Needs a genuine Arch makepkg (same requirement as the CI job). set -uo pipefail HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" FILTER="${1:-}" PASS=0; FAIL=0; FAILED_NAMES=() pass() { printf ' \033[32mok\033[0m %s\n' "$1"; PASS=$((PASS+1)); } fail() { printf ' \033[31mFAIL\033[0m %s\n %s\n' "$1" "${2-}"; FAIL=$((FAIL+1)); FAILED_NAMES+=("$1"); } assert_eq() { # expected actual label if [ "$1" = "$2" ]; then pass "$3"; else fail "$3" "expected '$1', got '$2'"; fi } assert_ne() { if [ "$1" != "$2" ]; then pass "$3"; else fail "$3" "expected something other than '$1'"; fi } # ---------------------------------------------------------------- sandbox --- SANDBOX="" setup() { SANDBOX="$(mktemp -d)" mkdir -p "$SANDBOX"/{origin,aur,work} cat > "$SANDBOX/fake-version.sh" <<'EOS' #!/usr/bin/env bash # Stands in for latest-version.sh. FAKE_VERSION_FAIL=1 simulates a broken # upstream query; FAKE_VERSION="" simulates one that returns nothing. if [ -n "${FAKE_VERSION_FAIL:-}" ]; then echo "ERROR: simulated upstream lookup failure" >&2 exit 1 fi printf '%s\n' "${FAKE_VERSION-1.0.0}" EOS chmod +x "$SANDBOX/fake-version.sh" # A local HTTP server so remote-source paths can be tested without network. mkdir -p "$SANDBOX/www" printf 'x86_64 binary payload\n' > "$SANDBOX/www/x86.bin" printf 'aarch64 binary payload\n' > "$SANDBOX/www/arm.bin" # -u: without it the "Serving HTTP on ... port N" line stays buffered and the # port can never be read back. ( cd "$SANDBOX/www" && exec python3 -u -m http.server 0 --bind 127.0.0.1 ) \ > "$SANDBOX/www.log" 2>&1 & WWW_PID=$! for _ in $(seq 1 50); do PORT="$(sed -n -E 's/.*http:\/\/127\.0\.0\.1:([0-9]+).*/\1/p' "$SANDBOX/www.log" | head -1)" [ -n "$PORT" ] && break sleep 0.1 done [ -n "${PORT:-}" ] || { echo "could not start test http server" >&2; exit 1; } # A git shim used by one test to simulate an SSH auth rejection on push. # Everything else passes straight through to the real git. REAL_GIT="$(command -v git)" mkdir -p "$SANDBOX/bin" cat > "$SANDBOX/bin/git" <&2 echo "fatal: Could not read from remote repository." >&2 exit 128 fi exec "$REAL_GIT" "\$@" EOS chmod +x "$SANDBOX/bin/git" export PKG_REPO_BASE="file://$SANDBOX/origin" export AUR_HTTPS_BASE="file://$SANDBOX/aur" export AUR_SSH_BASE="file://$SANDBOX/aur" export LATEST_VERSION_CMD="$SANDBOX/fake-version.sh" export GIT_AUTHOR_NAME=test GIT_AUTHOR_EMAIL=test@test export GIT_COMMITTER_NAME=test GIT_COMMITTER_EMAIL=test@test } teardown() { [ -n "${WWW_PID:-}" ] && kill "$WWW_PID" 2>/dev/null [ -n "$SANDBOX" ] && rm -rf "$SANDBOX" return 0 } # A multi-arch package whose arches download DIFFERENT files that both rename to # the SAME target -- freetube-appimage's exact shape, and the case that makes # `makepkg -g` emit identical sums for both arches. make_pkg_colliding_remote() { local pkg="$1" local src="$SANDBOX/build/$pkg" rm -rf "$src"; mkdir -p "$src" cat > "$src/PKGBUILD" < "$src/.autoupdate" <<'EOF' srchost="gitea" srcmntr="test" srcname="test" verstrip="" gui="false" EOF ( cd "$src" && "$HERE/update-checksums.sh" --remote . >/dev/null \ && makepkg --printsrcinfo > .SRCINFO ) git init --quiet --bare --initial-branch=main "$SANDBOX/origin/$pkg" git init --quiet --bare --initial-branch=master "$SANDBOX/aur/${pkg}.git" ( cd "$src" && git init --quiet --initial-branch=main && git add -A \ && git commit --quiet -m "initial" \ && git push --quiet "file://$SANDBOX/origin/$pkg" main \ && git push --quiet "file://$SANDBOX/aur/${pkg}.git" main:master ) } # Create a package repo (origin) plus its AUR counterpart. # make_pkg [multiarch] make_pkg() { local pkg="$1" multi="${2:-}" local src="$SANDBOX/build/$pkg" rm -rf "$src"; mkdir -p "$src" echo "payload for $pkg" > "$src/local.txt" if [ -n "$multi" ]; then echo "x86 payload" > "$src/pay-x86_64.txt" echo "arm payload" > "$src/pay-aarch64.txt" cat > "$src/PKGBUILD" < "$src/PKGBUILD" < "$src/.autoupdate" <<'EOF' srchost="gitea" srcmntr="test" srcname="test" verstrip="" gui="false" EOF echo "readme" > "$src/README.md" # Fix up the SKIP placeholders and generate a valid .SRCINFO. ( cd "$src" && "$HERE/update-checksums.sh" . >/dev/null \ && makepkg --printsrcinfo > .SRCINFO ) git init --quiet --bare --initial-branch=main "$SANDBOX/origin/$pkg" git init --quiet --bare --initial-branch=master "$SANDBOX/aur/${pkg}.git" ( cd "$src" && git init --quiet --initial-branch=main && git add -A \ && git commit --quiet -m "initial" \ && git push --quiet "file://$SANDBOX/origin/$pkg" main \ && git push --quiet "file://$SANDBOX/aur/${pkg}.git" main:master ) } # Run the updater. Any extra env goes in front of the call by the caller. run_update() { local pkg="$1"; shift WORKDIR="$SANDBOX/work/$(date +%s%N)" \ "$HERE/autoupdate.sh" "$pkg" "$@" >"$SANDBOX/last.log" 2>&1 } # Read a field from a bare repo's tip. field_at() { # git -C "$1" show "$2:PKGBUILD" 2>/dev/null \ | sed -n -E "s/^$3=[\"']?([^\"']*)[\"']?$/\1/p" | head -n1 } aur_field() { field_at "$SANDBOX/aur/$1.git" master "$2"; } origin_field() { field_at "$SANDBOX/origin/$1" main "$2"; } aur_head() { git -C "$SANDBOX/aur/$1.git" rev-parse master 2>/dev/null; } should_run() { [ -z "$FILTER" ] || [[ "$1" == *"$FILTER"* ]]; } # ------------------------------------------------------------------ tests --- test_new_version() { make_pkg p1 FAKE_VERSION=2.0.0 run_update p1 assert_eq "0" "$?" "new version: exits 0" assert_eq "2.0.0" "$(aur_field p1 pkgver)" "new version: pkgver reaches the AUR" assert_eq "1" "$(aur_field p1 pkgrel)" "new version: pkgrel resets to 1" } test_noop_is_quiet_and_green() { make_pkg p2 FAKE_VERSION=1.0.0 run_update p2 local rc=$? before after before="$(aur_head p2)" FAKE_VERSION=1.0.0 run_update p2 after="$(aur_head p2)" assert_eq "0" "$rc" "no-op: exits 0" assert_eq "$before" "$after" "no-op: second run pushes nothing" grep -q "AUR already in sync" "$SANDBOX/last.log" \ && pass "no-op: reports already in sync" \ || fail "no-op: reports already in sync" "$(tail -3 "$SANDBOX/last.log")" } # The headline regression: origin ahead of the AUR, nothing new upstream. # The old script aborted with "same (old) version" and exit 0, forever. # Reproduces the exact reported failure: a previous run published to origin but # its AUR push failed. Origin is therefore fully consistent -- correct sums, # correct .SRCINFO, pkgrel already bumped -- so this run computes NO local # changes at all. The only thing wrong is that the AUR is behind. The old # script hit "same (old) version specified", bare-exit 0'd, and never retried. # # The pkgrel is pre-bumped deliberately: otherwise the pkgrel rule would itself # produce a local change and the run would push for that reason instead, # leaving the actual retry path untested. test_failed_push_is_retried() { make_pkg p3 local clone="$SANDBOX/manual-p3" git clone --quiet "file://$SANDBOX/origin/p3" "$clone" ( cd "$clone" \ && sed -i 's/^pkgdesc=.*/pkgdesc="changed out of band"/;s/^pkgrel=.*/pkgrel="2"/' PKGBUILD \ && makepkg --printsrcinfo > .SRCINFO \ && git commit --quiet -am "published to origin; AUR push failed" \ && git push --quiet origin main ) assert_ne "changed out of band" "$(aur_field p3 pkgdesc)" "retry: AUR starts out of date" FAKE_VERSION=1.0.0 run_update p3 local rc=$? assert_eq "0" "$rc" "retry: exits 0" grep -q "no local changes" "$SANDBOX/last.log" \ && pass "retry: run produced no local changes (exercises the reconcile path)" \ || fail "retry: run produced no local changes" \ "the run committed something, so this is not the retry path: $(tail -5 "$SANDBOX/last.log")" assert_eq "changed out of band" "$(aur_field p3 pkgdesc)" \ "retry: reconciles to the AUR with nothing new locally or upstream" } test_manual_edit_bumps_pkgrel() { make_pkg p4 local clone="$SANDBOX/manual-p4" git clone --quiet "file://$SANDBOX/origin/p4" "$clone" ( cd "$clone" && sed -i 's/^pkgdesc=.*/pkgdesc="fixed package() bug"/' PKGBUILD \ && makepkg --printsrcinfo > .SRCINFO \ && git commit --quiet -am "fix package()" && git push --quiet origin main ) FAKE_VERSION=1.0.0 run_update p4 assert_eq "2" "$(aur_field p4 pkgrel)" "manual edit: pkgrel auto-bumped 1 -> 2" # The important one: re-running must not ratchet pkgrel upward. FAKE_VERSION=1.0.0 run_update p4 assert_eq "2" "$(aur_field p4 pkgrel)" "manual edit: pkgrel stable on re-run (idempotent)" FAKE_VERSION=1.0.0 run_update p4 assert_eq "2" "$(aur_field p4 pkgrel)" "manual edit: pkgrel still stable on third run" } test_hand_bumped_pkgrel_respected() { make_pkg p5 local clone="$SANDBOX/manual-p5" git clone --quiet "file://$SANDBOX/origin/p5" "$clone" ( cd "$clone" && sed -i 's/^pkgdesc=.*/pkgdesc="edited"/;s/^pkgrel=.*/pkgrel="7"/' PKGBUILD \ && makepkg --printsrcinfo > .SRCINFO \ && git commit --quiet -am "edit + own pkgrel" && git push --quiet origin main ) FAKE_VERSION=1.0.0 run_update p5 assert_eq "7" "$(aur_field p5 pkgrel)" "hand-bumped pkgrel is left alone" } test_readme_only_edit() { make_pkg p6 local clone="$SANDBOX/manual-p6" git clone --quiet "file://$SANDBOX/origin/p6" "$clone" ( cd "$clone" && echo "more docs" >> README.md \ && git commit --quiet -am "docs" && git push --quiet origin main ) FAKE_VERSION=1.0.0 run_update p6 local rc=$? # must be captured before any other command clobbers $? assert_eq "0" "$rc" "README-only edit: still exits 0" assert_eq "1" "$(aur_field p6 pkgrel)" "README-only edit: no pkgrel bump" assert_eq "more docs" "$(git -C "$SANDBOX/aur/p6.git" show master:README.md | tail -1)" \ "README-only edit: still reconciled to the AUR" } test_local_source_edit_bumps_pkgrel() { make_pkg p7 local clone="$SANDBOX/manual-p7" git clone --quiet "file://$SANDBOX/origin/p7" "$clone" ( cd "$clone" && echo "changed payload" > local.txt \ && git commit --quiet -am "edit local source" && git push --quiet origin main ) FAKE_VERSION=1.0.0 run_update p7 assert_eq "2" "$(aur_field p7 pkgrel)" "local source edit: re-hashed and pkgrel bumped" } test_force_rebuild() { make_pkg p8 FAKE_VERSION=1.0.0 run_update p8 FORCE_REBUILD=1 FAKE_VERSION=1.0.0 run_update p8 assert_eq "2" "$(aur_field p8 pkgrel)" "FORCE_REBUILD: pkgrel +1 with no other change" } test_lookup_failure_is_loud() { make_pkg p9 local before; before="$(aur_head p9)" FAKE_VERSION_FAIL=1 run_update p9 local rc=$? assert_eq "1" "$rc" "broken upstream lookup: exits 1, not mistaken for 'no new version'" assert_eq "$before" "$(aur_head p9)" "broken upstream lookup: nothing pushed" # Assert the REASON, not just the exit code. This previously passed even with # the lookup failure being swallowed: the run carried on with an empty version # and exited 1 several steps later when .SRCINFO generation rejected it, which # is the pman-helper pkgver="" bug wearing a different hat. grep -q "upstream version lookup failed" "$SANDBOX/last.log" \ && pass "broken upstream lookup: fails AT the lookup, not later" \ || fail "broken upstream lookup: fails AT the lookup, not later" \ "expected the lookup-failure message; got: $(tail -3 "$SANDBOX/last.log")" } test_empty_version_aborts_before_commit() { make_pkg p10 local before; before="$(aur_head p10)" FAKE_VERSION="" run_update p10 assert_eq "1" "$?" "empty version: exits 1" assert_eq "$before" "$(aur_head p10)" "empty version: aborts before committing" assert_eq "1.0.0" "$(origin_field p10 pkgver)" "empty version: origin left unpoisoned" } # reflex-appimage's real state: local ahead of the AUR carrying a stale pkgrel # that the old script never reset. test_stale_pkgrel_repaired() { make_pkg p11 local clone="$SANDBOX/manual-p11" git clone --quiet "file://$SANDBOX/origin/p11" "$clone" ( cd "$clone" && sed -i 's/^pkgver=.*/pkgver="1.5.0"/;s/^pkgrel=.*/pkgrel="4"/' PKGBUILD \ && makepkg --printsrcinfo > .SRCINFO \ && git commit --quiet -am "version bumped without resetting pkgrel" \ && git push --quiet origin main ) FAKE_VERSION=1.5.0 run_update p11 assert_eq "1.5.0" "$(aur_field p11 pkgver)" "stale pkgrel: version published" assert_eq "1" "$(aur_field p11 pkgrel)" "stale pkgrel: reset to 1 against the AUR" } test_multiarch_distinct_sums() { make_pkg p12 multi FAKE_VERSION=1.0.0 run_update p12 local x a x="$(git -C "$SANDBOX/aur/p12.git" show master:PKGBUILD \ | sed -n '/^sha256sums_x86_64=/,/)/p' | grep -oE '[0-9a-f]{64}')" a="$(git -C "$SANDBOX/aur/p12.git" show master:PKGBUILD \ | sed -n '/^sha256sums_aarch64=/,/)/p' | grep -oE '[0-9a-f]{64}')" assert_eq "$(sha256sum "$SANDBOX/build/p12/pay-x86_64.txt" | awk '{print $1}')" "$x" \ "multi-arch: x86_64 sum correct" assert_eq "$(sha256sum "$SANDBOX/build/p12/pay-aarch64.txt" | awk '{print $1}')" "$a" \ "multi-arch: aarch64 sum correct" assert_ne "$x" "$a" "multi-arch: arches get distinct sums (the makepkg -g failure mode)" } # The regression that rules out `makepkg -g`: two arches downloading different # files that rename to the same target must not end up sharing a checksum. test_multiarch_colliding_downloads() { make_pkg_colliding_remote p15 FAKE_VERSION=1.0.0 FORCE_REFRESH=1 run_update p15 local pb x a pb="$(git -C "$SANDBOX/aur/p15.git" show master:PKGBUILD)" x="$(printf '%s' "$pb" | sed -n '/^sha256sums_x86_64=/,/)/p' | grep -oE '[0-9a-f]{64}')" a="$(printf '%s' "$pb" | sed -n '/^sha256sums_aarch64=/,/)/p' | grep -oE '[0-9a-f]{64}')" assert_eq "$(sha256sum "$SANDBOX/www/x86.bin" | awk '{print $1}')" "$x" \ "colliding downloads: x86_64 hashed its own file" assert_eq "$(sha256sum "$SANDBOX/www/arm.bin" | awk '{print $1}')" "$a" \ "colliding downloads: aarch64 hashed its own file, not x86_64's" assert_ne "$x" "$a" "colliding downloads: sums differ despite the shared :: target" } # The ordering guarantee: if the AUR push fails, origin must NOT be pushed. # # This regressed in production. `( update_one ) || ...` disables set -e for the # whole subshell body, so a failed sync-aur.sh did not stop the run and origin # ended up ahead of the AUR -- the exact state the tool exists to prevent. test_aur_failure_leaves_origin_unpushed() { make_pkg p16 local clone="$SANDBOX/manual-p16" git clone --quiet "file://$SANDBOX/origin/p16" "$clone" ( cd "$clone" && sed -i 's/^pkgdesc=.*/pkgdesc="needs publishing"/' PKGBUILD \ && makepkg --printsrcinfo > .SRCINFO \ && git commit --quiet -am "change to publish" && git push --quiet origin main ) local origin_before aur_before origin_before="$(git -C "$SANDBOX/origin/p16" rev-parse main)" aur_before="$(aur_head p16)" # Push target does not exist -> the AUR push fails, the fetch still works. AUR_SSH_BASE="file://$SANDBOX/nonexistent" FAKE_VERSION=1.0.0 run_update p16 local rc=$? assert_eq "1" "$rc" "aur failure: run exits 1" assert_eq "$aur_before" "$(aur_head p16)" "aur failure: AUR unchanged" assert_eq "$origin_before" "$(git -C "$SANDBOX/origin/p16" rev-parse main)" \ "aur failure: origin NOT pushed (the ordering guarantee)" # It must not blame divergence for what is a plain push failure. grep -q "has commits origin does not" "$SANDBOX/last.log" \ && fail "aur failure: not misreported as divergence" "blamed divergence for a non-divergence failure" \ || pass "aur failure: not misreported as divergence" } # A real non-fast-forward must be named as such -- and must NOT suggest forcing, # since the AUR refuses force pushes from non-administrators. test_genuine_divergence_is_reported() { make_pkg p17 local clone="$SANDBOX/manual-p17" git clone --quiet "file://$SANDBOX/aur/p17.git" "$clone" ( cd "$clone" && echo "edited directly on the AUR" >> README.md \ && git commit --quiet -am "out-of-band AUR commit" \ && git push --quiet origin HEAD:master ) FAKE_VERSION=1.0.0 run_update p17 local rc=$? assert_eq "1" "$rc" "divergence: exits 1" grep -q "has commits origin does not" "$SANDBOX/last.log" \ && pass "divergence: reported as divergence" \ || fail "divergence: reported as divergence" "$(tail -4 "$SANDBOX/last.log")" grep -qi "AUR_FORCE" "$SANDBOX/last.log" \ && fail "divergence: does not suggest forcing" "AUR_FORCE is gone; nothing should mention it" \ || pass "divergence: does not suggest forcing" } test_auth_failure_is_reported_as_auth() { make_pkg p18 local clone="$SANDBOX/manual-p18" git clone --quiet "file://$SANDBOX/origin/p18" "$clone" ( cd "$clone" && sed -i 's/^pkgdesc=.*/pkgdesc="publish me"/' PKGBUILD \ && makepkg --printsrcinfo > .SRCINFO \ && git commit --quiet -am "change" && git push --quiet origin main ) PATH="$SANDBOX/bin:$PATH" FAKE_PUSH_AUTH_FAIL=1 FAKE_VERSION=1.0.0 run_update p18 local rc=$? assert_eq "1" "$rc" "auth failure: exits 1" grep -q "rejected the SSH key" "$SANDBOX/last.log" \ && pass "auth failure: reported as an SSH key problem" \ || fail "auth failure: reported as an SSH key problem" "$(tail -4 "$SANDBOX/last.log")" grep -q "has commits origin does not" "$SANDBOX/last.log" \ && fail "auth failure: not misreported as divergence" "this is the production bug" \ || pass "auth failure: not misreported as divergence" } test_srcinfo_guard() { make_pkg p13 local clone="$SANDBOX/manual-p13" git clone --quiet "file://$SANDBOX/origin/p13" "$clone" # pkgver that makepkg rejects -> .SRCINFO cannot be generated. ( cd "$clone" && sed -i 's/^pkgdesc=.*/pkgdesc="x"/' PKGBUILD \ && git commit --quiet -am "touch" && git push --quiet origin main ) local before; before="$(aur_head p13)" FAKE_VERSION="not a version!" run_update p13 assert_eq "1" "$?" ".SRCINFO guard: invalid version rejected" assert_eq "$before" "$(aur_head p13)" ".SRCINFO guard: nothing reached the AUR" } test_no_git_push_dry_run() { make_pkg p14 local before_aur before_origin before_aur="$(aur_head p14)" before_origin="$(git -C "$SANDBOX/origin/p14" rev-parse main)" NO_GIT_PUSH=1 FAKE_VERSION=3.0.0 run_update p14 assert_eq "0" "$?" "NO_GIT_PUSH: exits 0" assert_eq "$before_aur" "$(aur_head p14)" "NO_GIT_PUSH: AUR untouched" assert_eq "$before_origin" "$(git -C "$SANDBOX/origin/p14" rev-parse main)" \ "NO_GIT_PUSH: origin untouched" } # ------------------------------------------------------------------- main --- trap teardown EXIT setup for t in $(declare -F | awk '{print $3}' | grep '^test_' | sort); do should_run "$t" || continue printf '\n\033[1m%s\033[0m\n' "$t" "$t" done printf '\n────────────────────────\n' printf 'passed: %d failed: %d\n' "$PASS" "$FAIL" if [ "$FAIL" -gt 0 ]; then printf 'failing: %s\n' "${FAILED_NAMES[*]}" exit 1 fi