diff --git a/scripts/upgrade_fixtures_box.py b/scripts/upgrade_fixtures_box.py index d253f66..ca2e904 100644 --- a/scripts/upgrade_fixtures_box.py +++ b/scripts/upgrade_fixtures_box.py @@ -1163,7 +1163,261 @@ class Wishlist: say(f" wishlist: sign-in as the seeded user type={good.get('type')} ok={ok} (wrong password refused)") return ok +# ============================================================================================= +def _set_cookies(out): + """The `name=value` pairs of every Set-Cookie in a `curl -D -` answer (headers + body), joined for + a Cookie header. Kept in the fixture's own memory only; never printed.""" + pairs = re.findall(r"(?im)^set-cookie:\s*([^=;\s]+=[^;\r\n]*)", out or "") + return "; ".join(pairs) + + +class Sparkyfitness: + """SparkyFitness's OWN better-auth API: `POST /api/auth/sign-up/email` makes the first account (the + household's own first-run route — the box's sign-up block goes up only AFTER the setup, decision 47), + `POST /api/auth/sign-in/email` gives the session cookie, `POST /api/measurements/check-in` stores a + weight for one date, `GET /api/measurements/check-in/` reads it back. Measured on the bench + 2026-09-30 (v0.17.3, PostgreSQL 15): sign-up 200, check-in 200, readback equal, an empty date → `{}`, + a wrong password → 401. + + THE FIXTURE PROVES ITSELF ON EVERY CALL: verify() also requires a wrong password to be refused and a + date with no check-in to read back without the weight. + """ + sub = "sparky" + + def _signin(self, w, sub, email, pw): + # better-auth rate-limits sign-in per client address (a box's traefik is ONE address): a 429 is waited + # out, never read as a verdict (measured on 9202 2026-09-30: seed sign-in + wrong + right within 1 s → 429). + for _ in range(4): + rc, code, out = w.app_curl(sub, "/api/auth/sign-in/email", "-D", "-", "-H", "Content-Type: application/json", + "-H", f"Origin: https://{sub}.{w.DOMAIN}", + data=json.dumps({"email": email, "password": pw}), method="POST") + if code != "429": + break + time.sleep(15) + return code, _set_cookies(out) + + def seed(self, w, sub, say): + if not w.wait_app(sub, "/api/health", want=("200",), tries=120): + if not w.wait_app(sub, "/", want=("200",), tries=30): + return None + email = "drill" + secrets.token_hex(3) + "@gate.invalid" + pw = "Drill-" + secrets.token_hex(10) + weight = round(50 + secrets.randbelow(4000) / 100, 2) + rc, code, out = w.app_curl(sub, "/api/auth/sign-up/email", "-H", "Content-Type: application/json", + "-H", f"Origin: https://{sub}.{w.DOMAIN}", + data=json.dumps({"email": email, "password": pw, "name": "Drill"}), method="POST") + say(f" sparkyfitness: sign-up http={code}") + if code not in ("200", "201"): + self.tried = f"POST /api/auth/sign-up/email -> {code} {out[:120]}" + return None + code, ck = self._signin(w, sub, email, pw) + if code != "200" or not ck: + self.tried = f"POST /api/auth/sign-in/email -> {code}" + return None + rc, code, out = w.app_curl(sub, "/api/measurements/check-in", "-H", f"Cookie: {ck}", + "-H", "Content-Type: application/json", "-H", f"Origin: https://{sub}.{w.DOMAIN}", + data=json.dumps({"entry_date": "2026-09-01", "weight": weight}), method="POST") + say(f" sparkyfitness: check-in http={code}") + if code != "200": + self.tried = f"POST /api/measurements/check-in -> {code} {out[:120]}" + return None + say(f" sparkyfitness: seeded user {email.split('@')[0]} with a weight of {weight} on 2026-09-01") + return {"email": email, "pw": pw, "weight": weight} + + def verify(self, w, sub, t, say): + if not w.wait_app(sub, "/", want=("200",), tries=120): + say(" sparkyfitness: the app never served /") + return False + code, _ = self._signin(w, sub, t["email"], "wrong-" + secrets.token_hex(6)) + if code == "200": + say(" sparkyfitness: READBACK UNUSABLE — a wrong password signed in") + return False + code, ck = self._signin(w, sub, t["email"], t["pw"]) + if code != "200" or not ck: + say(f" sparkyfitness: the seeded user could not sign in (http {code})") + return False + rc, code, out = w.app_curl(sub, "/api/measurements/check-in/1999-01-01", "-H", f"Cookie: {ck}") + if code != "200" or '"weight"' in (out or ""): + say(f" sparkyfitness: READBACK UNUSABLE — an empty date answered {code} {out[:80]}") + return False + rc, code, out = w.app_curl(sub, "/api/measurements/check-in/2026-09-01", "-H", f"Cookie: {ck}") + try: + got = json.loads(out).get("weight") + except Exception: + got = None + say(f" sparkyfitness: readback of the seeded weight http={code} equal={got == t['weight']}") + return got == t["weight"] + + +class Rallly: + """Rallly's OWN better-auth API and its own tRPC, the household's route: `POST /api/better-auth/sign-up/email` + makes the account, which then needs the six-digit code Rallly e-mails. **The one step that is not the front + door:** the code is READ (a SELECT, never a write) from the app's own `verifications` row, standing in for the + household's mailbox — this venue has none. The code is then given back through the front door + (`POST /api/better-auth/email-otp/verify-email`), the session comes from `sign-in/email`, and a poll is made + with `polls.make` (tRPC). The readback is the public `polls.get` by the poll's id. Nothing is written by hand + (R-156). Measured on the bench 2026-09-30 (v4.11.1, PostgreSQL 16): sign-up 200 (the mail send fails — + ESOCKET — and the code is stored anyway), verify 200, `polls.make` 200, `polls.get` 200 with the title, an + unknown id → 404 "Poll not found". + + THE FIXTURE PROVES ITSELF ON EVERY CALL: verify() also requires an id that cannot exist to be not found. + """ + sub = "poll" + + def _auth(self, w, sub, path, body): + return w.app_curl(sub, "/api/better-auth/" + path, "-D", "-", "-H", "Content-Type: application/json", + "-H", f"Origin: https://{sub}.{w.DOMAIN}", data=json.dumps(body), method="POST") + + def _get(self, w, sub, poll_id): + q = json.dumps({"json": {"urlId": poll_id}}, separators=(",", ":")) + import urllib.parse + return w.app_curl(sub, "/api/trpc/polls.get?input=" + urllib.parse.quote(q)) + + def seed(self, w, sub, say): + if not w.wait_app(sub, "/login", want=("200",), tries=90): + return None + email = "drill" + secrets.token_hex(3) + "@gate.invalid" + pw = "Drill-" + secrets.token_hex(10) + rc, code, out = self._auth(w, sub, "sign-up/email", {"email": email, "password": pw, "name": "Drill"}) + say(f" rallly: sign-up http={code}") + if code != "200": + self.tried = f"POST /api/better-auth/sign-up/email -> {code}" + return None + otp = "" + for _ in range(10): + otp = w.guest("docker exec rallly-postgres psql -U rallly -d rallly -Atc " + f"\"select split_part(value,':',1) from verifications where identifier=" + f"'email-verification-otp-{email}' order by created_at desc limit 1\" 2>&1").strip() + if re.fullmatch(r"\d{6}", otp): + break + time.sleep(2) + if not re.fullmatch(r"\d{6}", otp): + self.tried = "the e-mail code was not in the app's own verifications table" + say(" rallly: no e-mail code found in the app's own table") + return None + rc, code, out = self._auth(w, sub, "email-otp/verify-email", {"email": email, "otp": otp}) + say(f" rallly: verify-email with the code http={code}") + if code != "200": + self.tried = f"POST /api/better-auth/email-otp/verify-email -> {code}" + return None + rc, code, out = self._auth(w, sub, "sign-in/email", {"email": email, "password": pw}) + ck = _set_cookies(out) + if code != "200" or "session_token" not in ck: + self.tried = f"POST /api/better-auth/sign-in/email -> {code}" + return None + title = "drillpoll-" + secrets.token_hex(4) + rc, code, out = w.app_curl(sub, "/api/trpc/polls.make", "-H", f"Cookie: {ck}", "-H", "Content-Type: application/json", + "-H", f"Origin: https://{sub}.{w.DOMAIN}", + data=json.dumps({"json": {"title": title, "timeZone": "Europe/Budapest", + "options": [{"startDate": "2026-12-01"}]}}), method="POST") + try: + pid = json.loads(out)["result"]["data"]["json"]["data"]["id"] + except Exception: + self.tried = f"POST /api/trpc/polls.make -> {code} {out[:120]}" + say(f" rallly: polls.make -> {code} {out[:120]}") + return None + say(f" rallly: seeded poll {title} ({pid})") + return {"id": pid, "title": title} + + def verify(self, w, sub, t, say): + if not w.wait_app(sub, "/login", want=("200",), tries=90): + say(" rallly: the app never served /login") + return False + rc, code, out = self._get(w, sub, "Nope" + secrets.token_hex(4)) + if code != "404": + say(f" rallly: READBACK UNUSABLE — an id that cannot exist answered {code}") + return False + rc, code, out = self._get(w, sub, t["id"]) + found = code == "200" and t["title"] in (out or "") + say(f" rallly: readback of the seeded poll http={code} found={found}") + return found + + +class Outline: + """Outline's OWN first-run API, the household's route while the app holds no workspace: + `POST /api/installation.create` makes the workspace and its admin and signs them in (Outline mounts it + only on self-hosted installs, and refuses it once a team exists). The session makes an API key + (`apiKeys.create`, with Outline's own CSRF cookie + header), the key makes a collection and a published + document, and the readback is `documents.info` with the key. Measured on the bench 2026-09-30 (v1.9.1, + PostgreSQL 16): every call 200, the title and body read back, an unknown id → 404, a wrong key → 401. + + THE FIXTURE PROVES ITSELF ON EVERY CALL: verify() also requires an unknown document id to be not found + and a wrong key to be refused. + """ + sub = "kb" + ZERO = "00000000-0000-4000-8000-000000000000" + + def _api(self, w, sub, call, body, key): + return w.app_curl(sub, "/api/" + call, "-H", f"Authorization: Bearer {key}", "-H", + "Content-Type: application/json", data=json.dumps(body), method="POST") + + def seed(self, w, sub, say): + if not w.wait_app(sub, "/_health", want=("200",), tries=90): + return None + o = f"https://{sub}.{w.DOMAIN}" + rc, code, out = w.app_curl(sub, "/api/installation.create", "-D", "-", "-H", "Content-Type: application/json", + "-H", f"Origin: {o}", + data=json.dumps({"teamName": "Drill", "userName": "Drill", + "userEmail": "drill" + secrets.token_hex(3) + "@gate.invalid"}), + method="POST") + ck = _set_cookies(out) + say(f" outline: installation.create http={code}") + if code not in ("200", "302") or "accessToken=" not in ck: + self.tried = f"POST /api/installation.create -> {code}" + return None + rc, code, out = w.app_curl(sub, "/home", "-D", "-", "-o", "/dev/null", "-H", f"Cookie: {ck}") + csrf = re.search(r"(?im)^set-cookie:\s*csrfToken=([^;\r\n]+)", out or "") + if not csrf: + self.tried = "no csrfToken cookie from GET /home" + return None + cs = csrf.group(1) + rc, code, out = w.app_curl(sub, "/api/apiKeys.create", "-H", f"Cookie: {ck}; csrfToken={cs}", "-H", f"x-csrf-token: {cs}", + "-H", "Content-Type: application/json", "-H", f"Origin: {o}", + data=json.dumps({"name": "drill"}), method="POST") + try: + key = json.loads(out)["data"]["value"] + except Exception: + self.tried = f"POST /api/apiKeys.create -> {code} {out[:120]}" + return None + rc, code, out = self._api(w, sub, "collections.create", {"name": "Drill"}, key) + try: + col = json.loads(out)["data"]["id"] + except Exception: + self.tried = f"POST /api/collections.create -> {code} {out[:120]}" + return None + title, body = "drilldoc-" + secrets.token_hex(4), "drill body " + secrets.token_hex(6) + rc, code, out = self._api(w, sub, "documents.create", + {"title": title, "text": body, "collectionId": col, "publish": True}, key) + try: + did = json.loads(out)["data"]["id"] + except Exception: + self.tried = f"POST /api/documents.create -> {code} {out[:120]}" + return None + say(f" outline: seeded document {title}") + return {"key": key, "id": did, "title": title, "body": body} + + def verify(self, w, sub, t, say): + if not w.wait_app(sub, "/_health", want=("200",), tries=90): + say(" outline: the app never served /_health") + return False + rc, code, _ = self._api(w, sub, "documents.info", {"id": self.ZERO}, t["key"]) + if code != "404": + say(f" outline: READBACK UNUSABLE — an unknown document answered {code}") + return False + rc, code, _ = self._api(w, sub, "documents.info", {"id": t["id"]}, "ol_api_" + secrets.token_hex(19)) + if code != "401": + say(f" outline: READBACK UNUSABLE — a wrong key answered {code}") + return False + rc, code, out = self._api(w, sub, "documents.info", {"id": t["id"]}, t["key"]) + found = code == "200" and t["title"] in (out or "") and t["body"] in (out or "") + say(f" outline: readback of the seeded document http={code} found={found}") + return found + + FIXTURES = { + "sparkyfitness": Sparkyfitness(), + "rallly": Rallly(), + "outline": Outline(), "home-assistant": HomeAssistant(), "romm": Romm(), "vikunja": Vikunja(), diff --git a/scripts/upgrade_fixtures_box28.py b/scripts/upgrade_fixtures_box28.py index 6b28e1b..8e8d80c 100644 --- a/scripts/upgrade_fixtures_box28.py +++ b/scripts/upgrade_fixtures_box28.py @@ -407,8 +407,6 @@ FIXTURES28.update({ "template; it stores no household data"), "plex": NoRoute("plex", "plex", "the first-run claim needs a token minted at plex.tv by a " "real Plex account; no account exists for this venue"), - "outline": NoRoute("outline", "outline", "sign-in requires an external identity provider " - "(OIDC/Slack/Google); no local sign-up route exists"), - "rallly": NoRoute("rallly", "rallly", "sign-in is an e-mail magic link; this venue has no " - "mailbox the harness can read"), + # outline and rallly WERE NoRoute here until 2026-09-30: both have a front-door first-run route after all + # (outline `installation.create`, rallly sign-up + its own e-mail code) — the fixtures are in upgrade_fixtures_box.py. })