"""sweep_fixtures.py — seed/read-back fixtures written for the catalog sweep of 2026-10-24, for apps the catalog's upgrade_fixtures_box.py does not cover. Same rule (R-156): everything goes in through the app's own API at its front door (traefik, the household's setup gate passed); verify() asks the app, with a negative control where one exists. Not in the catalog yet — candidates for upgrade_fixtures_box.py once they have run on more than one box.""" import json, re, secrets def _j(w, sub, path, body=None, method=None, headers=()): args = ["-H", "Content-Type: application/json"] for h in headers: args += ["-H", h] rc, code, out = w.app_curl(sub, path, *args, method=method or ("POST" if body is not None else None), data=json.dumps(body) if body is not None else None) try: return code, json.loads(out) except Exception: return code, out class Homebox: """Homebox: the first account through /api/v1/users/register, a location named by a marker, read back by name.""" sub = "homebox" def _login(self, w, sub, email, pw): rc, code, out = w.app_curl(sub, "/api/v1/users/login", "--data-urlencode", f"username={email}", "--data-urlencode", f"password={pw}", method="POST") try: return code, json.loads(out).get("token", "") except Exception: return code, "" def seed(self, w, sub, say): if not w.wait_app(sub, "/", want=("200",), tries=72): return None email, pw, mark = f"sweep-{secrets.token_hex(3)}@sweep.invalid", "Sweep-" + secrets.token_hex(8), "loc-" + secrets.token_hex(5) code, out = _j(w, sub, "/api/v1/users/register", {"name": "Sweep", "email": email, "password": pw}) say(f" homebox: register http={code}") code, tok = self._login(w, sub, email, pw) if not tok: say(f" homebox: login http={code}, no token") return None for path in ("/api/v1/locations", "/api/v1/entities"): # 0.26 moved items and locations to /entities code, out = _j(w, sub, path, {"name": mark, "description": "catalog sweep"}, headers=[f"Authorization: {tok}"]) say(f" homebox: create on {path} http={code}") if code in ("200", "201"): return {"email": email, "pw": pw, "mark": mark, "path": path} return None def verify(self, w, sub, t, say): if not w.wait_app(sub, "/", want=("200",), tries=72): return False code, tok = self._login(w, sub, t["email"], t["pw"]) if not tok: say(f" homebox: login as the seeded account http={code} — no token") return False code, out = _j(w, sub, t.get("path", "/api/v1/locations") + "?pageSize=500", headers=[f"Authorization: {tok}"]) rows = out if isinstance(out, list) else (out.get("items") or []) if isinstance(out, dict) else [] names = [x.get("name") for x in rows if isinstance(x, dict)] ok = t["mark"] in names and ("loc-absent-" + secrets.token_hex(3)) not in names say(f" homebox: list http={code} rows={len(names)} marker_present={t['mark'] in names}") return ok class Immich: """Immich: the admin sign-up, an album named by a marker, read back through the album list.""" sub = "immich" def seed(self, w, sub, say): if not w.wait_app(sub, "/api/server/ping", want=("200",), tries=120): return None email, pw, mark = f"sweep-{secrets.token_hex(3)}@sweep.invalid", "Sweep-" + secrets.token_hex(8), "album-" + secrets.token_hex(5) code, out = _j(w, sub, "/api/auth/admin-sign-up", {"email": email, "password": pw, "name": "Sweep"}) say(f" immich: admin sign-up http={code}") tok = self._token(w, sub, email, pw) if not tok: return None code, out = _j(w, sub, "/api/albums", {"albumName": mark}, headers=[f"Authorization: Bearer {tok}"]) say(f" immich: album create http={code}") return {"email": email, "pw": pw, "mark": mark} if code in ("200", "201") else None def _token(self, w, sub, email, pw): code, out = _j(w, sub, "/api/auth/login", {"email": email, "password": pw}) return out.get("accessToken", "") if isinstance(out, dict) else "" def verify(self, w, sub, t, say): if not w.wait_app(sub, "/api/server/ping", want=("200",), tries=120): return False if self._token(w, sub, t["email"], "wrong-" + secrets.token_hex(4)): say(" immich: READBACK UNUSABLE — a wrong password logged in") return False tok = self._token(w, sub, t["email"], t["pw"]) if not tok: say(" immich: login as the seeded admin failed") return False code, out = _j(w, sub, "/api/albums", headers=[f"Authorization: Bearer {tok}"]) names = [a.get("albumName") for a in out] if isinstance(out, list) else [] say(f" immich: albums http={code} marker_present={t['mark'] in names}") return t["mark"] in names class Komga: """Komga: the first account through its own claim endpoint; read back by signing in (a wrong password must fail).""" sub = "komga" def _me(self, w, sub, email, pw): rc, code, out = w.app_curl(sub, "/api/v2/users/me", "-u", f"{email}:{pw}") return code def seed(self, w, sub, say): if not w.wait_app(sub, "/", want=("200",), tries=90): return None email, pw = f"sweep-{secrets.token_hex(3)}@sweep.invalid", "Sweep-" + secrets.token_hex(8) rc, code, out = w.app_curl(sub, "/api/v1/claim", "-H", f"X-Komga-Email: {email}", "-H", f"X-Komga-Password: {pw}", method="POST") say(f" komga: claim http={code}") return {"email": email, "pw": pw} if code in ("200", "201") else None def verify(self, w, sub, t, say): if not w.wait_app(sub, "/", want=("200",), tries=90): return False bad = self._me(w, sub, t["email"], "wrong-" + secrets.token_hex(4)) good = self._me(w, sub, t["email"], t["pw"]) say(f" komga: me wrong-pw http={bad}, right-pw http={good}") return good == "200" and bad != "200" class Jellyfin: """Jellyfin: its own startup wizard API makes the first user; read back by AuthenticateByName.""" sub = "jellyfin" AUTH = 'Authorization: MediaBrowser Client="sweep", Device="sweep", DeviceId="sweep-1024", Version="1.0"' def seed(self, w, sub, say): if not w.wait_app(sub, "/health", want=("200",), tries=120): return None user, pw = "sweep" + secrets.token_hex(3), "Sweep-" + secrets.token_hex(8) for path, body in (("/Startup/Configuration", {"UICulture": "hu-HU", "MetadataCountryCode": "HU", "PreferredMetadataLanguage": "hu"}),): code, _ = _j(w, sub, path, body, headers=[self.AUTH]) say(f" jellyfin: {path} http={code}") _j(w, sub, "/Startup/User", headers=[self.AUTH]) code, _ = _j(w, sub, "/Startup/User", {"Name": user, "Password": pw}, headers=[self.AUTH]) say(f" jellyfin: /Startup/User http={code}") _j(w, sub, "/Startup/RemoteAccess", {"EnableRemoteAccess": True, "EnableAutomaticPortMapping": False}, headers=[self.AUTH]) code, _ = _j(w, sub, "/Startup/Complete", {}, headers=[self.AUTH]) say(f" jellyfin: /Startup/Complete http={code}") return {"user": user, "pw": pw} if self._auth(w, sub, user, pw) else None def _auth(self, w, sub, user, pw): code, out = _j(w, sub, "/Users/AuthenticateByName", {"Username": user, "Pw": pw}, headers=[self.AUTH]) return code == "200" and isinstance(out, dict) and bool(out.get("AccessToken")) def verify(self, w, sub, t, say): if not w.wait_app(sub, "/health", want=("200",), tries=120): return False bad, good = self._auth(w, sub, t["user"], "wrong-" + secrets.token_hex(4)), self._auth(w, sub, t["user"], t["pw"]) say(f" jellyfin: sign-in wrong-pw ok={bad}, right-pw ok={good}") return good and not bad class Ghost: """Ghost: the owner through /ghost/api/admin/authentication/setup; read back by an admin session sign-in.""" sub = "ghost" def _origin(self, w, sub): return f"Origin: https://{sub}.{w.DOMAIN}" def seed(self, w, sub, say): if not w.wait_app(sub, "/ghost/api/admin/site/", want=("200",), tries=120): return None email, pw = f"sweep-{secrets.token_hex(3)}@sweep.invalid", "Sweep-" + secrets.token_hex(10) code, out = _j(w, sub, "/ghost/api/admin/authentication/setup/", {"setup": [{"name": "Sweep", "email": email, "password": pw, "blogTitle": "Sweep"}]}, headers=[self._origin(w, sub)]) say(f" ghost: setup http={code}") return {"email": email, "pw": pw} if code in ("200", "201") else None def _session(self, w, sub, email, pw): code, _ = _j(w, sub, "/ghost/api/admin/session/", {"username": email, "password": pw}, headers=[self._origin(w, sub)]) return code def verify(self, w, sub, t, say): if not w.wait_app(sub, "/ghost/api/admin/site/", want=("200",), tries=120): return False bad, good = self._session(w, sub, t["email"], "wrong-" + secrets.token_hex(5)), self._session(w, sub, t["email"], t["pw"]) say(f" ghost: session wrong-pw http={bad}, right-pw http={good}") return good in ("200", "201") and bad not in ("200", "201") class Arr: """Radarr / Sonarr: the API key the app hands its own web page (initialize.json), a tag named by a marker.""" def __init__(self, sub): self.sub = sub def _key(self, w, sub, say): for p in ("/initialize.json", "/initialize.js"): rc, code, out = w.app_curl(sub, p) m = re.search(r'"?apiKey"?\s*[:=]\s*["\']([0-9a-f]{32})', out or "") if m: return m.group(1) say(f" {sub}: no API key on initialize.json/js (http={code})") return "" def seed(self, w, sub, say): if not w.wait_app(sub, "/ping", want=("200",), tries=90): return None key, mark = self._key(w, sub, say), "sweep" + secrets.token_hex(4) if not key: return None code, out = _j(w, sub, "/api/v3/tag", {"label": mark}, headers=[f"X-Api-Key: {key}"]) say(f" {sub}: tag create http={code}") return {"mark": mark} if code in ("200", "201") else None def verify(self, w, sub, t, say): if not w.wait_app(sub, "/ping", want=("200",), tries=90): return False key = self._key(w, sub, say) code, out = _j(w, sub, "/api/v3/tag", headers=[f"X-Api-Key: {key}"]) if key else ("-", []) labels = [x.get("label") for x in out] if isinstance(out, list) else [] say(f" {sub}: tags http={code} marker_present={t['mark'] in labels}") return t["mark"] in labels and ("absent" + secrets.token_hex(3)) not in labels SWEEP_FIXTURES = {"homebox": Homebox(), "immich": Immich(), "komga": Komga(), "jellyfin": Jellyfin(), "ghost": Ghost(), "radarr": Arr("radarr"), "sonarr": Arr("sonarr")}