Files
skeptical-updater/tests/e2e/skeptical-updater.bats
T
bryanpedini 296c77e319 feat(prompt): readline line editing with per-session history
When stdin is a terminal the prompt is read with bash's readline,
`IFS= read -e -r -p '> ' REPLY`, so the arrow keys no longer print escape
garbage: Left/Right, Home/End, Ctrl-A/E, Backspace/Delete edit the line,
and Up/Down walk the lines entered in this session (`history -s` for every
line with a non-blank character; empty lines are not recorded). Down past
the newest entry restores the line typed before Up, and Up at the oldest
entry or Down on the new line ring the bell, as readline does by default.
The Nx y/N answer is read the same way and never recorded.

Nothing persists: HISTFILE is unset at startup and the history is never
read or written. Verified on bash 4.3.48 and 5.2.15 that `read -e` and
`history -s` share one history list in a non-interactive shell, that
inherited HISTSIZE/HISTCONTROL/HISTIGNORE have no effect there, and that
Ctrl-C at the prompt restores the terminal. readline draws the prompt on
stderr, as bash does. Piped stdin keeps the plain `read -r` exactly,
including the echo of each line and EOF => exit 2.

New unit tests (tests/unit/lineedit.bats) drive a real pty with script(1):
history recall, the bell at both ends, the stashed line, Left-arrow
editing, the y/N answer kept out of the history, no history file under
HOME/TMPDIR, ^D => exit 2. The E2E suite gets a pty smoke test (Up
recalls 1?), skipped where the image has no script(1).
2026-10-10 14:01:38 +02:00

407 lines
17 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?? prints the native full info" {
run skeptical-updater --no-refresh <<<$'1??\nq'
assert_success
assert_line --regexp '^(Package: su-dummy-a|Name +: su-dummy-a)$'
}
@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.'
}