feat(scheduling): use central people picker

This commit is contained in:
2026-07-22 03:17:13 +02:00
parent 2cb86c90dc
commit ea2f721377
13 changed files with 529 additions and 231 deletions

View File

@@ -25,6 +25,8 @@ class SchedulingManifestTests(unittest.TestCase):
self.assertIn("poll.scheduling", manifest.required_capabilities)
self.assertIn("calendar.scheduling", manifest.optional_capabilities)
self.assertIn("policy.schedulingParticipantPrivacy", manifest.optional_capabilities)
self.assertIn("access.people_search", manifest.optional_capabilities)
self.assertIn("addresses.people_search", manifest.optional_capabilities)
self.assertIn("evaluation", manifest.optional_dependencies)
self.assertIsNotNone(manifest.route_factory)
self.assertIsNotNone(manifest.migration_spec)
@@ -38,7 +40,8 @@ class SchedulingManifestTests(unittest.TestCase):
self.assertIn("poll.workflow_context", {interface.name for interface in manifest.requires_interfaces})
self.assertIn("poll.governed_participation", {interface.name for interface in manifest.requires_interfaces})
self.assertIn("notifications.dispatch", {interface.name for interface in manifest.requires_interfaces})
self.assertIn("addresses.lookup", {interface.name for interface in manifest.requires_interfaces})
self.assertIn("access.people_search", {interface.name for interface in manifest.requires_interfaces})
self.assertIn("addresses.people_search", {interface.name for interface in manifest.requires_interfaces})
self.assertIn("calendar.scheduling", {interface.name for interface in manifest.requires_interfaces})
required_interfaces = {interface.name: interface for interface in manifest.requires_interfaces}
for interface_name in (

138
tests/test_people_search.py Normal file
View File

@@ -0,0 +1,138 @@
from __future__ import annotations
from types import SimpleNamespace
import unittest
from fastapi import HTTPException
from govoplan_core.auth import ApiPrincipal
from govoplan_core.core.access import PrincipalRef
from govoplan_core.core.people import (
CAPABILITY_ACCESS_PEOPLE_SEARCH,
PeopleSearchGroup,
PersonSearchCandidate,
)
from govoplan_scheduling.backend.manifest import WRITE_SCOPE
from govoplan_scheduling.backend.router import api_search_scheduling_people
from govoplan_scheduling.backend.runtime import configure_runtime
class _Registry:
def __init__(self, capabilities: dict[str, object] | None = None) -> None:
self.capabilities = capabilities or {}
def has_capability(self, name: str) -> bool:
return name in self.capabilities
def capability(self, name: str) -> object:
return self.capabilities[name]
class _PeopleProvider:
def __init__(self) -> None:
self.calls: list[tuple[object, object, str, int]] = []
def search_people(
self,
session: object,
principal: object,
*,
query: str,
limit: int = 25,
) -> tuple[PeopleSearchGroup, ...]:
self.calls.append((session, principal, query, limit))
return (
PeopleSearchGroup(
key="accounts",
label="Accounts",
candidates=(
PersonSearchCandidate(
selection_key="account:account-2",
kind="account",
reference_id="account-2",
display_name="Ada Lovelace",
email="ada@example.test",
source_module="access",
source_label="Accounts",
source_ref="access:account:account-2",
source_revision="revision-1",
description="Research",
provenance={"tenant_id": "tenant-1", "internal": "secret"},
metadata={"internal_group_ids": ["group-1"]},
),
),
),
)
def _principal(*scopes: str) -> ApiPrincipal:
return ApiPrincipal(
principal=PrincipalRef(
account_id="account-1",
membership_id="membership-1",
tenant_id="tenant-1",
email="organizer@example.test",
display_name="Organizer",
scopes=frozenset(scopes),
),
account=SimpleNamespace(id="account-1"),
user=SimpleNamespace(id="membership-1"),
)
class SchedulingPeopleSearchTests(unittest.TestCase):
def tearDown(self) -> None:
configure_runtime(registry=_Registry())
def test_search_uses_principal_aware_core_aggregator_and_redacts_provider_internals(self) -> None:
provider = _PeopleProvider()
registry = _Registry({CAPABILITY_ACCESS_PEOPLE_SEARCH: provider})
configure_runtime(registry=registry)
session = object()
principal = _principal(WRITE_SCOPE)
response = api_search_scheduling_people(
query="ada",
limit=12,
session=session, # type: ignore[arg-type] - provider contract is intentionally generic
principal=principal,
)
self.assertEqual([(session, principal, "ada", 12)], provider.calls)
payload = response.model_dump()
self.assertEqual("account:account-2", payload["groups"][0]["candidates"][0]["selection_key"])
self.assertEqual("revision-1", payload["groups"][0]["candidates"][0]["source_revision"])
self.assertNotIn("source_ref", payload["groups"][0]["candidates"][0])
self.assertNotIn("provenance", payload["groups"][0]["candidates"][0])
self.assertNotIn("metadata", payload["groups"][0]["candidates"][0])
def test_search_is_empty_when_no_optional_directory_provider_is_installed(self) -> None:
configure_runtime(registry=_Registry())
response = api_search_scheduling_people(
query="ada",
limit=25,
session=object(), # type: ignore[arg-type]
principal=_principal(WRITE_SCOPE),
)
self.assertEqual([], response.groups)
def test_search_requires_scheduling_write_or_admin_access(self) -> None:
provider = _PeopleProvider()
configure_runtime(registry=_Registry({CAPABILITY_ACCESS_PEOPLE_SEARCH: provider}))
with self.assertRaises(HTTPException) as raised:
api_search_scheduling_people(
query="ada",
limit=25,
session=object(), # type: ignore[arg-type]
principal=_principal("scheduling:schedule:read"),
)
self.assertEqual(403, raised.exception.status_code)
self.assertEqual([], provider.calls)
if __name__ == "__main__":
unittest.main()