#!/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 '^ 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: 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.' }