test: move the end-to-end here, and fix the race that made it flaky

The e2e harness and its mock endpoint were living in the private repo, justified
by "they name the endpoint host". That was true when written and false two
commits later, once the hardcoded URL came out of the wrapper so the route would
live only in Bitwarden. Rechecked: all three name the endpoint zero times -- the
URL, username and password are supplied at runtime -- and the only host they
mention is this repo. They belong next to the dotup they exercise and the unit
suite that covers the rest of it.

The move surfaced a worse problem than the misplacement. The harness was flaky,
and an earlier PASS was partly luck.

chezmoi writes "git user.email?" with the tty still in cooked mode and only then
switches to raw mode -- with TCSAFLUSH, which discards whatever is already
buffered. Answering on the prompt TEXT races that switch. One run answered all
seven prompts; the next lost the Enter after user.email, leaving the field
unsubmitted. Every later expect then waited out its own timeout and the whole
thing surfaced at the 600s ceiling as an unattributed "a prompt went
unanswered".

Two fixes:

  - each answer now waits for \033[?2004h, bracketed-paste-on, which the TUI
    emits only AFTER raw mode is established. "Probably ready" becomes
    "demonstrably ready", and each prompt emits its own, so it is per-answer.
  - a bare `expect -re {pat} {...}` treats timeout as "carry on", which is what
    turned one lost keystroke into a ten-minute mystery. Prompts now fail
    immediately naming which one was missed, and distinguish EOF (dotup exited
    early) from timeout.

Also moved red(), the redactor, above the run. It was defined below the new
early-abort path that calls it, so the one branch that most needs redaction
would have hit an undefined function.

Verified by running it twice end to end against a mock endpoint, both passing
identically, including the three assertions the argv fix exists for.

README: the suite is 101 assertions, not 81, and the end-to-end is documented.
This commit is contained in:
bcherb2
2026-08-17 22:14:35 -04:00
parent 24744997eb
commit cebb38b97a
4 changed files with 359 additions and 1 deletions
+48
View File
@@ -0,0 +1,48 @@
# A stand-in for the Caddy bootstrap endpoint, for testing ops/run-e2e.sh
# without the operator's password.
#
# It reproduces the three behaviours the client depends on:
# correct credentials -> 200 with the blob
# wrong credentials -> 401
# any other path -> connection closed with no response (Caddy's `abort`)
#
# The blob is composed IN MEMORY from credentials already present on this
# machine. Nothing is written to disk and nothing is ever logged.
import base64, os, http.server
USER = os.environ['MU']
PW = os.environ['MP']
ROUTE = os.environ['MR']
with open(os.path.expanduser('~/.config/bitwarden/bws-token')) as f:
bws = f.read().strip()
BLOB = ('PRIVATE_REPO_URL=%s\nBWS_ACCESS_TOKEN=%s\n' % (os.environ['MREPO'], bws)).encode()
WANT = 'Basic ' + base64.b64encode(('%s:%s' % (USER, PW)).encode()).decode()
class H(http.server.BaseHTTPRequestHandler):
protocol_version = 'HTTP/1.0'
def do_GET(self):
if self.path != '/%s/bootstrap.env' % ROUTE:
# Caddy's catch-all is `handle { abort }` -- no response at all, so
# curl reports 000. Closing without writing reproduces that.
self.close_connection = True
return
if self.headers.get('Authorization', '') != WANT:
self.send_response(401)
self.send_header('WWW-Authenticate', 'Basic realm="bootstrap"')
self.send_header('Content-Length', '0')
self.end_headers()
return
self.send_response(200)
self.send_header('Content-Type', 'text/plain')
self.send_header('Content-Length', str(len(BLOB)))
self.end_headers()
self.wfile.write(BLOB)
# the default handler logs every request line to stderr; the path contains
# the route, so it stays quiet
def log_message(self, *a):
pass
http.server.HTTPServer(('0.0.0.0', 8099), H).serve_forever()
+194
View File
@@ -0,0 +1,194 @@
#!/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"
export PATH="$HOME/.local/bin:$PATH"
command -v dotup >/dev/null || fail "dotup not on PATH after public apply"
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
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 -- "$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"
Executable
+87
View File
@@ -0,0 +1,87 @@
#!/bin/bash
# Phase 5 end-to-end against the live endpoint, in a throwaway container.
#
# RUN IT FROM A NORMAL TERMINAL, NOT FROM INSIDE A CLAUDE SESSION:
#
# bash .tests/e2e.sh
#
# Do NOT run it with the `!` prefix in Claude Code. The output is redacted, but
# the prompt is not worth risking -- and the whole point of this wrapper is that
# the password stays on your side.
#
# The password is never written to disk, never placed on any command line, and
# never printed. It is read from your terminal into a shell variable and handed
# to the container through the environment:
#
# `docker exec -e BOOT_PW` with NO `=value` tells docker to inherit the
# variable from this process. Writing `-e BOOT_PW=secret` would put it in
# docker's own argv, and /proc/<pid>/cmdline is world-readable -- the exact
# bug this test suite was just fixed to stop doing in dotup.
#
# What it proves that the unit suite cannot: that a machine which has only ever
# seen the public repo can reach the real endpoint, authenticate, clone the
# private tier, install bws, render secrets, and end up with no credential in
# its logs or its .git/config.
set -u
C=dotup-e2e
INNER=$(dirname "$0")/e2e-private-tier.sh
[ -r "$INNER" ] || { echo "missing $INNER"; exit 1; }
command -v docker >/dev/null || { echo "docker not installed"; exit 1; }
cleanup() { docker rm -f "$C" >/dev/null 2>&1 || :; unset BOOT_PW; }
trap cleanup EXIT INT TERM
# ---- credentials, from your terminal only -----------------------------------
# No default URL, deliberately. Hardcoding the route here would copy it out of
# Bitwarden and into a git repository, where it would then have to be edited
# every time the route rotates -- and a stale default is worse than no default,
# because it fails looking like a password problem.
printf 'Bootstrap URL (paste from Bitwarden): ' >&2
IFS= read -r BOOT_URL
[ -n "$BOOT_URL" ] || { echo "no URL given"; exit 1; }
printf 'Username [ben]: ' >&2
IFS= read -r BOOT_USER
BOOT_USER=${BOOT_USER:-ben}
printf 'Password: ' >&2
IFS= read -rs BOOT_PW; printf '\n' >&2
[ -n "$BOOT_PW" ] || { echo "empty password"; exit 1; }
export BOOT_URL BOOT_USER BOOT_PW
# ---- fail fast: do not spend ten minutes to discover a typo ------------------
# curl -K - reads credentials from stdin rather than argv.
printf 'checking the endpoint... ' >&2
code=$(printf 'user = %s:%s\nsilent\nwrite-out = "%%{http_code}"\noutput = "/dev/null"\n' \
"$BOOT_USER" "$BOOT_PW" | curl -K - -m 20 "${BOOT_URL%/}/bootstrap.env" || true)
if [ "$code" != 200 ]; then
echo "HTTP $code -- wrong password, wrong route, or endpoint down. Nothing was run." >&2
exit 1
fi
echo "200" >&2
# ---- the run ----------------------------------------------------------------
# A stock image with nothing preinstalled. If dotup needs a tool, dotup must
# install it -- that is half of what is being tested.
docker rm -f "$C" >/dev/null 2>&1 || :
docker run -d --name "$C" ubuntu:24.04 sleep infinity >/dev/null || exit 1
docker cp "$INNER" "$C":/root/e2e.sh >/dev/null || exit 1
# -e with a bare NAME inherits from this shell. Never NAME=value.
docker exec -e BOOT_URL -e BOOT_USER -e BOOT_PW "$C" bash /root/e2e.sh
rc=$?
echo
if [ "$rc" -eq 0 ]; then
echo "E2E exited 0"
else
echo "E2E exited $rc -- the container is left running as $C for inspection."
echo "If you shell into it, remember that /tmp/private.log inside contains the"
echo "credentials in cleartext: the inner script has a red() redactor, and the"
echo "one time it was bypassed for a quick tail, a live password was published"
echo "and had to be rotated. Pipe anything you read through it."
trap - EXIT INT TERM # leave the container up; still drop the password
unset BOOT_PW
fi
exit "$rc"
+30 -1
View File
@@ -190,7 +190,7 @@ DOTUP_TEST_IMAGE=ubuntu:22.04 sh .tests/test.sh --docker
DOTUP_TEST_IMAGE=debian:12 sh .tests/test.sh --docker DOTUP_TEST_IMAGE=debian:12 sh .tests/test.sh --docker
``` ```
81 assertions covering the toggle rule, the `@needs` closure both ways, the risk 101 assertions covering the toggle rule, the `@needs` closure both ways, the risk
model as invariants rather than prose, match confinement (two-sided: `--exact` model as invariants rather than prose, match confinement (two-sided: `--exact`
confines `nvidia` to three rows **and** fuzzy still over-matches, so removing confines `nvidia` to three rows **and** fuzzy still over-matches, so removing
`--exact` fails loudly), the fzf preflight, channel resolution on both `--exact` fails loudly), the fzf preflight, channel resolution on both
@@ -209,6 +209,35 @@ for it. That is not theatre: it caught the apt/brew fallback running in both
directions, which resolved Linux-only packages to `apt install davfs2` on a directions, which resolved Linux-only packages to `apt install davfs2` on a
machine that has never had apt. The fallback is one-directional now. machine that has never had apt. The fallback is one-directional now.
### End to end
The suite above installs nothing. `.tests/e2e.sh` does the opposite — a stock
`ubuntu:24.04` container, the public tier fetched from the anonymous URL, then
the private tier through a real bootstrap endpoint, then `bws`, then a rendered
`secrets.zsh`.
```sh
bash .tests/e2e.sh # prompts for endpoint URL, username, password
```
The password is never written to disk and never placed on a command line: it
reaches the container as `docker exec -e BOOT_PW` with a bare name, which
inherits from the calling shell. `-e BOOT_PW=…` would put it in docker's own
argv, and `/proc/<pid>/cmdline` is world-readable — the same bug this repo was
fixed to stop committing itself.
It asserts what the unit suite structurally cannot: that a machine which has
only ever seen the public repo can authenticate, clone the private tier, and end
up with **no credential in its logs and none in `.git/config`**, with the token
in a 600-mode credential file that a later `fetch` still authenticates from.
**Without an endpoint password**, `.tests/e2e-mock-endpoint.py` stands in for
Caddy — 200 with the blob, 401 on bad credentials, and a closed connection on
any other path, which is what `handle { abort }` does and why a retired route
reports `000` rather than `404`. Everything downstream stays real. That is how
the argv fix was verified, and running it that way immediately caught a rotation
script whose route regex had stopped matching.
## Rules this repo lives by ## Rules this repo lives by
- **Zero credentials, forever.** `gitleaks detect` runs over the full history - **Zero credentials, forever.** `gitleaks detect` runs over the full history