gates / gates (push) Successful in 13m17s
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.
223 lines
11 KiB
Python
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")}
|