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
145 lines
6.2 KiB
Python
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())
|