harness: five new front-door fixtures, gitea's installer, HTTPS backends, per-file change names (R-462, R-624, R-735)
gates / gates (push) Successful in 2s
gates / gates (push) Successful in 2s
- fixtures (upgrade_fixtures_box.py): calibre-web (Upload form -> OPDS readback + the served EPUB), wger (web login -> weight entry API), crafty-controller (API v2 roles), uptime-kuma (socket.io polling: setup, login, addStatusPage -> public /api/status-page/<slug>); gitea posts its own first-run installer form (R-624's fixable case) and keeps the admin CLI for an installed one. calibre-web and wger run the template's own after_install on the bench, which has none. - R-735: the bench's `password:N:special` now has the controller's shape (randomWithSpecial); test seen failing first (length 32, no special), then 45/45. - upgrade_boxport / the memory watch: a backend traefik reaches over https (loadbalancer.server.scheme) is reached over https on the bench too (crafty-controller). - upgrade-test: files-before/after-detail.json and `files_changed_detail` NAME the files behind a files_may_change mark (R-734's method, now in the harness); test ChangedFiles. - test_catalog_gates: the gate count was stale (9, the runner has 10) and red on main; now 10. Evidence: felhom.eu/documentation/audits/more-night-apps-2026-09-30/ Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
@@ -421,9 +421,20 @@ def _gen(spec, ftype):
|
|||||||
"""
|
"""
|
||||||
if spec:
|
if spec:
|
||||||
kind, _, n = spec.partition(":")
|
kind, _, n = spec.partition(":")
|
||||||
|
n, _, form = n.partition(":")
|
||||||
n = int(n) if n.isdigit() else 32
|
n = int(n) if n.isdigit() else 32
|
||||||
if kind == "hex":
|
if kind == "hex":
|
||||||
return secrets.token_hex(n)
|
return secrets.token_hex(n)
|
||||||
|
if kind == "password" and form == "special":
|
||||||
|
# R-735: `password:N:special` — deploy.go randomWithSpecial: N long, a letter or digit first, at least
|
||||||
|
# one lower, upper, digit and one of passwordSpecials. Pinned by
|
||||||
|
# test_check_volume_persistence.TestEnvBuilding.test_password_special_carries_the_controllers_shape.
|
||||||
|
alph, specials = "abcdefghijkmnpqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789", "-_.!@#%+="
|
||||||
|
while True:
|
||||||
|
p = "".join(secrets.choice(alph + specials) for _ in range(n))
|
||||||
|
if (p[0] not in specials and any(c in specials for c in p) and any(c.islower() for c in p)
|
||||||
|
and any(c.isupper() for c in p) and any(c.isdigit() for c in p)):
|
||||||
|
return p
|
||||||
if kind in ("password", "secret"):
|
if kind in ("password", "secret"):
|
||||||
alph = "abcdefghijkmnpqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789"
|
alph = "abcdefghijkmnpqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789"
|
||||||
return "".join(secrets.choice(alph) for _ in range(n))
|
return "".join(secrets.choice(alph) for _ in range(n))
|
||||||
|
|||||||
@@ -60,8 +60,9 @@ class CatalogGatesFastTest(unittest.TestCase):
|
|||||||
spec.loader.exec_module(mod)
|
spec.loader.exec_module(mod)
|
||||||
# STALE UNTIL 2026-09-23: this read 5 gates and 3 fast ones while the table had grown to 7
|
# STALE UNTIL 2026-09-23: this read 5 gates and 3 fast ones while the table had grown to 7
|
||||||
# (copy-i18n, probe-matches-compose) — the test was red and nobody ran it. Now 9 with the test
|
# (copy-i18n, probe-matches-compose) — the test was red and nobody ran it. Now 9 with the test
|
||||||
# record's two halves; the slow pair stays out of --fast.
|
# record's two halves; the slow pair stays out of --fast. STALE AGAIN until 2026-09-30: 10 since
|
||||||
self.assertEqual(len(mod.GATES), 9)
|
# probe-measured joined; the test had been red on main (found by the more-night-apps session).
|
||||||
|
self.assertEqual(len(mod.GATES), 10)
|
||||||
self.assertEqual([g[0] for g in mod.GATES if not g[3]], ["image-resolvable", "volume-persistence"])
|
self.assertEqual([g[0] for g in mod.GATES if not g[3]], ["image-resolvable", "volume-persistence"])
|
||||||
self.assertIn("test-record", [g[0] for g in mod.GATES if g[3] and not g[4]]) # runs in CI too
|
self.assertIn("test-record", [g[0] for g in mod.GATES if g[3] and not g[4]]) # runs in CI too
|
||||||
# the gates that need git history are the ones the CI half cannot run (R-452's shallow gap)
|
# the gates that need git history are the ones the CI half cannot run (R-452's shallow gap)
|
||||||
|
|||||||
@@ -442,6 +442,22 @@ class TestEnvBuilding(unittest.TestCase):
|
|||||||
import base64
|
import base64
|
||||||
self.assertEqual(len(base64.b64decode(v[len("base64:"):])), 32)
|
self.assertEqual(len(base64.b64decode(v[len("base64:"):])), 32)
|
||||||
|
|
||||||
|
def test_password_special_carries_the_controllers_shape(self):
|
||||||
|
"""R-735: `password:N:special` must be what the controller's randomWithSpecial mints (deploy.go L1335):
|
||||||
|
N long, a letter or digit first, at least one lower, upper, digit and one of `-_.!@#%+=`. Without the
|
||||||
|
special character calibre-web's own rule refuses the password and the bench cannot seed the app.
|
||||||
|
COMPANION RED-PROOF: before the fix `_gen` returned letters and digits only; this test failed."""
|
||||||
|
felhom = "deploy_fields:\n - env_var: ADMIN_PASSWORD\n type: password\n generate: \"password:24:special\"\n"
|
||||||
|
for _ in range(50):
|
||||||
|
v = cvp.build_env("calibre-web", felhom, "${ADMIN_PASSWORD}")["ADMIN_PASSWORD"]
|
||||||
|
self.assertEqual(len(v), 24, v)
|
||||||
|
self.assertTrue(any(c in "-_.!@#%+=" for c in v), f"no special character: {v}")
|
||||||
|
self.assertNotIn(v[0], "-_.!@#%+=")
|
||||||
|
for cls in ("abcdefghijklmnopqrstuvwxyz", "ABCDEFGHIJKLMNOPQRSTUVWXYZ", "0123456789"):
|
||||||
|
self.assertTrue(any(c in cls for c in v), f"missing one of {cls[:3]}…: {v}")
|
||||||
|
plain = "deploy_fields:\n - env_var: P\n type: password\n generate: \"password:16\"\n"
|
||||||
|
self.assertTrue(cvp.build_env("x", plain, "${P}")["P"].isalnum()) # the plain form is unchanged
|
||||||
|
|
||||||
def test_deploy_fields_block_ends_at_the_next_top_level_key(self):
|
def test_deploy_fields_block_ends_at_the_next_top_level_key(self):
|
||||||
felhom = "deploy_fields:\n - env_var: A\n type: text\napp_info:\n tagline: x\n"
|
felhom = "deploy_fields:\n - env_var: A\n type: text\napp_info:\n tagline: x\n"
|
||||||
self.assertEqual([f["env_var"] for f in cvp.parse_deploy_fields(felhom)], ["A"])
|
self.assertEqual([f["env_var"] for f in cvp.parse_deploy_fields(felhom)], ["A"])
|
||||||
|
|||||||
@@ -70,5 +70,16 @@ class ClearScratch(unittest.TestCase):
|
|||||||
self.assertTrue(os.path.exists(keep))
|
self.assertTrue(os.path.exists(keep))
|
||||||
|
|
||||||
|
|
||||||
|
class ChangedFiles(unittest.TestCase):
|
||||||
|
"""2026-09-30: a `files_may_change` mark names its files (R-734's method, now in the harness)."""
|
||||||
|
|
||||||
|
def test_names_changed_added_removed_and_nothing_else(self):
|
||||||
|
before = {"/d": {"a.txt": "1:aa", "b.txt": "1:bb", "gone": "1:cc"}}
|
||||||
|
after = {"/d": {"a.txt": "1:aa", "b.txt": "2:bd", "new": "1:dd"}}
|
||||||
|
self.assertEqual(ut.changed_files(before, after),
|
||||||
|
["/d/b.txt (changed)", "/d/gone (removed)", "/d/new (added)"])
|
||||||
|
self.assertEqual(ut.changed_files(before, before), [])
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main(verbosity=2)
|
unittest.main(verbosity=2)
|
||||||
|
|||||||
+56
-2
@@ -370,12 +370,14 @@ def memory_watch(app: str, project: str, workdir: Path, seconds: int, say, ev: P
|
|||||||
paths = LOAD_PATHS.get(app, ["/"])
|
paths = LOAD_PATHS.get(app, ["/"])
|
||||||
native = fx.FIXTURES.get(app)
|
native = fx.FIXTURES.get(app)
|
||||||
cname, port = getattr(native, "container", app), getattr(native, "port", 80)
|
cname, port = getattr(native, "container", app), getattr(native, "port", 80)
|
||||||
|
scheme = "http"
|
||||||
if native is None and boxport is not None:
|
if native is None and boxport is not None:
|
||||||
# a box-fixture app: load the container traefik routes `/` to, at its own port
|
# a box-fixture app: load the container traefik routes `/` to, at its own port
|
||||||
rts = boxport.routes((workdir / "docker-compose.yml").read_text())
|
rts = boxport.routes((workdir / "docker-compose.yml").read_text())
|
||||||
root = [r for r in rts if not r[0]] or rts
|
root = [r for r in rts if not r[0]] or rts
|
||||||
if root:
|
if root:
|
||||||
cname, port = root[0][1], root[0][2]
|
cname, port = root[0][1], root[0][2]
|
||||||
|
scheme = root[0][3] if len(root[0]) > 3 else "http" # an HTTPS backend (crafty-controller)
|
||||||
target = container_ip(cname)
|
target = container_ip(cname)
|
||||||
stop = threading.Event()
|
stop = threading.Event()
|
||||||
hits = {"n": 0, "codes": {}}
|
hits = {"n": 0, "codes": {}}
|
||||||
@@ -383,7 +385,8 @@ def memory_watch(app: str, project: str, workdir: Path, seconds: int, say, ev: P
|
|||||||
class _NoRedirect(urllib.request.HTTPRedirectHandler):
|
class _NoRedirect(urllib.request.HTTPRedirectHandler):
|
||||||
def redirect_request(self, *a, **k):
|
def redirect_request(self, *a, **k):
|
||||||
return None # a 30x is the app ANSWERING; following it to the unreachable domain is not load
|
return None # a 30x is the app ANSWERING; following it to the unreachable domain is not load
|
||||||
opener = urllib.request.build_opener(_NoRedirect)
|
import ssl
|
||||||
|
opener = urllib.request.build_opener(_NoRedirect, urllib.request.HTTPSHandler(context=ssl._create_unverified_context()))
|
||||||
host = None
|
host = None
|
||||||
try:
|
try:
|
||||||
envtxt = (workdir / ".env").read_text()
|
envtxt = (workdir / ".env").read_text()
|
||||||
@@ -398,7 +401,7 @@ def memory_watch(app: str, project: str, workdir: Path, seconds: int, say, ev: P
|
|||||||
def worker(i):
|
def worker(i):
|
||||||
k = 0
|
k = 0
|
||||||
while not stop.is_set():
|
while not stop.is_set():
|
||||||
url = f"http://{target}:{port}{paths[(i + k) % len(paths)]}"
|
url = f"{scheme}://{target}:{port}{paths[(i + k) % len(paths)]}"
|
||||||
k += 1
|
k += 1
|
||||||
try:
|
try:
|
||||||
req = urllib.request.Request(url, headers={"Host": host, "X-Forwarded-Proto": "https"} if host else {})
|
req = urllib.request.Request(url, headers={"Host": host, "X-Forwarded-Proto": "https"} if host else {})
|
||||||
@@ -565,6 +568,51 @@ def bind_tree_hash(project: str, workdir: Path) -> dict:
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def bind_tree_files(project: str, workdir: Path) -> dict:
|
||||||
|
"""{bind source dir: {relpath: "size:sha256[:16]"}} — the same walk as bind_tree_hash, per FILE, so a
|
||||||
|
`files_may_change` mark can NAME the files it saw change (R-734's method, 2026-09-30: the tree hash alone
|
||||||
|
could not say which file moved). Files above 64 MiB carry size and mtime only, as in bind_tree_hash."""
|
||||||
|
import hashlib
|
||||||
|
r = compose(workdir, project, "ps", "-aq", timeout=120)
|
||||||
|
srcs = set()
|
||||||
|
for cid in (r.stdout or "").split():
|
||||||
|
info = cvp._inspect(cid) or {}
|
||||||
|
for m in info.get("Mounts") or []:
|
||||||
|
if m.get("Type") == "bind" and os.path.isdir(m.get("Source") or "") \
|
||||||
|
and not (m.get("Source") or "").startswith(("/var/run", "/run", "/etc", "/proc", "/sys")):
|
||||||
|
srcs.add(m["Source"])
|
||||||
|
out = {}
|
||||||
|
for src in sorted(srcs):
|
||||||
|
files = {}
|
||||||
|
for dp, _, fn in os.walk(src):
|
||||||
|
for f in fn:
|
||||||
|
fp = os.path.join(dp, f)
|
||||||
|
try:
|
||||||
|
st = os.lstat(fp)
|
||||||
|
if st.st_size <= 64 * 1024 * 1024 and os.path.isfile(fp) and not os.path.islink(fp):
|
||||||
|
with open(fp, "rb") as fh:
|
||||||
|
d = hashlib.sha256(fh.read()).hexdigest()[:16]
|
||||||
|
else:
|
||||||
|
d = "mtime-%d" % int(st.st_mtime)
|
||||||
|
files[os.path.relpath(fp, src)] = "%d:%s" % (st.st_size, d)
|
||||||
|
except OSError:
|
||||||
|
files[os.path.relpath(fp, src)] = "unreadable"
|
||||||
|
out[src] = files
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def changed_files(before: dict, after: dict) -> list:
|
||||||
|
"""["<src>/<relpath> (added|removed|changed)"] between two bind_tree_files results."""
|
||||||
|
out = []
|
||||||
|
for src in sorted(set(before) | set(after)):
|
||||||
|
b, a = before.get(src) or {}, after.get(src) or {}
|
||||||
|
for rel in sorted(set(b) | set(a)):
|
||||||
|
if b.get(rel) != a.get(rel):
|
||||||
|
kind = "added" if rel not in b else "removed" if rel not in a else "changed"
|
||||||
|
out.append(f"{src}/{rel} ({kind})")
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
# --- the PostgreSQL major conversion on the bench (`09` §6.4 part 10, harness v4) ---------------------
|
# --- the PostgreSQL major conversion on the bench (`09` §6.4 part 10, harness v4) ---------------------
|
||||||
#
|
#
|
||||||
# The postgres image converts nothing and refuses an older major's datadir (R-463); 18 refuses even an
|
# The postgres image converts nothing and refuses an older major's datadir (R-463); 18 refuses even an
|
||||||
@@ -793,6 +841,8 @@ def run_edge(edge_id: str) -> dict:
|
|||||||
|
|
||||||
files_before = bind_tree_hash(project, workdir)
|
files_before = bind_tree_hash(project, workdir)
|
||||||
(ev / "files-before.json").write_text(json.dumps(files_before, indent=2))
|
(ev / "files-before.json").write_text(json.dumps(files_before, indent=2))
|
||||||
|
detail_before = bind_tree_files(project, workdir)
|
||||||
|
(ev / "files-before-detail.json").write_text(json.dumps(detail_before, indent=2))
|
||||||
|
|
||||||
# --- 4. TO ---
|
# --- 4. TO ---
|
||||||
swap_at = datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
|
swap_at = datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
|
||||||
@@ -846,8 +896,12 @@ def run_edge(edge_id: str) -> dict:
|
|||||||
(ev / "files-after.json").write_text(json.dumps(files_after, indent=2))
|
(ev / "files-after.json").write_text(json.dumps(files_after, indent=2))
|
||||||
rec["files_changed"] = sorted(k for k in set(files_before) | set(files_after)
|
rec["files_changed"] = sorted(k for k in set(files_before) | set(files_after)
|
||||||
if files_before.get(k) != files_after.get(k))
|
if files_before.get(k) != files_after.get(k))
|
||||||
|
detail_after = bind_tree_files(project, workdir)
|
||||||
|
(ev / "files-after-detail.json").write_text(json.dumps(detail_after, indent=2))
|
||||||
|
rec["files_changed_detail"] = changed_files(detail_before, detail_after)[:200]
|
||||||
if rec["files_changed"]:
|
if rec["files_changed"]:
|
||||||
rec["marks"] = sorted(set(rec["marks"]) | {"files_may_change"})
|
rec["marks"] = sorted(set(rec["marks"]) | {"files_may_change"})
|
||||||
|
say(f"files_may_change — the files: {rec['files_changed_detail'][:20]}")
|
||||||
say(f"files_may_change: the bind-mounted tree changed under {rec['files_changed']}")
|
say(f"files_may_change: the bind-mounted tree changed under {rec['files_changed']}")
|
||||||
|
|
||||||
# --- 5b. the MEMORY WATCH — only for an edge that just read back; a failed one is decided ---
|
# --- 5b. the MEMORY WATCH — only for an edge that just read back; a failed one is decided ---
|
||||||
|
|||||||
@@ -29,13 +29,16 @@ import upgrade_fixtures_box28 as _box28
|
|||||||
ROUTE_RULE_RE = re.compile(r"traefik\.http\.routers\.([A-Za-z0-9_-]+)\.rule[=:]\s*[\"']?(.+?)[\"']?\s*$")
|
ROUTE_RULE_RE = re.compile(r"traefik\.http\.routers\.([A-Za-z0-9_-]+)\.rule[=:]\s*[\"']?(.+?)[\"']?\s*$")
|
||||||
ROUTE_SVC_RE = re.compile(r"traefik\.http\.routers\.([A-Za-z0-9_-]+)\.service[=:]\s*[\"']?([A-Za-z0-9_-]+)")
|
ROUTE_SVC_RE = re.compile(r"traefik\.http\.routers\.([A-Za-z0-9_-]+)\.service[=:]\s*[\"']?([A-Za-z0-9_-]+)")
|
||||||
LB_PORT_RE = re.compile(r"traefik\.http\.services\.([A-Za-z0-9_-]+)\.loadbalancer\.server\.port[=:]\s*[\"']?(\d+)")
|
LB_PORT_RE = re.compile(r"traefik\.http\.services\.([A-Za-z0-9_-]+)\.loadbalancer\.server\.port[=:]\s*[\"']?(\d+)")
|
||||||
|
# 2026-09-30 (crafty-controller): a backend that speaks HTTPS says so to traefik; the bench must too.
|
||||||
|
LB_SCHEME_RE = re.compile(r"traefik\.http\.services\.([A-Za-z0-9_-]+)\.loadbalancer\.server\.scheme[=:]\s*[\"']?(https?)")
|
||||||
PATH_RE = re.compile(r"PathPrefix\(`([^`]+)`\)")
|
PATH_RE = re.compile(r"PathPrefix\(`([^`]+)`\)")
|
||||||
SERVICE_RE = re.compile(r"^ ([A-Za-z0-9_-]+):\s*$")
|
SERVICE_RE = re.compile(r"^ ([A-Za-z0-9_-]+):\s*$")
|
||||||
CNAME_RE = re.compile(r"^\s+container_name:\s*[\"']?([^\s\"']+)")
|
CNAME_RE = re.compile(r"^\s+container_name:\s*[\"']?([^\s\"']+)")
|
||||||
|
|
||||||
|
|
||||||
def routes(compose_text):
|
def routes(compose_text):
|
||||||
"""[(path_prefixes, container, port)] for every compose service traefik routes to."""
|
"""[(path_prefixes, container, port, scheme)] for every compose service traefik routes to. The scheme is
|
||||||
|
the 4th element so every reader of [0]..[2] is unchanged."""
|
||||||
out, cur, cname, labels = [], None, {}, {}
|
out, cur, cname, labels = [], None, {}, {}
|
||||||
for line in compose_text.splitlines():
|
for line in compose_text.splitlines():
|
||||||
m = SERVICE_RE.match(line)
|
m = SERVICE_RE.match(line)
|
||||||
@@ -50,9 +53,9 @@ def routes(compose_text):
|
|||||||
if "traefik." in line:
|
if "traefik." in line:
|
||||||
labels.setdefault(cur, []).append(line.strip().lstrip("- ").strip())
|
labels.setdefault(cur, []).append(line.strip().lstrip("- ").strip())
|
||||||
for svc, ls in labels.items():
|
for svc, ls in labels.items():
|
||||||
rules, rsvc, ports = {}, {}, {}
|
rules, rsvc, ports, schemes = {}, {}, {}, {}
|
||||||
for l in ls:
|
for l in ls:
|
||||||
for rx, d in ((ROUTE_RULE_RE, rules), (ROUTE_SVC_RE, rsvc), (LB_PORT_RE, ports)):
|
for rx, d in ((ROUTE_RULE_RE, rules), (ROUTE_SVC_RE, rsvc), (LB_PORT_RE, ports), (LB_SCHEME_RE, schemes)):
|
||||||
mm = rx.search(l)
|
mm = rx.search(l)
|
||||||
if mm:
|
if mm:
|
||||||
d[mm.group(1)] = mm.group(2)
|
d[mm.group(1)] = mm.group(2)
|
||||||
@@ -62,7 +65,7 @@ def routes(compose_text):
|
|||||||
prefixes = []
|
prefixes = []
|
||||||
for r, rule in rules.items():
|
for r, rule in rules.items():
|
||||||
prefixes += PATH_RE.findall(rule)
|
prefixes += PATH_RE.findall(rule)
|
||||||
out.append((prefixes, cname.get(svc, svc), port))
|
out.append((prefixes, cname.get(svc, svc), port, next(iter(schemes.values()), "http")))
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
@@ -83,12 +86,12 @@ class Venue:
|
|||||||
|
|
||||||
def _target(self, path):
|
def _target(self, path):
|
||||||
best, blen = None, -1
|
best, blen = None, -1
|
||||||
for prefixes, container, port in self.routes:
|
for prefixes, container, port, scheme in self.routes:
|
||||||
if not prefixes and blen < 0:
|
if not prefixes and blen < 0:
|
||||||
best, blen = (container, port), 0
|
best, blen = (container, port, scheme), 0
|
||||||
for p in prefixes:
|
for p in prefixes:
|
||||||
if path.startswith(p) and len(p) > blen:
|
if path.startswith(p) and len(p) > blen:
|
||||||
best, blen = (container, port), len(p)
|
best, blen = (container, port, scheme), len(p)
|
||||||
return best
|
return best
|
||||||
|
|
||||||
def sh(self, args, timeout=300, inp=None):
|
def sh(self, args, timeout=300, inp=None):
|
||||||
@@ -115,7 +118,7 @@ class Venue:
|
|||||||
args += ["-X", method]
|
args += ["-X", method]
|
||||||
if data is not None:
|
if data is not None:
|
||||||
args += ["--data-binary", "@-"]
|
args += ["--data-binary", "@-"]
|
||||||
args += list(extra) + ["http://%s:%d%s" % (ip, t[1], path)]
|
args += list(extra) + ["%s://%s:%d%s" % (t[2], ip, t[1], path)]
|
||||||
r = self.sh(args, timeout=timeout + 30, inp=data)
|
r = self.sh(args, timeout=timeout + 30, inp=data)
|
||||||
body, _, code = (r.stdout or "").rpartition("\n")
|
body, _, code = (r.stdout or "").rpartition("\n")
|
||||||
return r.returncode, code.strip(), body
|
return r.returncode, code.strip(), body
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ verify() must ask the APP, never the filesystem: a migration is supposed to rewr
|
|||||||
Where a fixture can prove itself (a negative control that must read as absent) it does so on EVERY
|
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.
|
call, so a readback that has broken into always saying "found" fails instead of passing everything.
|
||||||
"""
|
"""
|
||||||
import base64, json, re, secrets, time
|
import base64, json, os, re, secrets, time
|
||||||
|
|
||||||
|
|
||||||
def _gx(w, container, *cmd, timeout=240):
|
def _gx(w, container, *cmd, timeout=240):
|
||||||
@@ -172,20 +172,54 @@ class BookStack:
|
|||||||
|
|
||||||
# =============================================================================================
|
# =============================================================================================
|
||||||
class Gitea:
|
class Gitea:
|
||||||
"""Gitea's own admin CLI creates the first user; its own REST API (basic auth) then creates a
|
"""The first user comes from gitea's own FIRST-RUN INSTALLER (R-624, 2026-09-30): the template sets no
|
||||||
repository and reads it back. Both are the app's own interfaces."""
|
INSTALL_LOCK, so a fresh instance serves the installer form at `/`, and `gitea admin user create`
|
||||||
|
refuses (`MustInstalled()`). The household fills that form in; so does this fixture — `POST /` with the
|
||||||
|
form's OWN default values (read from the page, never typed) plus an admin account. Measured on the bench
|
||||||
|
2026-09-30 (1.27.0): no CSRF field on the install form; 200, gitea restarts its web server in-process, and
|
||||||
|
the admin's Basic auth answers `/api/v1/user` 200 within seconds. On a box the setup gate (decision 46) is
|
||||||
|
in front; walk.app_curl passes it as the household does. An instance that is ALREADY installed falls back
|
||||||
|
to the admin CLI (the old route). Its own REST API (basic auth) then creates a repository and reads it
|
||||||
|
back. All of these are the app's own interfaces."""
|
||||||
sub = "git"
|
sub = "git"
|
||||||
|
FORM_DEFAULTS = ("db_path", "app_name", "repo_root_path", "lfs_root_path", "run_user", "domain",
|
||||||
|
"ssh_port", "http_port", "app_url", "log_root_path")
|
||||||
|
|
||||||
def seed(self, w, sub, say):
|
def seed(self, w, sub, say):
|
||||||
if not w.wait_app(sub, "/", want=("200", "302")):
|
if not w.wait_app(sub, "/", want=("200", "302")):
|
||||||
return None
|
return None
|
||||||
user = "drill" + secrets.token_hex(3)
|
user = "drill" + secrets.token_hex(3)
|
||||||
pw = "Drill-" + secrets.token_hex(10)
|
pw = "Drill-" + secrets.token_hex(10)
|
||||||
|
rc, code, page = w.app_curl(sub, "/")
|
||||||
|
if 'name="db_type"' in (page or "") and 'name="admin_name"' in (page or ""):
|
||||||
|
args = ["--data-urlencode", "db_type=sqlite3", "--data-urlencode", "password_algorithm=pbkdf2"]
|
||||||
|
for n in self.FORM_DEFAULTS:
|
||||||
|
m = re.search(r'name="%s" value="([^"]*)"' % n, page)
|
||||||
|
if m:
|
||||||
|
args += ["--data-urlencode", f"{n}={m.group(1)}"]
|
||||||
|
args += ["--data-urlencode", f"admin_name={user}", "--data-urlencode", f"admin_passwd={pw}",
|
||||||
|
"--data-urlencode", f"admin_confirm_passwd={pw}",
|
||||||
|
"--data-urlencode", f"admin_email={user}@gate.invalid"]
|
||||||
|
rc, code, body = w.app_curl(sub, "/", *args, method="POST", timeout=120)
|
||||||
|
say(f" gitea: first-run installer POST / http={code}")
|
||||||
|
ok = False
|
||||||
|
for _ in range(40): # the installer restarts gitea's web server in-process
|
||||||
|
rc, c2, _ = w.app_curl(sub, "/api/v1/user", "-u", f"{user}:{pw}", timeout=15)
|
||||||
|
if c2 == "200":
|
||||||
|
ok = True
|
||||||
|
break
|
||||||
|
time.sleep(3)
|
||||||
|
if not ok:
|
||||||
|
self.tried = f"installer POST / -> {code}; the admin never authenticated (last {c2})"
|
||||||
|
say(f" gitea: {self.tried} :: {' '.join((body or '').split())[:160]}")
|
||||||
|
return None
|
||||||
|
say(" gitea: installed through its own first-run form; the admin authenticates")
|
||||||
|
else:
|
||||||
out = _gx(w, "gitea", "su", "git", "-c",
|
out = _gx(w, "gitea", "su", "git", "-c",
|
||||||
f"gitea admin user create --username {user} --password {pw} "
|
f"gitea admin user create --username {user} --password {pw} "
|
||||||
f"--email {user}@gate.invalid --admin --must-change-password=false")
|
f"--email {user}@gate.invalid --admin --must-change-password=false")
|
||||||
say(f" gitea: admin user create :: {' '.join(out.split())[:140]}")
|
say(f" gitea: already installed — admin user create :: {' '.join(out.split())[:140]}")
|
||||||
if "has been successfully created" not in out and "successfully created" not in out:
|
if "successfully created" not in out:
|
||||||
return None
|
return None
|
||||||
repo = "drillrepo" + secrets.token_hex(3)
|
repo = "drillrepo" + secrets.token_hex(3)
|
||||||
rc, code, body = w.app_curl(sub, "/api/v1/user/repos", "-u", f"{user}:{pw}",
|
rc, code, body = w.app_curl(sub, "/api/v1/user/repos", "-u", f"{user}:{pw}",
|
||||||
@@ -1417,7 +1451,421 @@ class Outline:
|
|||||||
return found
|
return found
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================================
|
||||||
|
class CalibreWeb:
|
||||||
|
"""Calibre-Web Automated (2026-09-30, R-462). THE FRONT DOOR is the household's own „Upload" button: log in
|
||||||
|
through the login form (Flask-WTF CSRF token read from the page), then `POST /upload` with the book, exactly
|
||||||
|
the form the page posts. The other door — dropping a file into the ingest folder (`${IMPORT_PATH}/calibre`,
|
||||||
|
reached through FileBrowser) — is NOT used: from here it would be a file planted in a mount (R-156).
|
||||||
|
Measured on the bench 2026-09-30: uploads are ON in a fresh v4.0.6; the book lands in the household's
|
||||||
|
library folder (`${USERDATA_PATH}/media/books/<author>/<title> (<id>)/`, backup class `mandatory`).
|
||||||
|
|
||||||
|
THE ADMIN PASSWORD. On a box the product's `after_install` (the app's own CLI, `cps.py -s admin:<pw>`) sets
|
||||||
|
it from the deploy field, and the walk holds the value it generated. The bench runs no `after_install`, so
|
||||||
|
when the generated password does not log in, the fixture runs THE SAME command the product runs — the
|
||||||
|
app's own CLI inside its own container — and says so.
|
||||||
|
|
||||||
|
READBACK: the app's OPDS feed (HTTP Basic, the route e-readers use): the search must list the title, and the
|
||||||
|
book's own download must be the uploaded book (the marker read out of the EPUB the app serves). Negative
|
||||||
|
controls on every call: a wrong password must answer 401, and a title that cannot exist must not be found."""
|
||||||
|
sub = "books"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _epub(marker):
|
||||||
|
import io, zipfile
|
||||||
|
buf = io.BytesIO()
|
||||||
|
z = zipfile.ZipFile(buf, "w")
|
||||||
|
z.writestr(zipfile.ZipInfo("mimetype"), "application/epub+zip")
|
||||||
|
z.writestr("META-INF/container.xml",
|
||||||
|
'<?xml version="1.0"?><container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container">'
|
||||||
|
'<rootfiles><rootfile full-path="OEBPS/content.opf" media-type="application/oebps-package+xml"/>'
|
||||||
|
'</rootfiles></container>')
|
||||||
|
z.writestr("OEBPS/content.opf",
|
||||||
|
'<?xml version="1.0" encoding="UTF-8"?><package xmlns="http://www.idpf.org/2007/opf" '
|
||||||
|
'unique-identifier="bid" version="2.0"><metadata xmlns:dc="http://purl.org/dc/elements/1.1/" '
|
||||||
|
f'xmlns:opf="http://www.idpf.org/2007/opf"><dc:title>{marker}</dc:title>'
|
||||||
|
'<dc:creator opf:role="aut">Drill Author</dc:creator><dc:language>en</dc:language>'
|
||||||
|
f'<dc:identifier id="bid">urn:uuid:{marker}</dc:identifier></metadata><manifest>'
|
||||||
|
'<item id="c1" href="c1.xhtml" media-type="application/xhtml+xml"/>'
|
||||||
|
'<item id="ncx" href="toc.ncx" media-type="application/x-dtbncx+xml"/></manifest>'
|
||||||
|
'<spine toc="ncx"><itemref idref="c1"/></spine></package>')
|
||||||
|
z.writestr("OEBPS/toc.ncx",
|
||||||
|
'<?xml version="1.0"?><ncx xmlns="http://www.daisy.org/z3986/2005/ncx/" version="2005-1">'
|
||||||
|
f'<head><meta name="dtb:uid" content="urn:uuid:{marker}"/></head><docTitle><text>{marker}</text>'
|
||||||
|
'</docTitle><navMap><navPoint id="n1" playOrder="1"><navLabel><text>One</text></navLabel>'
|
||||||
|
'<content src="c1.xhtml"/></navPoint></navMap></ncx>')
|
||||||
|
z.writestr("OEBPS/c1.xhtml",
|
||||||
|
'<?xml version="1.0" encoding="UTF-8"?><html xmlns="http://www.w3.org/1999/xhtml"><head>'
|
||||||
|
f'<title>{marker}</title></head><body><p>{marker}-body the household keeps this.</p></body></html>')
|
||||||
|
z.close()
|
||||||
|
return buf.getvalue()
|
||||||
|
|
||||||
|
def _opds(self, w, sub, pw, path):
|
||||||
|
return w.app_curl(sub, path, "-u", f"admin:{pw}")
|
||||||
|
|
||||||
|
def seed(self, w, sub, say):
|
||||||
|
import os, tempfile
|
||||||
|
if not w.wait_app(sub, "/login", want=("200",), tries=72):
|
||||||
|
return None
|
||||||
|
pw = (w.GENERATED.get("calibre-web") or {}).get("ADMIN_PASSWORD") or ""
|
||||||
|
rc, code, _ = self._opds(w, sub, pw, "/opds")
|
||||||
|
if code != "200":
|
||||||
|
say(f" calibre-web: the generated admin password does not log in (opds http={code}) — running the "
|
||||||
|
"template's own after_install command (the app's CLI, as the product does after an install)")
|
||||||
|
import shlex # as the template's after_install: `user: abc` (docker exec -u keeps the image's PATH; `su` does not)
|
||||||
|
out = w.guest("docker exec -u abc calibre-web python3 /app/calibre-web-automated/cps.py -p /config/app.db -s "
|
||||||
|
+ shlex.quote(f"admin:{pw}") + " 2>&1")
|
||||||
|
say(f" calibre-web: after_install :: {' '.join(out.split())[-80:]}")
|
||||||
|
rc, code, _ = self._opds(w, sub, pw, "/opds")
|
||||||
|
if code != "200":
|
||||||
|
self.tried = f"admin login after after_install -> {code}"
|
||||||
|
return None
|
||||||
|
jar = tempfile.mktemp(prefix="cw-jar-")
|
||||||
|
try:
|
||||||
|
rc, code, page = w.app_curl(sub, "/login", "-c", jar, "-b", jar)
|
||||||
|
m = re.search(r'name="csrf_token" value="([^"]+)"', page or "")
|
||||||
|
if not m:
|
||||||
|
self.tried = f"GET /login -> {code}, no csrf_token"
|
||||||
|
return None
|
||||||
|
rc, code, _ = w.app_curl(sub, "/login", "-c", jar, "-b", jar, "--data-urlencode", f"csrf_token={m.group(1)}",
|
||||||
|
"--data-urlencode", "username=admin", "--data-urlencode", f"password={pw}",
|
||||||
|
"--data-urlencode", "remember_me=on", method="POST")
|
||||||
|
rc, code, home = w.app_curl(sub, "/", "-c", jar, "-b", jar)
|
||||||
|
m = re.search(r'name="csrf_token" value="([^"]+)"', home or "")
|
||||||
|
has_form = 'action="/upload"' in (home or "")
|
||||||
|
if code != "200" or not has_form or not m:
|
||||||
|
self.tried = f"login -> home {code}, upload form present={has_form}"
|
||||||
|
say(f" calibre-web: {self.tried}")
|
||||||
|
return None
|
||||||
|
marker = "upg" + secrets.token_hex(6)
|
||||||
|
book = tempfile.mktemp(prefix="cw-", suffix=".epub")
|
||||||
|
open(book, "wb").write(self._epub(marker))
|
||||||
|
rc, code, out = w.app_curl(sub, "/upload", "-c", jar, "-b", jar, "-H", f"X-CSRFToken: {m.group(1)}",
|
||||||
|
"-F", f"csrf_token={m.group(1)}",
|
||||||
|
"-F", f"btn-upload=@{book};type=application/epub+zip", method="POST")
|
||||||
|
os.unlink(book)
|
||||||
|
say(f" calibre-web: POST /upload http={code} {out[:80]}")
|
||||||
|
if code != "200":
|
||||||
|
self.tried = f"POST /upload -> {code} {out[:120]}"
|
||||||
|
return None
|
||||||
|
finally:
|
||||||
|
if os.path.exists(jar):
|
||||||
|
os.unlink(jar)
|
||||||
|
t = {"pw": pw, "marker": marker}
|
||||||
|
for _ in range(24): # the upload is a task; the library entry follows within seconds
|
||||||
|
rc, code, feed = self._opds(w, sub, pw, f"/opds/search/{marker}")
|
||||||
|
if marker in (feed or ""):
|
||||||
|
say(f" calibre-web: seeded book {marker} is in the library")
|
||||||
|
return t
|
||||||
|
time.sleep(5)
|
||||||
|
self.tried = "uploaded, but the book never appeared in the OPDS search"
|
||||||
|
return None
|
||||||
|
|
||||||
|
def verify(self, w, sub, t, say):
|
||||||
|
import io, os, tempfile, zipfile
|
||||||
|
if not w.wait_app(sub, "/login", want=("200",), tries=72):
|
||||||
|
say(" calibre-web: the app never served /login")
|
||||||
|
return False
|
||||||
|
rc, code, _ = self._opds(w, sub, "wrong-" + secrets.token_hex(6), "/opds")
|
||||||
|
if code != "401":
|
||||||
|
say(f" calibre-web: READBACK UNUSABLE — a wrong password answered {code}")
|
||||||
|
return False
|
||||||
|
ghost = "upg" + secrets.token_hex(6)
|
||||||
|
rc, code, feed = self._opds(w, sub, t["pw"], f"/opds/search/{ghost}")
|
||||||
|
if code != "200" or "<entry>" in (feed or ""):
|
||||||
|
say(f" calibre-web: READBACK UNUSABLE — a title that cannot exist: http={code}, entries={(feed or '').count('<entry>')}")
|
||||||
|
return False
|
||||||
|
feed = ""
|
||||||
|
for _ in range(12):
|
||||||
|
rc, code, feed = self._opds(w, sub, t["pw"], f"/opds/search/{t['marker']}")
|
||||||
|
if code == "200" and f"<title>{t['marker']}</title>" in (feed or ""):
|
||||||
|
break
|
||||||
|
time.sleep(5)
|
||||||
|
listed = f"<title>{t['marker']}</title>" in (feed or "")
|
||||||
|
m = re.search(r'href="(/opds/download/\d+/epub/?)"', feed or "")
|
||||||
|
served = False
|
||||||
|
if listed and m:
|
||||||
|
f = tempfile.mktemp(prefix="cw-dl-")
|
||||||
|
rc, code, _ = w.app_curl(sub, m.group(1), "-u", f"admin:{t['pw']}", "-o", f)
|
||||||
|
try:
|
||||||
|
z = zipfile.ZipFile(io.BytesIO(open(f, "rb").read()))
|
||||||
|
served = any(f"{t['marker']}-body" in z.read(n).decode("utf-8", "replace") for n in z.namelist())
|
||||||
|
except Exception as e:
|
||||||
|
say(f" calibre-web: the download is not a readable EPUB (http={code}): {e}")
|
||||||
|
finally:
|
||||||
|
if os.path.exists(f):
|
||||||
|
os.unlink(f)
|
||||||
|
say(f" calibre-web: readback — listed={listed} download_link={bool(m)} book_content_served={served}")
|
||||||
|
return listed and served
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================================
|
||||||
|
class Wger:
|
||||||
|
"""wger (2026-09-30, R-462). THE FRONT DOOR is the web login form (`/en/user/login`, Django CSRF), then the
|
||||||
|
app's own REST API with that session: `POST /api/v2/weightentry/` (a weight on a date — household data in its
|
||||||
|
SQLite), read back with `GET /api/v2/weightentry/?weight=<w>`. Measured on the bench 2026-09-30 (2.6).
|
||||||
|
NOT the app-API login (`/allauth/app/v1/auth/login`): it answers 500 on a CORRECT password on this template
|
||||||
|
(no JWT_PRIVATE_KEY — filed, R-737). THE ADMIN PASSWORD: as calibre-web — the box's `after_install` sets it;
|
||||||
|
the bench runs the template's own command (password as the last argument) when the generated one does not
|
||||||
|
log in. Negative controls on every readback: no session answers 403, a weight never entered counts 0."""
|
||||||
|
sub = "fitness"
|
||||||
|
SET_PW = ("import os, sys; sys.path.insert(0, '/home/wger/src'); os.chdir('/home/wger/src'); "
|
||||||
|
"os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'settings.main'); import django; django.setup(); "
|
||||||
|
"from django.contrib.auth.models import User; u = User.objects.get(username='admin'); "
|
||||||
|
"u.set_password(sys.argv[1]); u.save(); print('FELHOM_AFTER_INSTALL_OK')")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _host(w, sub):
|
||||||
|
return w.host(sub) if hasattr(w, "host") else f"{sub}.{w.DOMAIN}"
|
||||||
|
|
||||||
|
def _login(self, w, sub, pw, jar):
|
||||||
|
o = f"https://{self._host(w, sub)}"
|
||||||
|
hdr = ["-H", f"Origin: {o}", "-H", f"Referer: {o}/en/user/login", "-c", jar, "-b", jar]
|
||||||
|
rc, code, page = w.app_curl(sub, "/en/user/login", *hdr)
|
||||||
|
m = re.search(r'name="csrfmiddlewaretoken" value="([^"]+)"', page or "")
|
||||||
|
if not m:
|
||||||
|
return None, f"GET /en/user/login -> {code}, no csrf"
|
||||||
|
rc, code, _ = w.app_curl(sub, "/en/user/login", *hdr, "--data-urlencode", f"csrfmiddlewaretoken={m.group(1)}",
|
||||||
|
"--data-urlencode", "login=admin", "--data-urlencode", f"password={pw}", method="POST")
|
||||||
|
jt = open(jar).read() if os.path.exists(jar) else ""
|
||||||
|
if code != "302" or "sessionid" not in jt:
|
||||||
|
return None, f"POST /en/user/login -> {code}"
|
||||||
|
csrf = re.search(r"csrftoken\s+(\S+)", jt)
|
||||||
|
return hdr + ["-H", f"X-CSRFToken: {csrf.group(1) if csrf else ''}"], "ok"
|
||||||
|
|
||||||
|
def seed(self, w, sub, say):
|
||||||
|
import shlex, tempfile
|
||||||
|
if not w.wait_app(sub, "/en/user/login", want=("200",), tries=72):
|
||||||
|
return None
|
||||||
|
pw = (w.GENERATED.get("wger") or {}).get("ADMIN_PASSWORD") or ""
|
||||||
|
jar = tempfile.mktemp(prefix="wger-jar-")
|
||||||
|
try:
|
||||||
|
hdr, why = self._login(w, sub, pw, jar)
|
||||||
|
if hdr is None:
|
||||||
|
say(f" wger: the generated admin password does not log in ({why}) — running the template's own "
|
||||||
|
"after_install command (the app's CLI, as the product does after an install)")
|
||||||
|
out = w.guest("docker exec wger python3 -c " + shlex.quote(self.SET_PW) + " " + shlex.quote(pw) + " 2>&1")
|
||||||
|
say(f" wger: after_install :: {' '.join(out.split())[-60:]}")
|
||||||
|
hdr, why = self._login(w, sub, pw, jar)
|
||||||
|
if hdr is None:
|
||||||
|
self.tried = f"web login after after_install: {why}"
|
||||||
|
return None
|
||||||
|
weight = "%d.%02d" % (60 + secrets.randbelow(60), secrets.randbelow(100))
|
||||||
|
rc, code, out = w.app_curl(sub, "/api/v2/weightentry/", *hdr, "-H", "Content-Type: application/json",
|
||||||
|
data=json.dumps({"date": "2026-09-30T10:00:00Z", "weight": weight}), method="POST")
|
||||||
|
say(f" wger: POST /api/v2/weightentry/ http={code}")
|
||||||
|
if code != "201":
|
||||||
|
self.tried = f"POST /api/v2/weightentry/ -> {code} {out[:120]}"
|
||||||
|
return None
|
||||||
|
return {"pw": pw, "weight": weight}
|
||||||
|
finally:
|
||||||
|
if os.path.exists(jar):
|
||||||
|
os.unlink(jar)
|
||||||
|
|
||||||
|
def verify(self, w, sub, t, say):
|
||||||
|
import tempfile
|
||||||
|
if not w.wait_app(sub, "/en/user/login", want=("200",), tries=72):
|
||||||
|
say(" wger: the app never served /en/user/login")
|
||||||
|
return False
|
||||||
|
rc, code, _ = w.app_curl(sub, f"/api/v2/weightentry/?weight={t['weight']}")
|
||||||
|
if code not in ("401", "403"):
|
||||||
|
say(f" wger: READBACK UNUSABLE — no session answered {code}")
|
||||||
|
return False
|
||||||
|
jar = tempfile.mktemp(prefix="wger-jar-")
|
||||||
|
try:
|
||||||
|
hdr, why = self._login(w, sub, t["pw"], jar)
|
||||||
|
if hdr is None:
|
||||||
|
say(f" wger: login failed: {why}")
|
||||||
|
return False
|
||||||
|
rc, code, out = w.app_curl(sub, "/api/v2/weightentry/?weight=199.99", *hdr)
|
||||||
|
if code != "200" or '"count":0' not in (out or "").replace(" ", ""):
|
||||||
|
say(f" wger: READBACK UNUSABLE — a weight never entered: http={code} {out[:80]}")
|
||||||
|
return False
|
||||||
|
rc, code, out = w.app_curl(sub, f"/api/v2/weightentry/?weight={t['weight']}", *hdr)
|
||||||
|
ok = code == "200" and f'"weight":"{t["weight"]}"' in (out or "").replace(" ", "")
|
||||||
|
say(f" wger: readback of the seeded weight entry http={code} found={ok}")
|
||||||
|
return ok
|
||||||
|
finally:
|
||||||
|
if os.path.exists(jar):
|
||||||
|
os.unlink(jar)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================================
|
||||||
|
class Crafty:
|
||||||
|
"""Crafty Controller 4 (2026-09-30, R-462). Its own REST API v2, behind its own HTTPS port (the template tells
|
||||||
|
traefik `scheme=https`; the bench adapter reads that label): `POST /api/v2/auth/login` as `admin` with the
|
||||||
|
deploy's CRAFTY_PASSWORD (the compose entrypoint writes it into default.json), then `POST /api/v2/roles` — a
|
||||||
|
role is a record in crafty's own database, created the way its panel creates one — read back with
|
||||||
|
`GET /api/v2/roles`. Measured on the bench 2026-09-30 (4.10.7): the POST needs `mfa_required` (500 without).
|
||||||
|
Negative control on every readback: a wrong token must answer 401/403."""
|
||||||
|
sub = "minecraft"
|
||||||
|
|
||||||
|
def _token(self, w, sub, pw):
|
||||||
|
rc, code, out = w.app_curl(sub, "/api/v2/auth/login", "-H", "Content-Type: application/json",
|
||||||
|
data=json.dumps({"username": "admin", "password": pw}), method="POST")
|
||||||
|
try:
|
||||||
|
return json.loads(out)["data"]["token"], code
|
||||||
|
except Exception:
|
||||||
|
return None, f"{code} {(out or '')[:120]}"
|
||||||
|
|
||||||
|
def seed(self, w, sub, say):
|
||||||
|
if not w.wait_app(sub, "/", want=("200", "302"), tries=90):
|
||||||
|
return None
|
||||||
|
pw = (w.GENERATED.get("crafty-controller") or {}).get("CRAFTY_PASSWORD") or ""
|
||||||
|
tok, why = None, None
|
||||||
|
for _ in range(12): # crafty answers `/` before its API accepts the seeded admin
|
||||||
|
tok, why = self._token(w, sub, pw)
|
||||||
|
if tok:
|
||||||
|
break
|
||||||
|
time.sleep(5)
|
||||||
|
if not tok:
|
||||||
|
self.tried = f"POST /api/v2/auth/login -> {why}"
|
||||||
|
return None
|
||||||
|
role = "upg" + secrets.token_hex(5)
|
||||||
|
rc, code, out = w.app_curl(sub, "/api/v2/roles", "-H", f"Authorization: Bearer {tok}",
|
||||||
|
"-H", "Content-Type: application/json",
|
||||||
|
data=json.dumps({"name": role, "servers": [], "mfa_required": False}), method="POST")
|
||||||
|
say(f" crafty: POST /api/v2/roles http={code} {out[:60]}")
|
||||||
|
if code != "200" or '"ok"' not in (out or ""):
|
||||||
|
self.tried = f"POST /api/v2/roles -> {code} {out[:120]}"
|
||||||
|
return None
|
||||||
|
return {"pw": pw, "role": role}
|
||||||
|
|
||||||
|
def verify(self, w, sub, t, say):
|
||||||
|
if not w.wait_app(sub, "/", want=("200", "302"), tries=90):
|
||||||
|
say(" crafty: the app never answered /")
|
||||||
|
return False
|
||||||
|
tok = None
|
||||||
|
for _ in range(12):
|
||||||
|
tok, why = self._token(w, sub, t["pw"])
|
||||||
|
if tok:
|
||||||
|
break
|
||||||
|
time.sleep(5)
|
||||||
|
if not tok:
|
||||||
|
say(f" crafty: login failed: {why}")
|
||||||
|
return False
|
||||||
|
rc, code, _ = w.app_curl(sub, "/api/v2/roles", "-H", f"Authorization: Bearer x{tok}")
|
||||||
|
if code not in ("401", "403"):
|
||||||
|
say(f" crafty: READBACK UNUSABLE — a wrong token answered {code}")
|
||||||
|
return False
|
||||||
|
rc, code, out = w.app_curl(sub, "/api/v2/roles", "-H", f"Authorization: Bearer {tok}")
|
||||||
|
names = [r.get("role_name") for r in (json.loads(out).get("data") or [])] if code == "200" else []
|
||||||
|
ok = t["role"] in names
|
||||||
|
say(f" crafty: readback of the seeded role http={code} found={ok} (roles listed: {len(names)})")
|
||||||
|
return ok
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================================
|
||||||
|
class UptimeKuma:
|
||||||
|
"""Uptime Kuma 2 (2026-09-30, R-462). Its web page talks to the server over socket.io, and its first-run setup,
|
||||||
|
login and every edit exist ONLY there — so the fixture speaks socket.io's plain HTTP long-polling transport
|
||||||
|
(Engine.IO v4: `GET/POST /socket.io/?EIO=4&transport=polling`) through the app's own front door, exactly the
|
||||||
|
messages the page sends: `setup` (the first admin), `login`, `addStatusPage` (a status page is a record in its
|
||||||
|
own SQLite). READBACK through its public REST route `GET /api/status-page/<slug>` (the page households share),
|
||||||
|
which must carry the seeded title; negative control: a slug never made must not answer 200 with a title."""
|
||||||
|
sub = "status"
|
||||||
|
RS = "\x1e"
|
||||||
|
|
||||||
|
class _IO:
|
||||||
|
def __init__(self, w, sub):
|
||||||
|
self.w, self.sub, self.sid, self.buf, self.ack = w, sub, None, [], 0
|
||||||
|
|
||||||
|
def _get(self):
|
||||||
|
rc, code, out = self.w.app_curl(self.sub, f"/socket.io/?EIO=4&transport=polling&sid={self.sid}", timeout=30)
|
||||||
|
if code != "200":
|
||||||
|
raise RuntimeError(f"poll http={code} {out[:80]}")
|
||||||
|
for p in (out or "").split(UptimeKuma.RS):
|
||||||
|
if p == "2": # ping → pong
|
||||||
|
self._post("3")
|
||||||
|
elif p:
|
||||||
|
self.buf.append(p)
|
||||||
|
|
||||||
|
def _post(self, body):
|
||||||
|
rc, code, out = self.w.app_curl(self.sub, f"/socket.io/?EIO=4&transport=polling&sid={self.sid}",
|
||||||
|
"-H", "Content-Type: text/plain;charset=UTF-8", data=body, method="POST")
|
||||||
|
if code != "200":
|
||||||
|
raise RuntimeError(f"post http={code} {out[:80]}")
|
||||||
|
|
||||||
|
def open(self):
|
||||||
|
rc, code, out = self.w.app_curl(self.sub, "/socket.io/?EIO=4&transport=polling")
|
||||||
|
m = re.search(r'"sid":"([^"]+)"', out or "")
|
||||||
|
if code != "200" or not m:
|
||||||
|
raise RuntimeError(f"handshake http={code} {out[:80]}")
|
||||||
|
self.sid = m.group(1)
|
||||||
|
self._post("40")
|
||||||
|
for _ in range(10):
|
||||||
|
self._get()
|
||||||
|
if any(p.startswith("40") for p in self.buf):
|
||||||
|
return self
|
||||||
|
raise RuntimeError("no socket.io connect")
|
||||||
|
|
||||||
|
def call(self, event, *args):
|
||||||
|
n = self.ack
|
||||||
|
self.ack += 1
|
||||||
|
self._post(f"42{n}" + json.dumps([event, *args]))
|
||||||
|
for _ in range(30):
|
||||||
|
for p in list(self.buf):
|
||||||
|
if p.startswith(f"43{n}["):
|
||||||
|
self.buf.remove(p)
|
||||||
|
return json.loads(p[len(f"43{n}"):])[0]
|
||||||
|
self._get()
|
||||||
|
raise RuntimeError(f"no answer to {event}")
|
||||||
|
|
||||||
|
def seed(self, w, sub, say):
|
||||||
|
if not w.wait_app(sub, "/", want=("200", "302"), tries=90):
|
||||||
|
return None
|
||||||
|
user, pw = "drill" + secrets.token_hex(3), "Drill-" + secrets.token_hex(10) + "A1"
|
||||||
|
try:
|
||||||
|
io = self._IO(w, sub).open()
|
||||||
|
r = io.call("setup", user, pw)
|
||||||
|
say(f" uptime-kuma: setup ok={r.get('ok')} {str(r.get('msg'))[:60]}")
|
||||||
|
if not r.get("ok"):
|
||||||
|
self.tried = f"socket.io setup -> {r}"
|
||||||
|
return None
|
||||||
|
io = self._IO(w, sub).open()
|
||||||
|
r = io.call("login", {"username": user, "password": pw, "token": ""})
|
||||||
|
if not r.get("ok"):
|
||||||
|
self.tried = f"socket.io login -> {str(r)[:120]}"
|
||||||
|
return None
|
||||||
|
title, slug = "Drill " + secrets.token_hex(4), "upg" + secrets.token_hex(4)
|
||||||
|
r = io.call("addStatusPage", title, slug)
|
||||||
|
say(f" uptime-kuma: addStatusPage ok={r.get('ok')} {str(r.get('msg'))[:60]}")
|
||||||
|
if not r.get("ok"):
|
||||||
|
self.tried = f"socket.io addStatusPage -> {str(r)[:120]}"
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
self.tried = f"socket.io: {e}"
|
||||||
|
say(f" uptime-kuma: {self.tried}")
|
||||||
|
return None
|
||||||
|
return {"user": user, "pw": pw, "title": title, "slug": slug}
|
||||||
|
|
||||||
|
def verify(self, w, sub, t, say):
|
||||||
|
if not w.wait_app(sub, "/", want=("200", "302"), tries=90):
|
||||||
|
say(" uptime-kuma: the app never answered /")
|
||||||
|
return False
|
||||||
|
rc, code, out = w.app_curl(sub, f"/api/status-page/upgnever{secrets.token_hex(4)}")
|
||||||
|
if code == "200" and '"title"' in (out or ""):
|
||||||
|
say(f" uptime-kuma: READBACK UNUSABLE — a slug never made answered {code} with a title")
|
||||||
|
return False
|
||||||
|
found = False
|
||||||
|
for _ in range(12):
|
||||||
|
rc, code, out = w.app_curl(sub, f"/api/status-page/{t['slug']}")
|
||||||
|
try:
|
||||||
|
found = code == "200" and json.loads(out)["config"]["title"] == t["title"]
|
||||||
|
except Exception:
|
||||||
|
found = False
|
||||||
|
if found:
|
||||||
|
break
|
||||||
|
time.sleep(5)
|
||||||
|
say(f" uptime-kuma: readback of the seeded status page http={code} found={found}")
|
||||||
|
return found
|
||||||
|
|
||||||
|
|
||||||
FIXTURES = {
|
FIXTURES = {
|
||||||
|
"uptime-kuma": UptimeKuma(),
|
||||||
|
"crafty-controller": Crafty(),
|
||||||
|
"wger": Wger(),
|
||||||
|
"calibre-web": CalibreWeb(),
|
||||||
"sparkyfitness": Sparkyfitness(),
|
"sparkyfitness": Sparkyfitness(),
|
||||||
"rallly": Rallly(),
|
"rallly": Rallly(),
|
||||||
"outline": Outline(),
|
"outline": Outline(),
|
||||||
|
|||||||
Reference in New Issue
Block a user