Files
skeptical-updater/tests/e2e/skeptical-updater.bats
T
bryanpedini 875c8294bf feat(show): merge the installed record with the upstream candidate
N?? and `show PKG` now print the installed record in the package
manager's own key/value layout, after one line explaining the format:

    # lines starting with > come from the upstream candidate: a differing
    value, or a field the installed record lacks

After each field whose candidate value differs comes the candidate's
line prefixed with "> "; fields only the candidate has are appended the
same way; installed-only fields stay; identical fields print once.
Multi-line values (pacman Optional Deps, dnf Description, RFC-822
continuation lines) are compared as blocks and a differing candidate
block gets "> " on every line. A package that is not installed shows the
candidate alone ("# not installed: this is the upstream candidate").

Merged where the backend gives comparable records for both sides:
- pacman: -Qi vs -Si REPO/PKG, REPO = the first repository -Sl lists
- apt: dpkg-query -s (Status, Conffiles, the long description; richer
  than apt-cache show PKG=<installed>, which gives the archive record
  when that version is still listed) vs apt-cache show --no-all-versions;
  Description-xx pairs with Description and a one-line candidate
  Description equal to the installed first line counts as identical
  (the archives ship the long text in Translation files)
- dnf: info --installed vs info --upgrades (--available lists other
  architectures first and nothing for an up-to-date package); not
  installed: --available; dnf5's differently padded keys are re-padded
zypper keeps `zypper info --requires`: rpm -qi and zypper info share
almost no keys and zypper cannot print the installed record itself.
No candidate, or not exactly one record per side, keeps the old output.

Formats verified in containers and recorded in discovery/*.md. Unit
tests replay recorded pacman, apt, dnf4 and dnf5 records; the E2E suite
checks 2?? on su-dummy-b on all 12 distros.
2026-10-10 14:24:39 +02:00

430 lines
18 KiB
Bash

#!/usr/bin/env bats
# SPDX-License-Identifier: GPL-2.0-or-later
# Copyright (C) 2026 Bryan Joshua Pedini
# E2E: skeptical-updater against the REAL package manager of the container
# (apt, pacman, dnf or zypper), on the su-dummy-{a,b,c} fixture: 1.0 installed,
# 2.0 available, su-dummy-a depends on su-dummy-b. Every test starts from that
# state (e2e_reset) with no statefile and no holds/locks.
load helpers
setup_file() { e2e_setup_file; }
setup() { e2e_reset; }
# shellcheck source=/dev/null
zypper_verb() { case $(. /etc/os-release && printf '%s' "$ID") in opensuse-tumbleweed | opensuse-slowroll) echo dist-upgrade ;; *) echo update ;; esac; }
@test "banner shows the detected backend and the distro, refresh succeeds" {
run skeptical-updater list
assert_success
assert_line --index 0 "Backend: $BE ($(pretty_name))"
assert_line --index 1 "Refreshing package lists... done."
}
@test "list shows exactly the three dummies 1.0 -> 2.0" {
run skeptical-updater list --no-refresh
assert_success
assert_line "3 upgradable, 0 trusted (hidden, use --all to show), 3 to review:"
assert_line --regexp '^ \[1\] su-dummy-a +1\.0(-1)? +-> +2\.0(-1)? +[^ ]+$'
assert_line --regexp '^ \[2\] su-dummy-b +1\.0(-1)? +-> +2\.0(-1)? +[^ ]+$'
assert_line --regexp '^ \[3\] su-dummy-c +1\.0(-1)? +-> +2\.0(-1)? +[^ ]+$'
refute_line --regexp '^.\[4\]'
}
@test "N? prints the description, the install reason and who requires it" {
local -a lines_=()
local i a=-1 b=-1
run skeptical-updater --no-refresh <<<$'1?\n2?\nq'
assert_success
assert_line --regexp '^su-dummy-a: .*([Dd]ummy|skeptical-updater).*$'
assert_line --regexp '^su-dummy-b: .*([Dd]ummy|skeptical-updater).*$'
# [1] su-dummy-a was installed explicitly and nothing needs it; [2] su-dummy-b was installed as
# a dependency of su-dummy-a - the two lines right under each description, no indentation
mapfile -t lines_ <<<"$output"
for i in "${!lines_[@]}"; do
case ${lines_[i]} in
'su-dummy-a: '*) a=$i ;;
'su-dummy-b: '*) b=$i ;;
esac
done
[[ ${lines_[a + 1]} == 'reason: explicitly installed' && ${lines_[a + 2]} == 'required by: nothing' ]] ||
fail "1? (su-dummy-a): got '${lines_[a + 1]}' / '${lines_[a + 2]}'"
[[ ${lines_[b + 1]} == 'reason: installed as a dependency' && ${lines_[b + 2]} == 'required by: su-dummy-a' ]] ||
fail "2? (su-dummy-b): got '${lines_[b + 1]}' / '${lines_[b + 2]}'"
refute_output --partial 'required by (installed)'
assert_line 'Quit: nothing was upgraded.'
}
@test "N?? shows the installed record merged with the upstream candidate (zypper: zypper info)" {
run skeptical-updater --no-refresh <<<$'2??\nq'
assert_success
if [[ $BE == zypper ]]; then # no comparable records: the plain zypper info --requires
assert_line 'Information for package su-dummy-b:'
refute_line --partial '# lines starting with >'
return 0
fi
assert_line '# lines starting with > come from the upstream candidate: a differing value, or a field the installed record lacks'
case $BE in
apt)
assert_line 'Package: su-dummy-b'
assert_line 'Status: install ok installed'
assert_output --partial "$(printf '%s\n' 'Version: 1.0' '> Version: 2.0')"
assert_line --regexp '^> Filename: .*su-dummy-b_2\.0_all\.deb$'
;;
pacman)
assert_output --partial "$(printf '%s\n' 'Version : 1.0-1' '> Version : 2.0-1')"
assert_line 'Install Reason : Installed as a dependency for another package'
assert_line '> Repository : su-dummy'
;;
dnf)
assert_output --partial "$(printf '%s\n' 'Version : 1.0' '> Version : 2.0')"
assert_output --partial "$(printf '%s\n' 'Repository : @System' '> Repository : su-dummy')"
;;
esac
[[ $(grep -c '^> ' <<<"$output") -ge 2 ]]
}
@test "N+ writes the user row and the dep row; list hides both and counts them" {
run skeptical-updater --no-refresh <<<$'1+\nq'
assert_success
assert_line 'trusted su-dummy-a (+1 dependency: su-dummy-b)'
[[ -f $STATE_FILE ]]
[[ $(stat -c %a "$STATE_DIR") == 755 ]]
run cat "$STATE_FILE"
assert_line --index 0 '# skeptical-updater trusted packages. Columns: backend package reason added(UTC ISO-8601)'
assert_line --index 1 --regexp "^$BE"$'\t'"su-dummy-a"$'\t'"user"$'\t''[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9:]{8}Z$'
assert_line --index 2 --regexp "^$BE"$'\t'"su-dummy-b"$'\t'"dep:su-dummy-a"$'\t''[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9:]{8}Z$'
run skeptical-updater list --no-refresh
assert_success
assert_line "3 upgradable, 2 trusted (hidden, use --all to show), 1 to review:"
refute_output --partial 'su-dummy-a'
refute_output --partial 'su-dummy-b'
assert_line --regexp '^ \[1\] su-dummy-c '
run skeptical-updater list --no-refresh --all
assert_success
assert_line "3 upgradable, 2 trusted (marked with =), 1 to review:"
assert_line --regexp '^=\[1\] su-dummy-a '
assert_line --regexp '^=\[2\] su-dummy-b '
assert_line --regexp '^ \[3\] su-dummy-c '
}
@test "trust / trusted / refresh / untrust from the command line" {
run skeptical-updater trust su-dummy-a
assert_success
assert_output 'trusted su-dummy-a (+1 dependency: su-dummy-b)'
run skeptical-updater trusted
assert_success
assert_line --partial "$BE"$'\t'"su-dummy-a"$'\t'"user"$'\t'
assert_line --partial "$BE"$'\t'"su-dummy-b"$'\t'"dep:su-dummy-a"$'\t'
# refresh recomputes the closure: a removed dep row comes back
sed -i '/dep:su-dummy-a/d' "$STATE_FILE"
run skeptical-updater refresh
assert_success
assert_output 'refreshed the dependency closures of 1 trusted root: 1 added, 0 removed'
run grep -c $'\tdep:su-dummy-a\t' "$STATE_FILE"
assert_output 1
run skeptical-updater untrust su-dummy-a
assert_success
assert_output 'untrusted su-dummy-a (2 rows removed)'
run skeptical-updater trusted
assert_success
assert_output '# skeptical-updater trusted packages. Columns: backend package reason added(UTC ISO-8601)'
}
@test "excluding su-dummy-b (2- then enter) upgrades the others" {
before=$(holds_snapshot)
run skeptical-updater <<<$'2-\n'
assert_success
assert_line 'excluded from this run: su-dummy-b'
assert_line 'Excluded from this run: su-dummy-b'
assert_line 'Upgrade finished.'
[[ $(versions) == "a=2.0 b=1.0 c=2.0" ]] || fail "versions after the run: $(versions)"
[[ $(holds_snapshot) == "$before" ]] || fail "hold/lock state changed: $(holds_snapshot)"
run skeptical-updater list --no-refresh
assert_line --regexp '^ \[1\] su-dummy-b +1\.0'
}
@test "a pre-existing hold/lock/IgnorePkg on su-dummy-c survives a run that excludes su-dummy-b" {
hold_c
before=$(holds_snapshot)
[[ -n $before ]]
run skeptical-updater list --no-refresh
assert_line --regexp '^ \[2\] su-dummy-b '
run skeptical-updater <<<$'2-\n'
assert_success
assert_line 'Excluded from this run: su-dummy-b'
[[ $(versions) == "a=2.0 b=1.0 c=1.0" ]] || fail "versions after the run: $(versions)"
after=$(holds_snapshot)
[[ $after == "$before" ]] || fail $'hold/lock state changed.\nbefore:\n'"$before"$'\nafter:\n'"$after"
if [[ $BE == apt ]]; then [[ ! -e $STATE_DIR/holds.added ]]; fi
}
@test "trusting a package re-includes its excluded dependency" {
run skeptical-updater <<<$'2-\n1+\n'
assert_success
assert_line ' (su-dummy-b is trusted now: no longer excluded from this run)'
assert_line 'Excluded from this run: none'
[[ $(versions) == "a=2.0 b=2.0 c=2.0" ]] || fail "versions after the run: $(versions)"
}
@test "enter upgrades everything" {
run skeptical-updater <<<''
assert_success
assert_line 'Excluded from this run: none'
[[ $(versions) == "a=2.0 b=2.0 c=2.0" ]] || fail "versions after the run: $(versions)"
run skeptical-updater list --no-refresh
assert_success
assert_output --partial 'Nothing to upgrade.'
}
@test "Nx purges su-dummy-c after a y confirmation" {
run skeptical-updater --no-refresh <<<$'3x\ny\nl\nq'
assert_success
assert_line 'Purge su-dummy-c now? [y/N] y'
assert_line 'purged: su-dummy-c'
assert_line '2 upgradable, 0 trusted (hidden, use --all to show), 2 to review:'
[[ $(versions) == "a=1.0 b=1.0 c=absent" ]] || fail "versions after the run: $(versions)"
[[ $(pkg_ver su-dummy-orphan) == 1.0 ]] || fail "the unrelated orphan was removed"
}
@test "Nx on su-dummy-b is refused: su-dummy-a depends on it; both stay, the row stays listed" {
run skeptical-updater --no-refresh <<<$'2x\nl\nq'
assert_success
assert_line 'not purged: su-dummy-b is required by: su-dummy-a - purge the dependents first'
refute_output --partial '[y/N]'
[[ $(grep -c '^ \[2\] su-dummy-b ' <<<"$output") -eq 2 ]] || fail "row 2 left the list"
[[ $(versions) == "a=1.0 b=1.0 c=1.0" ]] || fail "versions after the run: $(versions)"
}
@test "Nx on su-dummy-a removes su-dummy-b with it (its dependency, nothing else needs it); the orphan survives" {
run skeptical-updater --no-refresh <<<$'1x\ny\nl\nq'
assert_success
assert_line 'Purge su-dummy-a now? This also removes: su-dummy-b [y/N] y'
assert_line 'purged: su-dummy-a su-dummy-b'
assert_line '1 upgradable, 0 trusted (hidden, use --all to show), 1 to review:'
[[ $(versions) == "a=absent b=absent c=1.0" ]] || fail "versions after the run: $(versions)"
[[ $(pkg_ver su-dummy-orphan) == 1.0 ]] || fail "the unrelated orphan was removed"
}
@test "purging every package ends the session cleanly" {
run skeptical-updater --no-refresh <<<$'1x\ny\n3x\ny'
assert_success
assert_line 'Nothing left to upgrade.'
[[ $(versions) == "a=absent b=absent c=absent" ]] || fail "versions after the run: $(versions)"
[[ $(pkg_ver su-dummy-orphan) == 1.0 ]] || fail "the unrelated orphan was removed"
}
@test "Nx answered with anything but y keeps the package" {
run skeptical-updater --no-refresh <<<$'3x\nn\nq'
assert_success
assert_line 'not purged: su-dummy-c'
[[ $(versions) == "a=1.0 b=1.0 c=1.0" ]]
}
@test "q leaves everything untouched" {
before=$(holds_snapshot)
run skeptical-updater <<<$'2-\n1-\nq'
assert_success
assert_line 'Quit: nothing was upgraded.'
[[ $(versions) == "a=1.0 b=1.0 c=1.0" ]]
[[ $(holds_snapshot) == "$before" ]]
[[ ! -e $STATE_FILE ]]
}
@test "EOF aborts with exit 2 and changes nothing" {
before=$(holds_snapshot)
run skeptical-updater <<<'2-'
assert_failure 2
assert_output --partial 'end of input: aborted, nothing was upgraded'
[[ $(versions) == "a=1.0 b=1.0 c=1.0" ]]
[[ $(holds_snapshot) == "$before" ]]
[[ ! -e $STATE_FILE ]]
}
@test "on a terminal, Up recalls the previous line (readline, history of this session only)" {
pty_session $'1?\r\e[A\rq\r' --no-refresh
assert_success
[[ $(grep -c '^su-dummy-a: ' <<<"$output") -eq 2 ]] || fail "1? should have run twice (Up + Enter)"
assert_line 'Quit: nothing was upgraded.'
[[ $(versions) == "a=1.0 b=1.0 c=1.0" ]]
}
@test "--dry-run: enter prints the native upgrade command and changes nothing" {
before=$(holds_snapshot)
run skeptical-updater --dry-run <<<''
assert_success
assert_line --regexp "$(native_upgrade_regex)"
assert_line 'Dry run: nothing was changed.'
# (3+: trusting su-dummy-a would hide su-dummy-b and so re-include it)
run skeptical-updater --dry-run <<<$'2-\n3+\n'
assert_success
case $BE in
apt)
assert_line '+ apt-mark hold su-dummy-b'
assert_line '+ apt-mark unhold su-dummy-b'
;;
pacman)
assert_line --regexp '^\+ cp -p -- /tmp/skeptical-updater-db\.[0-9]+\.[A-Za-z0-9]+/sync/su-dummy\.db /var/lib/pacman/sync/$'
assert_line '+ pacman -Su --noconfirm --ignore su-dummy-b'
;;
dnf) assert_line --regexp '^\+ (dnf|yum) -y --setopt=skip_if_unavailable=0 --setopt=\\\*\.metadata_expire=-1 upgrade --exclude=su-dummy-b$' ;;
zypper)
assert_line --regexp '^\+ env ZYPP_CONF=.*/zypp\.conf zypper --non-interactive --quiet addlock su-dummy-b$'
assert_line --regexp "^\+ env ZYPP_CONF=.*/zypp\.conf zypper --non-interactive --no-refresh $(zypper_verb)\$"
;;
esac
assert_line --partial "+ $STATE_FILE add: $BE su-dummy-c user"
[[ $(versions) == "a=1.0 b=1.0 c=1.0" ]]
[[ $(holds_snapshot) == "$before" ]]
[[ ! -e $STATE_DIR ]]
}
@test "read-only commands work unprivileged" {
if command -v setpriv >/dev/null 2>&1; then
asuser() { setpriv --reuid=65534 --regid=65534 --clear-groups -- env TMPDIR=/tmp "$@"; }
elif command -v python3 >/dev/null 2>&1; then # almalinux:10 has neither setpriv nor runuser/su
asuser() { python3 -c 'import os, sys; os.setgroups([]); os.setgid(65534); os.setuid(65534); os.execvp(sys.argv[1], sys.argv[1:])' env TMPDIR=/tmp "$@"; }
else
skip "neither setpriv nor python3 available to drop privileges"
fi
skeptical-updater trust su-dummy-c >/dev/null
run asuser skeptical-updater trusted
assert_success
assert_line --partial "$BE"$'\t'"su-dummy-c"$'\t'"user"
run asuser skeptical-updater info su-dummy-b
assert_success
assert_line 'reason: installed as a dependency'
assert_line 'required by: su-dummy-a'
run asuser skeptical-updater info su-dummy-a
assert_success
assert_line 'reason: explicitly installed'
run asuser skeptical-updater --dry-run --no-refresh <<<''
assert_success
assert_line --regexp "$(native_upgrade_regex)"
run asuser skeptical-updater upgrade-me-please
assert_failure 1
# a real upgrade without root and without sudo is refused
if ! command -v sudo >/dev/null 2>&1; then
run asuser skeptical-updater <<<''
assert_failure 1
assert_output --partial 'upgrading needs root and sudo is not available'
fi
[[ $(versions) == "a=1.0 b=1.0 c=1.0" ]]
# a state directory an unprivileged user cannot enter is an error, not "no trusted packages"
chmod 0000 /var/lib/skeptical-updater
run asuser skeptical-updater trusted
chmod 0755 /var/lib/skeptical-updater
assert_failure 1
assert_output --partial 'cannot access the state directory /var/lib/skeptical-updater (permission denied)'
}
@test "pacman: list, q, end of input and --dry-run never touch the real sync database" {
[[ $BE == pacman ]] || skip "pacman only"
before=$(pacman_sync_snapshot)
run skeptical-updater list
assert_success
run skeptical-updater <<<'q'
assert_success
run skeptical-updater <<<'2-'
assert_failure 2
run skeptical-updater --dry-run <<<''
assert_success
assert_line 'Checking the reviewed transaction (pacman -Sup)... ok.'
assert_line '+ pacman -Su --noconfirm'
after=$(pacman_sync_snapshot)
[[ $after == "$before" ]] || fail $'the real sync database changed.\nbefore:\n'"$before"$'\nafter:\n'"$after"
run ls /tmp
refute_output --partial 'skeptical-updater-db.'
}
@test "apt: temporary holds wait for a busy dpkg lock instead of failing" {
[[ $BE == apt ]] || skip "apt only"
command -v perl >/dev/null 2>&1 || skip "perl not available"
# hold dpkg's frontend lock for 4 s (fcntl, as dpkg does), starting now
perl -e 'use Fcntl; open(my $f, ">>", "/var/lib/dpkg/lock-frontend") or die; my $l = pack("s s l l i", F_WRLCK, 0, 0, 0, 0); fcntl($f, F_SETLKW, $l) or die; sleep 4;' &
locker=$!
sleep 0.5
run skeptical-updater --no-refresh <<<$'2-\n'
wait "$locker" || true
assert_success
assert_line 'Excluded from this run: su-dummy-b'
[[ $(versions) == "a=2.0 b=1.0 c=2.0" ]] || fail "versions after the run: $(versions)"
[[ -z $(apt-mark showhold) ]]
}
@test "zypper: a lock whose locksfile.path comes from a zypp.conf.d drop-in is honoured" {
[[ $BE == zypper ]] || skip "zypper only"
mkdir -p /etc/zypp/zypp.conf.d
printf '[main]\nlocksfile.path = /etc/zypp/locks-su-e2e\n' >/etc/zypp/zypp.conf.d/50-su-e2e.conf
zypper --non-interactive --quiet addlock su-dummy-c >/dev/null
grep -q 'solvable_name: su-dummy-c' /etc/zypp/locks-su-e2e
before=$(cat /etc/zypp/locks-su-e2e)
run skeptical-updater <<<$'2-\n'
assert_success
assert_line 'Excluded from this run: su-dummy-b'
[[ $(versions) == "a=2.0 b=1.0 c=1.0" ]] || fail "versions after the run: $(versions)"
[[ $(cat /etc/zypp/locks-su-e2e) == "$before" ]]
}
@test "zypper: the reviewed metadata is applied (no refresh between review and upgrade)" {
[[ $BE == zypper ]] || skip "zypper only (libdnf reads file:// repositories live; apt never refreshes on upgrade)"
mkfifo "$BATS_TEST_TMPDIR/in"
skeptical-updater <"$BATS_TEST_TMPDIR/in" >"$BATS_TEST_TMPDIR/out" 2>&1 &
pid=$!
exec {w}>"$BATS_TEST_TMPDIR/in" # (fd 3 belongs to bats)
for _ in $(seq 1 600); do
grep -q '^<enter> upgrade' "$BATS_TEST_TMPDIR/out" && break
sleep 0.1
done
# a newer su-dummy-c appears in the repository after the review
rpm_publish su-dummy-c 3.0
echo >&"$w"
exec {w}>&-
rc=0
wait "$pid" || rc=$?
rpm_unpublish su-dummy-c 3.0
[[ $rc -eq 0 ]] || fail "exit $rc: $(cat "$BATS_TEST_TMPDIR/out")"
[[ $(versions) == "a=2.0 b=2.0 c=2.0" ]] || fail "versions after the run: $(versions)"
}
@test "pacman: package databases synced elsewhere during the review: refused (exit 2), nothing changed" {
[[ $BE == pacman ]] || skip "pacman only"
session_start
sleep 1
pacman_publish su-dummy-c 3.0
pacman -Sy >/dev/null 2>&1 # another terminal: the system's database is now newer than the reviewed one
synced=$(sha256sum /var/lib/pacman/sync/su-dummy.db | cut -d' ' -f1)
session_finish ''
pacman_unpublish
pacman -Sy >/dev/null 2>&1
[[ $SESSION_RC -eq 2 ]] || fail "exit $SESSION_RC: $(cat "$BATS_TEST_TMPDIR/out")"
grep -q 'package databases changed since the review (su-dummy); run skeptical-updater again' "$BATS_TEST_TMPDIR/out"
[[ $(versions) == "a=1.0 b=1.0 c=1.0" ]] || fail "versions after the run: $(versions)"
[[ ! -e /var/lib/pacman/db.lck ]]
[[ -n $synced ]]
}
@test "pacman: <enter> installs exactly the reviewed metadata (the real sync DB becomes the reviewed one)" {
[[ $BE == pacman ]] || skip "pacman only"
# the system's database is older than what the review will see (su-dummy-c 3.0) ...
# (1 s apart from the previous repo rebuild: pacman compares database mtimes in seconds)
sleep 1
pacman_publish su-dummy-c 3.0
session_start
reviewed=$(sha256sum /tmp/skeptical-updater-db."$SESSION_PID".*/sync/su-dummy.db | cut -d' ' -f1)
# ... and su-dummy-c 4.0 appears in the repository after the review
sleep 1
pacman_publish su-dummy-c 4.0
session_finish ''
pacman_unpublish
[[ $SESSION_RC -eq 0 ]] || fail "exit $SESSION_RC: $(cat "$BATS_TEST_TMPDIR/out")"
grep -qE '^\+ cp -p -- /tmp/skeptical-updater-db\.[0-9]+\.[A-Za-z0-9]+/sync/su-dummy\.db /var/lib/pacman/sync/$' "$BATS_TEST_TMPDIR/out"
[[ -n $reviewed ]]
[[ $(sha256sum /var/lib/pacman/sync/su-dummy.db | cut -d' ' -f1) == "$reviewed" ]] || fail "the system's su-dummy.db is not the reviewed one"
[[ $(versions) == "a=2.0 b=2.0 c=3.0" ]] || fail "versions after the run: $(versions)"
[[ ! -e /var/lib/pacman/db.lck ]]
run ls /tmp
refute_output --partial 'skeptical-updater-db.'
}