Files
dotfiles-public/.tests/e2e-private-tier.sh
T
bcherb2 286c3281b5 test: the e2e can no longer pass on a failed install
Two assertions could not fail. `dotup --yes install | tail -8` threw the status
away — no set -e, no pipefail, and $? was tail's — so a run in which 36
packages failed to install still reached "E2E PASS". And the zsh login check
was a `|| echo WARN` that fell straight through. Both now fail the run.
2026-08-21 22:34:53 -04:00

240 lines
12 KiB
Bash
Executable File

#!/bin/bash
# Phase 5 end-to-end, inside a clean container. Public tier from the live
# anonymous URL, then the private tier through the real bootstrap endpoint.
#
# Credentials arrive in the environment (BOOT_URL/BOOT_USER/BOOT_PW) and are
# never echoed. Package set is scoped to the private path and its dependency:
# the full manifest install is covered by the fake-package-manager suite, and
# what has never run end to end is bootstrap -> bws -> private repo -> secrets.
set -u
export DEBIAN_FRONTEND=noninteractive
fail() { echo "FAIL: $*"; exit 1; }
apt-get update -qq && apt-get install -y -qq git curl zsh ca-certificates expect >/dev/null 2>&1
# ---- 1. public tier, anonymously, exactly as a stranger would ---------------
sh -c "$(curl -fsLS get.chezmoi.io)" -- init --apply \
"${E2E_PUBLIC_URL:?}" >/dev/null 2>&1 \
|| fail "public tier init"
# Assert the ABSOLUTE path first, before touching PATH.
#
# A real operator has just run the installer in whatever shell they were already
# in, and on a fresh box that is bash. ~/.local/bin is added to PATH only by the
# .zshrc this tier ships -- for a zsh that dotup has not installed yet. So the
# first invocation is necessarily `~/.local/bin/dotup`, which is exactly what
# the README documents, and bare `dotup` is `command not found` on every new
# machine.
#
# This used to read `export PATH=...` and THEN `command -v dotup`, which is a
# vacuous assertion: it proved the export worked, not that the installer put
# anything anywhere. It passed happily while a real bash user hit
# `dotup: command not found`.
[ -x "$HOME/.local/bin/dotup" ] \
|| fail "public apply left no executable at ~/.local/bin/dotup"
# Only now, for the convenience of the rest of this script.
export PATH="$HOME/.local/bin:$PATH"
echo "1. public tier applied: $(chezmoi managed -p absolute | wc -l) files"
# absence assertions BEFORE the private tier exists
[ ! -e "$HOME/.config/zsh/secrets.zsh" ] || fail "secrets.zsh present pre-bootstrap"
[ ! -e "$HOME/.config/bitwarden/bws-token" ] || fail "bws token present pre-bootstrap"
[ ! -d "$HOME/.local/share/dotfiles-private" ] || fail "private tier present pre-bootstrap"
echo "2. public-only machine is clean: no token, no secrets, no private source"
# ---- 2. select the private rows --------------------------------------------
STATE=${XDG_CONFIG_HOME:-$HOME/.config}/dotfiles
mkdir -p "$STATE"
printf 'core/unzip\nprivate/private-repo\nprivate/bws-secrets\n' > "$STATE/selected"
echo " plan says:"; dotup plan 2>&1 | sed 's/^/ /' | grep -v '^ *$' | head -8
# ---- 3. install: unzip via apt. bws must NOT appear here -------------------
# The status was thrown away here, and that is not a detail: with no `set -e`,
# no `pipefail`, and nothing reading `$?` (which was `tail`'s anyway), a run in
# which 36 packages failed to install still reached `echo "E2E PASS"`. Both
# statements were true at once and only one of them was reported. Capture the
# status, print the output, then fail on it.
set -o pipefail
dotup --yes install 2>&1 | sed 's/^/ /' | tail -8
irc=$?
set +o pipefail
[ "$irc" -eq 0 ] || fail "dotup install exited $irc — at least one package did not install"
command -v unzip >/dev/null || fail "unzip not installed"
command -v bws >/dev/null && fail "bws installed by the package phase — it must come from the private tier"
echo "3. unzip installed; bws correctly absent (it is not a manifest package)"
# ---- 4. the private tier, through the real endpoint ------------------------
# TEN answers, not three: dotup asks for the endpoint URL, username and
# password, and then the private repo's .chezmoi.toml.tmpl asks its own seven
# promptStringOnce questions.
#
# They must be answered one at a time, as each prompt appears. Two earlier
# attempts piped all ten in up front through `script` and hung forever on
# `git user.name?`. chezmoi's prompt is a full-screen TUI -- it puts the tty in
# raw mode, and a raw-mode switch done with TCSAFLUSH DISCARDS input already
# sitting in the buffer. Everything pre-fed was written to the pty before
# chezmoi started, so it was thrown away, and the pty then waited on a stdin
# that had already closed. No amount of pre-feeding can work against that
# prompt; the answers have to arrive after it draws.
#
# expect also drops `script`'s worst property: script mirrors its stdin into
# its own output, which published the endpoint password into a session
# transcript once already. Credentials reach expect through the environment
# ($env(...)), never argv, never stdin.
red() { sed -e "s|$BOOT_URL|<BOOT_URL>|g" -e "s|$BOOT_PW|<BOOT_PW>|g" \
-e 's|[0-9a-f]\{32\}|<PATH>|g'; }
export E2E_NAME='E2E Test' E2E_EMAIL='e2e@example.invalid'
timeout 600 expect -f - > /tmp/private.log 2>&1 <<'EXP'
set timeout 180
log_user 1
spawn -noecho dotup private
# A bare `expect -re {pat} { ... }` treats a timeout as "carry on", so a missed
# prompt does not fail -- it silently falls through and every later expect waits
# out its own timeout in turn, surfacing at the 600s ceiling as an unattributed
# hang. Name the prompt that was actually missed instead.
proc wait_for {pat what} {
expect {
-re $pat {}
timeout { send_user "\nTIMEOUT: never saw $what\n"; exit 3 }
eof { send_user "\nEOF before $what -- dotup exited early\n"; exit 4 }
}
}
# dotup's own three: plain `read`, no TUI, so the text is the whole signal.
#
# The FIRST password sent here is deliberately wrong. These credentials are
# asked for at the very end of a run, so a typo used to be fatal -- one wrong
# character and the whole install had to be repeated to get back to this
# prompt. Getting it wrong on purpose is the only way to prove the retry loop
# exists and that the URL and username survive the attempt.
wait_for {Bootstrap URL:} "the bootstrap URL prompt"
send -- "$env(BOOT_URL)\r"
wait_for {Username:} "the username prompt"
send -- "$env(BOOT_USER)\r"
wait_for {Password:} "the password prompt"
send -- "definitely-not-the-password\r"
# The message must name the actual fault. "endpoint refused the credentials"
# was once emitted for a 401, a 404 and an unreachable host alike.
wait_for {wrong username or password} "the wrong-password message"
# Blank keeps the URL and the username, so only the password is retyped. The
# bracketed default in the prompt is what proves they were retained.
wait_for {Bootstrap URL \[} "the retry prompt, with the URL kept"
send -- "\r"
wait_for {Username \[} "the retry username prompt, with the user kept"
send -- "\r"
wait_for {Password:} "the retry password prompt"
send -- "$env(BOOT_PW)\r"
# The private repo's seven, each answered only once its TUI is genuinely ready
# to read.
#
# Matching the prompt TEXT is not enough, and this is a race that passes most of
# the time. chezmoi writes "git user.email?" while the tty is still in cooked
# mode, and only then switches to raw mode -- with TCSAFLUSH, which DISCARDS
# whatever is already sitting in the input buffer. An answer sent on the text
# alone can therefore land in the window before the switch and be thrown away.
# The value is gone, the field sits unsubmitted, and every later expect waits
# out its own timeout: one lost keystroke costs the full 600s ceiling and
# reports as "a prompt went unanswered".
#
# Observed exactly that way -- one run answered all seven, the next lost the
# Enter after user.email. Nothing about the two runs differed but timing.
#
# `\033[?2004h` is bracketed-paste-on, which the TUI emits only AFTER raw mode
# is established. Waiting for it turns "probably ready" into "demonstrably
# ready". Each prompt emits its own, so this is per-answer, not once.
proc ask {pat val} {
wait_for $pat "chezmoi prompt $pat"
wait_for "\033\\\[\\?2004h" "raw mode after $pat (TUI never became ready)"
send -- "$val\r"
}
ask {user\.name} "$env(E2E_NAME)"
ask {user\.email} "$env(E2E_EMAIL)"
# blank accepts the template's default, which is what a real operator does for
# the signing key and the four gitea URLs
ask {signing key} ""
ask {WAN ssh} ""
ask {LAN ssh} ""
ask {WAN web} ""
ask {LAN web} ""
expect eof
catch wait result
exit [lindex $result 3]
EXP
rc=$?
case $rc in
124) fail "dotup private hit the 600s ceiling — expect itself wedged, which its own timeout handlers should have prevented" ;;
3|4) red < /tmp/private.log | tr -d '\r' | tail -6 | sed 's/^/ /'
fail "a prompt went unanswered — see the TIMEOUT/EOF line above for which one" ;;
esac
# `script` mirrors its stdin into the pty output, so the three answers -- the
# secret path and the password among them -- are sitting at the top of that log.
# Redact before anything prints it. Learned the hard way: an earlier run of this
# test published both into a session transcript and they had to be rotated.
red < /tmp/private.log | tr -d '\r' | sed 's/^/ /' | tail -14
# ---- 5. assertions ---------------------------------------------------------
echo
echo "--- verification ---"
command -v bws >/dev/null || fail "bws not installed by the private tier"
echo "bws: $(bws --version 2>&1 | head -1), installed by the private tier"
[ -r "$HOME/.config/bitwarden/bws-token" ] || fail "bws token not written"
m=$(stat -c '%a' "$HOME/.config/bitwarden/bws-token")
[ "$m" = 600 ] || fail "bws token mode $m, want 600"
echo "bws token: present, mode 600"
[ -d "$HOME/.local/share/dotfiles-private" ] || fail "private source not cloned"
sm=$(stat -c '%a' "$HOME/.local/share/dotfiles-private")
case $sm in *00) echo "private source: cloned, mode $sm (go-rwx applied)" ;;
*) fail "private source mode $sm — group/other can read it" ;; esac
[ -f "$HOME/.config/chezmoi/private.toml" ] || fail "private.toml not created"
[ -f "$HOME/.config/chezmoi/chezmoi.toml" ] || fail "public chezmoi.toml missing"
d=$(grep -c '^\s*git' "$HOME/.config/chezmoi/private.toml" 2>/dev/null || echo 0)
echo "configs: chezmoi.toml + private.toml, private has $d identity keys"
[ -r "$HOME/.config/zsh/secrets.zsh" ] || fail "secrets.zsh not generated"
sm=$(stat -c '%a' "$HOME/.config/zsh/secrets.zsh")
[ "$sm" = 600 ] || fail "secrets.zsh mode $sm, want 600"
n=$(grep -c '^export ' "$HOME/.config/zsh/secrets.zsh")
echo "secrets.zsh: mode 600, $n exports (want 8)"
[ "$n" -eq 8 ] || fail "expected 8 exports, got $n"
# the credential must not be in any log dotup produced
if grep -qE '://[^/@[:space:]]*:[^/@[:space:]]+@' /tmp/private.log; then
echo "FAIL: a credential-bearing URL appears in dotup output"; exit 1
fi
echo "log hygiene: no credential-bearing URL in dotup output"
# ...nor in the clone's own config, which is where it used to live forever.
GC="$HOME/.local/share/dotfiles-private/.git/config"
[ -f "$GC" ] || fail "no .git/config in the private source"
grep -qE '://[^/@[:space:]]*:[^/@[:space:]]+@' "$GC" \
&& fail "the git remote in .git/config still carries the token"
echo "git remote: clean — $(git -C "$HOME/.local/share/dotfiles-private" remote get-url origin)"
CRED="${XDG_CONFIG_HOME:-$HOME/.config}/dotfiles/private-credentials"
[ -r "$CRED" ] || fail "credential file not written to $CRED"
cm=$(stat -c '%a' "$CRED")
[ "$cm" = 600 ] || fail "credential file mode $cm, want 600"
echo "credential file: present, mode 600, $(wc -l < "$CRED") line"
# the whole point: a later update must still authenticate, with the token only
# ever coming from that file
git -C "$HOME/.local/share/dotfiles-private" fetch -q 2>/dev/null \
&& echo "later fetch: authenticates from the credential file" \
|| fail "fetch failed — the credential helper did not survive the clone"
# identity arrived
git config --get user.email >/dev/null 2>&1 || fail "git identity not configured"
echo "git identity: configured"
# This was a `|| echo "WARN: …"` that fell straight through to `E2E PASS`, so
# the one assertion about the shell this repo exists to configure could not
# fail. If the login shell is broken, the run is not a pass.
zsh -ic 'exit' >/dev/null 2>&1 || fail "zsh -ic exited non-zero — the login shell this repo configures is broken"
echo "zsh: interactive login clean"
echo
echo "E2E PASS"