#!/usr/bin/env python3 """Per-app seed/verify fixtures for upgrade-test.py. THE ONE RULE, carried verbatim from survive2.py: *nothing is ever seeded into a volume by hand.* R-156's evidence shows a root-written canary making an empty volume read as populated — the exact confusion this harness exists to remove. So every seed below goes in through the app's OWN interface: its HTTP API, or its own CLI running inside its own container. A raw SQL INSERT or a planted file is NOT such a route and 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**: it tells us which apps can never be auto-verified, which is a fact nobody currently has. Each fixture returns an opaque `seeded` token from seed() and answers verify() with it. verify() must ask the APP, never the filesystem: a migration is supposed to rewrite files. """ import base64, json, re, secrets, subprocess, time def _sh(args, timeout=120, inp=None): try: return subprocess.run(args, capture_output=True, text=True, timeout=timeout, input=inp) except (subprocess.TimeoutExpired, OSError) as e: return subprocess.CompletedProcess(args, 124, "", f"{e}") def _curl(url, *extra, timeout=60, data=None, method=None): """One HTTP call. Gates on curl's OWN exit code, never on a summarising pipeline — the trap check-image-resolvable.py's docstring names and that has bitten this project twice.""" args = ["curl", "-sS", "--max-time", str(timeout), "-w", "\n%{http_code}"] if method: args += ["-X", method] if data is not None: args += ["--data-binary", "@-"] args += list(extra) + [url] r = _sh(args, timeout=timeout + 20, inp=data) body, _, code = (r.stdout or "").rpartition("\n") return r.returncode, code.strip(), body def _wait_http(url, want, tries=40, delay=5, say=print): """Settling says the container is running; this says the APP is answering. They are not the same thing, and conflating them is how "the container started" gets reported as a pass.""" for i in range(tries): rc, code, _ = _curl(url, timeout=15) if rc == 0 and code in want: return True time.sleep(delay) say(f" app never answered on {url} (last rc={rc} code={code})") return False # --------------------------------------------------------------------------------------------- class PrivateBin: """PrivateBin's own JSON API. A paste is an HTTP POST and reading it back is an HTTP GET — the app stores the blob and hands it back, which is an application-level round trip. PrivateBin is FILE-BACKED with no database, so this single seed IS the file half; there is no database half to seed separately. """ port = 8080 container = "privatebin" 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: say(" privatebin: no container IP") return None if not _wait_http(base, {"200"}, say=say): return None marker = "upg-" + secrets.token_hex(8) # The v2 paste envelope. PrivateBin validates it server-side: `ct`, the IV and the salt must # all be real base64 or the API answers {"status":1,"message":"Invalid data."} — measured, # and the reason the first attempt at this fixture failed. The marker is carried INSIDE `ct` # so a readback proves THIS paste came back, not merely A paste. 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 = _curl(base, "-H", "X-Requested-With: JSONHttpRequest", "-H", "Content-Type: application/json", data=body, method="POST") if rc != 0: say(f" privatebin: POST failed rc={rc}") return None 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, ipfn, seeded, say): base = self._base(ipfn) if not base: return False if not _wait_http(base, {"200"}, tries=24, say=say): return False rc, code, out = _curl(base + "?pasteid=" + seeded["id"], "-H", "X-Requested-With: JSONHttpRequest") if rc != 0 or code != "200": say(f" privatebin: readback rc={rc} http={code}") return False got = seeded["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 a page, then read it back.""" port = 3000 container = "docmost" 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: say(" docmost: no container IP") return None if not _wait_http(base + "/api/health", {"200", "404", "401"}, say=say): if not _wait_http(base + "/", {"200", "302", "404"}, tries=20, say=say): return None marker = "upg-" + secrets.token_hex(8) email = f"spike-{secrets.token_hex(4)}@gate.invalid" pw = "Spike-" + secrets.token_hex(10) setup = json.dumps({"workspaceName": "spike", "name": "spike", "email": email, "password": pw}) rc, code, out = _curl(base + "/api/auth/setup", "-H", "Content-Type: application/json", "-D", "/tmp/docmost.hdr", data=setup, method="POST") say(f" docmost: /api/auth/setup http={code} rc={rc}") if rc != 0 or code not in ("200", "201"): say(f" docmost: setup refused: {out[:250]}") return None tok = "" try: tok = json.loads(out).get("tokens", {}).get("accessToken", "") or json.loads(out).get("accessToken", "") except Exception: pass if not tok: m = re.search(r"authToken=([^;]+)", open("/tmp/docmost.hdr").read()) tok = m.group(1) if m else "" if not tok: say(" docmost: no auth token in the setup response") return None return {"marker": marker, "email": email, "pw": pw, "token": tok} def verify(self, ipfn, seeded, say): """Prove the app still holds the seeded ACCOUNT by asking it to authenticate — its own front door, and version-stable across the API churn between 0.25 and 0.95.""" base = self._base(ipfn) if not base: return False if not _wait_http(base + "/", {"200", "302", "404"}, tries=24, say=say): return False body = json.dumps({"email": seeded["email"], "password": seeded["pw"]}) rc, code, out = _curl(base + "/api/auth/login", "-H", "Content-Type: application/json", data=body, method="POST") ok = rc == 0 and 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 has no API token without a browser, so BOTH the seed and the readback go through `php artisan` — BookStack's OWN CLI, running inside its own container against its own application code and its own User model. That is categorically different from a raw SQL INSERT or a planted file, which is what R-156 forbids. WHY NOT THE HTTP LOGIN FORM, which was the first attempt and is the more obvious choice: BookStack derives APP_URL from the template as `https://${SUBDOMAIN}.${DOMAIN}`, so it marks its session and XSRF cookies **`secure`**. curl over plain http therefore stores neither, sends neither, and every login POST comes back **419 Page Expired** — measured, and it looks exactly like a wrong password. The container serves no TLS, so there is no http route to a logged-in session without changing the app's own configuration, which would be testing a different app. WHY THE EXIT CODE IS NOT THE GATE HERE, stated because the standing rule says to use it: `bookstack:reset-mfa` asks for interactive confirmation, finds no TTY, and exits **1 in both cases** — for a user it FOUND and for one it did not. The exit code carries no information, so the discriminator is the output, and it is required to be positive AND the not-found sentence is required to be ABSENT. It is non-destructive: it aborts at the unanswered prompt. THE FIXTURE PROVES ITSELF ON EVERY CALL. Each verify() also probes an email that cannot exist and requires the "could not be found" answer. A readback that has broken into always saying "found" therefore fails instead of passing everything. LIMITATION, recorded rather than papered over: this seeds the DATABASE half only. Seeding a FILE (an uploaded image or attachment) needs the API token this app cannot mint headlessly. """ port = 80 container = "bookstack" def _base(self, ipfn): ip = ipfn(self.container) return f"http://{ip}:{self.port}" if ip else "" def _artisan(self, *args, timeout=180): for path in ("/app/www/artisan", "/var/www/html/artisan"): r = _sh(["docker", "exec", self.container, "php", path] + list(args), timeout=timeout) out = (r.stdout or "") + (r.stderr or "") if "Could not open input file" not in out: return r return r def _lookup(self, email): """Ask BookStack whether it holds this account. Returns True/False/None(unusable).""" r = self._artisan("bookstack:reset-mfa", f"--email={email}") out = " ".join(((r.stdout or "") + (r.stderr or "")).split()) found = f"Email: {email}" in out missing = "could not be found" in out if found == missing: # neither, or both — the readback itself is broken return None, out return found, out def seed(self, ipfn, say): base = self._base(ipfn) if not base: say(" bookstack: no container IP") return None if not _wait_http(base + "/login", {"200"}, tries=60, say=say): return None email = f"spike-{secrets.token_hex(4)}@gate.invalid" pw = "Spike-" + secrets.token_hex(10) r = self._artisan("bookstack:create-admin", f"--email={email}", f"--name=spike-{secrets.token_hex(3)}", f"--password={pw}") out = " ".join(((r.stdout or "") + (r.stderr or "")).split()) say(f" bookstack: artisan create-admin rc={r.returncode} :: {out[:120]}") if "successfully created" not in out: return None return {"email": email, "pw": pw} def verify(self, ipfn, seeded, say): base = self._base(ipfn) if not base: return False # The app must be SERVING, not merely running — "the container started" is not a pass. if not _wait_http(base + "/login", {"200"}, tries=60, say=say): say(" bookstack: the app never served /login") return False # The fixture's own negative control, run every time. absent, _ = self._lookup(f"nobody-{secrets.token_hex(6)}@gate.invalid") if absent is not False: say(f" bookstack: READBACK IS UNUSABLE — an email that cannot exist did not read as absent ({absent})") return False found, out = self._lookup(seeded["email"]) say(f" bookstack: readback of the seeded account found={found} :: {out[:120]}") return found is True FIXTURES = {"privatebin": PrivateBin(), "docmost": Docmost(), "bookstack": BookStack()}