Files
app-catalog-felhom.eu/scripts/upgrade_fixtures_box28.py
T
admin e6f3ec2087 Fixtures: sparkyfitness, rallly and outline seed through their own front door (R-462, R-624)
- Sparkyfitness: better-auth sign-up/sign-in, a check-in weight stored and read back; a wrong
  password and an empty date must read as absent. Waits out the app's own 429 (one client
  address behind traefik).
- Rallly: sign-up, the six-digit e-mail code READ (select only) from the app's own
  verifications row in place of a mailbox, verify-email, sign-in, polls.make, readback by the
  public polls.get; an unknown id must be not found.
- Outline: the self-hosted first-run route installation.create (workspace + admin, refused once a
  team exists), an API key with Outline's own CSRF pair, a document, readback by documents.info;
  an unknown id must 404 and a wrong key 401.
- outline and rallly leave the NoRoute list: both had a front-door route after all.

Measured on the bench (LXC 9401) and on 9202 2026-09-30:
felhom.eu/documentation/audits/pg-last-six-2026-09-30/

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-30 12:53:35 +02:00

413 lines
20 KiB
Python

# PORTED 2026-09-23 (night shift, R-462) VERBATIM from felhom.eu/documentation/audits/the-28-2026-09-22/
# fixtures28.py. See upgrade_fixtures_box.py.
#!/usr/bin/env python3
"""fixtures28.py — seed/verify for the twenty-eight, same rule as `fixtures.py` (R-156).
*Nothing is ever seeded into a volume by hand.* Every seed goes in through the app's OWN interface:
its HTTP API through the household's real front door, or its own CLI inside its own container. A raw
SQL INSERT or a planted file is never used.
An app with no non-browser route returns None from `seed()` and carries a `tried` string naming
what was attempted. That is a RESULT — `inconclusive` — not a gap to be papered over.
Every `verify()` that can prove itself does so on the same call: it also asks for something that
MUST be absent, so a readback that has broken into always answering "found" fails instead of
passing everything.
"""
import json, re, secrets
def _gx(w, container, *cmd, timeout=240):
import shlex
return w.guest(f"docker exec {container} " + " ".join(shlex.quote(c) for c in cmd)
+ " 2>&1", timeout=timeout)
# ── the *arr family: their own v3 API, key read from their own config ────────────────────────────
class _Arr:
"""radarr / sonarr. The API key is minted by the app into its own config.xml; reading it is
how a household's own client authenticates, and the tag endpoints are ordinary app data."""
api = "v3"
def _key(self, w):
out = w.guest(f"docker exec {self.name} cat /config/config.xml 2>/dev/null")
m = re.search(r"<ApiKey>([0-9a-f]+)</ApiKey>", out or "")
return m.group(1) if m else None
def seed(self, w, sub, say):
if not w.wait_app(sub, "/", want=("200", "302", "401")):
return None
k = self._key(w)
if not k:
self.tried = "read ApiKey from the app's own /config/config.xml — not present yet"
say(f" {self.name}: no ApiKey in config.xml yet")
return None
label = "drill" + secrets.token_hex(4)
rc, code, out = w.app_curl(sub, f"/api/{self.api}/tag", "-H", f"X-Api-Key: {k}",
"-H", "Content-Type: application/json",
data=json.dumps({"label": label}), method="POST")
if code not in ("200", "201", "202"):
self.tried = f"POST /api/{self.api}/tag with the app's own key -> {code}"
say(f" {self.name}: POST tag -> {code} {out[:150]}")
return None
say(f" {self.name}: seeded tag {label}")
return {"label": label, "key": k}
def verify(self, w, sub, t, say):
k = self._key(w) or t["key"]
rc, code, out = w.app_curl(sub, f"/api/{self.api}/tag", "-H", f"X-Api-Key: {k}")
found = t["label"] in (out or "")
# negative control, EVERY call: a label that cannot exist must read as absent
absent = ("drillnope" + secrets.token_hex(6)) not in (out or "")
if not absent:
say(f" {self.name}: READBACK UNUSABLE — an impossible label read as present")
return None
say(f" {self.name}: readback found={found} (http {code}, control passed)")
return found
class Radarr(_Arr):
name = "radarr"; sub = "radarr"; route = "its own /api/v3/tag with the app's own ApiKey"
class Sonarr(_Arr):
name = "sonarr"; sub = "sonarr"; route = "its own /api/v3/tag with the app's own ApiKey"
# ── kimai — its own console, the route the app documents ─────────────────────────────────────────
class Kimai:
sub = "kimai"; route = "its own `bin/console kimai:user:create`"
def seed(self, w, sub, say):
u = "drill" + secrets.token_hex(4)
out = _gx(w, "kimai", "/opt/kimai/bin/console", "kimai:user:create", u,
f"{u}@example.invalid", "ROLE_USER", "Drill-" + secrets.token_hex(6) + "!aA")
if "success" not in (out or "").lower() and "created" not in (out or "").lower():
self.tried = "its own `bin/console kimai:user:create` -> " + (out or "")[:200]
say(f" kimai: console create said: {(out or '')[:200]}")
return None
say(f" kimai: seeded user {u}")
return {"user": u}
def verify(self, w, sub, t, say):
out = _gx(w, "kimai", "/opt/kimai/bin/console", "kimai:user:list") or ""
found = t["user"] in out
absent = ("nope" + secrets.token_hex(6)) not in out
if not absent:
say(" kimai: READBACK UNUSABLE — an impossible user read as present")
return None
say(f" kimai: readback found={found} (control passed)")
return found
# ── gramps-web — its own CLI ─────────────────────────────────────────────────────────────────────
class GrampsWeb:
sub = "gramps"; route = "its own `python3 -m gramps_webapi user add`"
def seed(self, w, sub, say):
u = "drill" + secrets.token_hex(4)
out = _gx(w, "gramps-web", "python3", "-m", "gramps_webapi", "--config",
"/app/config/config.cfg", "user", "add", u, "Drill-" + secrets.token_hex(6))
if "error" in (out or "").lower() or "traceback" in (out or "").lower():
self.tried = "its own `gramps_webapi user add` -> " + (out or "")[:200]
say(f" gramps-web: {(out or '')[:200]}")
return None
say(f" gramps-web: seeded user {u}")
return {"user": u}
def verify(self, w, sub, t, say):
out = _gx(w, "gramps-web", "python3", "-m", "gramps_webapi", "--config",
"/app/config/config.cfg", "user", "list") or ""
found = t["user"] in out
absent = ("nope" + secrets.token_hex(6)) not in out
if not absent:
say(" gramps-web: READBACK UNUSABLE")
return None
say(f" gramps-web: readback found={found} (control passed)")
return found
# ── homebox — its own registration + item API ────────────────────────────────────────────────────
class Homebox:
sub = "homebox"; route = "its own /api/v1/users/register + /api/v1/locations"
def seed(self, w, sub, say):
if not w.wait_app(sub, "/", want=("200", "302")):
return None
u = "drill" + secrets.token_hex(4) + "@example.invalid"
pw = "Drill-" + secrets.token_hex(8) + "!aA"
rc, code, out = w.app_curl(sub, "/api/v1/users/register", "-H", "Content-Type: application/json",
data=json.dumps({"name": "drill", "email": u, "password": pw}),
method="POST")
if code not in ("200", "201", "204"):
self.tried = f"POST /api/v1/users/register -> {code} {out[:150]}"
say(f" homebox: register -> {code} {out[:150]}")
return None
rc, code, out = w.app_curl(sub, "/api/v1/users/login", "-H", "Content-Type: application/json",
data=json.dumps({"username": u, "password": pw}), method="POST")
try:
tokv = json.loads(out)["token"]
except Exception:
self.tried = f"POST /api/v1/users/login -> {code} {out[:150]}"
say(f" homebox: login -> {code} {out[:150]}")
return None
name = "drillloc" + secrets.token_hex(4)
rc, code, out = w.app_curl(sub, "/api/v1/locations", "-H", f"Authorization: {tokv}",
"-H", "Content-Type: application/json",
data=json.dumps({"name": name, "description": "drill"}),
method="POST")
if code not in ("200", "201"):
self.tried = f"POST /api/v1/locations -> {code} {out[:150]}"
say(f" homebox: create location -> {code} {out[:150]}")
return None
say(f" homebox: seeded location {name}")
return {"name": name, "tok": tokv, "u": u, "pw": pw}
def verify(self, w, sub, t, say):
rc, code, out = w.app_curl(sub, "/api/v1/users/login", "-H", "Content-Type: application/json",
data=json.dumps({"username": t["u"], "password": t["pw"]}),
method="POST")
try:
tokv = json.loads(out)["token"]
except Exception:
tokv = t["tok"]
rc, code, out = w.app_curl(sub, "/api/v1/locations", "-H", f"Authorization: {tokv}")
found = t["name"] in (out or "")
absent = ("nope" + secrets.token_hex(6)) not in (out or "")
if not absent:
say(" homebox: READBACK UNUSABLE")
return None
say(f" homebox: readback found={found} (http {code}, control passed)")
return found
FIXTURES28 = {
"radarr": Radarr(), "sonarr": Sonarr(), "kimai": Kimai(),
"gramps-web": GrampsWeb(), "homebox": Homebox(),
}
# ── apps whose front door is a SIGN-UP or SETUP call ─────────────────────────────────────────────
def _neg(w, sub, path, hdr, say, name):
"""The negative control every verify() runs: something that CANNOT exist must read absent."""
rc, code, out = w.app_curl(sub, path, *hdr)
return ("nope" + secrets.token_hex(6)) not in (out or ""), out, code
class Termix:
sub = "termix"; route = "its own /users/create sign-up, then /users/me"
def seed(self, w, sub, say):
if not w.wait_app(sub, "/", want=("200", "302")):
return None
u = "drill" + secrets.token_hex(4)
pw = "Drill-" + secrets.token_hex(8) + "!aA"
for p in ("/users/create", "/api/users/create", "/users/register"):
rc, code, out = w.app_curl(sub, p, "-H", "Content-Type: application/json",
data=json.dumps({"username": u, "password": pw}),
method="POST")
if code in ("200", "201"):
say(f" termix: seeded user {u} via {p}")
return {"u": u, "pw": pw, "path": p}
self.tried = "POST /users/create, /api/users/create, /users/register — none accepted"
say(f" termix: no sign-up route accepted (last {code} {out[:120]})")
return None
def verify(self, w, sub, t, say):
rc, code, out = w.app_curl(sub, "/users/login", "-H", "Content-Type: application/json",
data=json.dumps({"username": t["u"], "password": t["pw"]}),
method="POST")
found = code in ("200", "201") and ("token" in (out or "") or t["u"] in (out or ""))
rc2, code2, out2 = w.app_curl(sub, "/users/login", "-H", "Content-Type: application/json",
data=json.dumps({"username": "nope" + secrets.token_hex(6),
"password": t["pw"]}), method="POST")
if code2 in ("200", "201"):
say(" termix: READBACK UNUSABLE — an impossible user logged in")
return None
say(f" termix: readback found={found} (http {code}, control refused as it must)")
return found
class Ghost:
sub = "blog"; route = "its own /ghost/api/admin/authentication/setup/"
def seed(self, w, sub, say):
if not w.wait_app(sub, "/", want=("200", "301", "302")):
return None
title = "Drill-" + secrets.token_hex(6)
u = "drill" + secrets.token_hex(4) + "@example.invalid"
pw = "Drill-" + secrets.token_hex(8) + "aA1"
body = json.dumps({"setup": [{"name": "Drill", "email": u, "password": pw,
"blogTitle": title}]})
rc, code, out = w.app_curl(sub, "/ghost/api/admin/authentication/setup/",
"-H", "Content-Type: application/json",
"-H", "Accept-Version: v5.0", data=body, method="POST")
if code not in ("200", "201"):
self.tried = f"POST /ghost/api/admin/authentication/setup/ -> {code} {out[:150]}"
say(f" ghost: setup -> {code} {out[:160]}")
return None
say(f" ghost: seeded site title {title}")
return {"title": title, "u": u}
def verify(self, w, sub, t, say):
rc, code, out = w.app_curl(sub, "/", "-L")
found = t["title"] in (out or "")
absent = ("Drill-nope" + secrets.token_hex(6)) not in (out or "")
if not absent:
say(" ghost: READBACK UNUSABLE")
return None
say(f" ghost: readback found={found} (http {code}, control passed)")
return found
class Komga:
sub = "komga"; route = "its own POST /api/v1/claim, then GET /api/v2/users/me"
def seed(self, w, sub, say):
if not w.wait_app(sub, "/", want=("200", "302", "401")):
return None
u = "drill" + secrets.token_hex(4) + "@example.invalid"
pw = "Drill-" + secrets.token_hex(8)
rc, code, out = w.app_curl(sub, "/api/v1/claim", "-H", f"X-Komga-Email: {u}",
"-H", f"X-Komga-Password: {pw}", method="POST")
if code not in ("200", "201"):
self.tried = f"POST /api/v1/claim -> {code} {out[:150]}"
say(f" komga: claim -> {code} {out[:150]}")
return None
say(f" komga: claimed the server as {u}")
return {"u": u, "pw": pw}
def verify(self, w, sub, t, say):
import base64 as _b
a = _b.b64encode(f"{t['u']}:{t['pw']}".encode()).decode()
rc, code, out = w.app_curl(sub, "/api/v2/users/me", "-H", f"Authorization: Basic {a}") # v2 since komga 1.x; v1 answers 404 (measured 2026-09-23)
found = code == "200" and t["u"] in (out or "")
bad = _b.b64encode(f"nope{secrets.token_hex(6)}:{t['pw']}".encode()).decode()
rc2, code2, _ = w.app_curl(sub, "/api/v2/users/me", "-H", f"Authorization: Basic {bad}")
if code2 == "200":
say(" komga: READBACK UNUSABLE — an impossible user authenticated")
return None
say(f" komga: readback found={found} (http {code}, control refused {code2})")
return found
class Immich:
sub = "photos"; route = "its own /api/auth/admin-sign-up, then an album"
def seed(self, w, sub, say):
if not w.wait_app(sub, "/", want=("200", "302"), tries=90):
return None
u = "drill" + secrets.token_hex(4) + "@example.invalid"
pw = "Drill-" + secrets.token_hex(8)
rc, code, out = w.app_curl(sub, "/api/auth/admin-sign-up", "-H", "Content-Type: application/json",
data=json.dumps({"email": u, "password": pw, "name": "Drill"}),
method="POST")
if code not in ("200", "201"):
self.tried = f"POST /api/auth/admin-sign-up -> {code} {out[:150]}"
say(f" immich: sign-up -> {code} {out[:160]}")
return None
rc, code, out = w.app_curl(sub, "/api/auth/login", "-H", "Content-Type: application/json",
data=json.dumps({"email": u, "password": pw}), method="POST")
try:
at = json.loads(out)["accessToken"]
except Exception:
self.tried = f"POST /api/auth/login -> {code} {out[:150]}"
return None
name = "drillalbum" + secrets.token_hex(4)
rc, code, out = w.app_curl(sub, "/api/albums", "-H", f"Authorization: Bearer {at}",
"-H", "Content-Type: application/json",
data=json.dumps({"albumName": name}), method="POST")
if code not in ("200", "201"):
self.tried = f"POST /api/albums -> {code} {out[:150]}"
say(f" immich: album -> {code} {out[:150]}")
return None
say(f" immich: seeded album {name}")
return {"name": name, "u": u, "pw": pw}
def verify(self, w, sub, t, say):
rc, code, out = w.app_curl(sub, "/api/auth/login", "-H", "Content-Type: application/json",
data=json.dumps({"email": t["u"], "password": t["pw"]}),
method="POST")
try:
at = json.loads(out)["accessToken"]
except Exception:
say(f" immich: could not log back in (http {code})")
return False
rc, code, out = w.app_curl(sub, "/api/albums", "-H", f"Authorization: Bearer {at}")
found = t["name"] in (out or "")
absent = ("nope" + secrets.token_hex(6)) not in (out or "")
if not absent:
say(" immich: READBACK UNUSABLE")
return None
say(f" immich: readback found={found} (http {code}, control passed)")
return found
class _MediaServer:
"""jellyfin / emby — the startup wizard IS the front door on a fresh install."""
def seed(self, w, sub, say):
if not w.wait_app(sub, "/", want=("200", "302"), tries=90):
return None
u = "drill" + secrets.token_hex(4)
pw = "Drill-" + secrets.token_hex(8)
rc, code, out = w.app_curl(sub, "/Startup/User", "-H", "Content-Type: application/json",
data=json.dumps({"Name": u, "Password": pw}), method="POST")
if code not in ("200", "204"):
self.tried = f"POST /Startup/User -> {code} {out[:150]}"
say(f" {self.name}: /Startup/User -> {code} {out[:150]}")
return None
w.app_curl(sub, "/Startup/Complete", method="POST")
say(f" {self.name}: seeded first user {u}")
return {"u": u}
def verify(self, w, sub, t, say):
rc, code, out = w.app_curl(sub, "/Users/Public")
found = t["u"] in (out or "")
absent = ("nope" + secrets.token_hex(6)) not in (out or "")
if not absent:
say(f" {self.name}: READBACK UNUSABLE")
return None
say(f" {self.name}: readback found={found} (http {code}, control passed)")
return found
class Jellyfin(_MediaServer):
name = "jellyfin"; sub = "jellyfin"; route = "its own /Startup/User wizard, then /Users/Public"
class Emby(_MediaServer):
name = "emby"; sub = "emby"; route = "its own /Startup/User wizard, then /Users/Public"
class NoRoute:
"""An app whose only way in is a browser. The fixture RUNS, states what it tried, and returns
None. `inconclusive` with the attempts named is a result; a blank is not."""
def __init__(self, name, sub, tried):
self.name, self.sub, self.tried = name, sub, tried
self.route = "none — " + tried
def seed(self, w, sub, say):
w.wait_app(sub, "/", want=("200", "301", "302", "401", "403"), tries=30)
say(f" {self.name}: no non-browser seed route — {self.tried}")
return None
def verify(self, w, sub, t, say):
return False
FIXTURES28.update({
"termix": Termix(), "ghost": Ghost(), "komga": Komga(), "immich": Immich(),
"jellyfin": Jellyfin(), "emby": Emby(),
"code-server": NoRoute("code-server", "code", "its front door is a browser IDE behind one "
"password; it exposes no data API, and writing a file with docker exec "
"would not be the front door (R-156)"),
"onlyoffice": NoRoute("onlyoffice", "office", "a stateless document server: it holds no "
"household data of its own, so there is nothing to seed"),
"homepage": NoRoute("homepage", "home", "a dashboard rendered from config files in the "
"template; it stores no household data"),
"plex": NoRoute("plex", "plex", "the first-run claim needs a token minted at plex.tv by a "
"real Plex account; no account exists for this venue"),
# outline and rallly WERE NoRoute here until 2026-09-30: both have a front-door first-run route after all
# (outline `installation.create`, rallly sign-up + its own e-mail code) — the fixtures are in upgrade_fixtures_box.py.
})