test: container lab for the two-tier apply
A disposable ubuntu container, a fake private tier and a fake bootstrap endpoint, so the whole documented path — chezmoi init --apply, dotup pick, dotup private, cmp apply, dotsecrets — can run end to end without touching a real machine or a real credential. The fake tier mirrors the real one's structure (seven secrets and one alias) because dotsecrets is copied verbatim and the "8 exports, not 7" assertion depends on that cardinality; its ids are sequential and obviously synthetic. check-verbatim.sh keeps the fake tier's copies of shipped files honest, and snapshot.sh records file modes so a 644 where a 600 belongs is a diff.
This commit is contained in:
@@ -0,0 +1,193 @@
|
||||
#!/bin/bash
|
||||
# The private tier, end to end, against a local stand-in for everything remote.
|
||||
#
|
||||
# Nothing here touches the real endpoint, the real repos or the real secrets
|
||||
# manager, so it can be run as often as it takes and costs nothing when it
|
||||
# fails. What it does exercise is the real code: dotup's prompt loop, the real
|
||||
# credential splitting, chezmoi's real seven-question TUI, and the private
|
||||
# tier's real `dotsecrets` -- copied verbatim into the fake source tree, so it
|
||||
# is the shipping implementation being measured, not a rewrite of it.
|
||||
#
|
||||
# The first password is deliberately wrong. These credentials are asked for at
|
||||
# the very end of a run, so before the retry loop existed one typo meant redoing
|
||||
# the entire install. Getting it wrong on purpose is the only way to prove the
|
||||
# recovery path is there and that the URL and username survive the mistake.
|
||||
set -u
|
||||
fail() { echo "FAIL: $*"; exit 1; }
|
||||
ok() { echo " ok $*"; }
|
||||
umask 022 # modes below assume it; do not let the daemon's umask decide
|
||||
|
||||
sudo apt-get update -qq && sudo apt-get install -y -qq git expect unzip >/dev/null 2>&1
|
||||
echo " NOTE installed git by hand -- see ISSUE-1"
|
||||
|
||||
# ---- public tier first: the private tier is a continuation, never a start ----
|
||||
sh -c "$(curl -fsLS get.chezmoi.io)" -- init --apply "$PUB_URL" >/tmp/init.log 2>&1 \
|
||||
|| { tail -5 /tmp/init.log; fail "public tier init"; }
|
||||
export PATH="$HOME/.local/bin:$HOME/bin:$PATH"
|
||||
D=$HOME/.local/bin/dotup
|
||||
[ -x "$D" ] || fail "no dotup after the public apply"
|
||||
ok "public tier applied"
|
||||
|
||||
# absence assertions BEFORE the private tier exists, so their later presence
|
||||
# means something
|
||||
for f in "$HOME/.config/zsh/secrets.zsh" "$HOME/.config/bitwarden/bws-token" \
|
||||
"$HOME/.local/share/dotfiles-private"; do
|
||||
[ ! -e "$f" ] || fail "$f exists on a public-only machine"
|
||||
done
|
||||
ok "public-only machine carries no token, no secrets, no private source"
|
||||
|
||||
# ---- a fake bws, so nothing reaches the network -----------------------------
|
||||
# `ensure_bws` checks `have bws` first and `have` searches ~/.local/bin, so
|
||||
# putting the stub there is enough to keep the real download out of this run.
|
||||
# The real ensure_bws (pin, checksum, musl target) is a separate scenario --
|
||||
# faking it here would only prove the fake works.
|
||||
mkdir -p "$HOME/.local/bin"
|
||||
cat > "$HOME/.local/bin/bws" <<'BWS'
|
||||
#!/bin/sh
|
||||
# Stand-in for the Bitwarden Secrets Manager CLI. Answers exactly the call
|
||||
# dotsecrets makes: `bws secret get <uuid> -o env`, printing KEY=VALUE.
|
||||
# LAB_BWS_MODE bends it to drive the failure branches.
|
||||
case "${LAB_BWS_MODE:-ok}" in
|
||||
fail) exit 1 ;;
|
||||
wrong) printf 'SOMETHING_ELSE=x\n'; exit 0 ;;
|
||||
empty) printf 'LAB_ALPHA_API_KEY=\n'; exit 0 ;;
|
||||
esac
|
||||
[ "$1" = secret ] && [ "$2" = get ] || { echo "unsupported: $*" >&2; exit 2; }
|
||||
case "$3" in
|
||||
*0001) k=LAB_ALPHA_API_KEY ;; *0002) k=LAB_BRAVO_API_KEY ;;
|
||||
*0003) k=LAB_CHARLIE_API_KEY ;; *0004) k=LAB_DELTA_API_KEY ;;
|
||||
*0005) k=LAB_ECHO_API_KEY ;; *0006) k=LAB_FOXTROT_API_KEY ;;
|
||||
*0007) k=LAB_GOLF_API_KEY ;; *) echo "unknown id $3" >&2; exit 1 ;;
|
||||
esac
|
||||
printf '%s=lab-value-for-%s\n' "$k" "$k"
|
||||
BWS
|
||||
chmod 755 "$HOME/.local/bin/bws"
|
||||
ok "fake bws in place; this run reaches no network"
|
||||
|
||||
# ---- tick the private rows, as the picker would ------------------------------
|
||||
S=${XDG_CONFIG_HOME:-$HOME/.config}/dotfiles
|
||||
mkdir -p "$S"; printf 'private/private-repo\nprivate/bws-secrets\n' > "$S/selected"
|
||||
|
||||
# ---- drive it exactly as a person sitting at the keyboard would --------------
|
||||
export E2E_NAME='Lab Tester' E2E_EMAIL='lab@example.invalid'
|
||||
timeout 420 expect -f - >/tmp/private.log 2>&1 <<'EXP'
|
||||
set timeout 120
|
||||
log_user 1
|
||||
spawn -noecho env PATH=$env(PATH) $env(HOME)/.local/bin/dotup private
|
||||
|
||||
# A bare `expect -re {pat} {}` treats a timeout as "carry on", so a missed
|
||||
# prompt falls through silently and every later expect waits out its own
|
||||
# timeout, surfacing much later as an unattributed hang. Name what was missed.
|
||||
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 prompts are plain `read` in cooked mode, so the text is the
|
||||
# whole signal.
|
||||
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 -- "wrong-on-purpose\r"
|
||||
|
||||
wait_for {wrong username or password} "the 401 message naming the actual fault"
|
||||
# The bracketed default is the proof that the URL and username were retained,
|
||||
# so only the password has to be retyped.
|
||||
wait_for {Bootstrap URL \[} "the retry prompt with the URL kept"
|
||||
send -- "\r"
|
||||
wait_for {Username \[} "the retry prompt with the username kept"
|
||||
send -- "\r"
|
||||
wait_for {Password:} "the retry password prompt"
|
||||
send -- "$env(BOOT_PW)\r"
|
||||
|
||||
# chezmoi's seven are a full-screen TUI, and matching the prompt TEXT is not
|
||||
# enough. chezmoi writes the prompt while the tty is still in cooked mode and
|
||||
# only then switches to raw with TCSAFLUSH, which DISCARDS anything already
|
||||
# buffered. An answer sent on the text alone can land in that window and be
|
||||
# thrown away -- the field sits unsubmitted and the run burns its whole
|
||||
# timeout. `\033[?2004h` is bracketed-paste-on, emitted only after raw mode is
|
||||
# established, so waiting for it turns "probably ready" into "demonstrably
|
||||
# ready". Each prompt emits its own.
|
||||
proc ask {pat val} {
|
||||
wait_for $pat "chezmoi prompt $pat"
|
||||
wait_for "\033\\\[\\?2004h" "raw mode after $pat (the TUI never became ready)"
|
||||
send -- "$val\r"
|
||||
}
|
||||
ask {user\.name} "$env(E2E_NAME)"
|
||||
ask {user\.email} "$env(E2E_EMAIL)"
|
||||
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=$?
|
||||
red() { sed -e "s|$BOOT_PW|<BOOT_PW>|g" -e "s|$BOOT_URL|<BOOT_URL>|g"; }
|
||||
case $rc in
|
||||
124) red </tmp/private.log | tail -8 | sed 's/^/ /'
|
||||
fail "hit the 420s ceiling -- expect wedged past its own handlers" ;;
|
||||
3|4) red </tmp/private.log | tr -d '\r' | tail -8 | sed 's/^/ /'
|
||||
fail "a prompt went unanswered -- the TIMEOUT/EOF line above names it" ;;
|
||||
esac
|
||||
red </tmp/private.log | tr -d '\r' | tail -6 | sed 's/^/ | /'
|
||||
|
||||
# ---- what must be true afterwards -------------------------------------------
|
||||
echo "-- verification --"
|
||||
m() { stat -c '%a' "$1" 2>/dev/null; }
|
||||
|
||||
[ -d "$HOME/.local/share/dotfiles-private" ] || fail "private source not cloned"
|
||||
case $(m "$HOME/.local/share/dotfiles-private") in *00) ;; *)
|
||||
fail "private source is mode $(m "$HOME/.local/share/dotfiles-private") — group/other can read it" ;; esac
|
||||
ok "private source cloned, go-rwx"
|
||||
|
||||
[ "$(m "$HOME/.config/bitwarden/bws-token")" = 600 ] || fail "bws token mode $(m "$HOME/.config/bitwarden/bws-token"), want 600"
|
||||
ok "bws token written, mode 600"
|
||||
|
||||
SEC=$HOME/.config/zsh/secrets.zsh
|
||||
[ -r "$SEC" ] || fail "secrets.zsh not generated"
|
||||
[ "$(m "$SEC")" = 600 ] || fail "secrets.zsh mode $(m "$SEC"), want 600"
|
||||
n=$(grep -c '^export ' "$SEC")
|
||||
[ "$n" -eq 8 ] || fail "secrets.zsh has $n exports, want 8 (7 secrets + 1 alias)"
|
||||
grep -q '^export LAB_GOLF_ALIAS_KEY=' "$SEC" || fail "the alias export is missing"
|
||||
ok "secrets.zsh mode 600, 8 exports, alias present"
|
||||
|
||||
for p in "$HOME/.config/zsh:700" "$HOME/.ssh:700" "$HOME/.ssh/config:600" \
|
||||
"$HOME/.config/zsh/local.zsh:600" "$HOME/.local/bin/dotsecrets:700"; do
|
||||
want=${p##*:}; path=${p%:*}
|
||||
[ "$(m "$path")" = "$want" ] || fail "$path is mode $(m "$path"), want $want"
|
||||
done
|
||||
ok "every private destination carries the mode it claims"
|
||||
|
||||
# The credential must survive as a file and nowhere else.
|
||||
CRED=${XDG_CONFIG_HOME:-$HOME/.config}/dotfiles/private-credentials
|
||||
[ "$(m "$CRED")" = 600 ] || fail "credential file mode $(m "$CRED"), want 600"
|
||||
GC=$HOME/.local/share/dotfiles-private/.git/config
|
||||
grep -qE '://[^/@[:space:]]*:[^/@[:space:]]+@' "$GC" \
|
||||
&& fail "the clone's .git/config still carries the token"
|
||||
grep -qE '://[^/@[:space:]]*:[^/@[:space:]]+@' /tmp/private.log \
|
||||
&& fail "a credential-bearing URL appears in dotup's own output"
|
||||
ok "token is in the 600-mode credential file only — not in .git/config, not in the log"
|
||||
|
||||
git -C "$HOME/.local/share/dotfiles-private" fetch -q 2>/dev/null \
|
||||
|| fail "a later fetch cannot authenticate — the credential helper did not survive"
|
||||
ok "a later fetch still authenticates from that file"
|
||||
|
||||
# ---- the failure branches: yesterday's keys beat no keys --------------------
|
||||
before=$(sha256sum "$SEC" | cut -d' ' -f1)
|
||||
for mode in fail wrong empty; do
|
||||
LAB_BWS_MODE=$mode "$HOME/.local/bin/dotsecrets" >/dev/null 2>&1
|
||||
[ "$(sha256sum "$SEC" | cut -d' ' -f1)" = "$before" ] \
|
||||
|| fail "bws mode '$mode' modified secrets.zsh — it must be left alone on failure"
|
||||
done
|
||||
ls "$HOME/.config/zsh"/.secrets.zsh.* >/dev/null 2>&1 && fail "a temp file survived a failed refresh"
|
||||
ok "a failed refresh leaves secrets.zsh byte-identical and no temp file behind"
|
||||
|
||||
echo "PRIVATE TIER PASS"
|
||||
Reference in New Issue
Block a user