pdf-quiz-generator/backend/tests/test_sso_roles.py
Daniel 7f36e07af0
Some checks failed
Tests / backend (push) Failing after 4s
Tests / frontend (push) Failing after 33s
Tests / e2e (push) Failing after 34s
feat: SSO reads roles from the provider's groups, and needs a verified address
Everybody arriving through SSO landed as a learner and was promoted by
hand. That is fine for three people and wrong for three hundred: the
list of who may edit the bank then lives in two places and only one of
them is maintained. OIDC_ROLE_CLAIM names the claim that carries somebody's
groups ("groups" for most providers, "roles" for Entra app roles), and
OIDC_ADMIN_GROUPS / OIDC_MODERATOR_GROUPS say which values mean what.
All three shapes a provider might send are read — a list, a
space-separated string, a comma-separated one — and matched case-blind.

Applied on every sign-in, not only at creation, because a group somebody
can be added to and never removed from is not a list anybody can rely
on. The one demotion it refuses is the last administrator: a mistyped
group name should not lock everybody out of the settings page,
including the person who could fix the group name. It is logged when it
happens.

Blank OIDC_ROLE_CLAIM is the old behaviour exactly: role "user", set
once, never touched.

And an address the provider will not vouch for is not an identity. This
matches on email, so an explicit email_verified:false would hand an
existing account to whoever typed its address at a careless provider.
Refused now — only on an explicit false, since a provider that omits the
claim is not asserting anything either way.

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

96 lines
4 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
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()