Upgrade harness: four fixtures and seven real upstream edges from the update night (R-462)
gates / gates (push) Successful in 1s
gates / gates (push) Successful in 1s
Test code only — no template changed and no image: line moved. The update night walked real within-a-major upstream edges on scratch guest 9202 through the product's own guarded Update, against a PRIVATE DRILL CATALOG; the live catalog was never touched. This brings the expensive half of that work — the seed routes — back into the harness so the same edges can be run here WITH their ABORT step, which the box deliberately does not offer (09 6.1: whether the old image starts on migrated data is per-app and unpredictable). - upgrade_fixtures.py: ActualBudget, Navidrome, AudiobookShelf, Vikunja. Each seeds through the app's OWN interface (R-156); each carries a negative control run on every verify(), so a readback that has broken into always succeeding fails instead of passing everything. - upgrade-test.py: edges U1..U7, all real upstream moves existing 2026-09-21 that this catalog has NOT made, each holding its database engine constant. - Limitations kept: Navidrome and AudiobookShelf seed the DATABASE half only, and say so. OWED, stated so it is not mistaken for done: the U1..U7 harness RUNS, and with them the per-app ABORT answers. The code is in; the runs are not. Gates: catalog_gates.py --fast — image-pins, engine-major, catalog-since, copy-i18n all OK. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
+230
-1
@@ -265,4 +265,233 @@ class BookStack:
|
||||
return found is True
|
||||
|
||||
|
||||
FIXTURES = {"privatebin": PrivateBin(), "docmost": Docmost(), "bookstack": BookStack()}
|
||||
# ---------------------------------------------------------------------------------------------
|
||||
# Added 2026-09-21 by the update night (R-462's widening). Each of these was written and PROVEN
|
||||
# box-side first, on guest 9202 through the product's own guarded Update, and then ported here so
|
||||
# the same edge can be run on the harness venue with its ABORT step. The box-side evidence is
|
||||
# `felhom.eu/documentation/audits/update-night-2026-09-21/apps/<app>/`.
|
||||
#
|
||||
# The port is mechanical and one thing changes: box-side the app is reached through traefik with a
|
||||
# `Host:` header, here through the container's own IP. The SEED ROUTE is identical, and that is the
|
||||
# expensive half.
|
||||
# ---------------------------------------------------------------------------------------------
|
||||
|
||||
|
||||
class ActualBudget:
|
||||
"""Actual's own bootstrap API sets the server password; its own login proves it survived.
|
||||
|
||||
Actual keeps its data in SQLite inside its own volume and performs its own schema migration on
|
||||
start, so this single seed is the whole data half.
|
||||
"""
|
||||
port = 5006
|
||||
container = "actualbudget"
|
||||
|
||||
def _base(self, ipfn):
|
||||
ip = ipfn(self.container)
|
||||
return f"http://{ip}:{self.port}" if ip else ""
|
||||
|
||||
def seed(self, ipfn, say):
|
||||
base = self._base(ipfn)
|
||||
if not base or not _wait_http(base + "/", {"200", "302"}, say=say):
|
||||
say(" actualbudget: no container IP, or the app never answered")
|
||||
return None
|
||||
pw = "Spike-" + secrets.token_hex(10)
|
||||
rc, code, out = _curl(base + "/account/bootstrap", "-H", "Content-Type: application/json",
|
||||
data=json.dumps({"password": pw}), method="POST")
|
||||
say(f" actualbudget: /account/bootstrap http={code} :: {out[:140]}")
|
||||
if rc != 0 or '"status":"ok"' not in out:
|
||||
return None
|
||||
return {"pw": pw}
|
||||
|
||||
def verify(self, ipfn, seeded, say):
|
||||
base = self._base(ipfn)
|
||||
if not base or not _wait_http(base + "/", {"200", "302"}, tries=24, say=say):
|
||||
return False
|
||||
|
||||
def login(p):
|
||||
return _curl(base + "/account/login", "-H", "Content-Type: application/json",
|
||||
data=json.dumps({"loginMethod": "password", "password": p}),
|
||||
method="POST")
|
||||
|
||||
# the fixture's own negative control, run on every verify
|
||||
rc, code, out = login("wrong-" + secrets.token_hex(6))
|
||||
if '"status":"ok"' in out:
|
||||
say(" actualbudget: READBACK IS UNUSABLE — a wrong password authenticated")
|
||||
return False
|
||||
rc, code, out = login(seeded["pw"])
|
||||
ok = '"status":"ok"' in out
|
||||
say(f" actualbudget: login with the seeded password http={code} ok={ok}")
|
||||
return ok
|
||||
|
||||
|
||||
class Navidrome:
|
||||
"""Navidrome's own /auth/createAdmin makes the first account; its own /auth/login proves it
|
||||
survived. LIMITATION: this is the DATABASE half. Navidrome's other half is the music library on
|
||||
the drive, which the harness does not populate."""
|
||||
port = 4533
|
||||
container = "navidrome"
|
||||
|
||||
def _base(self, ipfn):
|
||||
ip = ipfn(self.container)
|
||||
return f"http://{ip}:{self.port}" if ip else ""
|
||||
|
||||
def seed(self, ipfn, say):
|
||||
base = self._base(ipfn)
|
||||
if not base or not _wait_http(base + "/", {"200", "302"}, say=say):
|
||||
say(" navidrome: no container IP, or the app never answered")
|
||||
return None
|
||||
user = "spike" + secrets.token_hex(3)
|
||||
pw = "Spike-" + secrets.token_hex(10)
|
||||
rc, code, out = _curl(base + "/auth/createAdmin", "-H", "Content-Type: application/json",
|
||||
data=json.dumps({"username": user, "password": pw}), method="POST")
|
||||
say(f" navidrome: createAdmin http={code}")
|
||||
if rc != 0 or code not in ("200", "201"):
|
||||
say(f" navidrome: refused {out[:200]}")
|
||||
return None
|
||||
return {"user": user, "pw": pw}
|
||||
|
||||
def verify(self, ipfn, seeded, say):
|
||||
base = self._base(ipfn)
|
||||
if not base or not _wait_http(base + "/", {"200", "302"}, tries=24, say=say):
|
||||
return False
|
||||
|
||||
def login(p):
|
||||
return _curl(base + "/auth/login", "-H", "Content-Type: application/json",
|
||||
data=json.dumps({"username": seeded["user"], "password": p}),
|
||||
method="POST")
|
||||
|
||||
rc, code, _ = login("wrong-" + secrets.token_hex(6))
|
||||
if code in ("200", "201"):
|
||||
say(" navidrome: READBACK IS UNUSABLE — a wrong password authenticated")
|
||||
return False
|
||||
rc, code, out = login(seeded["pw"])
|
||||
ok = code in ("200", "201")
|
||||
say(f" navidrome: login as the seeded user http={code} ok={ok}")
|
||||
return ok
|
||||
|
||||
|
||||
class AudiobookShelf:
|
||||
"""audiobookshelf's own /init creates the first root account; its own /login proves it
|
||||
survived. LIMITATION: the DATABASE half only — the library on the drive is not populated."""
|
||||
port = 80
|
||||
container = "audiobookshelf"
|
||||
|
||||
def _base(self, ipfn):
|
||||
ip = ipfn(self.container)
|
||||
return f"http://{ip}:{self.port}" if ip else ""
|
||||
|
||||
def seed(self, ipfn, say):
|
||||
base = self._base(ipfn)
|
||||
if not base or not _wait_http(base + "/status", {"200"}, say=say):
|
||||
say(" audiobookshelf: no container IP, or the app never answered /status")
|
||||
return None
|
||||
user = "spike" + secrets.token_hex(3)
|
||||
pw = "Spike-" + secrets.token_hex(10)
|
||||
rc, code, out = _curl(base + "/init", "-H", "Content-Type: application/json",
|
||||
data=json.dumps({"newRoot": {"username": user, "password": pw}}),
|
||||
method="POST")
|
||||
say(f" audiobookshelf: /init http={code}")
|
||||
if rc != 0 or code not in ("200", "204"):
|
||||
say(f" audiobookshelf: refused {out[:200]}")
|
||||
return None
|
||||
return {"user": user, "pw": pw}
|
||||
|
||||
def verify(self, ipfn, seeded, say):
|
||||
base = self._base(ipfn)
|
||||
if not base or not _wait_http(base + "/status", {"200"}, tries=24, say=say):
|
||||
return False
|
||||
|
||||
def login(p):
|
||||
return _curl(base + "/login", "-H", "Content-Type: application/json",
|
||||
data=json.dumps({"username": seeded["user"], "password": p}),
|
||||
method="POST")
|
||||
|
||||
rc, code, _ = login("wrong-" + secrets.token_hex(6))
|
||||
if code == "200":
|
||||
say(" audiobookshelf: READBACK IS UNUSABLE — a wrong password authenticated")
|
||||
return False
|
||||
rc, code, out = login(seeded["pw"])
|
||||
ok = code == "200" and seeded["user"] in out
|
||||
say(f" audiobookshelf: login as the seeded root http={code} ok={ok}")
|
||||
return ok
|
||||
|
||||
|
||||
class Vikunja:
|
||||
"""Vikunja's own REST API: register, log in, create a project, read the project back. Four
|
||||
calls, all the app's own front door, and the readback is a real authenticated GET."""
|
||||
port = 3456
|
||||
container = "vikunja"
|
||||
|
||||
def _base(self, ipfn):
|
||||
ip = ipfn(self.container)
|
||||
return f"http://{ip}:{self.port}" if ip else ""
|
||||
|
||||
def _token(self, base, seeded, pw=None):
|
||||
rc, code, out = _curl(base + "/api/v1/login", "-H", "Content-Type: application/json",
|
||||
data=json.dumps({"username": seeded["user"],
|
||||
"password": pw or seeded["pw"]}), method="POST")
|
||||
if code != "200":
|
||||
return None
|
||||
try:
|
||||
return json.loads(out)["token"]
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def seed(self, ipfn, say):
|
||||
base = self._base(ipfn)
|
||||
if not base or not _wait_http(base + "/api/v1/info", {"200"}, say=say):
|
||||
say(" vikunja: no container IP, or the app never answered /api/v1/info")
|
||||
return None
|
||||
user = "spike" + secrets.token_hex(3)
|
||||
pw = "Spike-" + secrets.token_hex(10)
|
||||
rc, code, out = _curl(base + "/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 {out[:200]}")
|
||||
return None
|
||||
seeded = {"user": user, "pw": pw}
|
||||
tok = self._token(base, seeded)
|
||||
if not tok:
|
||||
say(" vikunja: could not log in after registering")
|
||||
return None
|
||||
title = "spike-" + secrets.token_hex(5)
|
||||
rc, code, out = _curl(base + "/api/v1/projects", "-H", f"Authorization: Bearer {tok}",
|
||||
"-H", "Content-Type: application/json",
|
||||
data=json.dumps({"title": title}), method="POST")
|
||||
say(f" vikunja: create project http={code}")
|
||||
if code not in ("200", "201"):
|
||||
say(f" vikunja: project refused {out[:200]}")
|
||||
return None
|
||||
seeded["title"] = title
|
||||
seeded["pid"] = json.loads(out).get("id")
|
||||
return seeded
|
||||
|
||||
def verify(self, ipfn, seeded, say):
|
||||
base = self._base(ipfn)
|
||||
if not base or not _wait_http(base + "/api/v1/info", {"200"}, tries=24, say=say):
|
||||
return False
|
||||
if self._token(base, seeded, pw="wrong-" + secrets.token_hex(6)):
|
||||
say(" vikunja: READBACK IS UNUSABLE — a wrong password authenticated")
|
||||
return False
|
||||
tok = self._token(base, seeded)
|
||||
if not tok:
|
||||
say(" vikunja: the seeded account no longer authenticates")
|
||||
return False
|
||||
rc, code, out = _curl(base + f"/api/v1/projects/{seeded['pid']}",
|
||||
"-H", f"Authorization: Bearer {tok}")
|
||||
ok = code == "200" and seeded["title"] in out
|
||||
say(f" vikunja: readback of the seeded project http={code} ok={ok}")
|
||||
return ok
|
||||
|
||||
|
||||
FIXTURES = {
|
||||
"privatebin": PrivateBin(),
|
||||
"docmost": Docmost(),
|
||||
"bookstack": BookStack(),
|
||||
"actualbudget": ActualBudget(),
|
||||
"navidrome": Navidrome(),
|
||||
"audiobookshelf": AudiobookShelf(),
|
||||
"vikunja": Vikunja(),
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user