Files
tonymandClaude Fable 5.1 71abf89784
Build / Build and release (push) Failing after 19s
feat: Sign in with OTM, health, changelog, tides beacon, Ideas — first OTM release (0.4.0)
app/otm.py is the Python port of pickup's ticket verifier (HS256, mandatory
exp, audience check, alg only read to reject, never raises) plus a signed
session cookie and an in-memory single-use jti guard. portable.py gates every
route but PUBLIC_PATHS behind a session and fails CLOSED (503) with no secret
— it is a fetch proxy. /api/health reports the version, /changelog renders
CHANGELOG.md, the footer carries version/sign-out/Ideas, tides beacon from
TIDES_HOST/TIDES_SITE_ID. Dockerfile: OTM labels + HEALTHCHECK. 17 tests.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014LCQmFvKshyAJsE8C3B5ET
2026-10-18 22:22:27 -05:00

86 lines
3.6 KiB
Python

"""End-to-end through Flask's test client: the gate, the SSO callback, replay,
health. Run with the venv python: `cd app && ../.venv/bin/python -m unittest`."""
import os
import unittest
os.environ["OTM_SSO_SECRET"] = "sso-secret"
os.environ["AUTH_SECRET"] = "auth-secret"
import portable # noqa: E402 (env must be set before import? no — read per request; set anyway)
import otm # noqa: E402
class Gate(unittest.TestCase):
def setUp(self):
self.c = portable.app.test_client()
def test_health_is_public(self):
r = self.c.get("/api/health")
self.assertEqual(r.status_code, 200)
self.assertEqual(r.get_json()["version"], otm.APP_VERSION)
def test_anonymous_is_bounced_to_login(self):
r = self.c.get("/")
self.assertEqual(r.status_code, 302)
self.assertTrue(r.headers["Location"].startswith("/login?next=%2F"))
r = self.c.get("/https://example.com/article")
self.assertEqual(r.status_code, 302)
self.assertIn("/login", r.headers["Location"])
r = self.c.post("/article", data={"link": "https://example.com"})
self.assertEqual(r.status_code, 401)
def test_login_page_offers_otm(self):
r = self.c.get("/login?next=/x")
self.assertEqual(r.status_code, 200)
self.assertIn(b"sso/authorize?aud=app%3A13ft&amp;next=%2Fx", r.data)
def test_ticket_signs_in_once(self):
t = otm.mint_sso_ticket("sso-secret", {"aud": "app:13ft", "sub": "u1", "email": "tony@example.com", "role": "admin", "next": "/"})
r = self.c.get(f"/api/auth/otm-sso?token={t}")
self.assertEqual(r.status_code, 302)
self.assertEqual(r.headers["Location"], "/")
self.assertIn(otm.SESSION_COOKIE, r.headers.get("Set-Cookie", ""))
# Signed in: the home page renders, with the OTM footer.
r = self.c.get("/")
self.assertEqual(r.status_code, 200)
self.assertIn(b"Powered by OTM", r.data)
self.assertIn(b"tony@example.com", r.data)
# Replay of the same ticket is refused.
fresh = portable.app.test_client()
r = fresh.get(f"/api/auth/otm-sso?token={t}")
self.assertEqual(r.headers["Location"], "/login?reason=link_already_used")
def test_bad_ticket(self):
r = self.c.get("/api/auth/otm-sso?token=nope")
self.assertEqual(r.headers["Location"], "/login?reason=invalid_token")
wrong = otm.mint_sso_ticket("sso-secret", {"aud": "app:qr", "sub": "u1", "email": "e@x", "role": "admin"})
r = self.c.get(f"/api/auth/otm-sso?token={wrong}")
self.assertEqual(r.headers["Location"], "/login?reason=invalid_token")
def test_logout_clears(self):
t = otm.mint_sso_ticket("sso-secret", {"aud": "app:13ft", "sub": "u1", "email": "e@x.io", "role": "admin"})
self.c.get(f"/api/auth/otm-sso?token={t}")
self.assertEqual(self.c.get("/").status_code, 200)
self.c.get("/logout")
self.assertEqual(self.c.get("/").status_code, 302)
class FailClosed(unittest.TestCase):
def test_no_secret_means_503_everywhere(self):
saved = {k: os.environ.pop(k, None) for k in ("OTM_SSO_SECRET", "AUTH_SECRET")}
try:
c = portable.app.test_client()
self.assertEqual(c.get("/").status_code, 503)
self.assertEqual(c.get("/https://example.com").status_code, 503)
self.assertEqual(c.get("/api/health").status_code, 200)
self.assertIn(b"isn't wired to OTM", c.get("/login").data)
finally:
for k, v in saved.items():
if v is not None:
os.environ[k] = v
if __name__ == "__main__":
unittest.main()