Source code for scim2_server.testing

"""A test suite checking that a storage follows the :class:`~scim2_server.storage.ScimStorage` contract.

Subclass :class:`ScimStorageContract` in the tests of a storage, and give it a
``storage`` fixture returning a new, empty storage::

    from scim2_server.testing import ScimStorageContract


    class TestMyStorage(ScimStorageContract):
        @pytest.fixture
        def storage(self):
            return MyStorage()

For an :class:`~scim2_server.storage.AsyncScimStorage`, subclass
:class:`AsyncScimStorageContract` and give it an ``async_storage`` fixture
instead.

The suite needs the ``testing`` extra, which installs pytest.
"""

import asyncio
from collections.abc import Coroutine
from collections.abc import Generator
from collections.abc import Iterator
from contextlib import contextmanager
from typing import Any
from typing import TypeVar
from typing import Union
from typing import cast

import pytest
from scim2_models import NotFoundException
from scim2_models import PreconditionFailedException
from scim2_models import Resource
from scim2_models import ResourceType
from scim2_models import ScimProvider
from scim2_models import SearchRequest
from scim2_models import UniquenessException

from scim2_server.storage import AsyncScimStorage
from scim2_server.storage import ScimStorage
from scim2_server.utils import load_default_provider
from scim2_server.utils import parametrize

T = TypeVar("T")


[docs] class ScimStorageContract: """The rules every :class:`~scim2_server.storage.ScimStorage` follows. The suite uses the ``User`` and ``Group`` resource types of the ``provider`` fixture, which serves the default resource types of :rfc:`RFC 7643 <7643>` unless overridden. The tests of a feature that the configuration of the provider does not announce, such as sorting, are skipped. """ supports_root_search: bool = True """Whether the storage searches several resource types at once. Set it to :data:`False` for a storage raising :class:`~scim2_models.NotImplementedException` in that case. """ @pytest.fixture def provider(self) -> ScimProvider: """Return the description of the service the storage serves.""" return load_default_provider() @pytest.fixture def user_type(self, provider: ScimProvider) -> ResourceType: return next(rt for rt in provider.resource_types if rt.id == "User") @pytest.fixture def group_type(self, provider: ScimProvider) -> ResourceType: return next(rt for rt in provider.resource_types if rt.id == "Group") @pytest.fixture def user_model( self, provider: ScimProvider, user_type: ResourceType ) -> type[Resource[Any]]: return cast(type[Resource[Any]], provider.model_for(user_type)) @pytest.fixture def group_model( self, provider: ScimProvider, group_type: ResourceType ) -> type[Resource[Any]]: return cast(type[Resource[Any]], provider.model_for(group_type)) @staticmethod def search_request( models: list[type[Resource[Any]]], **parameters: Any ) -> SearchRequest[Any]: """Build a search request on the given models, as the server builds it.""" return parametrize(SearchRequest, Union[tuple(models)])(**parameters) # noqa: UP007 @staticmethod def require(supported: bool, feature: str) -> None: """Skip the current test when the service does not support a feature.""" if not supported: pytest.skip(f"The service does not support {feature}") @staticmethod def announces(provider: ScimProvider, capability: str) -> bool: """Tell whether the configuration of the service announces a capability, such as ``"sort"``.""" feature = getattr(provider.config, capability, None) return bool(feature and feature.supported) def create_users( self, storage: ScimStorage, user_type: ResourceType, user_model: Any, *user_names: str, ) -> list[Any]: return [ storage.create(user_type, user_model(user_name=user_name)) for user_name in user_names ] def test_create_fills_the_identifier_and_the_meta( self, storage: Any, user_type: ResourceType, user_model: Any ) -> None: """A created resource gets an id, its resource type, its dates and a version, but no location.""" (user,) = self.create_users(storage, user_type, user_model, "bjensen") assert user.id assert user.meta.resource_type == user_type.name assert user.meta.created is not None assert user.meta.last_modified == user.meta.created assert user.meta.version assert user.meta.location is None def test_create_does_not_change_the_given_resource( self, storage: Any, user_type: ResourceType, user_model: Any ) -> None: """The resource given to create keeps having no id and no meta.""" given = user_model(user_name="bjensen") storage.create(user_type, given) assert given.id is None assert given.meta is None def test_created_resources_get_distinct_identifiers( self, storage: Any, user_type: ResourceType, user_model: Any ) -> None: """Two created resources never share an id.""" first, second = self.create_users( storage, user_type, user_model, "bjensen", "jsmith" ) assert first.id != second.id def test_create_returns_a_copy( self, storage: Any, user_type: ResourceType, user_model: Any ) -> None: """Changing a created resource does not change the stored one.""" (user,) = self.create_users(storage, user_type, user_model, "bjensen") user.user_name = "changed" assert storage.get(user_type, user.id).user_name == "bjensen" def test_get_returns_the_stored_resource( self, storage: Any, user_type: ResourceType, user_model: Any ) -> None: """A stored resource is read back with its values and its meta.""" (user,) = self.create_users(storage, user_type, user_model, "bjensen") stored = storage.get(user_type, user.id) assert stored.user_name == "bjensen" assert stored.meta.version == user.meta.version def test_get_returns_a_copy( self, storage: Any, user_type: ResourceType, user_model: Any ) -> None: """Changing a read resource does not change the stored one.""" (user,) = self.create_users(storage, user_type, user_model, "bjensen") storage.get(user_type, user.id).user_name = "changed" assert storage.get(user_type, user.id).user_name == "bjensen" def test_get_an_unknown_resource( self, storage: Any, user_type: ResourceType ) -> None: """Reading a resource that does not exist raises a 404.""" with pytest.raises(NotFoundException): storage.get(user_type, "unknown") def test_get_a_resource_of_another_type( self, storage: Any, user_type: ResourceType, group_type: ResourceType, user_model: Any, ) -> None: """A resource is only found under its own resource type.""" (user,) = self.create_users(storage, user_type, user_model, "bjensen") with pytest.raises(NotFoundException): storage.get(group_type, user.id) def test_update_replaces_the_stored_resource( self, storage: Any, user_type: ResourceType, user_model: Any ) -> None: """An update stores the new state, keeps the creation date, and changes the version.""" (user,) = self.create_users(storage, user_type, user_model, "bjensen") user.display_name = "Barbara" updated = storage.update(user_type, user) assert updated.display_name == "Barbara" assert storage.get(user_type, user.id).display_name == "Barbara" assert updated.meta.created == user.meta.created assert updated.meta.last_modified >= user.meta.created assert updated.meta.version != user.meta.version assert updated.meta.location is None def test_update_does_not_change_the_given_resource( self, storage: Any, user_type: ResourceType, user_model: Any ) -> None: """The resource given to update keeps its former version.""" (user,) = self.create_users(storage, user_type, user_model, "bjensen") version = user.meta.version storage.update(user_type, user) assert user.meta.version == version def test_update_returns_a_copy( self, storage: Any, user_type: ResourceType, user_model: Any ) -> None: """Changing an updated resource does not change the stored one.""" (user,) = self.create_users(storage, user_type, user_model, "bjensen") storage.update(user_type, user).user_name = "changed" assert storage.get(user_type, user.id).user_name == "bjensen" def test_update_an_unknown_resource( self, storage: Any, user_type: ResourceType, user_model: Any ) -> None: """Updating a resource that does not exist raises a 404.""" with pytest.raises(NotFoundException): storage.update(user_type, user_model(id="unknown", user_name="bjensen")) def test_update_with_the_expected_version( self, storage: Any, user_type: ResourceType, user_model: Any ) -> None: """An update succeeds when the stored version is the expected one.""" (user,) = self.create_users(storage, user_type, user_model, "bjensen") user.display_name = "Barbara" storage.update(user_type, user, expected_version=user.meta.version) assert storage.get(user_type, user.id).display_name == "Barbara" def test_update_with_an_outdated_version( self, storage: Any, user_type: ResourceType, user_model: Any ) -> None: """An update raises a 412 and stores nothing when the stored version changed.""" (user,) = self.create_users(storage, user_type, user_model, "bjensen") outdated = user.meta.version user.display_name = "Babs" storage.update(user_type, user) user.display_name = "Barbara" with pytest.raises(PreconditionFailedException): storage.update(user_type, user, expected_version=outdated) assert storage.get(user_type, user.id).display_name == "Babs" def test_delete_removes_the_resource( self, storage: Any, user_type: ResourceType, user_model: Any ) -> None: """A deleted resource cannot be read anymore.""" (user,) = self.create_users(storage, user_type, user_model, "bjensen") storage.delete(user_type, user.id) with pytest.raises(NotFoundException): storage.get(user_type, user.id) def test_delete_an_unknown_resource( self, storage: Any, user_type: ResourceType ) -> None: """Deleting a resource that does not exist raises a 404.""" with pytest.raises(NotFoundException): storage.delete(user_type, "unknown") def test_delete_with_the_expected_version( self, storage: Any, user_type: ResourceType, user_model: Any ) -> None: """A deletion succeeds when the stored version is the expected one.""" (user,) = self.create_users(storage, user_type, user_model, "bjensen") storage.delete(user_type, user.id, expected_version=user.meta.version) with pytest.raises(NotFoundException): storage.get(user_type, user.id) def test_delete_with_an_outdated_version( self, storage: Any, user_type: ResourceType, user_model: Any ) -> None: """A deletion raises a 412 and keeps the resource when the stored version changed.""" (user,) = self.create_users(storage, user_type, user_model, "bjensen") outdated = user.meta.version user.display_name = "Babs" storage.update(user_type, user) with pytest.raises(PreconditionFailedException): storage.delete(user_type, user.id, expected_version=outdated) assert storage.get(user_type, user.id).user_name == "bjensen" def test_create_with_a_taken_unique_value( self, storage: Any, user_type: ResourceType, user_model: Any ) -> None: """A userName already taken, whatever its case, raises a 409 on creation.""" self.create_users(storage, user_type, user_model, "bjensen") with pytest.raises(UniquenessException): storage.create(user_type, user_model(user_name="BJensen")) def test_update_with_a_taken_unique_value( self, storage: Any, user_type: ResourceType, user_model: Any ) -> None: """A userName already taken by another resource raises a 409 on update.""" _, user = self.create_users(storage, user_type, user_model, "bjensen", "jsmith") user.user_name = "bjensen" with pytest.raises(UniquenessException): storage.update(user_type, user) assert storage.get(user_type, user.id).user_name == "jsmith" def test_search_a_resource_type( self, storage: Any, user_type: ResourceType, group_type: ResourceType, user_model: Any, group_model: Any, ) -> None: """A search on a resource type returns its resources only, and counts them.""" self.create_users(storage, user_type, user_model, "alice", "bob") storage.create(group_type, group_model(display_name="admins")) total, resources = storage.search( [user_type], self.search_request([user_model]) ) assert total == 2 assert sorted(r.user_name for r in resources) == ["alice", "bob"] def test_search_returns_copies( self, storage: Any, user_type: ResourceType, user_model: Any ) -> None: """Changing a found resource does not change the stored one.""" (user,) = self.create_users(storage, user_type, user_model, "bjensen") _, (found,) = storage.search([user_type], self.search_request([user_model])) found.user_name = "changed" assert storage.get(user_type, user.id).user_name == "bjensen" def test_search_pages_the_results( self, storage: Any, user_type: ResourceType, user_model: Any ) -> None: """A page holds count resources from startIndex, and the total counts them all.""" self.create_users(storage, user_type, user_model, "alice", "bob", "carol") total, resources = storage.search( [user_type], self.search_request([user_model], start_index=2, count=1) ) assert total == 3 assert len(resources) == 1 def test_search_without_count_returns_every_resource( self, storage: Any, user_type: ResourceType, user_model: Any ) -> None: """A search without count is not paged.""" self.create_users(storage, user_type, user_model, "alice", "bob", "carol") total, resources = storage.search( [user_type], self.search_request([user_model]) ) assert total == 3 assert len(resources) == 3 def test_search_with_a_filter( self, storage: Any, provider: ScimProvider, user_type: ResourceType, user_model: Any, ) -> None: """A filter keeps the matching resources, and the total counts them only.""" self.require(self.announces(provider, "filter"), "filtering") self.create_users(storage, user_type, user_model, "alice", "bob", "carol") total, resources = storage.search( [user_type], self.search_request([user_model], filter='userName eq "bob"', count=10), ) assert total == 1 assert [r.user_name for r in resources] == ["bob"] def test_search_sorted( self, storage: Any, provider: ScimProvider, user_type: ResourceType, user_model: Any, ) -> None: """A sorted search orders the resources before paging them.""" self.require(self.announces(provider, "sort"), "sorting") self.create_users(storage, user_type, user_model, "bob", "carol", "alice") _, resources = storage.search( [user_type], self.search_request( [user_model], sort_by="userName", sort_order="descending", count=2 ), ) assert [r.user_name for r in resources] == ["carol", "bob"] def test_search_at_the_root( self, storage: Any, user_type: ResourceType, group_type: ResourceType, user_model: Any, group_model: Any, ) -> None: """A search on several resource types returns their resources as one collection.""" self.require(self.supports_root_search, "searching at the root") self.create_users(storage, user_type, user_model, "alice") storage.create(group_type, group_model(display_name="admins")) total, resources = storage.search( [user_type, group_type], self.search_request([user_model, group_model]) ) assert total == 2 assert {r.meta.resource_type for r in resources} == { user_type.name, group_type.name, } def test_search_at_the_root_on_an_attribute_some_types_lack( self, storage: Any, provider: ScimProvider, user_type: ResourceType, group_type: ResourceType, user_model: Any, group_model: Any, ) -> None: """An attribute a resource type does not declare matches none of its resources.""" self.require(self.supports_root_search, "searching at the root") self.require(self.announces(provider, "filter"), "filtering") self.create_users(storage, user_type, user_model, "alice") storage.create(group_type, group_model(display_name="admins")) total, resources = storage.search( [user_type, group_type], self.search_request([user_model, group_model], filter="userName pr"), ) assert total == 1 assert resources[0].user_name == "alice" def test_operation( self, storage: Any, user_type: ResourceType, user_model: Any ) -> None: """The operation context encloses the calls of one SCIM operation.""" with storage.operation(): (user,) = self.create_users(storage, user_type, user_model, "bjensen") storage.get(user_type, user.id) def test_operation_lets_exceptions_through(self, storage: Any) -> None: """An exception raised within an operation is not swallowed.""" with pytest.raises(RuntimeError), storage.operation(): raise RuntimeError
class BlockingStorage(ScimStorage): """Run the coroutines of an asynchronous storage one by one, for the contract tests.""" def __init__(self, storage: AsyncScimStorage, runner: asyncio.Runner) -> None: self.storage = storage self.runner = runner def run(self, coroutine: Coroutine[Any, Any, T]) -> T: return self.runner.run(coroutine) def get(self, resource_type: ResourceType, resource_id: str) -> Resource[Any]: return self.run(self.storage.get(resource_type, resource_id)) def search( self, resource_types: list[ResourceType], search_request: SearchRequest[Any] ) -> tuple[int, list[Resource[Any]]]: return self.run(self.storage.search(resource_types, search_request)) def create( self, resource_type: ResourceType, resource: Resource[Any] ) -> Resource[Any]: return self.run(self.storage.create(resource_type, resource)) def update( self, resource_type: ResourceType, resource: Resource[Any], *, expected_version: str | None = None, ) -> Resource[Any]: return self.run( self.storage.update( resource_type, resource, expected_version=expected_version ) ) def delete( self, resource_type: ResourceType, resource_id: str, *, expected_version: str | None = None, ) -> None: self.run( self.storage.delete( resource_type, resource_id, expected_version=expected_version ) ) @contextmanager def operation(self) -> Generator[None]: manager = self.storage.operation() self.run(manager.__aenter__()) try: yield except BaseException as exception: self.run( manager.__aexit__(type(exception), exception, exception.__traceback__) ) raise self.run(manager.__aexit__(None, None, None))
[docs] class AsyncScimStorageContract(ScimStorageContract): """The rules every :class:`~scim2_server.storage.AsyncScimStorage` follows. These are the rules of :class:`ScimStorageContract`. Each test runs the coroutines of the storage on a single event loop, one after the other. """ @pytest.fixture def storage(self, async_storage: AsyncScimStorage) -> Iterator[ScimStorage]: with asyncio.Runner() as runner: yield BlockingStorage(async_storage, runner)