#!/usr/bin/env bats # Seam: scripts/audiobookshelf-init.sh — the Audiobookshelf bootstrapper. # # A freshly deployed Audiobookshelf answers /healthcheck 200 while it has no # user, no library and no scan, so "healthy" proves nothing about usefulness. # This seam pins what the bootstrapper must make true, against a stub that # speaks the same API as the pinned image (POST /init + POST /login at the # root, bearer-authenticated /api/*, library count only on /items): # 1. an unregistered server gains the account, the library and a scan, and the # credential lands mode 600 with both keys; # 2. a second run creates nothing new (exactly one /init, one library) and # still fires the incremental scan; # 3. neither the bearer token nor the password ever rides a URL — a secret in # a URL or an argv is world-readable in /proc//cmdline, which is how # an HF token leaked into a transcript on 2026-09-16; # 4. a server that cannot be reached is refused by URL with exit 1, never # reported as success (a green deploy over a dead API is a lie); # 5. ABS_SCAN outside 0/1 is a usage error raised before anything is touched; # 6. the deployer runs the bootstrapper only after the health gate answered. load helpers setup() { INIT="$REPO_ROOT/scripts/audiobookshelf-init.sh" TMP="$(mktemp -d)" PORT=18937 cat > "$TMP/stub.py" <<'PY' import json, sys from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer port, logf = int(sys.argv[1]), sys.argv[2] state = {"init": False, "user": None, "pw": None, "libs": []} def log(line): with open(logf, "a") as f: f.write(line + "\n") class H(BaseHTTPRequestHandler): def log_message(self, *a): pass def send(self, code, body, as_json=False): data = json.dumps(body).encode() if as_json else str(body).encode() self.send_response(code) self.send_header("Content-Type", "application/json" if as_json else "text/plain") self.send_header("Content-Length", str(len(data))) self.end_headers() self.wfile.write(data) def body(self): n = int(self.headers.get("Content-Length") or 0) return json.loads(self.rfile.read(n) or b"{}") def authed(self): return self.headers.get("Authorization") == "Bearer stub-token" def do_GET(self): path = self.path.split("?")[0] log("GET %s auth=%s" % (self.path, "yes" if self.headers.get("Authorization") else "no")) if path == "/status": return self.send(200, {"app": "audiobookshelf", "isInit": state["init"]}, True) if not self.authed(): return self.send(401, "Unauthorized") if path == "/api/libraries": return self.send(200, {"libraries": state["libs"]}, True) if path.endswith("/items"): return self.send(200, {"total": 42, "results": []}, True) return self.send(404, "nope") def do_POST(self): path = self.path.split("?")[0] payload = self.body() log("POST %s auth=%s" % (self.path, "yes" if self.headers.get("Authorization") else "no")) if path == "/init": if state["init"]: return self.send(500, "already initialized") root = payload.get("newRoot", {}) state["init"] = True state["user"] = root.get("username") state["pw"] = root.get("password") log("INIT %s" % root.get("username")) return self.send(200, "OK") if path == "/login": if not state["init"]: return self.send(401, "Unauthorized") if (payload.get("username"), payload.get("password")) != (state["user"], state["pw"]): return self.send(401, "Unauthorized") return self.send(200, {"user": {"username": state["user"], "type": "root", "id": "u1", "token": "stub-token"}}, True) if not self.authed(): return self.send(401, "Unauthorized") if path == "/api/libraries": lib = {"id": "lib-1", "name": payload.get("name"), "folders": payload.get("folders", [])} state["libs"].append(lib) log("LIBRARY %s %s" % (lib["name"], lib["folders"])) return self.send(200, lib, True) if path.endswith("/scan"): log("SCAN") return self.send(200, "OK") return self.send(404, "nope") ThreadingHTTPServer(("127.0.0.1", port), H).serve_forever() PY python3 "$TMP/stub.py" "$PORT" "$TMP/requests.log" > "$TMP/stub.out" 2>&1 & STUB_PID=$! for _ in $(seq 1 50); do curl -s -o /dev/null "http://127.0.0.1:$PORT/status" && break sleep 0.1 done } teardown() { [ -n "${STUB_PID:-}" ] && kill "$STUB_PID" 2>/dev/null || true rm -rf "$TMP" } boot() { env ABS_URL="http://127.0.0.1:$PORT" ABS_SECRET="$TMP/cred" \ ABS_ADMIN_USER=frank ABS_LIBRARY=Audiobooks ABS_FOLDER=/audiobooks \ ABS_SCAN="${SCAN:-1}" bash "$INIT" } @test "an unregistered server gains the account, the library and the scan" { run boot [ "$status" -eq 0 ] printf '%s\n' "${lines[@]}" | grep -qx "credential: created $TMP/cred" printf '%s\n' "${lines[@]}" | grep -q "^account: created (frank)" printf '%s\n' "${lines[@]}" | grep -q "^token: obtained (root)" printf '%s\n' "${lines[@]}" | grep -q "^library: created Audiobooks (lib-1)" printf '%s\n' "${lines[@]}" | grep -q "^scan: accepted" printf '%s\n' "${lines[@]}" | grep -q "^items: 42" # The state roots of this service hold an account credential, so the file # that carries it must never be group- or world-readable. [ "$(stat -c %a "$TMP/cred")" = "600" ] grep -qx 'ABS_ADMIN_USER=frank' "$TMP/cred" grep -Eq '^ABS_ADMIN_PASSWORD=[0-9a-f]{24}$' "$TMP/cred" # The folder the library indexes is the bind inside the container, not the # host path — a library pointed at /mnt/entropy would answer 0 items and # look like an empty audiobook collection. grep -q "LIBRARY Audiobooks \[{'fullPath': '/audiobooks'}\]" "$TMP/requests.log" } @test "a second run creates nothing new and still fires the incremental scan" { boot > /dev/null [ -e "$TMP/requests.log" ] run boot [ "$status" -eq 0 ] printf '%s\n' "${lines[@]}" | grep -q "^account: already registered" printf '%s\n' "${lines[@]}" | grep -q "^library: exists Audiobooks (lib-1)" printf '%s\n' "${lines[@]}" | grep -q "^scan: accepted" # No second credential, no second account, no duplicate library: the deployer # may run this on every re-deploy of the service. ! printf '%s\n' "${lines[@]}" | grep -q "credential: created" [ "$(grep -c '^INIT ' "$TMP/requests.log")" -eq 1 ] [ "$(grep -c '^LIBRARY ' "$TMP/requests.log")" -eq 1 ] [ "$(grep -c '^SCAN$' "$TMP/requests.log")" -eq 2 ] } @test "the bearer token and the password never ride a URL" { boot # The stub logs the request line (method + path). A secret showing up there # means it travelled as a query parameter — visible in access logs, shell # history and /proc//cmdline. ! awk '{print $2}' "$TMP/requests.log" | grep -q 'stub-token' pw=$(awk -F= '/^ABS_ADMIN_PASSWORD=/{print $2}' "$TMP/cred") [ -n "$pw" ] ! awk '{print $2}' "$TMP/requests.log" | grep -q -- "$pw" # and the authenticated routes only answered once the header was present grep -q '^GET /api/libraries auth=yes$' "$TMP/requests.log" } @test "a server that does not answer is refused by URL, not reported as success" { run env ABS_URL="http://127.0.0.1:1" ABS_SECRET="$TMP/cred" bash "$INIT" [ "$status" -eq 1 ] [[ "$output" == *refused:* ]] [[ "$output" == *"127.0.0.1:1"* ]] } @test "ABS_SCAN outside 0/1 is a usage error raised before anything is touched" { run env ABS_URL="http://127.0.0.1:$PORT" ABS_SECRET="$TMP/cred" ABS_SCAN=2 \ bash "$INIT" [ "$status" -eq 2 ] [[ "$output" == *ABS_SCAN* ]] [ ! -e "$TMP/cred" ] } @test "the deployer bootstraps only after the health gate has answered" { body=$(awk '/^deploy_audiobookshelf\(\)/,/^}/' "$REPO_ROOT/scripts/deploy-remote.sh") [ -n "$body" ] printf '%s\n' "$body" | grep -q \ 'wait_for_health audiobookshelf http://127\.0\.0\.1:8000/healthcheck' printf '%s\n' "$body" | grep -q 'scripts/audiobookshelf-init\.sh' printf '%s\n' "$body" | awk ' /wait_for_health audiobookshelf/ { health = NR } /audiobookshelf-init\.sh/ { init = NR } END { exit !(health && init && init > health) }' }