Files
felhom.eu/documentation/audits/catalog-sweep-2026-10-24/tools/sweep_fixtures.py
T
admin 67053335d1
gates / gates (push) Successful in 13m17s
Catalog sweep 2026-10-24: all 65 catalog apps on a fresh box (52 work, 10 with a problem, 0 broken); register 208 -> 217 (R-1269..R-1277 opened; notes on R-739, R-1267); STATUS + D46; map B1 re-checked; evidence
Every catalog app installed, used, backed up, removed keeping data, reinstalled, restored and removed with data on a
fresh box from the public ISO (demo-hp VM 9360, Tester4), endpoint-level. Worst: a reinstall's capture rewrites the
kept unit so its restore breaks the app (R-1269, P2), the restore still reports success (R-1270, P2), and „use my kept
data" loaded another box's future-dated database (R-1271, P2). Drill catalog fixes for the morning list (n8n usage
data; OpenGist 256M, proven by a cgroup probe). VM 9360 and LXC 9361 destroyed; hub host Tester4-aa9466 left for the
operator. REPORT-catalog-sweep-2026-10-24.md.
2026-10-25 00:13:05 +02:00

223 lines
11 KiB
Python

"""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")}