"""Integration tests for OIDC group sync (#3107). Same regression contract as the LDAP group sync (test_ldap_group_sync.py, #1292): the sync manages only the Bambuddy groups named in the provider's group_mapping values, and every login replaces exactly that slice. Manual assignments to groups outside the mapping survive; revocation at the IdP propagates on the next login. One deliberate difference from LDAP: there is no default-group fallback in the OIDC sync. The provider's default_group_id is applied once at account creation (routes/mfa.py) and never re-asserted, so promoting an auto-created user out of Viewers is a manual action that sticks. """ import logging from typing import NoReturn import pytest from pydantic import ValidationError from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload from backend.app.models.group import Group from backend.app.models.oidc_provider import OIDCProvider from backend.app.models.user import User from backend.app.schemas.auth import OIDCProviderCreate from backend.app.services.oidc_group_sync import ( _MAX_CLAIM_ITEMS, extract_idp_groups, resolve_oidc_group_mapping, sync_oidc_user_groups, ) async def _make_group(db: AsyncSession, name: str) -> Group: group = Group(name=name, description=f"Test group {name}") db.add(group) await db.commit() await db.refresh(group) return group async def _make_user(db: AsyncSession, username: str, groups: list[Group]) -> User: user = User( username=username, email=f"{username}@example.com", password_hash=None, role="user", auth_source="oidc", is_active=True, ) user.groups = groups db.add(user) await db.commit() await db.refresh(user, attribute_names=["groups"]) return user # ─── claim-shape helpers ────────────────────────────────────────────────────── class TestExtractIdpGroups: """Providers disagree on the groups-claim shape (#3107). All accepted.""" def test_json_array(self): assert extract_idp_groups(["fablab-staff", "students"]) == ["fablab-staff", "students"] def test_space_separated_string(self): assert extract_idp_groups("fablab-staff students") == ["fablab-staff", "students"] def test_comma_separated_string(self): assert extract_idp_groups("fablab-staff, students") == ["fablab-staff", "students"] def test_single_group_string(self): assert extract_idp_groups("fablab-staff") == ["fablab-staff"] def test_duplicates_removed(self): assert extract_idp_groups(["a", "b", "a", "b "]) == ["a", "b"] def test_none_and_non_group_payloads(self): assert extract_idp_groups(None) == [] assert extract_idp_groups(42) == [] assert extract_idp_groups({"odd": "shape"}) == [] assert extract_idp_groups(["ok", 7, None, ""]) == ["ok"] def test_list_claim_is_bounded(self): # Review on #3122: the bound is the only defence against a hostile # oversized token, and it must hold for every accepted shape, not # just the list one. oversized = [f"g{i}" for i in range(_MAX_CLAIM_ITEMS + 100)] result = extract_idp_groups(oversized) assert len(result) == _MAX_CLAIM_ITEMS assert result[0] == "g0" assert result[-1] == f"g{_MAX_CLAIM_ITEMS - 1}" def test_space_separated_claim_is_bounded(self): # The shape the original bound missed: a string claim splits into # arbitrarily many fragments, so the slice has to apply after # splitting, not only on the list path. oversized = " ".join(f"g{i}" for i in range(_MAX_CLAIM_ITEMS + 100)) result = extract_idp_groups(oversized) assert len(result) == _MAX_CLAIM_ITEMS assert result[-1] == f"g{_MAX_CLAIM_ITEMS - 1}" def test_comma_separated_claim_is_bounded(self): # No spaces around the commas: the split happens on raw fragments, # so ", "-joined input would spend half the budget on empty # fragments. The contract being pinned is the upper bound. oversized = ",".join(f"g{i}" for i in range(_MAX_CLAIM_ITEMS + 100)) result = extract_idp_groups(oversized) assert len(result) == _MAX_CLAIM_ITEMS class TestResolveMapping: def test_case_insensitive_on_idp_side(self): assert resolve_oidc_group_mapping(["IDP-STAFF"], {"idp-staff": "Operators"}) == ["Operators"] def test_unmapped_groups_ignored(self): assert resolve_oidc_group_mapping(["nope", "idp-staff"], {"idp-staff": "Operators"}) == ["Operators"] def test_empty_mapping_disables(self): assert resolve_oidc_group_mapping(["idp-staff"], {}) == [] def test_two_idp_groups_to_one_bambuddy_group(self): mapping = {"staff": "Operators", "admins": "Operators"} assert resolve_oidc_group_mapping(["admins", "staff"], mapping) == ["Operators"] # ─── sync semantics ─────────────────────────────────────────────────────────── class TestSyncOidcUserGroups: @pytest.mark.asyncio async def test_adds_mapped_group_on_login(self, db_session: AsyncSession): operators = await _make_group(db_session, "Operators") user = await _make_user(db_session, "alice", []) await sync_oidc_user_groups( db_session, user, group_claim="groups", group_mapping={"idp-staff": "Operators"}, claims={"groups": ["idp-staff"]}, ) await db_session.refresh(user, attribute_names=["groups"]) assert {g.id for g in user.groups} == {operators.id} @pytest.mark.asyncio async def test_manual_group_survives_login(self, db_session: AsyncSession): """The #1292 contract: a group outside the mapping is a manual assignment and must never be touched by the sync.""" admins = await _make_group(db_session, "Administrators") await _make_group(db_session, "Operators") user = await _make_user(db_session, "alice", [admins]) await sync_oidc_user_groups( db_session, user, group_claim="groups", group_mapping={"idp-staff": "Operators"}, claims={"groups": ["idp-staff"]}, ) await db_session.refresh(user, attribute_names=["groups"]) assert {g.name for g in user.groups} == {"Administrators", "Operators"} @pytest.mark.asyncio async def test_revocation_at_idp_propagates(self, db_session: AsyncSession): """Losing the IdP group must remove the mapped Bambuddy group on the next login — otherwise IdP-side revocation would be decorative.""" operators = await _make_group(db_session, "Operators") user = await _make_user(db_session, "bob", [operators]) await sync_oidc_user_groups( db_session, user, group_claim="groups", group_mapping={"idp-staff": "Operators"}, claims={"groups": []}, ) await db_session.refresh(user, attribute_names=["groups"]) assert {g.name for g in user.groups} == set() @pytest.mark.asyncio async def test_revocation_keeps_manual_groups(self, db_session: AsyncSession): admins = await _make_group(db_session, "Administrators") operators = await _make_group(db_session, "Operators") user = await _make_user(db_session, "carol", [admins, operators]) await sync_oidc_user_groups( db_session, user, group_claim="groups", group_mapping={"idp-staff": "Operators"}, claims={"groups": []}, ) await db_session.refresh(user, attribute_names=["groups"]) assert {g.name for g in user.groups} == {"Administrators"} @pytest.mark.asyncio async def test_manual_assignment_to_managed_group_overridden(self, db_session: AsyncSession): """An admin who manually grants a mapped group is overridden by IdP truth, same as LDAP: revocation must work for those users too.""" operators = await _make_group(db_session, "Operators") user = await _make_user(db_session, "dave", [operators]) await sync_oidc_user_groups( db_session, user, group_claim="groups", group_mapping={"idp-staff": "Operators"}, claims={"groups": []}, ) await db_session.refresh(user, attribute_names=["groups"]) assert {g.name for g in user.groups} == set() @pytest.mark.asyncio async def test_no_mapping_is_a_noop(self, db_session: AsyncSession): """Default state for every upgraded install: nothing configured, so nothing changes — including groups that would have matched a mapping if one existed.""" admins = await _make_group(db_session, "Administrators") user = await _make_user(db_session, "eve", [admins]) await sync_oidc_user_groups( db_session, user, group_claim="groups", group_mapping={}, claims={"groups": ["idp-staff"]}, ) await db_session.refresh(user, attribute_names=["groups"]) assert {g.name for g in user.groups} == {"Administrators"} @pytest.mark.asyncio async def test_missing_claim_is_not_fatal(self, db_session: AsyncSession): """A provider that never sends the claim means 'no mapped groups', not an error: the login must proceed and the managed slice clears.""" operators = await _make_group(db_session, "Operators") user = await _make_user(db_session, "frank", [operators]) await sync_oidc_user_groups( db_session, user, group_claim="groups", group_mapping={"idp-staff": "Operators"}, claims={}, # claim absent entirely ) await db_session.refresh(user, attribute_names=["groups"]) assert {g.name for g in user.groups} == set() @pytest.mark.asyncio async def test_mapping_to_deleted_group_skipped(self, db_session: AsyncSession): """A dangling mapping value (group deleted after the mapping was saved) is skipped at sync time, mirroring default_group_id's SQLite story.""" await _make_group(db_session, "Operators") user = await _make_user(db_session, "grace", []) await sync_oidc_user_groups( db_session, user, group_claim="groups", group_mapping={"idp-staff": "Operators", "idp-ghost": "DeletedGroup"}, claims={"groups": ["idp-staff", "idp-ghost"]}, ) await db_session.refresh(user, attribute_names=["groups"]) assert {g.name for g in user.groups} == {"Operators"} @pytest.mark.asyncio async def test_custom_claim_name(self, db_session: AsyncSession): """group_claim='roles' reads the roles claim and ignores a groups claim that happens to be present.""" operators = await _make_group(db_session, "Operators") user = await _make_user(db_session, "heidi", []) await sync_oidc_user_groups( db_session, user, group_claim="roles", group_mapping={"op": "Operators"}, claims={"roles": ["op"], "groups": ["unrelated"]}, ) await db_session.refresh(user, attribute_names=["groups"]) assert {g.id for g in user.groups} == {operators.id} @pytest.mark.asyncio async def test_sync_failure_never_blocks_login(self, db_session: AsyncSession, monkeypatch, caplog): """The service's contract with oidc_callback: never raise. A failure mid-sync is logged and the user keeps the groups they had — the login already authenticated, so the sync must not take it down with it. The commit is the interesting failure point: by then the user object is dirty, so the except path's rollback has real work to do and the in-memory relationship is post-rollback state. Database truth is re-selected rather than read off the expired instance.""" admins = await _make_group(db_session, "Administrators") await _make_group(db_session, "Operators") user = await _make_user(db_session, "ivan", [admins]) user_id = user.id # captured pre-sync: the rollback expires the whole instance, PK included async def _failing_commit() -> NoReturn: raise RuntimeError("simulated commit failure") monkeypatch.setattr(db_session, "commit", _failing_commit) with caplog.at_level(logging.ERROR): await sync_oidc_user_groups( # must not raise db_session, user, group_claim="groups", group_mapping={"idp-staff": "Operators"}, claims={"groups": ["idp-staff"]}, ) fresh = ( await db_session.execute(select(User).where(User.id == user_id).options(selectinload(User.groups))) ).scalar_one() assert {g.name for g in fresh.groups} == {"Administrators"} assert "OIDC group sync failed for user ivan" in caplog.text # ─── schema validation ──────────────────────────────────────────────────────── class TestProviderSchema: def test_create_defaults(self): provider = OIDCProviderCreate(name="t", issuer_url="https://id.example.com", client_id="a", client_secret="b") assert provider.group_claim == "groups" assert provider.group_mapping == {} def test_create_with_mapping(self): provider = OIDCProviderCreate( name="t", issuer_url="https://id.example.com", client_id="a", client_secret="b", group_claim="roles", group_mapping={"op": "Operators"}, ) assert provider.group_claim == "roles" assert provider.group_mapping == {"op": "Operators"} def test_invalid_group_claim_rejected(self): with pytest.raises(ValidationError): OIDCProviderCreate( name="t", issuer_url="https://id.example.com", client_id="a", client_secret="b", group_claim="not a claim!", ) def test_non_object_mapping_rejected(self): with pytest.raises(ValidationError): OIDCProviderCreate( name="t", issuer_url="https://id.example.com", client_id="a", client_secret="b", group_mapping=["not", "an", "object"], ) def test_empty_mapping_values_rejected(self): with pytest.raises(ValidationError): OIDCProviderCreate( name="t", issuer_url="https://id.example.com", client_id="a", client_secret="b", group_mapping={"op": " "}, ) def test_case_colliding_mapping_keys_rejected(self): """Review round 2: {"Admins": ..., "admins": ...} would silently collapse in the sync's case-insensitive lookup — rejected at save.""" with pytest.raises(ValidationError): OIDCProviderCreate( name="t", issuer_url="https://id.example.com", client_id="a", client_secret="b", group_mapping={"Admins": "Administrators", "admins": "Viewers"}, ) def test_case_colliding_keys_same_target_also_rejected(self): with pytest.raises(ValidationError): OIDCProviderCreate( name="t", issuer_url="https://id.example.com", client_id="a", client_secret="b", group_mapping={"Admins": "Operators", "admins": "Operators"}, ) def test_update_none_leaves_unchanged(self): from backend.app.schemas.auth import OIDCProviderUpdate update = OIDCProviderUpdate() assert update.group_claim is None assert update.group_mapping is None # ─── model column round-trip ────────────────────────────────────────────────── class TestProviderModelRoundTrip: @pytest.mark.asyncio async def test_columns_persist(self, db_session: AsyncSession): provider = OIDCProvider( name="idp-test", issuer_url="https://id.example.com", client_id="a", client_secret="b", group_claim="roles", group_mapping={"op": "Operators"}, ) db_session.add(provider) await db_session.commit() await db_session.refresh(provider) assert provider.group_claim == "roles" assert provider.group_mapping == {"op": "Operators"}