53022b8146
The endpoint credentials are asked for at the very end of a run, after every package is installed -- correct, because a password typed at picker time would sit in memory through ten minutes of downloads. But any non-200 was fatal, so one mistyped character meant repeating the whole install to get back to a three-line prompt. Now it retries, up to five attempts. URL and username persist across them and blank keeps them, shown as `Bootstrap URL [https://...]:`, so only the password is retyped -- retyping an address that was already pasted correctly is its own source of error. `q` at the URL prompt leaves the machine public-only. The message also names the fault. Every failure used to print "endpoint refused the credentials", including a 404 and an unreachable host -- which is precisely how a URL-shape bug reads as a password problem. The status is now captured alongside the body rather than relying on --fail: 401 wrong username or password 404 reached the host, but no bootstrap.env is there -- check the route 000 could not reach that address (DNS, TLS, or the host is down) The body is used only when the code is 200, so an error page is still never parsed as a blob; that was --fail's job and an explicit check is stronger. `dotup private` already re-ran only this step, leaving installed packages alone -- it was simply missing from --help, so the cheap way back in was undiscoverable. Documented, and the failure path now points at it. The end-to-end sends a wrong password first on purpose and asserts both the message and that blank-blank-correct works, which is the only way to test a recovery path. Note that harness clones the PUBLISHED repo, so it validates what a user gets, not the working tree -- these invariants are covered in the unit suite, which reads the tree directly. 105 -> 113.
228 lines
11 KiB
Bash
Executable File
228 lines
11 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 -------------------
|
|
dotup --yes install 2>&1 | sed 's/^/ /' | tail -8
|
|
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"
|
|
|
|
zsh -ic 'exit' >/dev/null 2>&1 && echo "zsh: interactive login clean" \
|
|
|| echo "WARN: zsh -ic exited non-zero"
|
|
echo
|
|
echo "E2E PASS"
|