Files
felhom.eu/documentation/audits/r649-2026-09-23/fixtures.py
T
2026-09-23 18:14:38 +02:00

1009 lines
49 KiB
Python

#!/usr/bin/env python3
"""Box-side seed/verify fixtures for walk.py, guest 9202.
THE ONE RULE (R-156), carried verbatim from `app-catalog-felhom.eu/scripts/upgrade_fixtures.py`:
*nothing is ever seeded into a volume by hand.* Every seed here goes in through the app's OWN
interface — its HTTP API through the household's real front door (traefik, `Host: <sub>.<domain>`),
or its own CLI running inside its own container. A raw SQL INSERT or a planted file is never used.
If an app has no non-browser route, its fixture returns None and the edge is recorded
`inconclusive — no non-browser seed route`, WITH WHAT WAS TRIED. That is a result, not a gap.
Each fixture:
seed(w, sub, say) -> an opaque token, or None
verify(w, sub, tok, say) -> True / False
verify() must ask the APP, never the filesystem: a migration is supposed to rewrite files.
Where a fixture can prove itself (a negative control that must read as absent) it does so on EVERY
call, so a readback that has broken into always saying "found" fails instead of passing everything.
"""
import base64, json, re, secrets, time
def _gx(w, container, *cmd, timeout=240):
"""Run a command inside the app's OWN container on 9202 (its own CLI, not our SQL)."""
import shlex
line = " ".join(shlex.quote(c) for c in cmd)
return w.guest(f"docker exec {container} {line} 2>&1", timeout=timeout)
# =============================================================================================
class PrivateBin:
"""PrivateBin's own JSON API. A paste is a POST and reading it back is a GET — an
application-level round trip. File-backed, no database: this single seed IS the file half."""
sub = "paste"
def seed(self, w, sub, say):
if not w.wait_app(sub, "/", want=("200",)):
return None
marker = "upg-" + secrets.token_hex(8)
ct = base64.b64encode(marker.encode()).decode()
body = json.dumps({
"v": 2,
"adata": [[base64.b64encode(secrets.token_bytes(16)).decode(),
base64.b64encode(secrets.token_bytes(8)).decode(),
100000, 256, 128, "aes", "gcm", "none"], "plaintext", 0, 0],
"ct": ct, "meta": {"expire": "never"}})
rc, code, out = w.app_curl(sub, "/", "-H", "X-Requested-With: JSONHttpRequest",
"-H", "Content-Type: application/json",
data=body, method="POST")
try:
j = json.loads(out)
except Exception:
say(f" privatebin: POST returned non-JSON (http {code}): {out[:200]}")
return None
if j.get("status") != 0 or not j.get("id"):
say(f" privatebin: POST refused: {out[:250]}")
return None
say(f" privatebin: seeded paste id={j['id']}")
return {"id": j["id"], "marker": ct}
def verify(self, w, sub, t, say):
if not w.wait_app(sub, "/", want=("200",), tries=36):
return False
# negative control, every call: a paste id that cannot exist must NOT read back
rc, code, out = w.app_curl(sub, "/?pasteid=" + secrets.token_hex(8),
"-H", "X-Requested-With: JSONHttpRequest")
if t["marker"] in out:
say(" privatebin: READBACK UNUSABLE — a paste id that cannot exist returned the marker")
return False
rc, code, out = w.app_curl(sub, "/?pasteid=" + t["id"],
"-H", "X-Requested-With: JSONHttpRequest")
got = code == "200" and t["marker"] in out
say(f" privatebin: readback http={code} marker_present={got}")
return got
# =============================================================================================
class Docmost:
"""Docmost's own REST API: create the first workspace+user, then prove the account survives by
asking the app to AUTHENTICATE it. Login is version-stable across the API churn."""
sub = "docs"
def seed(self, w, sub, say):
if not w.wait_app(sub, "/", want=("200", "302", "404")):
return None
email = f"drill-{secrets.token_hex(4)}@gate.invalid"
pw = "Drill-" + secrets.token_hex(10)
body = json.dumps({"workspaceName": "drill", "name": "drill", "email": email, "password": pw})
rc, code, out = w.app_curl(sub, "/api/auth/setup", "-H", "Content-Type: application/json",
data=body, method="POST")
say(f" docmost: /api/auth/setup http={code} rc={rc}")
if code not in ("200", "201"):
say(f" docmost: setup refused: {out[:250]}")
return None
return {"email": email, "pw": pw}
def verify(self, w, sub, t, say):
if not w.wait_app(sub, "/", want=("200", "302", "404"), tries=36):
return False
# negative control: a password that was never set must NOT authenticate
bad = json.dumps({"email": t["email"], "password": "definitely-" + secrets.token_hex(8)})
rc, code, _ = w.app_curl(sub, "/api/auth/login", "-H", "Content-Type: application/json",
data=bad, method="POST")
if code in ("200", "201"):
say(" docmost: READBACK UNUSABLE — a wrong password authenticated")
return False
body = json.dumps({"email": t["email"], "password": t["pw"]})
rc, code, out = w.app_curl(sub, "/api/auth/login", "-H", "Content-Type: application/json",
data=body, method="POST")
ok = code in ("200", "201")
say(f" docmost: login as the seeded user http={code} ok={ok}")
if not ok:
say(f" docmost: login body {out[:200]}")
return ok
# =============================================================================================
class BookStack:
"""BookStack mints no API token without a browser, so BOTH halves go through `php artisan` —
BookStack's OWN CLI, inside its own container, against its own User model.
The exit code carries no information here (`bookstack:reset-mfa` exits 1 for a user it FOUND
and for one it did not), so the discriminator is the OUTPUT: the positive sentence required and
the not-found sentence required absent. The negative control runs on every verify.
LIMITATION (R-460): this seeds the DATABASE half only. The FILE half needs the API token the
app cannot mint headlessly — so a bookstack edge is at best HALF-proven here.
"""
sub = "wiki"
def _artisan(self, w, *args):
for path in ("/app/www/artisan", "/var/www/html/artisan"):
out = _gx(w, "bookstack", "php", path, *args)
if "Could not open input file" not in out:
return " ".join(out.split())
return " ".join(out.split())
def _lookup(self, w, email):
out = self._artisan(w, "bookstack:reset-mfa", f"--email={email}")
found = f"Email: {email}" in out
missing = "could not be found" in out
if found == missing:
return None, out
return found, out
def seed(self, w, sub, say):
if not w.wait_app(sub, "/login", want=("200",), tries=72):
return None
email = f"drill-{secrets.token_hex(4)}@gate.invalid"
pw = "Drill-" + secrets.token_hex(10)
out = self._artisan(w, "bookstack:create-admin", f"--email={email}",
f"--name=drill-{secrets.token_hex(3)}", f"--password={pw}")
say(f" bookstack: artisan create-admin :: {out[:140]}")
if "successfully created" not in out:
return None
return {"email": email, "pw": pw}
def verify(self, w, sub, t, say):
if not w.wait_app(sub, "/login", want=("200",), tries=72):
say(" bookstack: the app never served /login")
return False
absent, _ = self._lookup(w, f"nobody-{secrets.token_hex(6)}@gate.invalid")
if absent is not False:
say(f" bookstack: READBACK UNUSABLE — an email that cannot exist did not read absent ({absent})")
return False
found, out = self._lookup(w, t["email"])
say(f" bookstack: readback of the seeded account found={found} :: {out[:140]}")
return found is True
# =============================================================================================
class Gitea:
"""Gitea's own admin CLI creates the first user; its own REST API (basic auth) then creates a
repository and reads it back. Both are the app's own interfaces."""
sub = "git"
def seed(self, w, sub, say):
if not w.wait_app(sub, "/", want=("200", "302")):
return None
user = "drill" + secrets.token_hex(3)
pw = "Drill-" + secrets.token_hex(10)
out = _gx(w, "gitea", "su", "git", "-c",
f"gitea admin user create --username {user} --password {pw} "
f"--email {user}@gate.invalid --admin --must-change-password=false")
say(f" gitea: admin user create :: {' '.join(out.split())[:140]}")
if "has been successfully created" not in out and "successfully created" not in out:
return None
repo = "drillrepo" + secrets.token_hex(3)
rc, code, body = w.app_curl(sub, "/api/v1/user/repos", "-u", f"{user}:{pw}",
"-H", "Content-Type: application/json",
data=json.dumps({"name": repo, "private": True}), method="POST")
say(f" gitea: create repo http={code}")
if code not in ("201", "200"):
say(f" gitea: repo refused {body[:200]}")
return None
return {"user": user, "pw": pw, "repo": repo}
def verify(self, w, sub, t, say):
if not w.wait_app(sub, "/", want=("200", "302"), tries=36):
return False
rc, code, _ = w.app_curl(sub, f"/api/v1/repos/{t['user']}/nope{secrets.token_hex(4)}",
"-u", f"{t['user']}:{t['pw']}")
if code == "200":
say(" gitea: READBACK UNUSABLE — a repo that cannot exist returned 200")
return False
rc, code, body = w.app_curl(sub, f"/api/v1/repos/{t['user']}/{t['repo']}",
"-u", f"{t['user']}:{t['pw']}")
ok = code == "200" and t["repo"] in body
say(f" gitea: readback of the seeded repo http={code} ok={ok}")
return ok
# =============================================================================================
class Navidrome:
"""Navidrome's own REST API: create the first admin through /auth/createAdmin, then prove the
account survives by logging in through the same door."""
sub = "music"
def seed(self, w, sub, say):
if not w.wait_app(sub, "/", want=("200", "302")):
return None
user = "drill" + secrets.token_hex(3)
pw = "Drill-" + secrets.token_hex(10)
rc, code, out = w.app_curl(sub, "/auth/createAdmin", "-H", "Content-Type: application/json",
data=json.dumps({"username": user, "password": pw}), method="POST")
say(f" navidrome: createAdmin http={code}")
if code not in ("200", "201"):
say(f" navidrome: refused {out[:200]}")
return None
return {"user": user, "pw": pw}
def verify(self, w, sub, t, say):
if not w.wait_app(sub, "/", want=("200", "302"), tries=36):
return False
bad = json.dumps({"username": t["user"], "password": "wrong-" + secrets.token_hex(6)})
rc, code, _ = w.app_curl(sub, "/auth/login", "-H", "Content-Type: application/json",
data=bad, method="POST")
if code in ("200", "201"):
say(" navidrome: READBACK UNUSABLE — a wrong password authenticated")
return False
body = json.dumps({"username": t["user"], "password": t["pw"]})
rc, code, out = w.app_curl(sub, "/auth/login", "-H", "Content-Type: application/json",
data=body, method="POST")
ok = code in ("200", "201")
say(f" navidrome: login as the seeded user http={code} ok={ok}")
return ok
# =============================================================================================
class Vaultwarden:
"""Vaultwarden's own account API: register an account, then prove it survives by asking the app
to issue a token for it (its own login endpoint, the household's own route)."""
sub = "vault"
def seed(self, w, sub, say):
if not w.wait_app(sub, "/alive", want=("200",)):
return None
email = f"drill-{secrets.token_hex(4)}@gate.invalid"
# Vaultwarden stores an already-hashed master key; the value is opaque to the server.
key = base64.b64encode(secrets.token_bytes(32)).decode()
body = json.dumps({"email": email, "name": "drill", "masterPasswordHash": key,
"key": "0." + base64.b64encode(secrets.token_bytes(48)).decode(),
"kdf": 0, "kdfIterations": 600000})
rc, code, out = w.app_curl(sub, "/api/accounts/register",
"-H", "Content-Type: application/json",
data=body, method="POST")
say(f" vaultwarden: register http={code}")
if code not in ("200", "204"):
say(f" vaultwarden: refused {out[:250]}")
return None
return {"email": email, "key": key}
def verify(self, w, sub, t, say):
if not w.wait_app(sub, "/alive", want=("200",), tries=36):
return False
def login(pwhash):
return w.app_curl(sub, "/identity/connect/token",
"-H", "Content-Type: application/x-www-form-urlencoded",
data=("grant_type=password&scope=api%20offline_access"
f"&client_id=web&deviceType=9&deviceIdentifier=drill"
f"&deviceName=drill&username={t['email']}&password={pwhash}"),
method="POST")
rc, code, _ = login(base64.b64encode(secrets.token_bytes(32)).decode())
if code == "200":
say(" vaultwarden: READBACK UNUSABLE — a wrong master key authenticated")
return False
rc, code, out = login(t["key"].replace("+", "%2B").replace("=", "%3D").replace("/", "%2F"))
ok = code == "200" and "access_token" in out
say(f" vaultwarden: token for the seeded account http={code} ok={ok}")
if not ok:
say(f" vaultwarden: body {out[:200]}")
return ok
# =============================================================================================
class Django:
"""A Django app's OWN management CLI, inside its own container, against its own User model.
Same category as BookStack's `php artisan`: the app's own code and its own ORM, never a raw SQL
INSERT and never a planted file (R-156). `createsuperuser --noinput` is Django's own documented
non-interactive route, and the readback asks the SAME ORM whether the account exists.
THE FIXTURE PROVES ITSELF ON EVERY CALL: each verify() also asks for a username that cannot
exist and requires the answer False. A readback that has broken into always saying True
therefore fails instead of passing everything.
LIMITATION, recorded rather than papered over: this seeds the DATABASE half only. An app whose
data is also FILES (adventurelog's images) has a file half this fixture does not touch.
"""
def __init__(self, container, sub, ready_path="/", ready=("200", "302", "301", "404"),
python="python", workdir=None):
# `python` and `workdir` are per-app because the image decides them: adventurelog's
# interpreter is on PATH, tandoor ships a VENV and the bare `python` cannot import Django
# at all ("Couldn't import Django. Are you sure it's installed…"). Measured, not guessed.
self.container = container
self.sub = sub
self.ready_path = ready_path
self.ready = ready
self.python = python
self.workdir = workdir
def _wd(self):
return f"-w {self.workdir} " if self.workdir else ""
def _manage(self, w, code):
# -c is passed to `manage.py shell`; the app's own shell, its own ORM.
return w.guest(
f"docker exec {self._wd()}{self.container} {self.python} manage.py shell "
f"-c {json.dumps(code)} 2>&1", timeout=300)
def _exists(self, w, username):
# ONE LINE, semicolon-separated. A `\n` inside a double-quoted shell argument reaches
# python as a literal backslash-n and is a SyntaxError — which is exactly how the first
# adventurelog run read as `inconclusive`. The fixture refused to guess, which is right,
# but the instrument was the thing that was broken.
out = self._manage(w, (
"from django.contrib.auth import get_user_model; "
f"print('DRILL_ANSWER=' + str(get_user_model().objects.filter(username={username!r}).exists()))"
))
m = re.search(r"DRILL_ANSWER=(True|False)", out)
return (m.group(1) == "True") if m else None, " ".join(out.split())[-300:]
def seed(self, w, sub, say):
if not w.wait_app(sub, self.ready_path, want=self.ready, tries=90):
return None
user = "drill" + secrets.token_hex(3)
pw = "Drill-" + secrets.token_hex(10)
out = w.guest(
f"docker exec -e DJANGO_SUPERUSER_PASSWORD={pw} {self._wd()}{self.container} "
f"{self.python} manage.py createsuperuser --noinput "
f"--username {user} --email {user}@gate.invalid 2>&1", timeout=300)
say(f" {self.container}: createsuperuser :: {' '.join(out.split())[:160]}")
got, detail = self._exists(w, user)
if got is not True:
say(f" {self.container}: the account did not appear in the app's own ORM :: {detail[:200]}")
return None
say(f" {self.container}: seeded superuser {user}")
return {"user": user, "pw": pw}
def verify(self, w, sub, t, say):
if not w.wait_app(sub, self.ready_path, want=self.ready, tries=90):
say(f" {self.container}: the app never served {self.ready_path}")
return False
absent, detail = self._exists(w, "nobody" + secrets.token_hex(6))
if absent is not False:
say(f" {self.container}: READBACK UNUSABLE — a username that cannot exist did not "
f"read as absent ({absent}) :: {detail[:200]}")
return False
found, detail = self._exists(w, t["user"])
say(f" {self.container}: readback of the seeded account found={found}")
if found is not True:
say(f" {self.container}: :: {detail[:250]}")
return found is True
# =============================================================================================
class Nextcloud:
"""Nextcloud's OWN admin CLI, `occ`, inside its own container: its own code, its own user
backend. Not a SQL INSERT and not a planted file (R-156).
`occ user:info` is the readback, and it PROVES ITSELF on every call: a uid that cannot exist
must answer "user not found". A readback that has broken into always succeeding therefore
fails instead of passing everything.
This is the app chosen for the MariaDB engine-major edge (`09` §3 decision 5, R-469 lifted):
the app image does NOT move, only the `mariadb:` sidecar, so the edge carries exactly one
migration and a failure is readable.
"""
sub = "cloud"
def _occ(self, w, *args, timeout=420):
import shlex
line = " ".join(shlex.quote(a) for a in args)
return w.guest(f"docker exec -u www-data nextcloud php occ {line} 2>&1", timeout=timeout)
def _info(self, w, uid):
out = self._occ(w, "user:info", uid)
flat = " ".join(out.split())
if "user not found" in flat.lower() or "could not be found" in flat.lower():
return False, flat
if f"user_id: {uid}" in flat or f"- user_id: {uid}" in flat or f"user_id: {uid}" in out:
return True, flat
return None, flat
def seed(self, w, sub, say):
if not w.wait_app(sub, "/status.php", want=("200",), tries=120):
return None
uid = "drill" + secrets.token_hex(3)
pw = "Drill-" + secrets.token_hex(10)
out = w.guest(
f"docker exec -u www-data -e OC_PASS={pw} nextcloud php occ user:add "
f"--password-from-env --display-name={uid} {uid} 2>&1", timeout=420)
say(f" nextcloud: occ user:add :: {' '.join(out.split())[:160]}")
got, flat = self._info(w, uid)
if got is not True:
say(f" nextcloud: the account did not appear via occ user:info :: {flat[:220]}")
return None
say(f" nextcloud: seeded user {uid}")
return {"uid": uid, "pw": pw}
def verify(self, w, sub, t, say):
if not w.wait_app(sub, "/status.php", want=("200",), tries=120):
say(" nextcloud: the app never served /status.php")
return False
absent, flat = self._info(w, "nobody" + secrets.token_hex(6))
if absent is not False:
say(f" nextcloud: READBACK UNUSABLE — a uid that cannot exist did not read absent "
f"({absent}) :: {flat[:200]}")
return False
found, flat = self._info(w, t["uid"])
say(f" nextcloud: readback of the seeded user found={found}")
if found is not True:
say(f" nextcloud: :: {flat[:250]}")
return found is True
# =============================================================================================
class Grafana:
"""Grafana's own HTTP API as the admin the DEPLOY created. The password is the one the
controller showed the household — read from the app's own `app.yaml`, not invented — and the
data (a folder) goes in and comes back through the app's own REST API."""
sub = "grafana"
def _auth(self, w, name="grafana"):
# app.yaml stores this ENCRYPTED (`ENC:…`), so it cannot be read back off the box — which
# is correct, and is why the harness uses the value IT generated for the deploy.
pw = (w.GENERATED.get(name) or {}).get("GF_SECURITY_ADMIN_PASSWORD") or "admin"
return f"admin:{pw}"
def seed(self, w, sub, say):
if not w.wait_app(sub, "/api/health", want=("200",), tries=72):
return None
au = self._auth(w)
title = "drill-" + secrets.token_hex(5)
rc, code, body = w.app_curl(sub, "/api/folders", "-u", au,
"-H", "Content-Type: application/json",
data=json.dumps({"title": title}), method="POST")
say(f" grafana: create folder http={code}")
if code not in ("200", "201"):
say(f" grafana: refused {body[:220]}")
return None
try:
uid = json.loads(body)["uid"]
except Exception:
say(f" grafana: no uid in {body[:200]}")
return None
return {"uid": uid, "title": title}
def verify(self, w, sub, t, say):
if not w.wait_app(sub, "/api/health", want=("200",), tries=72):
return False
au = self._auth(w)
rc, code, _ = w.app_curl(sub, "/api/folders/nope" + secrets.token_hex(5), "-u", au)
if code == "200":
say(" grafana: READBACK UNUSABLE — a folder uid that cannot exist returned 200")
return False
rc, code, body = w.app_curl(sub, f"/api/folders/{t['uid']}", "-u", au)
ok = code == "200" and t["title"] in body
say(f" grafana: readback of the seeded folder http={code} ok={ok}")
return ok
# =============================================================================================
class AudiobookShelf:
"""audiobookshelf's own /init endpoint creates the first root account; its own /login proves
the account survived. Both are the app's own API."""
sub = "audiobooks"
def seed(self, w, sub, say):
if not w.wait_app(sub, "/status", want=("200",), tries=72):
return None
user = "drill" + secrets.token_hex(3)
pw = "Drill-" + secrets.token_hex(10)
rc, code, body = w.app_curl(sub, "/init", "-H", "Content-Type: application/json",
data=json.dumps({"newRoot": {"username": user, "password": pw}}),
method="POST")
say(f" audiobookshelf: /init http={code}")
if code not in ("200", "204"):
say(f" audiobookshelf: refused {body[:220]}")
return None
return {"user": user, "pw": pw}
def verify(self, w, sub, t, say):
if not w.wait_app(sub, "/status", want=("200",), tries=72):
return False
bad = json.dumps({"username": t["user"], "password": "wrong-" + secrets.token_hex(6)})
rc, code, _ = w.app_curl(sub, "/login", "-H", "Content-Type: application/json",
data=bad, method="POST")
if code == "200":
say(" audiobookshelf: READBACK UNUSABLE — a wrong password authenticated")
return False
rc, code, body = w.app_curl(sub, "/login", "-H", "Content-Type: application/json",
data=json.dumps({"username": t["user"], "password": t["pw"]}),
method="POST")
ok = code == "200" and t["user"] in body
say(f" audiobookshelf: login as the seeded root http={code} ok={ok}")
return ok
# =============================================================================================
class ActualBudget:
"""Actual's own bootstrap API sets the server password; its own login proves it survived."""
sub = "budget"
def seed(self, w, sub, say):
if not w.wait_app(sub, "/", want=("200", "302"), tries=72):
return None
pw = "Drill-" + secrets.token_hex(10)
rc, code, body = w.app_curl(sub, "/account/bootstrap",
"-H", "Content-Type: application/json",
data=json.dumps({"password": pw}), method="POST")
say(f" actualbudget: /account/bootstrap http={code} :: {body[:140]}")
if code not in ("200", "201") or '"status":"ok"' not in body:
return None
return {"pw": pw}
def verify(self, w, sub, t, say):
if not w.wait_app(sub, "/", want=("200", "302"), tries=72):
return False
def login(p):
return w.app_curl(sub, "/account/login", "-H", "Content-Type: application/json",
data=json.dumps({"loginMethod": "password", "password": p}),
method="POST")
rc, code, body = login("wrong-" + secrets.token_hex(6))
if '"status":"ok"' in body:
say(" actualbudget: READBACK UNUSABLE — a wrong password authenticated")
return False
rc, code, body = login(t["pw"])
ok = '"status":"ok"' in body
say(f" actualbudget: login with the seeded password http={code} ok={ok}")
if not ok:
say(f" actualbudget: body {body[:200]}")
return ok
# =============================================================================================
class Mealie:
"""Mealie ships a documented first-run admin. We log in as it through the app's own OAuth-style
token endpoint, create a recipe through the app's own API, and read the recipe back."""
sub = "recipes"
def _token(self, w, sub, pw="MyPassword"):
rc, code, body = w.app_curl(
sub, "/api/auth/token", "-H", "Content-Type: application/x-www-form-urlencoded",
data=f"username=changeme%40example.com&password={pw}", method="POST")
if code != "200":
return None, f"http={code} {body[:200]}"
try:
return json.loads(body)["access_token"], ""
except Exception:
return None, body[:200]
def seed(self, w, sub, say):
if not w.wait_app(sub, "/api/app/about", want=("200",), tries=90):
return None
tok, why = self._token(w, sub)
if not tok:
say(f" mealie: could not authenticate as the first-run admin :: {why}")
return None
name = "drill-" + secrets.token_hex(5)
rc, code, body = w.app_curl(sub, "/api/recipes", "-H", f"Authorization: Bearer {tok}",
"-H", "Content-Type: application/json",
data=json.dumps({"name": name}), method="POST")
say(f" mealie: create recipe http={code}")
if code not in ("200", "201"):
say(f" mealie: refused {body[:220]}")
return None
slug = body.strip().strip('"')
return {"slug": slug, "name": name}
def verify(self, w, sub, t, say):
if not w.wait_app(sub, "/api/app/about", want=("200",), tries=90):
return False
tok, why = self._token(w, sub)
if not tok:
say(f" mealie: could not authenticate after the update :: {why}")
return False
rc, code, _ = w.app_curl(sub, "/api/recipes/nope" + secrets.token_hex(5),
"-H", f"Authorization: Bearer {tok}")
if code == "200":
say(" mealie: READBACK UNUSABLE — a slug that cannot exist returned 200")
return False
rc, code, body = w.app_curl(sub, f"/api/recipes/{t['slug']}",
"-H", f"Authorization: Bearer {tok}")
ok = code == "200" and t["name"] in body
say(f" mealie: readback of the seeded recipe http={code} ok={ok}")
return ok
# =============================================================================================
class N8n:
"""n8n's own owner-setup API creates the first account; its own login proves it survived."""
sub = "auto"
def seed(self, w, sub, say):
if not w.wait_app(sub, "/healthz", want=("200",), tries=90):
return None
email = f"drill-{secrets.token_hex(4)}@gate.invalid"
pw = "Drill" + secrets.token_hex(8) + "1"
rc, code, body = w.app_curl(sub, "/rest/owner/setup", "-H", "Content-Type: application/json",
data=json.dumps({"email": email, "firstName": "drill",
"lastName": "drill", "password": pw}),
method="POST")
say(f" n8n: /rest/owner/setup http={code}")
if code not in ("200", "201"):
say(f" n8n: refused {body[:220]}")
return None
return {"email": email, "pw": pw}
def verify(self, w, sub, t, say):
if not w.wait_app(sub, "/healthz", want=("200",), tries=90):
return False
def login(p):
return w.app_curl(sub, "/rest/login", "-H", "Content-Type: application/json",
data=json.dumps({"emailOrLdapLoginId": t["email"], "password": p}),
method="POST")
rc, code, _ = login("wrong-" + secrets.token_hex(6))
if code == "200":
say(" n8n: READBACK UNUSABLE — a wrong password authenticated")
return False
rc, code, body = login(t["pw"])
ok = code == "200" and t["email"] in body
say(f" n8n: login as the seeded owner http={code} ok={ok}")
return ok
# =============================================================================================
class Zipline:
"""Zipline's own setup/login API. Zipline 4 creates the first user through its own endpoint."""
sub = "img"
def seed(self, w, sub, say):
if not w.wait_app(sub, "/api/healthcheck", want=("200",), tries=90):
if not w.wait_app(sub, "/", want=("200", "302", "307"), tries=30):
return None
user = "drill" + secrets.token_hex(3)
pw = "Drill-" + secrets.token_hex(10)
for path in ("/api/auth/register", "/api/auth/setup"):
rc, code, body = w.app_curl(sub, path, "-H", "Content-Type: application/json",
data=json.dumps({"username": user, "password": pw}),
method="POST")
say(f" zipline: {path} http={code} :: {body[:160]}")
if code in ("200", "201"):
return {"user": user, "pw": pw}
say(" zipline: neither register nor setup accepted a first user")
return None
def verify(self, w, sub, t, say):
if not w.wait_app(sub, "/", want=("200", "302", "307"), tries=60):
return False
def login(p):
return w.app_curl(sub, "/api/auth/login", "-H", "Content-Type: application/json",
data=json.dumps({"username": t["user"], "password": p}),
method="POST")
rc, code, _ = login("wrong-" + secrets.token_hex(6))
if code == "200":
say(" zipline: READBACK UNUSABLE — a wrong password authenticated")
return False
rc, code, body = login(t["pw"])
ok = code == "200"
say(f" zipline: login as the seeded user http={code} ok={ok}")
return ok
# =============================================================================================
class Vikunja:
"""Vikunja's own REST API: register a user, log in, create a project, read the project back.
Four calls, all the app's own front door."""
sub = "tasks"
def _token(self, w, sub, t, pw=None):
rc, code, body = w.app_curl(sub, "/api/v1/login", "-H", "Content-Type: application/json",
data=json.dumps({"username": t["user"],
"password": pw or t["pw"]}), method="POST")
if code != "200":
return None, f"http={code} {body[:160]}"
try:
return json.loads(body)["token"], ""
except Exception:
return None, body[:160]
def seed(self, w, sub, say):
if not w.wait_app(sub, "/api/v1/info", want=("200",), tries=72):
return None
user = "drill" + secrets.token_hex(3)
pw = "Drill-" + secrets.token_hex(10)
rc, code, body = w.app_curl(sub, "/api/v1/register", "-H", "Content-Type: application/json",
data=json.dumps({"username": user, "password": pw,
"email": f"{user}@gate.invalid"}),
method="POST")
say(f" vikunja: register http={code}")
if code not in ("200", "201"):
say(f" vikunja: refused {body[:220]}")
return None
t = {"user": user, "pw": pw}
tok, why = self._token(w, sub, t)
if not tok:
say(f" vikunja: could not log in after registering :: {why}")
return None
title = "drill-" + secrets.token_hex(5)
# Vikunja CREATES with PUT, not POST — a POST answers `405 Method Not Allowed`, which
# reads like a broken fixture and is really the wrong verb. Measured 2026-09-21.
rc, code, body = w.app_curl(sub, "/api/v1/projects", "-H", f"Authorization: Bearer {tok}",
"-H", "Content-Type: application/json",
data=json.dumps({"title": title}), method="PUT")
say(f" vikunja: create project http={code}")
if code not in ("200", "201"):
say(f" vikunja: project refused {body[:220]}")
return None
t["title"] = title
t["pid"] = json.loads(body).get("id")
return t
def verify(self, w, sub, t, say):
if not w.wait_app(sub, "/api/v1/info", want=("200",), tries=72):
return False
bad, why = self._token(w, sub, t, pw="wrong-" + secrets.token_hex(6))
if bad:
say(" vikunja: READBACK UNUSABLE — a wrong password authenticated")
return False
tok, why = self._token(w, sub, t)
if not tok:
say(f" vikunja: the seeded account no longer authenticates :: {why}")
return False
rc, code, body = w.app_curl(sub, f"/api/v1/projects/{t['pid']}",
"-H", f"Authorization: Bearer {tok}")
ok = code == "200" and t["title"] in body
say(f" vikunja: readback of the seeded project http={code} ok={ok}")
return ok
# =============================================================================================
class OpenGist:
"""Opengist's own sign-up and sign-in FORMS.
Two things had to be measured. Its sign-up is CSRF-protected: a bare POST answers 500 with an
HTML page, which reads like a broken app and is really a missing token — fetch the form, keep
its cookie, send its `_csrf` back. And its REST API refuses the account's own password
(`401 {"message":"Bad crendentials"}`) because it wants a token the app will not mint without a
browser. So the SEEDED DATA is the account itself and the READBACK is a real sign-in, which is
the same shape the docmost and navidrome fixtures use.
LIMITATION, recorded rather than papered over: this is the DATABASE half. A gist's CONTENT is
not seeded, because that needs the API token above.
"""
sub = "gist"
def _form(self, w, sub, path, jar, fields):
rc, code, html = w.app_curl(sub, path, "-b", jar, "-c", jar)
m = re.search(r'name="_csrf"[^>]*value="([^"]+)"', html or "")
if not m:
return None, f"no _csrf on {path} (http={code})"
body = "&".join([f"_csrf={m.group(1)}"] + [f"{k}={v}" for k, v in fields.items()])
rc, code, out = w.app_curl(sub, path, "-b", jar, "-c", jar,
"-H", "Content-Type: application/x-www-form-urlencoded",
data=body, method="POST")
return code, out
def seed(self, w, sub, say):
if not w.wait_app(sub, "/", want=("200", "302"), tries=72):
return None
user = "drill" + secrets.token_hex(3)
pw = "Drill-" + secrets.token_hex(10)
jar = f"/tmp/og-{secrets.token_hex(4)}.jar"
code, out = self._form(w, sub, "/register", jar, {"username": user, "password": pw})
say(f" opengist: /register (with its own _csrf) http={code}")
if code not in ("200", "302", "303"):
say(f" opengist: refused {str(out)[:200]}")
return None
return {"user": user, "pw": pw}
def verify(self, w, sub, t, say):
# Wait for the LOGIN FORM, not for the root page. Measured 2026-09-21: immediately after a
# successful update the root answers while /login does not yet carry its `_csrf`, so the
# sign-in silently fails and the app looks like it lost the account. It had not.
if not w.wait_app(sub, "/login", want=("200",), tries=72):
say(" opengist: /login never came back after the update")
return False
for _ in range(24):
rc, code, html = w.app_curl(sub, "/login")
if code == "200" and '_csrf' in (html or ""):
break
time.sleep(5)
jar = f"/tmp/og-{secrets.token_hex(4)}.jar"
code, _ = self._form(w, sub, "/login", jar,
{"username": t["user"], "password": "wrong-" + secrets.token_hex(5)})
rc, c2, home = w.app_curl(sub, "/", "-b", jar)
if t["user"] in (home or ""):
say(" opengist: READBACK UNUSABLE — a wrong password signed in")
return False
jar2 = f"/tmp/og-{secrets.token_hex(4)}.jar"
code, _ = self._form(w, sub, "/login", jar2, {"username": t["user"], "password": t["pw"]})
rc, c2, home = w.app_curl(sub, "/", "-b", jar2)
ok = t["user"] in (home or "")
say(f" opengist: sign-in as the seeded account http={code} name_on_page={ok}")
return ok
# =============================================================================================
class Papra:
"""Papra's own e-mail sign-up and sign-in endpoints."""
sub = "papra"
def seed(self, w, sub, say):
if not w.wait_app(sub, "/api/health", want=("200",), tries=72):
if not w.wait_app(sub, "/", want=("200", "302"), tries=30):
return None
email = f"drill-{secrets.token_hex(4)}@gate.invalid"
pw = "Drill-" + secrets.token_hex(10)
rc, code, body = w.app_curl(sub, "/api/auth/sign-up/email",
"-H", "Content-Type: application/json",
data=json.dumps({"email": email, "password": pw,
"name": "drill"}), method="POST")
say(f" papra: sign-up http={code}")
if code not in ("200", "201"):
say(f" papra: refused {body[:220]}")
return None
return {"email": email, "pw": pw}
def verify(self, w, sub, t, say):
if not w.wait_app(sub, "/", want=("200", "302"), tries=72):
return False
def signin(p):
return w.app_curl(sub, "/api/auth/sign-in/email",
"-H", "Content-Type: application/json",
data=json.dumps({"email": t["email"], "password": p}), method="POST")
rc, code, _ = signin("wrong-" + secrets.token_hex(6))
if code == "200":
say(" papra: READBACK UNUSABLE — a wrong password authenticated")
return False
rc, code, body = signin(t["pw"])
ok = code == "200"
say(f" papra: sign-in as the seeded account http={code} ok={ok}")
return ok
# =============================================================================================
class HomeAssistant:
"""Home Assistant's own onboarding API creates the owner account and hands back a code the
same API exchanges for a token. Both are the app's own documented non-browser route."""
sub = "ha"
def seed(self, w, sub, say):
if not w.wait_app(sub, "/", want=("200", "302"), tries=120):
return None
user = "drill" + secrets.token_hex(3)
pw = "Drill-" + secrets.token_hex(10)
rc, code, body = w.app_curl(sub, "/api/onboarding/users",
"-H", "Content-Type: application/json",
data=json.dumps({"client_id": f"https://{sub}.felhom.invalid/",
"name": "drill", "username": user,
"password": pw, "language": "en"}),
method="POST")
say(f" home-assistant: /api/onboarding/users http={code}")
if code not in ("200", "201"):
say(f" home-assistant: refused {body[:220]}")
return None
return {"user": user, "pw": pw}
def _login(self, w, sub, user, pw):
"""The app's own login flow: start it, then answer it. A 200 with a step_id of
`mfa`/`init` means the credentials were REFUSED; only `create_entry` is a pass."""
rc, code, body = w.app_curl(sub, "/auth/login_flow",
"-H", "Content-Type: application/json",
data=json.dumps({"client_id": f"https://{sub}.felhom.invalid/",
"handler": ["homeassistant", None],
"redirect_uri": f"https://{sub}.felhom.invalid/",
"type": "authorize"}), method="POST")
if code not in ("200", "201"):
return None, f"flow start http={code} {body[:160]}"
try:
fid = json.loads(body)["flow_id"]
except Exception:
return None, body[:160]
rc, code, body = w.app_curl(sub, f"/auth/login_flow/{fid}",
"-H", "Content-Type: application/json",
data=json.dumps({"client_id": f"https://{sub}.felhom.invalid/",
"username": user, "password": pw}),
method="POST")
try:
j = json.loads(body)
except Exception:
return None, body[:160]
return (j.get("result") if j.get("type") == "create_entry" else None), body[:200]
def verify(self, w, sub, t, say):
if not w.wait_app(sub, "/", want=("200", "302"), tries=120):
return False
bad, why = self._login(w, sub, t["user"], "wrong-" + secrets.token_hex(6))
if bad:
say(" home-assistant: READBACK UNUSABLE — a wrong password authenticated")
return False
good, why = self._login(w, sub, t["user"], t["pw"])
ok = bool(good)
say(f" home-assistant: login as the seeded owner ok={ok}")
if not ok:
say(f" home-assistant: {why}")
return ok
# =============================================================================================
class Romm:
"""RomM's own user API, driven the way RomM's own front end drives it.
Three things had to be measured rather than guessed, and each one answered a 403 or a 422 that
looked like a different fault: RomM sets a **`romm_csrftoken` cookie** on any GET and requires
it back in an **`x-csrftoken` header** (a bare POST is `403 CSRF token verification failed`,
which reads like an auth problem); the fields go in the **JSON body**, not the query string (a
query-string POST is `422 Field required` for every field it was just given); and `email` is
required alongside username, password and role.
On a fresh install with no admin the first `POST /api/users` is accepted unauthenticated;
afterwards it is not — which is what makes the readback (`POST /api/login` as that user) a real
authentication rather than a repeat of the seed.
LIMITATION: this is the DATABASE half. RomM's other half is the ROM library on the drive, which
this does not populate.
"""
sub = "arcade"
def _csrf(self, w, sub):
jar = f"/tmp/romm-{secrets.token_hex(4)}.jar"
w.app_curl(sub, "/api/heartbeat", "-c", jar)
out = w.sh(["bash", "-lc", f"grep -i csrf {jar} | awk '{{print $7}}'"]).stdout or ""
return jar, out.strip()
def seed(self, w, sub, say):
if not w.wait_app(sub, "/api/heartbeat", want=("200",), tries=120):
if not w.wait_app(sub, "/", want=("200", "302"), tries=30):
return None
jar, tok = self._csrf(w, sub)
if not tok:
say(" romm: no romm_csrftoken cookie was set on /api/heartbeat")
return None
user = "drill" + secrets.token_hex(3)
pw = "Drill-" + secrets.token_hex(10)
rc, code, body = w.app_curl(
sub, "/api/users", "-b", jar, "-H", f"x-csrftoken: {tok}",
"-H", "Content-Type: application/json",
data=json.dumps({"username": user, "email": f"{user}@gate.invalid",
"password": pw, "role": "admin"}), method="POST")
say(f" romm: POST /api/users http={code}")
if code not in ("200", "201"):
say(f" romm: refused {body[:220]}")
return None
return {"user": user, "pw": pw}
def verify(self, w, sub, t, say):
if not w.wait_app(sub, "/api/heartbeat", want=("200",), tries=120):
return False
jar, tok = self._csrf(w, sub)
rc, code, _ = w.app_curl(sub, "/api/login", "-b", jar, "-H", f"x-csrftoken: {tok}",
"-u", f"{t['user']}:wrong-{secrets.token_hex(5)}", method="POST")
if code == "200":
say(" romm: READBACK UNUSABLE — a wrong password authenticated")
return False
rc, code, body = w.app_curl(sub, "/api/login", "-b", jar, "-H", f"x-csrftoken: {tok}",
"-u", f"{t['user']}:{t['pw']}", method="POST")
ok = code == "200"
say(f" romm: login as the seeded user http={code} ok={ok}")
if not ok:
say(f" romm: body {body[:200]}")
return ok
FIXTURES = {
"home-assistant": HomeAssistant(),
"romm": Romm(),
"vikunja": Vikunja(),
"opengist": OpenGist(),
"papra": Papra(),
"mealie": Mealie(),
"n8n": N8n(),
"zipline": Zipline(),
"grafana": Grafana(),
"audiobookshelf": AudiobookShelf(),
"actualbudget": ActualBudget(),
"nextcloud": Nextcloud(),
"adventurelog": Django("adventurelog", "travel", "/admin/login/"),
"tandoor": Django("tandoor", "recipes", "/accounts/login/",
python="/opt/recipes/venv/bin/python", workdir="/opt/recipes"),
"privatebin": PrivateBin(),
"docmost": Docmost(),
"bookstack": BookStack(),
"gitea": Gitea(),
"navidrome": Navidrome(),
"vaultwarden": Vaultwarden(),
}