pdf-quiz-generator/backend/tests/test_sso_roles.py
Daniel 12453ba473
Some checks failed
Tests / backend (push) Failing after 4s
Tests / frontend (push) Failing after 28s
Tests / e2e (push) Failing after 34s
fix: a role is not set here while the provider holds it
Two endpoints still set a role locally — PUT /access/{id}/role and PUT
/admin/users/{id}/role. With OIDC_ROLE_CLAIM configured, my sync applies
the person's groups on every sign-in, so a role set in the app lasts
until they next sign in and is then overwritten. That is the worst kind
of setting: it appears to work, holds for an afternoon, and quietly
undoes itself.

Both now answer 409 naming where the switch actually is. With no mapping
configured they behave exactly as before, which is what a site with no
provider needs.

The two existing tests now patch is_configured explicitly rather than
inheriting whatever the container's .env happens to say — a test that
reads differently on two machines is not a test.

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

145 lines
6.2 KiB
Python

"""What the identity provider's groups make somebody here.
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
import unittest.mock
from types import SimpleNamespace
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
from sqlalchemy.pool import StaticPool
from app.database import Base
from app.models.user import User
from app.services import sso_roles
def config(claim="groups", admin="", moderator=""):
return SimpleNamespace(OIDC_ROLE_CLAIM=claim, OIDC_ADMIN_GROUPS=admin,
OIDC_MODERATOR_GROUPS=moderator)
class RoleMappingTests(unittest.TestCase):
def test_off_unless_configured(self):
# The old behaviour, exactly: no claim named, no opinion about anybody.
self.assertIsNone(sso_roles.role_for({"groups": ["peds-admins"]}, config()))
self.assertIsNone(sso_roles.role_for({"groups": ["peds-admins"]},
config(claim="", admin="peds-admins")))
def test_reads_the_three_shapes_a_provider_sends(self):
cfg = config(admin="Peds-Admins")
for claim in (["peds-admins"], "peds-admins", "peds-admins other",
"peds-admins,other", ("PEDS-ADMINS",)):
self.assertEqual(sso_roles.role_for({"groups": claim}, cfg), "admin", claim)
def test_admin_beats_moderator(self):
cfg = config(admin="bosses", moderator="educators")
self.assertEqual(
sso_roles.role_for({"groups": ["educators", "bosses"]}, cfg), "admin")
def test_belonging_to_neither_is_a_learner(self):
cfg = config(admin="bosses", moderator="educators")
self.assertEqual(sso_roles.role_for({"groups": ["everyone"]}, cfg), "user")
self.assertEqual(sso_roles.role_for({}, cfg), "user")
class RoleSyncTests(unittest.TestCase):
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)
def tearDown(self):
self.db.close()
self.engine.dispose()
def add(self, email, role):
user = User(name=email, email=email, hashed_password="unused", role=role)
self.db.add(user)
self.db.commit()
return user
def test_the_directory_promotes_and_demotes(self):
# Demotion is the point. A group somebody can be added to and never
# removed from is not a list anybody can rely on.
cfg = config(admin="bosses", moderator="educators")
learner = self.add("new@example.test", "user")
self.assertEqual(sso_roles.apply(self.db, learner, {"groups": ["educators"]}, cfg),
"moderator")
self.assertEqual(learner.role, "moderator")
self.assertEqual(sso_roles.apply(self.db, learner, {"groups": []}, cfg), "user")
self.assertEqual(learner.role, "user")
def test_it_will_not_demote_the_last_admin(self):
# A mistyped group name should not lock everybody out of the settings
# page, including the person who could fix the group name.
cfg = config(admin="bosses")
boss = self.add("boss@example.test", "admin")
self.assertIsNone(sso_roles.apply(self.db, boss, {"groups": ["typo"]}, cfg))
self.assertEqual(boss.role, "admin")
# With a second admin standing, the demotion goes through.
self.add("other@example.test", "admin")
self.assertEqual(sso_roles.apply(self.db, boss, {"groups": ["typo"]}, cfg), "user")
self.assertEqual(boss.role, "user")
def test_an_unconfigured_site_never_touches_a_role(self):
boss = self.add("boss@example.test", "admin")
self.assertIsNone(sso_roles.apply(self.db, boss, {"groups": []}, config()))
self.assertEqual(boss.role, "admin")
if __name__ == "__main__":
unittest.main()
class SsoLoginShapeTests(unittest.TestCase):
"""The login redirect has to be awaited, not returned.
Authlib's Starlette client is the async one: `authorize_redirect` hands
back a coroutine. A sync endpoint returned that coroutine to FastAPI,
which tried to serialise it and answered 500 — so the SSO button 500'd the
first time anybody had a provider configured to click it with. A cheap
shape check, because the alternative is standing up an OIDC provider in a
unit test.
"""
def test_the_redirect_endpoint_is_a_coroutine_function(self):
import inspect
from app.routers import auth
self.assertTrue(inspect.iscoroutinefunction(auth.sso_login))
self.assertTrue(inspect.iscoroutinefunction(auth.sso_callback))
source = inspect.getsource(auth.sso_login)
self.assertIn("await oauth.oidc.authorize_redirect", source)
class LocalRoleChangeTests(unittest.TestCase):
"""Where the switch is, once the provider holds it.
A role set in this app while group mapping is on lasts until the person
next signs in and is then overwritten from their groups — a change that
appears to work, holds for an afternoon and quietly undoes itself. The two
role endpoints say where the real switch is instead.
"""
def test_refused_while_mapping_is_configured(self):
from fastapi import HTTPException
from types import SimpleNamespace
mapped = SimpleNamespace(OIDC_ROLE_CLAIM="groups", OIDC_ADMIN_GROUPS="bosses",
OIDC_MODERATOR_GROUPS="")
with unittest.mock.patch("app.config.settings", mapped):
with self.assertRaises(HTTPException) as caught:
sso_roles.refuse_local_role_change()
self.assertEqual(caught.exception.status_code, 409)
self.assertIn("identity provider", caught.exception.detail)
def test_allowed_when_nothing_is_mapped(self):
from types import SimpleNamespace
unmapped = SimpleNamespace(OIDC_ROLE_CLAIM="", OIDC_ADMIN_GROUPS="",
OIDC_MODERATOR_GROUPS="")
with unittest.mock.patch("app.config.settings", unmapped):
self.assertIsNone(sso_roles.refuse_local_role_change())