Files
govoplan-idm/tests/test_scim.py
T
zemion 3dd7766b08
Module Package Release / publish-packages (push) Successful in 12s
feat(idm): add SCIM provisioning preview
2026-08-23 11:20:42 +02:00

192 lines
6.1 KiB
Python

from __future__ import annotations
from datetime import UTC, datetime
import json
import pytest
from govoplan_core.security.http_fetch import HttpFetchResponse
from govoplan_idm.backend.scim import (
SCIM_GROUP_SCHEMA,
SCIM_LIST_SCHEMA,
SCIM_USER_SCHEMA,
ScimClient,
ScimError,
ScimLocalProjection,
ScimProfile,
ScimProvisioningPlanner,
ScimSnapshot,
parse_scim_list_response,
)
def _profile(*, absent_user_action="review") -> ScimProfile:
return ScimProfile(
provider_id="institutional-idp",
base_url="https://idp.example.test/scim/v2",
credential_ref="core-credential:scim",
immutable_match_attribute="urn:example:params:scim:schemas:extension:staff:2.0:User:personnelNumber",
absent_user_action=absent_user_action,
page_size=2,
)
def _user(resource_id: str, number: str, *, active: bool = True) -> dict[str, object]:
return {
"schemas": [
SCIM_USER_SCHEMA,
"urn:example:params:scim:schemas:extension:staff:2.0:User",
],
"id": resource_id,
"userName": f"user-{number}",
"active": active,
"urn:example:params:scim:schemas:extension:staff:2.0:User:personnelNumber": number,
"meta": {"version": f'W/"{resource_id}"'},
}
def _list(resources: list[dict[str, object]], *, total: int, start: int) -> bytes:
return json.dumps(
{
"schemas": [SCIM_LIST_SCHEMA],
"totalResults": total,
"startIndex": start,
"itemsPerPage": len(resources),
"Resources": resources,
}
).encode()
def test_scim_client_reads_complete_one_based_paginated_snapshot() -> None:
calls: list[str] = []
def transport(url, *, method, headers, body):
calls.append(url)
assert method == "GET" and body is None
assert "Authorization" not in headers
if "/Users?" in url and "startIndex=1" in url:
payload = _list([_user("u-1", "100"), _user("u-2", "200")], total=3, start=1)
elif "/Users?" in url:
payload = _list([_user("u-3", "300")], total=3, start=3)
else:
payload = _list([], total=0, start=1)
return HttpFetchResponse(200, {"Content-Type": "application/scim+json"}, payload)
snapshot = ScimClient(_profile(), transport=transport).fetch_snapshot()
assert snapshot.complete is True
assert [item.resource_id for item in snapshot.users] == ["u-1", "u-2", "u-3"]
assert snapshot.groups == ()
assert snapshot.page_count == 3
assert len(calls) == 3
def test_planner_links_by_explicit_immutable_attribute_and_quarantines_collision() -> None:
page = parse_scim_list_response(
json.loads(_list([_user("u-1", "100"), _user("u-2", "200")], total=2, start=1)),
resource_type="User",
)
snapshot = ScimSnapshot(
provider_id="institutional-idp",
observed_at=datetime(2026, 8, 23, tzinfo=UTC),
users=page.resources,
complete=True,
page_count=1,
)
local = (
ScimLocalProjection("local-100", "User", "100", revision=2),
ScimLocalProjection("local-200-a", "User", "200", revision=1),
ScimLocalProjection("local-200-b", "User", "200", revision=1),
)
plan = ScimProvisioningPlanner(_profile()).plan(snapshot, local)
assert [(item.action, item.local_id) for item in plan.operations] == [
("link", "local-100"),
("quarantine", None),
]
assert len(plan.plan_sha256) == 64
def test_absence_never_deactivates_from_incomplete_snapshot() -> None:
local = (
ScimLocalProjection(
"local-100",
"User",
"100",
revision=3,
provider_resource_id="u-1",
),
)
incomplete = ScimSnapshot(
provider_id="institutional-idp",
observed_at=datetime(2026, 8, 23, tzinfo=UTC),
complete=False,
)
complete = ScimSnapshot(
provider_id="institutional-idp",
observed_at=datetime(2026, 8, 23, tzinfo=UTC),
complete=True,
)
first = ScimProvisioningPlanner(_profile(absent_user_action="deactivate")).plan(incomplete, local)
second = ScimProvisioningPlanner(_profile(absent_user_action="deactivate")).plan(complete, local)
assert first.operations == ()
assert first.warnings
assert second.operations[0].action == "deactivate"
assert second.operations[0].expected_local_revision == 3
def test_groups_are_business_projections_and_not_access_grants() -> None:
group = {
"schemas": [SCIM_GROUP_SCHEMA],
"id": "g-1",
"displayName": "Payroll reviewers",
"externalId": "group-100",
"members": [{"value": "u-1"}],
}
profile = ScimProfile(
provider_id="institutional-idp",
base_url="https://idp.example.test/scim/v2",
credential_ref="core-credential:scim",
immutable_match_attribute="externalIdImmutable",
)
group["externalIdImmutable"] = "group-stable-100"
page = parse_scim_list_response(
{
"schemas": [SCIM_LIST_SCHEMA],
"totalResults": 1,
"startIndex": 1,
"itemsPerPage": 1,
"Resources": [group],
},
resource_type="Group",
)
plan = ScimProvisioningPlanner(profile).plan(
ScimSnapshot(
provider_id="institutional-idp",
observed_at=datetime(2026, 8, 23, tzinfo=UTC),
groups=page.resources,
complete=True,
),
(),
)
assert plan.operations[0].resource_type == "Group"
assert plan.operations[0].action == "create"
assert profile.group_projection_mode == "business_membership_only"
def test_parser_and_profile_reject_unsafe_identity_assumptions() -> None:
with pytest.raises(ValueError, match="immutable"):
ScimProfile(
provider_id="idp",
base_url="https://idp.example.test/scim/v2",
credential_ref="credential",
immutable_match_attribute="userName",
)
with pytest.raises(ScimError, match="ListResponse"):
parse_scim_list_response({"Resources": []}, resource_type="User")