#!/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. # 7. the SSO arm sends the base path: ABS builds its redirect_uri as # /auth/openid/callback, and the # value must be the path the app is mounted under — never the literal # "undefined" a fresh database leaves there. 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": [], "oidc": None} def log(line): with open(logf, "a") as f: f.write(line + "\n") def norm(p): # ABS mounts every route under ROUTER_BASE_PATH. The stub accepts the API at the # root or under that prefix, so a test can still assert which one the client used. return p[len("/audiobookshelf"):] if p.startswith("/audiobookshelf") else p 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 = norm(self.path.split("?")[0]) log("GET %s auth=%s" % (self.path, "yes" if self.headers.get("Authorization") else "no")) if path == "/status": methods = ["local", "openid"] if state["oidc"] else ["local"] return self.send(200, {"app": "audiobookshelf", "isInit": state["init"], "authMethods": methods}, True) if not self.authed(): return self.send(401, "Unauthorized") if path == "/api/libraries": return self.send(200, {"libraries": state["libs"]}, True) if path == "/api/auth-settings": return self.send(200, state["oidc"] or {}, True) if path.endswith("/items"): return self.send(200, {"total": 42, "results": []}, True) return self.send(404, "nope") def do_POST(self): path = norm(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") def do_PATCH(self): path = norm(self.path.split("?")[0]) payload = self.body() log("PATCH %s auth=%s" % (self.path, "yes" if self.headers.get("Authorization") else "no")) if not self.authed(): return self.send(401, "Unauthorized") if path == "/api/auth-settings": # Stored and echoed verbatim: the test asserts the wire, not the stub's # opinion about which keys are patchable. state["oidc"] = payload for k in sorted(payload): log("OIDCFG %s=%r" % (k, payload[k])) return self.send(200, payload, True) 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) }' } @test "the SSO arm sends the base path as the redirect subfolder" { # What authentik has registered must equal what ABS builds: # /auth/openid/callback. A database # created before SSO leaves that key unset and ABS then sends the literal # "/undefined/auth/openid/callback" — measured 2026-11-02, where the live login died # on authentik's "Redirect URI Error" page. So the value has to reach the wire, and # it has to be the path the app is served under. umask 077 cat > "$TMP/cred" <<'CRED' ABS_ADMIN_USER=frank ABS_ADMIN_PASSWORD=0123456789abcdef0123456789 ABS_OIDC_CLIENT_SECRET=0123456789abcdef0123456789abcdef0123456789abcdef0123456789 CRED run env ABS_URL="http://127.0.0.1:$PORT/audiobookshelf" ABS_SECRET="$TMP/cred" \ ABS_SCAN=0 bash "$INIT" [ "$status" -eq 0 ] printf '%s\n' "${lines[@]}" | grep -q "^oidc: armed" grep -q "^OIDCFG authOpenIDSubfolderForRedirectURLs='/audiobookshelf'$" "$TMP/requests.log" # every route the bootstrapper calls rides under the same prefix it armed with grep -q "^POST /audiobookshelf/init auth=no$" "$TMP/requests.log" grep -q "^GET /audiobookshelf/api/auth-settings auth=yes$" "$TMP/requests.log" # A root-mounted deployment sends the EMPTY subfolder (the value the 2.17.4 upgrade # uses), not whatever the last URL happened to carry. run env ABS_URL="http://127.0.0.1:$PORT" ABS_SECRET="$TMP/cred" ABS_SCAN=0 bash "$INIT" [ "$status" -eq 0 ] [ "$(grep "^OIDCFG authOpenIDSubfolderForRedirectURLs=" "$TMP/requests.log" | tail -1)" \ = "OIDCFG authOpenIDSubfolderForRedirectURLs=''" ] } @test "the app and the IdP provider script build the same redirect path" { # The provider row is written by scripts/authentik-audiobookshelf-sso.sh from # ABS_BASE; the app builds its redirect_uri from the base path the deployer hands # audiobookshelf-init.sh, which comes from the unit. Drift between the two is a # redirect_uri mismatch — the login dies at the IdP before any credential is issued. unit="$REPO_ROOT/dankserver/audiobookshelf/quadlet/audiobookshelf.container" mounted=$(awk -F= '/^Environment=ROUTER_BASE_PATH=/{print $3}' "$unit") [ -n "$mounted" ] provider=$(grep -m1 '^BASE=' "$REPO_ROOT/scripts/authentik-audiobookshelf-sso.sh" \ | grep -oE 'https?://[^"}]+') bootstrapper=$(grep -m1 '^ABS_URL=' "$INIT" | grep -oE 'https?://[^"}]+') # Origins legitimately differ (LAN address vs loopback); the PATH must not. [ "${provider#http://192.168.0.235:8000}/auth/openid/callback" \ = "${bootstrapper#http://127.0.0.1:8000}/auth/openid/callback" ] [ "${bootstrapper#http://127.0.0.1:8000}" = "$mounted" ] }