feat: Sign in with OTM, health, changelog, tides beacon, Ideas — first OTM release (0.4.0)
Build / Build and release (push) Failing after 19s
Build / Build and release (push) Failing after 19s
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
This commit is contained in:
@@ -0,0 +1,85 @@
|
||||
"""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&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()
|
||||
Reference in New Issue
Block a user