pdf-quiz-generator/backend/tests/test_sso_hardening.py
Daniel 963ca04cf8
Some checks failed
Tests / backend (push) Failing after 7s
Tests / frontend (push) Successful in 30s
Tests / e2e (push) Failing after 33s
feat: no account creation here — the provider makes accounts
Gone: /auth/register, /auth/signup-policy, POST /admin/users, the
RegisterPage, the register half of the landing modal, the Register
button, the "Sign up" link, the "Create an account" hero button, and the
UserCreate schema. /register redirects to /login for anybody holding a
bookmark. A first admin on a fresh install still comes from
DEFAULT_ADMIN_EMAIL at startup, so nothing is locked out.

And no flash of the old way in. Both sign-in surfaces defaulted to "no
provider" and drew the email form while /auth/sso/config was in flight,
then swapped it — so a reload showed a form that does not exist, briefly,
every time. They render nothing until the answer arrives. The landing
modal is now one button, "Sign in with PedsHub SSO", with no sentence
under it: the button already says where you are going.

Also, the section strip takes the width it has. It sat inside the 1200px
measure that keeps an article readable, so on a wide desktop the last
entries fell off the end and a scroll arrow appeared beside acres of
empty space. Verified at 1280, 1600 and 1920: ten links, no arrows.

And "Make a deck" comes out of the strip and the phone menu — that was
an over-reach on my part. The landing CTA keeps it, pointing at
app.pedshub.com/#resources, which is what was actually asked for.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-13 14:37:10 +02:00

118 lines
4.8 KiB
Python

"""The password doors while a site is single sign-on, and the token in the URL.
Run: DATABASE_URL=sqlite:///:memory: PYTHONPATH=backend python -m unittest discover -s backend/tests
"""
import os
os.environ.setdefault("DATABASE_URL", "sqlite:///:memory:")
import unittest
from unittest.mock import patch
from fastapi import FastAPI
from fastapi.testclient import TestClient
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
from sqlalchemy.pool import StaticPool
from app.database import Base, get_db
from app.models.user import User
from app.routers import auth
from app.utils.auth import get_current_user
class SsoOnlyClosesThePasswordDoors(unittest.TestCase):
"""Login checked the flag. The other password doors did not.
Registration is gone entirely — accounts are made at the provider — so
what is left to guard is the password somebody already has: resetting it,
or setting a new one while signed in.
"""
def setUp(self):
self.engine = create_engine("sqlite://", connect_args={"check_same_thread": False},
poolclass=StaticPool)
Base.metadata.create_all(self.engine)
self.db = Session(self.engine)
# No password of their own, so the "set a first password" path in
# PUT /auth/me is the one under test rather than bcrypt's opinion of a
# fake hash.
self.user = User(id=1, name="Reader", email="reader@example.com",
hashed_password=None)
self.db.add(self.user)
self.db.commit()
app = FastAPI()
app.include_router(auth.router, prefix="/auth")
app.dependency_overrides[get_db] = lambda: self.db
app.dependency_overrides[get_current_user] = lambda: self.user
self.client = TestClient(app)
def tearDown(self):
self.client.close()
self.db.close()
self.engine.dispose()
def calls(self):
return [
("/auth/forgot-password", {"email": "reader@example.com"}),
("/auth/reset-password", {"token": "x" * 20, "new_password": "abcdefgh"}),
("/auth/me", {"new_password": "abcdefgh"}),
]
def test_every_password_door_is_shut(self):
with patch.object(auth, "_get_sso_settings", return_value={"sso_only": True}):
for path, body in self.calls():
method = self.client.put if path == "/auth/me" else self.client.post
response = method(path, json=body)
self.assertEqual(response.status_code, 403, f"{path}: {response.text}")
self.assertIn("SSO", response.json()["detail"], path)
def test_and_open_again_when_the_site_is_not_sso_only(self):
# Not asserting success — these have their own rules — only that the
# flag is no longer the thing refusing them.
with patch.object(auth, "_get_sso_settings", return_value={"sso_only": False}):
for path, body in self.calls():
method = self.client.put if path == "/auth/me" else self.client.post
self.assertNotEqual(method(path, json=body).status_code, 403, path)
class OneTimeCodeTests(unittest.TestCase):
"""The redirect carries a code, and the code is worth exactly one exchange.
It used to carry the access token itself, in a query string that nginx
writes to its access log on every sign-in — a live bearer token, good for
a day, in a file, in the browser's history, and in the next Referer.
"""
def setUp(self):
app = FastAPI()
app.include_router(auth.router, prefix="/auth")
self.client = TestClient(app)
self.store = {}
def tearDown(self):
self.client.close()
class FakeRedis:
def __init__(self, store): self.store = store
def setex(self, key, ttl, value): self.store[key] = value
def getdel(self, key): return self.store.pop(key, None)
def test_a_code_is_spent_once(self):
with patch.object(auth, "_sso_store", return_value=self.FakeRedis(self.store)):
code = auth._stash_sso_token("a.jwt.value")
self.assertTrue(code and "a.jwt.value" not in code)
first = self.client.post("/auth/sso/exchange", json={"code": code})
self.assertEqual(first.status_code, 200, first.text)
self.assertEqual(first.json()["access_token"], "a.jwt.value")
# Replayed from a log, a history entry or a Referer: worth nothing.
again = self.client.post("/auth/sso/exchange", json={"code": code})
self.assertEqual(again.status_code, 400)
def test_an_unknown_code_is_refused(self):
with patch.object(auth, "_sso_store", return_value=self.FakeRedis(self.store)):
response = self.client.post("/auth/sso/exchange", json={"code": "y" * 40})
self.assertEqual(response.status_code, 400)
if __name__ == "__main__":
unittest.main()