From cec283fb419c847150c33faebd8e43da18d60a4e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89loi=20Rivard?= Date: Fri, 11 Jul 2025 13:45:17 +0200 Subject: [PATCH] feat: implement PatchOp.patch --- doc/changelog.rst | 1 + doc/tutorial.rst | 46 ++- scim2_models/rfc7644/message.py | 10 +- scim2_models/rfc7644/patch_op.py | 385 ++++++++++++++++++++--- scim2_models/urn.py | 46 ++- scim2_models/utils.py | 24 +- tests/test_models.py | 4 +- tests/test_patch_op.py | 500 ------------------------------ tests/test_patch_op_add.py | 278 +++++++++++++++++ tests/test_patch_op_extensions.py | 335 ++++++++++++++++++++ tests/test_patch_op_remove.py | 289 +++++++++++++++++ tests/test_patch_op_replace.py | 219 +++++++++++++ tests/test_patch_op_validation.py | 464 +++++++++++++++++++++++++++ tests/test_path_validation.py | 3 - tests/test_utils.py | 14 + 15 files changed, 2060 insertions(+), 558 deletions(-) delete mode 100644 tests/test_patch_op.py create mode 100644 tests/test_patch_op_add.py create mode 100644 tests/test_patch_op_extensions.py create mode 100644 tests/test_patch_op_remove.py create mode 100644 tests/test_patch_op_replace.py create mode 100644 tests/test_patch_op_validation.py diff --git a/doc/changelog.rst b/doc/changelog.rst index ed97224..79d8b91 100644 --- a/doc/changelog.rst +++ b/doc/changelog.rst @@ -7,6 +7,7 @@ Changelog Added ^^^^^ - Proper path validation for :attr:`~scim2_models.SearchRequest.attributes`, :attr:`~scim2_models.SearchRequest.excluded_attributes` and :attr:`~scim2_models.SearchRequest.sort_by`. +- Implement :meth:`~scim2_models.PatchOp.patch` Fixed ^^^^^ diff --git a/doc/tutorial.rst b/doc/tutorial.rst index 49d8e6e..36bcfb1 100644 --- a/doc/tutorial.rst +++ b/doc/tutorial.rst @@ -403,9 +403,49 @@ This can be used by client applications that intends to dynamically discover ser :language: json :caption: schema-group.json -Bulk and Patch operations -========================= +Patch operations +================ + +:class:`~scim2_models.PatchOp` allows you to apply patch operations to modify SCIM resources. +The :meth:`~scim2_models.PatchOp.patch` method applies operations in sequence and returns whether the resource was modified. The return code is a boolean indicating whether the object have been modified by the operations. + +.. note:: + :class:`~scim2_models.PatchOp` takes a type parameter that should be the class of the resource + that is expected to be patched. + +.. code-block:: python + + >>> from scim2_models import User, PatchOp, PatchOperation + >>> user = User(user_name="john.doe", nick_name="Johnny") + + >>> payload = { + ... "schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"], + ... "Operations": [ + ... {"op": "replace", "path": "nickName", "value": "John" }, + ... {"op": "add", "path": "emails", "value": [{"value": "john@example.com"}]}, + ... ] + ... } + >>> patch = PatchOp[User].model_validate( + ... payload, scim_ctx=Context.RESOURCE_PATCH_REQUEST + ... ) + + >>> modified = patch.patch(user) + >>> print(modified) + True + >>> print(user.nick_name) + John + >>> print(user.emails[0].value) + john@example.com + +.. warning:: + + Patch operations are validated in the :attr:`~scim2_models.Context.RESOURCE_PATCH_REQUEST` + context. Make sure to validate patch operations with the correct context to + ensure proper validation of mutability and required constraints. + +Bulk operations +=============== .. todo:: - Bulk and Patch operations are not implemented yet, but any help is welcome! + Bulk operations are not implemented yet, but any help is welcome! diff --git a/scim2_models/rfc7644/message.py b/scim2_models/rfc7644/message.py index febfba8..d382abc 100644 --- a/scim2_models/rfc7644/message.py +++ b/scim2_models/rfc7644/message.py @@ -114,12 +114,6 @@ def __new__( def get_resource_class(obj) -> Optional[type[Resource]]: """Extract the resource class from generic type parameter.""" - metadata = getattr(obj.__class__, "__pydantic_generic_metadata__", None) - if not metadata or not metadata.get("args"): - return None - + metadata = getattr(obj.__class__, "__pydantic_generic_metadata__", {"args": [None]}) resource_class = metadata["args"][0] - if isinstance(resource_class, type) and issubclass(resource_class, Resource): - return resource_class - - return None + return resource_class diff --git a/scim2_models/rfc7644/patch_op.py b/scim2_models/rfc7644/patch_op.py index a368c8c..63424eb 100644 --- a/scim2_models/rfc7644/patch_op.py +++ b/scim2_models/rfc7644/patch_op.py @@ -1,10 +1,13 @@ from enum import Enum +from inspect import isclass from typing import Annotated from typing import Any from typing import Generic from typing import Optional +from typing import TypeVar from pydantic import Field +from pydantic import ValidationInfo from pydantic import field_validator from pydantic import model_validator from typing_extensions import Self @@ -13,13 +16,18 @@ from ..annotations import Required from ..attributes import ComplexAttribute from ..base import BaseModel -from ..rfc7643.resource import AnyResource +from ..context import Context +from ..rfc7643.resource import Resource +from ..urn import resolve_path_to_target from ..utils import extract_field_name +from ..utils import find_field_name from ..utils import validate_scim_path_syntax from .error import Error from .message import Message from .message import get_resource_class +T = TypeVar("T", bound=Resource) + class PatchOperation(ComplexAttribute): class Op(str, Enum): @@ -42,38 +50,26 @@ class Op(str, Enum): """The "path" attribute value is a String containing an attribute path describing the target of the operation.""" - @field_validator("path") - @classmethod - def validate_path_syntax(cls, v: Optional[str]) -> Optional[str]: - """Validate path syntax according to RFC 7644 ABNF grammar (simplified).""" - if v is None: - return v - - # RFC 7644 Section 3.5.2: Path syntax validation according to ABNF grammar - if not validate_scim_path_syntax(v): - raise ValueError(Error.make_invalid_path_error().detail) - - return v - def _validate_mutability( self, resource_class: type[BaseModel], field_name: str ) -> None: """Validate mutability constraints.""" - # RFC 7644 Section 3.5.2: Servers should be tolerant of schema extensions + # RFC 7644 Section 3.5.2: "Servers should be tolerant of schema extensions" if field_name not in resource_class.model_fields: return mutability = resource_class.get_field_annotation(field_name, Mutability) - # RFC 7643 Section 7: Attributes with mutability "readOnly" SHALL NOT be modified - if mutability == Mutability.read_only: - if self.op in (PatchOperation.Op.add, PatchOperation.Op.replace_): - raise ValueError(Error.make_mutability_error().detail) + # RFC 7643 Section 7: "Attributes with mutability 'readOnly' SHALL NOT be modified" + if mutability == Mutability.read_only and self.op in ( + PatchOperation.Op.add, + PatchOperation.Op.replace_, + ): + raise ValueError(Error.make_mutability_error().detail) - # RFC 7643 Section 7: Attributes with mutability "immutable" SHALL NOT be updated - elif mutability == Mutability.immutable: - if self.op == PatchOperation.Op.replace_: - raise ValueError(Error.make_mutability_error().detail) + # RFC 7643 Section 7: "Attributes with mutability 'immutable' SHALL NOT be updated" + if mutability == Mutability.immutable and self.op == PatchOperation.Op.replace_: + raise ValueError(Error.make_mutability_error().detail) def _validate_required_attribute( self, resource_class: type[BaseModel], field_name: str @@ -83,24 +79,33 @@ def _validate_required_attribute( if self.op != PatchOperation.Op.remove: return - # RFC 7644 Section 3.5.2: Servers should be tolerant of schema extensions + # RFC 7644 Section 3.5.2: "Servers should be tolerant of schema extensions" if field_name not in resource_class.model_fields: return required = resource_class.get_field_annotation(field_name, Required) - # RFC 7643 Section 7: Required attributes SHALL NOT be removed + # RFC 7643 Section 7: "Required attributes SHALL NOT be removed" if required == Required.true: raise ValueError(Error.make_invalid_value_error().detail) @model_validator(mode="after") - def validate_operation_requirements(self) -> Self: + def validate_operation_requirements(self, info: ValidationInfo) -> Self: """Validate operation requirements according to RFC 7644.""" - # RFC 7644 Section 3.5.2.3: Path is required for remove operations + # Only validate in PATCH request context + scim_ctx = info.context.get("scim") if info.context else None + if scim_ctx != Context.RESOURCE_PATCH_REQUEST: + return self + + # RFC 7644 Section 3.5.2: "Path syntax validation according to ABNF grammar" + if self.path is not None and not validate_scim_path_syntax(self.path): + raise ValueError(Error.make_invalid_path_error().detail) + + # RFC 7644 Section 3.5.2.3: "Path is required for remove operations" if self.path is None and self.op == PatchOperation.Op.remove: - raise ValueError(Error.make_invalid_value_error().detail) + raise ValueError(Error.make_invalid_path_error().detail) - # RFC 7644 Section 3.5.2.1: Value is required for "add" operations + # RFC 7644 Section 3.5.2.1: "Value is required for add operations" if self.op == PatchOperation.Op.add and self.value is None: raise ValueError(Error.make_invalid_value_error().detail) @@ -125,8 +130,57 @@ def normalize_op(cls, v: Any) -> Any: return v -class PatchOp(Message, Generic[AnyResource]): - """Patch Operation as defined in :rfc:`RFC7644 §3.5.2 <7644#section-3.5.2>`.""" +class PatchOp(Message, Generic[T]): + """Patch Operation as defined in :rfc:`RFC7644 §3.5.2 <7644#section-3.5.2>`. + + Type parameter T is required and must be a concrete Resource subclass. + Usage: PatchOp[User], PatchOp[Group], etc. + + .. note:: + - Always use with a specific type parameter, e.g., PatchOp[User] + - PatchOp[Resource] is not allowed - use a concrete subclass instead + - Union types are not supported - use a specific resource type + - Using PatchOp without a type parameter raises TypeError + """ + + def __new__(cls, *args, **kwargs): + """Create new PatchOp instance with type parameter validation. + + Only handles the case of direct instantiation without type parameter (PatchOp()). + All type parameter validation is handled by __class_getitem__. + """ + if ( + cls.__name__ == "PatchOp" + and not hasattr(cls, "__origin__") + and not hasattr(cls, "__args__") + ): + raise TypeError( + "PatchOp requires a type parameter. " + "Use PatchOp[YourResourceType] instead of PatchOp. " + "Example: PatchOp[User], PatchOp[Group], etc." + ) + + return super().__new__(cls) + + def __class_getitem__(cls, item): + """Validate type parameter when creating parameterized type. + + Ensures the type parameter is a concrete Resource subclass (not Resource itself). + Rejects invalid types (str, int, etc.) and Union types. + """ + # Check if type parameter is a concrete Resource subclass (not Resource itself) + if item is Resource: + raise TypeError( + "PatchOp requires a concrete Resource subclass, not Resource itself. " + "Use PatchOp[User], PatchOp[Group], etc. instead of PatchOp[Resource]." + ) + if not (isclass(item) and issubclass(item, Resource) and item is not Resource): + raise TypeError( + f"PatchOp type parameter must be a concrete Resource subclass, got {item}. " + "Use PatchOp[User], PatchOp[Group], etc." + ) + + return super().__class_getitem__(item) schemas: Annotated[list[str], Required.true] = [ "urn:ietf:params:scim:api:messages:2.0:PatchOp" @@ -149,15 +203,276 @@ def validate_operations(self) -> Self: if resource_class is None or not self.operations: return self + # RFC 7644 Section 3.5.2: "Validate each operation against schema constraints" for operation in self.operations: if operation.path is None: continue field_name = extract_field_name(operation.path) - if field_name is None: + operation._validate_mutability(resource_class, field_name) # type: ignore[arg-type] + operation._validate_required_attribute(resource_class, field_name) # type: ignore[arg-type] + + return self + + def patch(self, resource: T) -> bool: + """Apply all PATCH operations to the given SCIM resource in sequence. + + The resource is modified in-place. + + Each operation in the PatchOp is applied in order, modifying the resource in-place + according to :rfc:`RFC7644 §3.5.2 <7644#section-3.5.2>`. Supported operations are + "add", "replace", and "remove". If any operation modifies the resource, the method + returns True; otherwise, False. + + :param resource: The SCIM resource to patch. This object is modified in-place. + :type resource: T + :return: True if the resource was modified by any operation, False otherwise. + :raises ValueError: If an operation is invalid (e.g., invalid path, forbidden mutation). + """ + if not self.operations: + return False + + modified = False + # RFC 7644 Section 3.5.2: "Apply each operation in sequence" + for operation in self.operations: + if self._apply_operation(resource, operation): + modified = True + + return modified + + def _apply_operation(self, resource: Resource, operation: PatchOperation) -> bool: + """Apply a single patch operation to a resource. + + :return: :data:`True` if the resource was modified, else :data:`False`. + """ + if operation.op in (PatchOperation.Op.add, PatchOperation.Op.replace_): + return self._apply_add_replace(resource, operation) + if operation.op == PatchOperation.Op.remove: + return self._apply_remove(resource, operation) + + raise ValueError(Error.make_invalid_value_error().detail) + + def _apply_add_replace(self, resource: Resource, operation: PatchOperation) -> bool: + """Apply an add or replace operation.""" + # RFC 7644 Section 3.5.2.1: "If path is specified, add/replace at that path" + if operation.path is not None: + return self._set_value_at_path( + resource, + operation.path, + operation.value, + is_add=operation.op == PatchOperation.Op.add, + ) + + # RFC 7644 Section 3.5.2.1: "If no path specified, add/replace at root level" + return self._apply_root_attributes(resource, operation.value) + + def _apply_remove(self, resource: Resource, operation: PatchOperation) -> bool: + """Apply a remove operation.""" + # RFC 7644 Section 3.5.2.3: "Path is required for remove operations" + if operation.path is None: + raise ValueError(Error.make_invalid_path_error().detail) + + # RFC 7644 Section 3.5.2.3: "If a value is specified, remove only that value" + if operation.value is not None: + return self._remove_specific_value( + resource, operation.path, operation.value + ) + + return self._remove_value_at_path(resource, operation.path) + + def _apply_root_attributes(self, resource: BaseModel, value: Any) -> bool: + """Apply attributes to the resource root.""" + if not isinstance(value, dict): + return False + + modified = False + for attr_name, val in value.items(): + field_name = find_field_name(type(resource), attr_name) + if not field_name: continue - operation._validate_mutability(resource_class, field_name) - operation._validate_required_attribute(resource_class, field_name) + old_value = getattr(resource, field_name) + if old_value != val: + setattr(resource, field_name, val) + modified = True - return self + return modified + + def _set_value_at_path( + self, resource: Resource, path: str, value: Any, is_add: bool + ) -> bool: + """Set a value at a specific path.""" + target, attr_path = resolve_path_to_target(resource, path) + + if not attr_path or not target: + raise ValueError(Error.make_invalid_path_error().detail) + + path_parts = attr_path.split(".") + if len(path_parts) == 1: + return self._set_simple_attribute(target, path_parts[0], value, is_add) + + return self._set_complex_attribute(target, path_parts, value, is_add) + + def _set_simple_attribute( + self, resource: BaseModel, attr_name: str, value: Any, is_add: bool + ) -> bool: + """Set a value on a simple (non-nested) attribute.""" + field_name = find_field_name(type(resource), attr_name) + if not field_name: + raise ValueError(Error.make_no_target_error().detail) + + # RFC 7644 Section 3.5.2.1: "For multi-valued attributes, add operation appends values" + if is_add and self._is_multivalued_field(resource, field_name): + return self._handle_multivalued_add(resource, field_name, value) + + old_value = getattr(resource, field_name) + if old_value == value: + return False + + setattr(resource, field_name, value) + return True + + def _set_complex_attribute( + self, resource: BaseModel, path_parts: list[str], value: Any, is_add: bool + ) -> bool: + """Set a value on a complex (nested) attribute.""" + parent_attr = path_parts[0] + sub_path = ".".join(path_parts[1:]) + + parent_field_name = find_field_name(type(resource), parent_attr) + if not parent_field_name: + raise ValueError(Error.make_no_target_error().detail) + + parent_obj = getattr(resource, parent_field_name) + if parent_obj is None: + parent_obj = self._create_parent_object(resource, parent_field_name) + if parent_obj is None: + return False + + return self._set_value_at_path(parent_obj, sub_path, value, is_add) + + def _is_multivalued_field(self, resource: BaseModel, field_name: str) -> bool: + """Check if a field is multi-valued.""" + return hasattr(resource, field_name) and type(resource).get_field_multiplicity( + field_name + ) + + def _handle_multivalued_add( + self, resource: BaseModel, field_name: str, value: Any + ) -> bool: + """Handle adding values to a multi-valued attribute.""" + current_list = getattr(resource, field_name) or [] + + # RFC 7644 Section 3.5.2.1: "Add operation appends values to multi-valued attributes" + if isinstance(value, list): + return self._add_multiple_values(resource, field_name, current_list, value) + + return self._add_single_value(resource, field_name, current_list, value) + + def _add_multiple_values( + self, resource: BaseModel, field_name: str, current_list: list, values: list + ) -> bool: + """Add multiple values to a multi-valued attribute.""" + new_values = [] + # RFC 7644 Section 3.5.2.1: "Do not add duplicate values" + for new_val in values: + if not self._value_exists_in_list(current_list, new_val): + new_values.append(new_val) + + if not new_values: + return False + + setattr(resource, field_name, current_list + new_values) + return True + + def _add_single_value( + self, resource: BaseModel, field_name: str, current_list: list, value: Any + ) -> bool: + """Add a single value to a multi-valued attribute.""" + # RFC 7644 Section 3.5.2.1: "Do not add duplicate values" + if self._value_exists_in_list(current_list, value): + return False + + current_list.append(value) + setattr(resource, field_name, current_list) + return True + + def _value_exists_in_list(self, current_list: list, new_value: Any) -> bool: + """Check if a value already exists in a list.""" + return any(self._values_match(item, new_value) for item in current_list) + + def _create_parent_object(self, resource: BaseModel, parent_field_name: str) -> Any: + """Create a parent object if it doesn't exist.""" + parent_class = type(resource).get_field_root_type(parent_field_name) + if not parent_class or not isclass(parent_class): + return None + + parent_obj = parent_class() + setattr(resource, parent_field_name, parent_obj) + return parent_obj + + def _remove_value_at_path(self, resource: Resource, path: str) -> bool: + """Remove a value at a specific path.""" + target, attr_path = resolve_path_to_target(resource, path) + + # RFC 7644 Section 3.5.2.3: "Path must resolve to a valid attribute" + if not attr_path or not target: + raise ValueError(Error.make_invalid_path_error().detail) + + parent_attr, *path_parts = attr_path.split(".") + field_name = find_field_name(type(target), parent_attr) + if not field_name: + raise ValueError(Error.make_no_target_error().detail) + parent_obj = getattr(target, field_name) + + if parent_obj is None: + return False + + # RFC 7644 Section 3.5.2.3: "Remove entire attribute if no sub-path" + if not path_parts: + setattr(target, field_name, None) + return True + + sub_path = ".".join(path_parts) + return self._remove_value_at_path(parent_obj, sub_path) + + def _remove_specific_value( + self, resource: Resource, path: str, value_to_remove: Any + ) -> bool: + """Remove a specific value from a multi-valued attribute.""" + target, attr_path = resolve_path_to_target(resource, path) + + # RFC 7644 Section 3.5.2.3: "Path must resolve to a valid attribute" + if not attr_path or not target: + raise ValueError(Error.make_invalid_path_error().detail) + + field_name = find_field_name(type(target), attr_path) + if not field_name: + raise ValueError(Error.make_no_target_error().detail) + + current_list = getattr(target, field_name) + if not isinstance(current_list, list): + return False + + new_list = [] + modified = False + # RFC 7644 Section 3.5.2.3: "Remove matching values from multi-valued attributes" + for item in current_list: + if not self._values_match(item, value_to_remove): + new_list.append(item) + else: + modified = True + + if modified: + setattr(target, field_name, new_list if new_list else None) + return True + + return False + + def _values_match(self, value1: Any, value2: Any) -> bool: + """Check if two values match, converting BaseModel to dict for comparison.""" + + def to_dict(value): + return value.model_dump() if isinstance(value, BaseModel) else value + + return to_dict(value1) == to_dict(value2) diff --git a/scim2_models/urn.py b/scim2_models/urn.py index 7ba2a20..3521a8e 100644 --- a/scim2_models/urn.py +++ b/scim2_models/urn.py @@ -10,15 +10,32 @@ from .rfc7643.resource import Resource -def normalize_path(model: type["BaseModel"], path: str) -> tuple[str, str]: +def _get_or_create_extension_instance( + model: "Resource", extension_class: type +) -> "BaseModel": + """Get existing extension instance or create a new one.""" + extension_instance = model[extension_class] + if extension_instance is None: + extension_instance = extension_class() + model[extension_class] = extension_instance + return extension_instance + + +def normalize_path(model: Optional[type["BaseModel"]], path: str) -> tuple[str, str]: """Resolve a path to (schema_urn, attribute_path).""" + from .rfc7643.resource import Resource + # Absolute URN if ":" in path: parts = path.rsplit(":", 1) return parts[0], parts[1] - schemas_field = model.model_fields.get("schemas") - return schemas_field.default[0], path # type: ignore + # Relative URN with a schema + elif model and issubclass(model, Resource) and hasattr(model, "model_fields"): + schemas_field = model.model_fields.get("schemas") + return schemas_field.default[0], path # type: ignore + + return "", path def validate_model_attribute(model: type["BaseModel"], attribute_base: str) -> None: @@ -67,3 +84,26 @@ def validate_attribute_urn( return None return f"{schema}:{attribute_base}" + + +def resolve_path_to_target( + resource: "Resource", path: str +) -> tuple[Optional["BaseModel"], str]: + """Resolve a path to a target and an attribute_path. + + The target can be the resource itself, or an extension object. + """ + schema_urn, attr_path = normalize_path(type(resource), path) + + if not schema_urn: + return resource, attr_path + + if schema_urn in resource.schemas: + return resource, attr_path + + extension_class = resource.get_extension_model(schema_urn) + if not extension_class: + return (None, "") + + extension_instance = _get_or_create_extension_instance(resource, extension_class) + return extension_instance, attr_path diff --git a/scim2_models/utils.py b/scim2_models/utils.py index 982c8e0..4e51883 100644 --- a/scim2_models/utils.py +++ b/scim2_models/utils.py @@ -173,13 +173,29 @@ def extract_field_name(path: str) -> Optional[str]: parts = path.rsplit(":", 1) return parts[1] - # Handle simple paths (no brackets, no filters) - if "[" in path or "]" in path: - return None # Complex filter path, not handled - # Simple attribute path (may have dots for sub-attributes) # For now, just take the first part before any dot if "." in path: return path.split(".")[0] return path + + +def find_field_name(resource_class, attr_name: str) -> Optional[str]: + """Find the actual field name in a resource class from an attribute name. + + Args: + resource_class: The resource class to search in + attr_name: The attribute name to find (e.g., "nickName") + + Returns: + The actual field name if found (e.g., "nick_name"), None otherwise + + """ + normalized_attr_name = normalize_attribute_name(attr_name) + + for field_key in resource_class.model_fields: + if normalize_attribute_name(field_key) == normalized_attr_name: + return field_key + + return None diff --git a/tests/test_models.py b/tests/test_models.py index 88d2b11..fc79b28 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -28,7 +28,7 @@ def test_parse_and_serialize_examples(load_sample): "list_response": ListResponse[ Union[User[EnterpriseUser], Group, Schema, ResourceType] ], - "patch_op": PatchOp, + "patch_op": PatchOp[User], "bulk_request": BulkRequest, "bulk_response": BulkResponse, "search_request": SearchRequest, @@ -149,7 +149,7 @@ def test_everything_is_optional(): ResourceType, ServiceProviderConfig, ListResponse[User], - PatchOp, + PatchOp[User], BulkRequest, BulkResponse, SearchRequest, diff --git a/tests/test_patch_op.py b/tests/test_patch_op.py deleted file mode 100644 index 43abf39..0000000 --- a/tests/test_patch_op.py +++ /dev/null @@ -1,500 +0,0 @@ -"""Simplified tests for PatchOp and PatchOperation - keeping only essential tests.""" - -from typing import Annotated - -import pytest -from pydantic import ValidationError - -from scim2_models import PatchOp -from scim2_models import PatchOperation -from scim2_models.annotations import Mutability -from scim2_models.annotations import Required -from scim2_models.rfc7643.resource import Resource -from scim2_models.rfc7644.message import get_resource_class - - -# Test resource class for basic syntax tests -class MockResource(Resource): - """Test resource class with various metadata annotations.""" - - mutable_field: str - read_only_field: Annotated[str, Mutability.read_only] - immutable_field: Annotated[str, Mutability.immutable] - required_field: Annotated[str, Required.true] - optional_field: Annotated[str, Required.false] = "default" - - -def test_validate_patchop_case_insensitivith(): - """Validate that a patch operation's Op declaration is case-insensitive. - - RFC 7644 Section 3.5.2: "The "op" parameter value is case insensitive." - """ - assert PatchOp.model_validate( - { - "operations": [ - {"op": "Replace", "path": "userName", "value": "Rivard"}, - {"op": "ADD", "path": "userName", "value": "Rivard"}, - {"op": "ReMove", "path": "userName", "value": "Rivard"}, - ], - }, - ) == PatchOp( - operations=[ - PatchOperation( - op=PatchOperation.Op.replace_, path="userName", value="Rivard" - ), - PatchOperation(op=PatchOperation.Op.add, path="userName", value="Rivard"), - PatchOperation( - op=PatchOperation.Op.remove, path="userName", value="Rivard" - ), - ] - ) - with pytest.raises( - ValidationError, - match="1 validation error for PatchOp", - ): - PatchOp.model_validate( - { - "operations": [{"op": 42, "path": "userName", "value": "Rivard"}], - }, - ) - - -def test_path_required_for_remove_operations(): - """Test that path is required for remove operations. - - RFC 7644 Section 3.5.2.3: "If the "path" parameter is missing, the operation fails with HTTP status code 400." - """ - PatchOp.model_validate( - { - "operations": [ - {"op": "replace", "value": "foobar"}, - ], - } - ) - PatchOp.model_validate( - { - "operations": [ - {"op": "add", "value": "foobar"}, - ], - } - ) - - with pytest.raises(ValidationError): - PatchOp.model_validate( - { - "operations": [ - {"op": "remove", "value": "foobar"}, - ], - } - ) - - -def test_patch_operation_path_syntax_validation(): - """Test that invalid path syntax is rejected. - - RFC 7644 Section 3.5.2: Path syntax must follow ABNF rules defined in the specification. - """ - # Test invalid path starting with digit - with pytest.raises(ValidationError, match="path.*invalid"): - PatchOp.model_validate( - {"operations": [{"op": "replace", "path": "123invalid", "value": "test"}]} - ) - - # Test invalid path with double dots - with pytest.raises(ValidationError, match="path.*invalid"): - PatchOp.model_validate( - { - "operations": [ - {"op": "replace", "path": "invalid..path", "value": "test"} - ] - } - ) - - # Test invalid path with invalid characters - with pytest.raises(ValidationError, match="path.*invalid"): - PatchOp.model_validate( - {"operations": [{"op": "replace", "path": "invalid@path", "value": "test"}]} - ) - - # Test invalid URN path - with pytest.raises(ValidationError, match="path.*invalid"): - PatchOp.model_validate( - {"operations": [{"op": "replace", "path": "urn:invalid", "value": "test"}]} - ) - - # Test valid paths should work - valid_paths = [ - "userName", - "name.familyName", - "emails.value", - "urn:ietf:params:scim:schemas:core:2.0:User:userName", - "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:employeeNumber", - ] - - for path in valid_paths: - # Should not raise exception - patch_op = PatchOp.model_validate( - {"operations": [{"op": "replace", "path": path, "value": "test"}]} - ) - assert patch_op.operations[0].path == path - - -def test_patch_operation_value_required_for_add(): - """Test that value is required for add operations. - - RFC 7644 Section 3.5.2.1: "The "value" parameter contains a set of attributes to be added to the resource." - """ - # Test add without value should fail - with pytest.raises(ValidationError, match="required value.*missing"): - PatchOp.model_validate({"operations": [{"op": "add", "path": "userName"}]}) - - # Test add with null value should fail - with pytest.raises(ValidationError, match="required value.*missing"): - PatchOp.model_validate( - {"operations": [{"op": "add", "path": "userName", "value": None}]} - ) - - # Test add with value should work - patch_op = PatchOp.model_validate( - {"operations": [{"op": "add", "path": "userName", "value": "test"}]} - ) - assert patch_op.operations[0].value == "test" - - # Test replace without value should work (optional) - patch_op = PatchOp.model_validate( - {"operations": [{"op": "replace", "path": "userName"}]} - ) - assert patch_op.operations[0].value is None - - # Test remove without value should work (not applicable) - patch_op = PatchOp.model_validate( - {"operations": [{"op": "remove", "path": "userName"}]} - ) - assert patch_op.operations[0].value is None - - -def test_patch_operation_urn_path_validation(): - """Test URN path validation edge cases. - - RFC 7644 Section 3.5.2: URN paths must follow the format "urn:ietf:params:scim:schemas:..." for schema extensions. - """ - # Test valid URN paths - valid_urn_paths = [ - "urn:ietf:params:scim:schemas:core:2.0:User:userName", - "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:employeeNumber", - "urn:custom:namespace:schema:1.0:Resource:attribute", - ] - - for urn_path in valid_urn_paths: - patch_op = PatchOp.model_validate( - {"operations": [{"op": "replace", "path": urn_path, "value": "test"}]} - ) - assert patch_op.operations[0].path == urn_path - - # Test invalid URN paths - invalid_urn_paths = [ - "urn:too:short", # Not enough segments - "urn:ietf:params:scim:schemas:core:2.0:User:", # Empty attribute - "urn:ietf:params:scim:schemas:core:2.0:User:123invalid", # Attribute starts with digit - "noturn:ietf:params:scim:schemas:core:2.0:User:userName", # Doesn't start with urn: - ] - - for urn_path in invalid_urn_paths: - with pytest.raises(ValidationError, match="path.*invalid"): - PatchOp.model_validate( - {"operations": [{"op": "replace", "path": urn_path, "value": "test"}]} - ) - - -def test_patch_operation_path_edge_cases(): - """Test edge cases for path validation.""" - # Test None path (should be allowed for non-remove operations) - patch_op = PatchOp.model_validate( - {"operations": [{"op": "replace", "value": "test"}]} - ) - assert patch_op.operations[0].path is None - - # Test empty path (should be invalid) - with pytest.raises(ValidationError, match="path.*invalid"): - PatchOp.model_validate( - {"operations": [{"op": "replace", "path": "", "value": "test"}]} - ) - - # Test whitespace-only path (should be invalid) - with pytest.raises(ValidationError, match="path.*invalid"): - PatchOp.model_validate( - {"operations": [{"op": "replace", "path": " ", "value": "test"}]} - ) - - # Test URN with not enough parts (should be invalid) - with pytest.raises(ValidationError, match="path.*invalid"): - PatchOp.model_validate( - { - "operations": [ - {"op": "replace", "path": "urn:invalid:path", "value": "test"} - ] - } - ) - - # Test URN with no colon separation (should be invalid) - with pytest.raises(ValidationError, match="path.*invalid"): - PatchOp.model_validate( - {"operations": [{"op": "replace", "path": "urn:invalid", "value": "test"}]} - ) - - -def test_patch_operation_path_validator_directly(): - """Test path validator directly to ensure full coverage.""" - # Test that None path is handled correctly - result = PatchOperation.validate_path_syntax(None) - assert result is None - - # Test empty string handling - with pytest.raises(ValueError, match="path.*invalid"): - PatchOperation.validate_path_syntax("") - - # Test URN edge cases for full coverage - # This tests the case where a path starts with "urn" but has invalid format - with pytest.raises(ValueError, match="path.*invalid"): - PatchOperation.validate_path_syntax("urn:invalid") - - -# Test resource classes with different metadata -class MockUser(Resource): - """Test user resource with metadata annotations.""" - - user_name: str - password: Annotated[str, Mutability.write_only] - read_only_field: Annotated[str, Mutability.read_only] - immutable_field: Annotated[str, Mutability.immutable] - required_field: Annotated[str, Required.true] - optional_field: Annotated[str, Required.false] = "default" - - -class MockGroup(Resource): - """Test group resource with metadata annotations.""" - - display_name: Annotated[str, Required.true] - members: Annotated[list, Mutability.read_only] = [] - - -def test_patch_op_automatic_validation_with_user_type(): - """Test that PatchOp[User] automatically validates against User metadata. - - RFC 7643 Section 7: Attributes with mutability "readOnly" or "immutable" must be protected from modification. - """ - # Test read-only field violation - with pytest.raises(ValidationError, match="attempted modification.*mutability"): - PatchOp[MockUser].model_validate( - {"operations": [{"op": "add", "path": "read_only_field", "value": "test"}]} - ) - - # Test immutable field violation - with pytest.raises(ValidationError, match="attempted modification.*mutability"): - PatchOp[MockUser].model_validate( - { - "operations": [ - {"op": "replace", "path": "immutable_field", "value": "test"} - ] - } - ) - - # Test required field removal - with pytest.raises(ValidationError, match="required value.*missing"): - PatchOp[MockUser].model_validate( - {"operations": [{"op": "remove", "path": "required_field"}]} - ) - - -def test_patch_op_automatic_validation_allows_valid_operations(): - """Test that valid operations pass automatic validation.""" - # Valid operations should work - patch_op = PatchOp[MockUser].model_validate( - { - "operations": [ - {"op": "add", "path": "user_name", "value": "john.doe"}, - {"op": "replace", "path": "optional_field", "value": "new_value"}, - {"op": "remove", "path": "optional_field"}, - { - "op": "add", - "path": "immutable_field", - "value": "initial_value", - }, # ADD is allowed for immutable - ] - } - ) - - assert len(patch_op.operations) == 4 - - -def test_patch_op_automatic_validation_with_group_type(): - """Test that PatchOp[Group] validates against Group metadata. - - RFC 7643 Section 7: Attributes with mutability "readOnly" must be protected from modification. - """ - # Test read-only members field - with pytest.raises(ValidationError, match="attempted modification.*mutability"): - PatchOp[MockGroup].model_validate( - { - "operations": [ - {"op": "add", "path": "members", "value": [{"value": "user123"}]} - ] - } - ) - - # Valid operation should work - patch_op = PatchOp[MockGroup].model_validate( - { - "operations": [ - {"op": "replace", "path": "display_name", "value": "New Group Name"} - ] - } - ) - - assert len(patch_op.operations) == 1 - - -def test_patch_op_without_type_parameter_no_validation(): - """Test that PatchOp without type parameter doesn't do metadata validation.""" - # This should work even though it would violate User metadata - # because no type parameter means no automatic validation - patch_op = PatchOp.model_validate( - {"operations": [{"op": "add", "path": "read_only_field", "value": "test"}]} - ) - - assert len(patch_op.operations) == 1 - - -def test_patch_op_complex_paths_ignored(): - """Test that complex paths are ignored in automatic validation. - - RFC 7644 Section 3.5.2: Complex path expressions with filters are allowed but not validated at schema level. - """ - # Complex paths should be ignored (no validation error) - patch_op = PatchOp[MockUser].model_validate( - { - "operations": [ - { - "op": "replace", - "path": 'emails[type eq "work"].value', - "value": "test", - }, - {"op": "add", "path": 'groups[display eq "Admin"]', "value": "test"}, - ] - } - ) - - assert len(patch_op.operations) == 2 - - -def test_patch_op_nonexistent_fields_allowed(): - """Test that operations on non-existent fields are allowed. - - RFC 7644 Section 3.5.2: Servers should be tolerant of schema extensions and unrecognized attributes. - """ - # Operations on fields that don't exist should be allowed - patch_op = PatchOp[MockUser].model_validate( - { - "operations": [ - {"op": "add", "path": "nonexistent_field", "value": "test"}, - {"op": "remove", "path": "another_nonexistent_field"}, - ] - } - ) - - assert len(patch_op.operations) == 2 - - -def test_patch_op_field_without_annotations(): - """Test that operations on fields without mutability/required annotations are allowed.""" - - class MockUserWithoutAnnotations(Resource): - user_name: str # No mutability annotation - description: str = "default" # No required annotation - - # Operations on fields without annotations should be allowed - patch_op = PatchOp[MockUserWithoutAnnotations].model_validate( - { - "operations": [ - {"op": "add", "path": "user_name", "value": "test"}, - {"op": "replace", "path": "user_name", "value": "test2"}, - {"op": "remove", "path": "description"}, - ] - } - ) - - assert len(patch_op.operations) == 3 - - -def test_patch_op_urn_paths_validated(): - """Test that URN paths are validated with automatic validation.""" - # URN path for read-only field should fail - with pytest.raises(ValidationError, match="attempted modification.*mutability"): - PatchOp[MockUser].model_validate( - { - "operations": [ - { - "op": "replace", - "path": "urn:ietf:params:scim:schemas:core:2.0:User:read_only_field", - "value": "test", - } - ] - } - ) - - -def test_patch_op_read_only_field_remove_operation(): - """Test that remove operations on read-only fields are allowed. - - RFC 7643 Section 7: Read-only fields can be removed but not added/replaced. - """ - # Remove operation on read-only field should work - patch_op = PatchOp[MockUser].model_validate( - {"operations": [{"op": "remove", "path": "read_only_field"}]} - ) - assert len(patch_op.operations) == 1 - - -def test_patch_op_none_path_validation(): - """Test validation when path is None.""" - # Test with None path - should not validate field metadata - patch_op = PatchOp[MockUser].model_validate( - {"operations": [{"op": "replace", "value": {"read_only_field": "test"}}]} - ) - assert len(patch_op.operations) == 1 - assert patch_op.operations[0].path is None - - -def test_patch_op_get_resource_class_method(): - """Test the get_resource_class method directly.""" - # With type parameter - typed_patch = PatchOp[MockUser].model_validate( - {"operations": [{"op": "add", "path": "user_name", "value": "test"}]} - ) - resource_class = get_resource_class(typed_patch) - assert resource_class == MockUser - - # With a wrong type parameter - untyped_patch = PatchOp[int].model_validate( - {"operations": [{"op": "add", "path": "user_name", "value": "test"}]} - ) - resource_class = get_resource_class(untyped_patch) - assert resource_class is None - - # Without type parameter - untyped_patch = PatchOp.model_validate( - {"operations": [{"op": "add", "path": "user_name", "value": "test"}]} - ) - resource_class = get_resource_class(untyped_patch) - assert resource_class is None - - -def test_patch_op_not_type_parameter(): - """Test that existing code without type parameters still works.""" - # Old way of creating PatchOp should still work - patch_op = PatchOp( - operations=[{"op": "add", "path": "any_field", "value": "any_value"}] - ) - - assert len(patch_op.operations) == 1 - assert get_resource_class(patch_op) is None diff --git a/tests/test_patch_op_add.py b/tests/test_patch_op_add.py new file mode 100644 index 0000000..082bc23 --- /dev/null +++ b/tests/test_patch_op_add.py @@ -0,0 +1,278 @@ +from scim2_models import Group +from scim2_models import GroupMember +from scim2_models import PatchOp +from scim2_models import PatchOperation +from scim2_models import User + + +def test_add_operation_single_attribute(): + """Test adding a value to a single-valued attribute.""" + user = User() + patch = PatchOp[User]( + operations=[ + PatchOperation(op=PatchOperation.Op.add, path="nickName", value="Babs") + ] + ) + result = patch.patch(user) + assert result is True + assert user.nick_name == "Babs" + + +def test_add_operation_single_attribute_already_present(): + """Test adding a value to a single-valued attribute that already has a value.""" + user = User(nick_name="foobar") + patch = PatchOp[User]( + operations=[ + PatchOperation(op=PatchOperation.Op.add, path="nickName", value="Babs") + ] + ) + result = patch.patch(user) + assert result is True + assert user.nick_name == "Babs" + + +def test_add_operation_same_value(): + """Test adding the same value to a single-valued attribute.""" + user = User(nick_name="Test") + patch = PatchOp[User]( + operations=[ + PatchOperation(op=PatchOperation.Op.add, path="nickName", value="Test") + ] + ) + result = patch.patch(user) + assert result is False + assert user.nick_name == "Test" + + +def test_add_operation_sub_attribute(): + """Test adding a value to a sub-attribute of a complex attribute.""" + user = User() + patch = PatchOp[User]( + operations=[ + PatchOperation( + op=PatchOperation.Op.add, path="name.familyName", value="Jensen" + ) + ] + ) + result = patch.patch(user) + assert result is True + assert user.name.family_name == "Jensen" + + +def test_add_operation_complex_attribute(): + """Test adding a complex attribute with sub-attributes.""" + user = User() + patch = PatchOp[User]( + operations=[ + PatchOperation( + op=PatchOperation.Op.add, path="name", value={"familyName": "Jensen"} + ) + ] + ) + result = patch.patch(user) + assert result is True + assert user.name.family_name == "Jensen" + + +def test_add_operation_creates_parent_complex_object(): + """Test add operation creating parent complex object when it doesn't exist.""" + user = User() + + # Add to a sub-attribute when parent doesn't exist + patch = PatchOp[User]( + operations=[ + PatchOperation( + op=PatchOperation.Op.add, path="name.givenName", value="John" + ) + ] + ) + + result = patch.patch(user) + assert result is True + assert user.name is not None + assert user.name.given_name == "John" + + +def test_add_operation_multiple_attribute(): + """Test adding values to a multi-valued attribute.""" + group = Group() + patch = PatchOp[Group]( + operations=[ + PatchOperation( + op=PatchOperation.Op.add, + path="members", + value=[ + { + "display": "Babs Jensen", + "$ref": "https://example.com/v2/Users/2819c223...413861904646", + "value": "2819c223-7f76-453a-919d-413861904646", + } + ], + ) + ] + ) + result = patch.patch(group) + assert result is True + assert group.members[0].display == "Babs Jensen" + + +def test_add_operation_multiple_attribute_already_present(): + """Test adding a value that already exists in a multi-valued attribute.""" + member = GroupMember( + display="Babs Jensen", + ref="https://example.com/v2/Users/2819c223...413861904646", + value="2819c223-7f76-453a-919d-413861904646", + ) + group = Group(members=[member]) + assert len(group.members) == 1 + + patch = PatchOp[Group]( + operations=[ + PatchOperation( + op=PatchOperation.Op.add, + path="members", + value=[member.model_dump()], + ) + ] + ) + result = patch.patch(group) + assert result is False + assert len(group.members) == 1 + + +def test_add_operation_single_value_in_multivalued_field(): + """Test adding a single value (not a list) to a multi-valued field. + + :rfc:`RFC7644 §3.5.2.1 <7644#section-3.5.2.1>`: "If the target location is a + multi-valued attribute, a new value is added to the attribute." + """ + group = Group(members=[]) + patch = PatchOp[Group]( + operations=[ + PatchOperation( + op=PatchOperation.Op.add, + path="members", + value={ + "display": "Single Member", + "$ref": "https://example.com/v2/Users/single", + "value": "single-id", + }, + ) + ] + ) + result = patch.patch(group) + assert result is True + assert len(group.members) == 1 + assert group.members[0].display == "Single Member" + + +def test_add_multiple_values_empty_new_values(): + """Test _add_multiple_values when all values already exist.""" + member1 = GroupMember( + display="Member 1", + ref="https://example.com/v2/Users/1", + value="1", + ) + member2 = GroupMember( + display="Member 2", + ref="https://example.com/v2/Users/2", + value="2", + ) + + group = Group(members=[member1, member2]) + patch = PatchOp[Group]( + operations=[ + PatchOperation( + op=PatchOperation.Op.add, + path="members", + value=[ + member1.model_dump(), + member2.model_dump(), + ], + ) + ] + ) + + result = patch.patch(group) + assert result is False + assert len(group.members) == 2 + + +def test_add_single_value_existing(): + """Test _add_single_value when value already exists (line 266).""" + group = Group(members=[GroupMember(value="123", display="Test User")]) + patch = PatchOp[Group]( + operations=[ + PatchOperation( + op=PatchOperation.Op.add, + path="members", + value={"value": "123", "display": "Test User"}, # Same value + ) + ] + ) + + result = patch.patch(group) + assert result is False # Should return False because value already exists + assert len(group.members) == 1 + + +def test_add_operation_no_path(): + """Test adding multiple attributes when no path is specified.""" + user = User() + patch = PatchOp[User]( + operations=[ + PatchOperation( + op=PatchOperation.Op.add, + value={ + "emails": [{"value": "babs@jensen.org", "type": "home"}], + "nickName": "Babs", + }, + ) + ] + ) + result = patch.patch(user) + assert result is True + assert user.nick_name == "Babs" + assert user.emails[0].value == "babs@jensen.org" + + +def test_add_operation_no_path_same_attributes(): + """Test add operation with no path but same attribute values should return False.""" + user = User(nick_name="Test") + patch = PatchOp[User]( + operations=[ + PatchOperation( + op=PatchOperation.Op.add, + value={"nickName": "Test"}, + ) + ] + ) + result = patch.patch(user) + assert result is False + assert user.nick_name == "Test" + + +def test_add_operation_no_path_with_invalid_attribute(): + """Test add operation with no path but invalid attribute name.""" + user = User(nick_name="Test") + patch = PatchOp[User]( + operations=[ + PatchOperation( + op=PatchOperation.Op.add, + value={"invalidAttributeName": "value", "nickName": "Updated"}, + ) + ] + ) + result = patch.patch(user) + assert result is True + assert user.nick_name == "Updated" + + +def test_add_operation_with_non_dict_value_no_path(): + """Test add operation with no path and non-dict value should return False.""" + user = User() + patch = PatchOp[User]( + operations=[PatchOperation(op=PatchOperation.Op.add, value="invalid_value")] + ) + result = patch.patch(user) + assert result is False diff --git a/tests/test_patch_op_extensions.py b/tests/test_patch_op_extensions.py new file mode 100644 index 0000000..79d9a0b --- /dev/null +++ b/tests/test_patch_op_extensions.py @@ -0,0 +1,335 @@ +from typing import TypeVar +from typing import Union + +import pytest +from pydantic import Field + +from scim2_models import Group +from scim2_models import GroupMember +from scim2_models import PatchOp +from scim2_models import PatchOperation +from scim2_models import User +from scim2_models.rfc7643.enterprise_user import EnterpriseUser +from scim2_models.rfc7643.resource import Resource + + +def test_patch_operation_extension_simple_attribute(): + """Test PATCH operations on simple extension attributes using schema URN paths.""" + user = User[EnterpriseUser].model_validate( + { + "userName": "john.doe", + "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User": { + "employeeNumber": "12345", + "costCenter": "Engineering", + }, + } + ) + + patch1 = PatchOp[User]( + operations=[ + PatchOperation( + op=PatchOperation.Op.replace_, + path="urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:employeeNumber", + value="54321", + ) + ] + ) + result = patch1.patch(user) + assert result is True + assert user[EnterpriseUser].employee_number == "54321" + + patch2 = PatchOp[User]( + operations=[ + PatchOperation( + op=PatchOperation.Op.add, + path="urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:organization", + value="ACME Corp", + ) + ] + ) + result = patch2.patch(user) + assert result is True + assert user[EnterpriseUser].organization == "ACME Corp" + + patch3 = PatchOp[User]( + operations=[ + PatchOperation( + op=PatchOperation.Op.remove, + path="urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:costCenter", + ) + ] + ) + result = patch3.patch(user) + assert result is True + assert user[EnterpriseUser].cost_center is None + + +def test_patch_operation_extension_complex_attribute(): + """Test PATCH operations on complex extension attributes using schema URN paths.""" + user = User[EnterpriseUser].model_validate( + { + "userName": "jane.doe", + "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User": { + "employeeNumber": "67890", + "manager": {"value": "manager-123", "displayName": "John Smith"}, + }, + } + ) + + patch1 = PatchOp[User]( + operations=[ + PatchOperation( + op=PatchOperation.Op.replace_, + path="urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:manager.value", + value="new-manager-456", + ) + ] + ) + result = patch1.patch(user) + assert result is True + assert user[EnterpriseUser].manager.value == "new-manager-456" + assert user[EnterpriseUser].manager.display_name == "John Smith" + + patch2 = PatchOp[User]( + operations=[ + PatchOperation( + op=PatchOperation.Op.replace_, + path="urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:manager", + value={ + "value": "super-manager-789", + "displayName": "Alice Johnson", + "$ref": "https://example.com/Users/super-manager-789", + }, + ) + ] + ) + result = patch2.patch(user) + assert result is True + assert user[EnterpriseUser].manager.value == "super-manager-789" + assert user[EnterpriseUser].manager.display_name == "Alice Johnson" + + patch3 = PatchOp[User]( + operations=[ + PatchOperation( + op=PatchOperation.Op.remove, + path="urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:manager", + ) + ] + ) + result = patch3.patch(user) + assert result is True + assert user[EnterpriseUser].manager is None + + +def test_patch_operation_extension_mutability_handled_by_model(): + """Test that extension mutability is handled by model validation. + + Note: Mutability validation for extensions is now handled at the model level + during PatchOp validation, not during patch execution. + """ + user = User[EnterpriseUser].model_validate( + { + "userName": "test.user", + "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User": { + "manager": {"value": "manager-123", "displayName": "John Smith"} + }, + } + ) + + # This operation would fail during model validation for mutability, + # but patch method assumes operations are already validated + patch = PatchOp[User]( + operations=[ + PatchOperation( + op=PatchOperation.Op.replace_, + path="urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:employeeNumber", + value="12345", + ) + ] + ) + result = patch.patch(user) + assert result is True + assert user[EnterpriseUser].employee_number == "12345" + + +def test_patch_operation_extension_no_target_error(): + """Test noTarget error for invalid extension paths. + + :rfc:`RFC7644 §3.5.2.2 <7644#section-3.5.2.2>`: noTarget errors apply to + extension attributes when the specified path does not exist. + """ + user = User[EnterpriseUser].model_validate({"userName": "test.user"}) + + patch1 = PatchOp[User]( + operations=[ + PatchOperation( + op=PatchOperation.Op.add, + path="urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:invalidAttribute", + value="test", + ) + ] + ) + with pytest.raises(ValueError, match="no match"): + patch1.patch(user) + + patch2 = PatchOp[User]( + operations=[ + PatchOperation( + op=PatchOperation.Op.add, + path="urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:manager.invalidField", + value="test", + ) + ] + ) + with pytest.raises(ValueError, match="no match"): + patch2.patch(user) + + +def test_urn_parsing_errors(): + """Test URN parsing errors for malformed URNs.""" + user = User() + + # Test with malformed URN that causes extract_schema_and_attribute_base to fail + patch = PatchOp[User]( + operations=[ + PatchOperation( + op=PatchOperation.Op.add, path="urn:malformed:incomplete", value="test" + ) + ] + ) + + with pytest.raises( + ValueError, match='The "path" attribute was invalid or malformed' + ): + patch.patch(user) + + +def test_values_match_integration(): + """Test values matching through remove operation with BaseModel objects.""" + # Test removing existing member (values should match) + member1 = GroupMember(value="123", display="Test User") + group = Group(members=[member1]) + + # Remove with exact matching dict + patch_op = PatchOp[Group]( + operations=[ + PatchOperation( + op=PatchOperation.Op.remove, + path="members", + value={"value": "123", "display": "Test User"}, + ) + ] + ) + result = patch_op.patch(group) + assert result is True + assert group.members is None or len(group.members) == 0 + + # Test removing non-existing member (values should not match) + member2 = GroupMember(value="456", display="Other User") + group = Group(members=[member2]) + + patch_op = PatchOp[Group]( + operations=[ + PatchOperation( + op=PatchOperation.Op.remove, + path="members", + value={"value": "123", "display": "Test User"}, + ) + ] + ) + result = patch_op.patch(group) + assert result is False + assert len(group.members) == 1 + + +def test_generic_patchop_rejects_union(): + """Test that PatchOp rejects Union types.""" + with pytest.raises( + TypeError, match="PatchOp type parameter must be a concrete Resource subclass" + ): + PatchOp[Union[User, Group]] + + +def test_generic_patchop_with_single_type(): + """Test that generic PatchOp works with single types.""" + patch_data = { + "operations": [{"op": "add", "path": "userName", "value": "test.user"}] + } + + # This should not trigger the union-specific metaclass code + patch = PatchOp[User].model_validate(patch_data) + assert patch.operations[0].value == "test.user" + + +def test_malformed_urn_extract_error(): + """Test URN extraction error with truly malformed URN.""" + user = User() + patch = PatchOp[User]( + operations=[ + PatchOperation( + op=PatchOperation.Op.add, path="urn:malformed:incomplete", value="test" + ) + ] + ) + + # Should raise error for malformed URN + with pytest.raises( + ValueError, match='The "path" attribute was invalid or malformed' + ): + patch.patch(user) + + +def test_create_parent_object_return_none(): + """Test _create_parent_object returns None when field type is not a class.""" + T = TypeVar("T") + + class TestResourceTypeVar(Resource): + schemas: list[str] = Field(default=["urn:test:TestResource"]) + # TypeVar is not a class, so _create_parent_object should return None + typevar_field: T = None + + user = TestResourceTypeVar() + patch = PatchOp[TestResourceTypeVar]( + operations=[ + PatchOperation( + op=PatchOperation.Op.add, path="typevarField.subfield", value="test" + ) + ] + ) + + # This should fail gracefully - _create_parent_object returns None, + # so the operation should return False + result = patch.patch(user) + assert result is False + + +def test_complex_object_creation_and_basemodel_matching(): + """Test automatic complex object creation and BaseModel value matching in lists.""" + # Test creation of parent object for valid complex field + user = User() + patch = PatchOp[User]( + operations=[ + PatchOperation( + op=PatchOperation.Op.add, path="name.givenName", value="John" + ) + ] + ) + + result = patch.patch(user) + assert result is True + assert user.name.given_name == "John" + + # Test BaseModel conversion in values matching + group = Group(members=[GroupMember(value="123", display="Test")]) + patch = PatchOp[Group]( + operations=[ + PatchOperation( + op=PatchOperation.Op.remove, + path="members", + value=GroupMember(value="123", display="Test"), + ) + ] + ) + + result = patch.patch(group) + assert result is True diff --git a/tests/test_patch_op_remove.py b/tests/test_patch_op_remove.py new file mode 100644 index 0000000..d1452c7 --- /dev/null +++ b/tests/test_patch_op_remove.py @@ -0,0 +1,289 @@ +import pytest +from pydantic import ValidationError + +from scim2_models import Group +from scim2_models import GroupMember +from scim2_models import PatchOp +from scim2_models import PatchOperation +from scim2_models import User +from scim2_models.base import Context + + +def test_remove_operation_single_attribute(): + """Test removing a single-valued attribute.""" + user = User(nick_name="Babs") + patch = PatchOp[User]( + operations=[PatchOperation(op=PatchOperation.Op.remove, path="nickName")] + ) + result = patch.patch(user) + assert result is True + assert user.nick_name is None + + +def test_remove_operation_nonexistent_attribute(): + """Test removing an attribute that doesn't exist should not raise an error.""" + user = User() + patch = PatchOp[User]( + operations=[PatchOperation(op=PatchOperation.Op.remove, path="nickName")] + ) + result = patch.patch(user) + assert result is False + assert user.nick_name is None + + +def test_remove_operation_on_non_list_attribute(): + """Test remove specific value operation on non-list attribute.""" + user = User(nick_name="TestValue") + + # Try to remove specific value from a single-valued field + patch = PatchOp[User]( + operations=[ + PatchOperation( + op=PatchOperation.Op.remove, path="nickName", value="TestValue" + ) + ] + ) + + # Should return False because nickName is not a list + result = patch.patch(user) + assert result is False + assert user.nick_name == "TestValue" + + +def test_remove_operation_sub_attribute(): + """Test removing a sub-attribute of a complex attribute.""" + user = User(name={"familyName": "Jensen", "givenName": "Barbara"}) + patch = PatchOp[User]( + operations=[PatchOperation(op=PatchOperation.Op.remove, path="name.familyName")] + ) + result = patch.patch(user) + assert result is True + assert user.name.family_name is None + assert user.name.given_name == "Barbara" + + +def test_remove_operation_complex_attribute(): + """Test removing an entire complex attribute.""" + user = User(name={"familyName": "Jensen", "givenName": "Barbara"}) + patch = PatchOp[User]( + operations=[PatchOperation(op=PatchOperation.Op.remove, path="name")] + ) + result = patch.patch(user) + assert result is True + assert user.name is None + + +def test_remove_operation_sub_attribute_parent_none(): + """Test removing a sub-attribute when parent is None.""" + user = User(name=None) + patch = PatchOp[User]( + operations=[PatchOperation(op=PatchOperation.Op.remove, path="name.familyName")] + ) + result = patch.patch(user) + assert result is False + assert user.name is None + + +def test_remove_operation_multiple_attribute_all(): + """Test removing all items from a multi-valued attribute.""" + group = Group( + members=[ + { + "display": "Babs Jensen", + "$ref": "https://example.com/v2/Users/2819c223...413861904646", + "value": "2819c223-7f76-453a-919d-413861904646", + }, + { + "display": "John Smith", + "$ref": "https://example.com/v2/Users/1234567...413861904646", + "value": "1234567-7f76-453a-919d-413861904646", + }, + ] + ) + patch = PatchOp[Group]( + operations=[PatchOperation(op=PatchOperation.Op.remove, path="members")] + ) + result = patch.patch(group) + assert result is True + assert group.members is None or len(group.members) == 0 + + +def test_remove_operation_multiple_attribute_with_value(): + """Test removing specific items from a multi-valued attribute by providing value.""" + user = User( + emails=[ + {"value": "work@example.com", "type": "work"}, + {"value": "home@example.com", "type": "home"}, + ] + ) + patch = PatchOp[User]( + operations=[ + PatchOperation( + op=PatchOperation.Op.remove, + path="emails", + value={"value": "work@example.com", "type": "work"}, + ) + ] + ) + result = patch.patch(user) + assert result is True + assert len(user.emails) == 1 + assert user.emails[0].value == "home@example.com" + + +def test_remove_operation_with_value_not_in_list(): + """Test remove operation with value not present in list should return False.""" + user = User(emails=[{"value": "test@example.com", "type": "work"}]) + patch = PatchOp[User]( + operations=[ + PatchOperation( + op=PatchOperation.Op.remove, + path="emails", + value={"value": "other@example.com", "type": "work"}, + ) + ] + ) + result = patch.patch(user) + assert result is False + assert len(user.emails) == 1 + assert user.emails[0].value == "test@example.com" + + +def test_values_match_basemodel_second_parameter(): + """Test _values_match when first value is dict and second is BaseModel (line 423->426).""" + # Create a group with a member as dict + group = Group() + group.members = [{"value": "123", "display": "Test User"}] # Dict, not BaseModel + + # Try to remove using a BaseModel object + member_obj = GroupMember(value="123", display="Test User") # BaseModel + patch = PatchOp[Group]( + operations=[ + PatchOperation( + op=PatchOperation.Op.remove, + path="members", + value=member_obj, # BaseModel as second parameter + ) + ] + ) + + # This should trigger _values_match where: + # - value1 (dict from list) is not BaseModel -> skip lines 423-424 + # - value2 (member_obj) is BaseModel -> execute lines 426-427 + result = patch.patch(group) + assert result is True + assert group.members is None or len(group.members) == 0 + + +def test_remove_operations_on_nonexistent_and_basemodel_values(): + """Test remove operations on non-existent values and BaseModel comparisons.""" + user = User(emails=[{"value": "existing@example.com", "type": "work"}]) + + # Test removing non-existent value + patch = PatchOp[User]( + operations=[ + PatchOperation( + op=PatchOperation.Op.remove, + path="emails", + value={"value": "nonexistent@example.com", "type": "work"}, + ) + ] + ) + + result = patch.patch(user) + assert result is False + assert len(user.emails) == 1 + + +def test_complex_object_creation_and_basemodel_matching(): + """Test complex object creation and BaseModel value matching.""" + # Test removing from existing multi-valued attribute + group = Group( + members=[ + GroupMember(value="123", display="Test User"), + GroupMember(value="456", display="Another User"), + ] + ) + + # Remove specific member by BaseModel value + patch = PatchOp[Group]( + operations=[ + PatchOperation( + op=PatchOperation.Op.remove, + path="members", + value=GroupMember(value="123", display="Test User"), + ) + ] + ) + + result = patch.patch(group) + assert result is True + assert len(group.members) == 1 + assert group.members[0].value == "456" + + +def test_remove_operation_bypass_validation_no_path(): + """Test remove operation with no path raises error during validation per RFC7644.""" + # Path validation now happens during model validation + with pytest.raises(ValidationError, match="path.*invalid"): + PatchOp.model_validate( + { + "operations": [ + {"op": "remove", "value": "test"}, + ], + }, + context={"scim": Context.RESOURCE_PATCH_REQUEST}, + ) + + +def test_defensive_path_check_in_remove(): + """Test defensive path check in _apply_remove method.""" + user = User(nick_name="Test") + patch = PatchOp[User]( + operations=[PatchOperation(op=PatchOperation.Op.remove, path="nickName")] + ) + + # Force path to None to test defensive check + patch.operations[0] = PatchOperation.model_construct( + op=PatchOperation.Op.remove, path=None + ) + + with pytest.raises(ValueError, match="path.*invalid"): + patch.patch(user) + + +def test_remove_value_empty_attr_path(): + """Test _remove_value_at_path with empty attr_path after URN resolution (line 291).""" + user = User() + + # URN with trailing colon results in empty attr_path after parsing + patch = PatchOp[User]( + operations=[ + PatchOperation( + op=PatchOperation.Op.remove, + path="urn:ietf:params:scim:schemas:core:2.0:User:", + ) + ] + ) + + with pytest.raises(ValueError, match="path"): + patch.patch(user) + + +def test_remove_specific_value_empty_attr_path(): + """Test _remove_specific_value with empty attr_path after URN resolution (line 316).""" + user = User() + + # URN with trailing colon results in empty attr_path after parsing + patch = PatchOp[User]( + operations=[ + PatchOperation( + op=PatchOperation.Op.remove, + path="urn:ietf:params:scim:schemas:core:2.0:User:", + value={"some": "value"}, + ) + ] + ) + + with pytest.raises(ValueError, match="path"): + patch.patch(user) diff --git a/tests/test_patch_op_replace.py b/tests/test_patch_op_replace.py new file mode 100644 index 0000000..5dc2bb6 --- /dev/null +++ b/tests/test_patch_op_replace.py @@ -0,0 +1,219 @@ +from typing import Annotated + +import pytest +from pydantic import Field +from pydantic import ValidationError + +from scim2_models import PatchOp +from scim2_models import PatchOperation +from scim2_models import User +from scim2_models.annotations import Mutability +from scim2_models.rfc7643.resource import Resource + + +def test_replace_operation_single_attribute(): + """Test replacing a single-valued attribute. + + :rfc:`RFC7644 §3.5.2.3 <7644#section-3.5.2.3>`: "The 'replace' operation replaces + the value at the target location specified by the 'path'." + """ + user = User(nick_name="OldNick") + patch = PatchOp[User]( + operations=[ + PatchOperation( + op=PatchOperation.Op.replace_, path="nickName", value="NewNick" + ) + ] + ) + result = patch.patch(user) + assert result is True + assert user.nick_name == "NewNick" + + +def test_replace_operation_single_attribute_none_to_value(): + """Test replacing a None single-valued attribute with a value.""" + user = User(nick_name=None) + patch = PatchOp[User]( + operations=[ + PatchOperation( + op=PatchOperation.Op.replace_, path="nickName", value="NewNick" + ) + ] + ) + result = patch.patch(user) + assert result is True + assert user.nick_name == "NewNick" + + +def test_replace_operation_nonexistent_attribute(): + """Test replacing a nonexistent attribute should be treated as add.""" + user = User() + patch = PatchOp[User]( + operations=[ + PatchOperation( + op=PatchOperation.Op.replace_, path="nickName", value="NewNick" + ) + ] + ) + result = patch.patch(user) + assert result is True + assert user.nick_name == "NewNick" + + +def test_replace_operation_same_value(): + """Test replace operation with same value should return False.""" + user = User(nick_name="Test") + patch = PatchOp[User]( + operations=[ + PatchOperation(op=PatchOperation.Op.replace_, path="nickName", value="Test") + ] + ) + result = patch.patch(user) + assert result is False + assert user.nick_name == "Test" + + +def test_replace_operation_sub_attribute(): + """Test replacing a sub-attribute of a complex attribute.""" + user = User(name={"familyName": "OldName", "givenName": "Barbara"}) + patch = PatchOp[User]( + operations=[ + PatchOperation( + op=PatchOperation.Op.replace_, path="name.familyName", value="NewName" + ) + ] + ) + result = patch.patch(user) + assert result is True + assert user.name.family_name == "NewName" + assert user.name.given_name == "Barbara" + + +def test_replace_operation_complex_attribute(): + """Test replacing an entire complex attribute.""" + user = User(name={"familyName": "OldName", "givenName": "Barbara"}) + patch = PatchOp[User]( + operations=[ + PatchOperation( + op=PatchOperation.Op.replace_, + path="name", + value={"familyName": "NewName", "givenName": "John"}, + ) + ] + ) + result = patch.patch(user) + assert result is True + assert user.name.family_name == "NewName" + assert user.name.given_name == "John" + + +def test_replace_operation_sub_attribute_parent_none(): + """Test replacing a sub-attribute when parent is None (should create parent).""" + user = User(name=None) + patch = PatchOp[User]( + operations=[ + PatchOperation( + op=PatchOperation.Op.replace_, path="name.familyName", value="NewName" + ) + ] + ) + result = patch.patch(user) + assert result is True + assert user.name is not None + assert user.name.family_name == "NewName" + + +def test_replace_operation_multiple_attribute_all(): + """Test replacing all items in a multi-valued attribute.""" + user = User( + emails=[ + {"value": "old1@example.com", "type": "work"}, + {"value": "old2@example.com", "type": "home"}, + ] + ) + patch = PatchOp[User]( + operations=[ + PatchOperation( + op=PatchOperation.Op.replace_, + path="emails", + value=[{"value": "new@example.com", "type": "work"}], + ) + ] + ) + result = patch.patch(user) + assert result is True + assert len(user.emails) == 1 + assert user.emails[0].value == "new@example.com" + assert user.emails[0].type == "work" + + +def test_replace_operation_no_path(): + """Test replacing multiple attributes when no path is specified. + + :rfc:`RFC7644 §3.5.2.3 <7644#section-3.5.2.3>`: "If the 'path' parameter is omitted, + the target is assumed to be the resource itself, and the 'value' parameter + SHALL contain the replacement attributes." + """ + user = User(nick_name="OldNick", display_name="Old Display") + patch = PatchOp[User]( + operations=[ + PatchOperation( + op=PatchOperation.Op.replace_, + value={ + "nickName": "NewNick", + "displayName": "New Display", + }, + ) + ] + ) + result = patch.patch(user) + assert result is True + assert user.nick_name == "NewNick" + assert user.display_name == "New Display" + + +def test_replace_operation_no_path_same_attributes(): + """Test replace operation with no path but same attribute values should return False.""" + user = User(nick_name="Test", display_name="Display") + patch = PatchOp[User]( + operations=[ + PatchOperation( + op=PatchOperation.Op.replace_, + value={"nickName": "Test", "displayName": "Display"}, + ) + ] + ) + result = patch.patch(user) + assert result is False + assert user.nick_name == "Test" + assert user.display_name == "Display" + + +def test_replace_operation_with_non_dict_value_no_path(): + """Test replace operation with no path and non-dict value should return False.""" + user = User(nick_name="Test") + patch = PatchOp[User]( + operations=[ + PatchOperation(op=PatchOperation.Op.replace_, value="invalid_value") + ] + ) + result = patch.patch(user) + assert result is False + assert user.nick_name == "Test" + + +def test_immutable_field(): + """Test that replace operations on immutable fields raise validation errors.""" + + class Dummy(Resource): + schemas: list[str] = Field(default=["urn:test:TestResource"]) + immutable: Annotated[str, Mutability.immutable] + + with pytest.raises(ValidationError, match="mutability"): + PatchOp[Dummy]( + operations=[ + PatchOperation( + op=PatchOperation.Op.replace_, path="immutable", value="new_value" + ) + ] + ) diff --git a/tests/test_patch_op_validation.py b/tests/test_patch_op_validation.py new file mode 100644 index 0000000..0277799 --- /dev/null +++ b/tests/test_patch_op_validation.py @@ -0,0 +1,464 @@ +from typing import TypeVar +from typing import Union + +import pytest +from pydantic import ValidationError + +from scim2_models import Group +from scim2_models import PatchOp +from scim2_models import PatchOperation +from scim2_models import User +from scim2_models.base import Context +from scim2_models.rfc7643.resource import Resource + + +def test_patch_op_without_type_parameter(): + """Test that PatchOp cannot be instantiated without a type parameter.""" + with pytest.raises(TypeError, match="PatchOp requires a type parameter"): + PatchOp(operations=[{"op": "replace", "path": "userName", "value": "test"}]) + + +def test_patch_op_with_resource_type(): + """Test that PatchOp[Resource] is rejected.""" + with pytest.raises( + TypeError, + match="PatchOp requires a concrete Resource subclass, not Resource itself", + ): + PatchOp[Resource] + + +def test_patch_op_with_invalid_type(): + """Test that PatchOp with invalid types like str is rejected.""" + with pytest.raises( + TypeError, match="PatchOp type parameter must be a concrete Resource subclass" + ): + PatchOp[str] + + +def test_patch_op_union_types_not_supported(): + """Test that PatchOp with Union types are rejected.""" + with pytest.raises( + TypeError, match="PatchOp type parameter must be a concrete Resource subclass" + ): + PatchOp[Union[User, Group]] + + +def test_validate_patchop_case_insensitivity(): + """Validate that a patch operation's Op declaration is case-insensitive. + + Note: While :rfc:`RFC7644 §3.4.2.2 <7644#section-3.4.2.2>` specifies case insensitivity + for attribute names and operators in filters, this implementation extends this principle + to PATCH operation names for Microsoft Entra compatibility. + """ + assert PatchOp[User].model_validate( + { + "operations": [ + {"op": "Replace", "path": "userName", "value": "Rivard"}, + {"op": "ADD", "path": "userName", "value": "Rivard"}, + {"op": "ReMove", "path": "userName", "value": "Rivard"}, + ], + }, + ) == PatchOp[User]( + operations=[ + PatchOperation( + op=PatchOperation.Op.replace_, path="userName", value="Rivard" + ), + PatchOperation(op=PatchOperation.Op.add, path="userName", value="Rivard"), + PatchOperation( + op=PatchOperation.Op.remove, path="userName", value="Rivard" + ), + ] + ) + with pytest.raises( + ValidationError, + match="1 validation error for PatchOp", + ): + PatchOp[User].model_validate( + { + "operations": [{"op": 42, "path": "userName", "value": "Rivard"}], + }, + ) + + +def test_path_required_for_remove_operations(): + """Test that path is required for remove operations. + + :rfc:`RFC7644 §3.5.2.2 <7644#section-3.5.2.2>`: "If 'path' is unspecified, + the operation fails with HTTP status code 400 and a 'scimType' error code of 'noTarget'." + """ + PatchOp[User].model_validate( + { + "operations": [ + {"op": "replace", "value": "foobar"}, + ], + }, + context={"scim": Context.RESOURCE_PATCH_REQUEST}, + ) + PatchOp[User].model_validate( + { + "operations": [ + {"op": "add", "value": "foobar"}, + ], + }, + context={"scim": Context.RESOURCE_PATCH_REQUEST}, + ) + + # Validation now happens during model validation + with pytest.raises(ValidationError, match="path.*invalid"): + PatchOp[User].model_validate( + { + "operations": [ + {"op": "remove", "value": "foobar"}, + ], + }, + context={"scim": Context.RESOURCE_PATCH_REQUEST}, + ) + + +def test_value_required_for_add_operations(): + """Test that value is required for add operations. + + :rfc:`RFC7644 §3.5.2.1 <7644#section-3.5.2.1>`: "The operation MUST contain a 'value' + member whose content specifies the value to be added." + """ + PatchOp[User].model_validate( + { + "operations": [ + {"op": "replace", "path": "foobar"}, + ], + }, + context={"scim": Context.RESOURCE_PATCH_REQUEST}, + ) + with pytest.raises(ValidationError): + PatchOp[User].model_validate( + { + "operations": [ + {"op": "add", "path": "foobar"}, + ], + }, + context={"scim": Context.RESOURCE_PATCH_REQUEST}, + ) + + PatchOp[User].model_validate( + { + "operations": [ + {"op": "remove", "path": "foobar"}, + ], + }, + context={"scim": Context.RESOURCE_PATCH_REQUEST}, + ) + + +def test_patch_operation_validation_contexts(): + """Test RFC7644 validation behavior in different contexts. + + Validates that operations are only validated in PATCH request contexts, + following :rfc:`RFC7644 §3.5.2 <7644#section-3.5.2>` validation requirements. + """ + with pytest.raises(ValidationError, match="path"): + PatchOperation.model_validate( + {"op": "add", "path": " ", "value": "test"}, + context={"scim": Context.RESOURCE_PATCH_REQUEST}, + ) + + # Validation for missing path in remove operations now happens during model validation + with pytest.raises(ValidationError, match="path.*invalid"): + PatchOperation.model_validate( + {"op": "remove"}, + context={"scim": Context.RESOURCE_PATCH_REQUEST}, + ) + + with pytest.raises(ValidationError, match="required value was missing"): + PatchOperation.model_validate( + {"op": "add", "path": "test"}, + context={"scim": Context.RESOURCE_PATCH_REQUEST}, + ) + + operation1 = PatchOperation.model_validate( + {"op": "add", "path": " ", "value": "test"} + ) + assert operation1.path == " " + + operation2 = PatchOperation.model_validate({"op": "remove"}) + assert operation2.path is None + + operation3 = PatchOperation.model_validate({"op": "add", "path": "test"}) + assert operation3.value is None + + +def test_validate_mutability_readonly_error(): + """Test mutability validation error for readOnly attributes.""" + # Test add operation on readOnly field + with pytest.raises(ValidationError, match="mutability"): + PatchOp[User].model_validate( + {"operations": [{"op": "add", "path": "id", "value": "new-id"}]}, + context={"scim": Context.RESOURCE_PATCH_REQUEST}, + ) + + # Test replace operation on readOnly field + with pytest.raises(ValidationError, match="mutability"): + PatchOp[User].model_validate( + {"operations": [{"op": "replace", "path": "id", "value": "new-id"}]}, + context={"scim": Context.RESOURCE_PATCH_REQUEST}, + ) + + +def test_validate_mutability_immutable_error(): + """Test mutability validation error for immutable attributes.""" + # Test replace operation on immutable field within groups complex attribute + with pytest.raises(ValidationError, match="mutability"): + PatchOp[User].model_validate( + { + "operations": [ + { + "op": "replace", + "path": "groups.value", + "value": "new-group-id", + } + ] + }, + context={"scim": Context.RESOURCE_PATCH_REQUEST}, + ) + + +def test_patch_validation_allows_unknown_fields(): + """Test that patch validation allows unknown fields in operations.""" + # This should not raise an error even though 'unknownField' doesn't exist on User + patch_op = PatchOp[User].model_validate( + { + "operations": [ + {"op": "add", "path": "unknownField", "value": "some-value"}, + ] + }, + context={"scim": Context.RESOURCE_PATCH_REQUEST}, + ) + assert len(patch_op.operations) == 1 + assert patch_op.operations[0].path == "unknownField" + + +def test_non_replace_operations_on_immutable_fields_allowed(): + """Test that non-replace operations on immutable fields are allowed.""" + # Test with non-immutable fields since groups.value is immutable + patch_op = PatchOp[User].model_validate( + { + "operations": [ + {"op": "add", "path": "nickName", "value": "test-nick"}, + {"op": "remove", "path": "nickName"}, + ] + }, + context={"scim": Context.RESOURCE_PATCH_REQUEST}, + ) + assert len(patch_op.operations) == 2 + + +def test_remove_operation_on_unknown_field_validates(): + """Test remove operation on unknown field validates successfully.""" + patch_op = PatchOp[User].model_validate( + { + "operations": [ + {"op": "remove", "path": "unknownField"}, + ] + }, + context={"scim": Context.RESOURCE_PATCH_REQUEST}, + ) + assert len(patch_op.operations) == 1 + assert patch_op.operations[0].path == "unknownField" + + +def test_remove_operation_on_non_required_field_allowed(): + """Test remove operation on non-required field is allowed.""" + # nickName is not required, so remove should be allowed + PatchOp[User].model_validate( + { + "operations": [ + {"op": "remove", "path": "nickName"}, + ] + }, + context={"scim": Context.RESOURCE_PATCH_REQUEST}, + ) + + +def test_patch_operations_with_none_path_skipped(): + """Test that patch operations with None path are skipped during validation.""" + # Create a patch operation with None path (bypassing normal validation) + patch_op = PatchOp[User]( + operations=[ + PatchOperation.model_construct( + op=PatchOperation.Op.add, path=None, value="test" + ) + ] + ) + + # The validate_operations method should skip operations with None path + # This should not raise an error + user = User(user_name="test") + result = patch_op.patch(user) + assert result is False # Should return False because operation was skipped + + +def test_patch_operation_with_schema_only_urn_path(): + """Test patch operation with URN path that contains only schema.""" + # Test edge case where extract_field_name returns None for schema-only URNs + user = User(user_name="test") + + # This URN resolves to just the schema without an attribute name + patch = PatchOp[User]( + operations=[ + PatchOperation( + op=PatchOperation.Op.add, + path="urn:ietf:params:scim:schemas:core:2.0:User", + value="test", + ) + ] + ) + + # This should trigger the path where extract_field_name returns None + with pytest.raises(ValueError, match="path"): + patch.patch(user) + + +def test_add_remove_operations_on_group_members_allowed(): + """Test that add/remove operations work on group collections.""" + # Test operations on group collection (not the immutable value field) + patch_op = PatchOp[User].model_validate( + { + "operations": [ + {"op": "add", "path": "emails", "value": {"value": "test@example.com"}}, + { + "op": "remove", + "path": "emails", + "value": {"value": "test@example.com"}, + }, + ] + }, + context={"scim": Context.RESOURCE_PATCH_REQUEST}, + ) + assert len(patch_op.operations) == 2 + + +def test_patch_error_handling_invalid_path(): + """Test error handling for invalid patch paths.""" + user = User(user_name="test") + + # Test with invalid path format + patch = PatchOp[User]( + operations=[ + PatchOperation(op=PatchOperation.Op.add, path="invalid..path", value="test") + ] + ) + + with pytest.raises(ValueError): + patch.patch(user) + + +def test_patch_error_handling_no_operations(): + """Test patch behavior with no operations (using model_construct to bypass validation).""" + user = User(user_name="test") + # Use model_construct to bypass Pydantic validation that requires at least 1 operation + patch = PatchOp[User].model_construct(operations=[]) + + result = patch.patch(user) + assert result is False + + +def test_patch_error_handling_type_mismatch(): + """Test error handling when patch value type doesn't match field type.""" + user = User(user_name="test") + + # Try to set active (boolean) to a string + patch = PatchOp[User]( + operations=[ + PatchOperation( + op=PatchOperation.Op.replace_, path="active", value="not_a_boolean" + ) + ] + ) + + with pytest.raises(ValidationError): + patch.patch(user) + + +T = TypeVar("T", bound=Resource) + + +def test_create_parent_object_return_none(): + """Test _create_parent_object returns None when type resolution fails.""" + # This test uses a TypeVar to trigger the case where get_field_root_type returns None + user = User() + + # Create a patch that will trigger _create_parent_object with complex path + patch = PatchOp[User]( + operations=[ + PatchOperation( + op=PatchOperation.Op.add, + path="complexField.subField", # Non-existent complex field + value="test", + ) + ] + ) + + # This should raise ValueError because field doesn't exist + with pytest.raises(ValueError, match="no match|did not yield"): + patch.patch(user) + + +def test_validate_required_field_removal(): + """Test that removing required fields raises validation error.""" + # Test removing schemas (required field) should raise validation error + with pytest.raises(ValidationError, match="required value was missing"): + PatchOp[User].model_validate( + {"operations": [{"op": "remove", "path": "schemas"}]}, + context={"scim": Context.RESOURCE_PATCH_REQUEST}, + ) + + +def test_patch_error_handling_invalid_operation(): + """Test error handling when patch operation has invalid operation type.""" + user = User(user_name="test") + patch = PatchOp[User]( + operations=[ + PatchOperation(op=PatchOperation.Op.add, path="nickName", value="test") + ] + ) + + # Force invalid operation type to test error handling + object.__setattr__(patch.operations[0], "op", "invalid_operation") + + with pytest.raises(ValueError, match="invalid value|required value was missing"): + patch.patch(user) + + +def test_remove_value_at_path_invalid_field(): + """Test removing value at path with invalid parent field name.""" + user = User(name={"familyName": "Test"}) + + # Create patch that attempts to remove from invalid parent field + patch = PatchOp[User]( + operations=[ + PatchOperation(op=PatchOperation.Op.remove, path="invalidParent.subField") + ] + ) + + # This should raise ValueError for invalid field name + with pytest.raises(ValueError, match="no match|did not yield"): + patch.patch(user) + + +def test_remove_specific_value_invalid_field(): + """Test removing specific value from invalid field name.""" + user = User() + + # Create patch that attempts to remove specific value from invalid field + patch = PatchOp[User]( + operations=[ + PatchOperation( + op=PatchOperation.Op.remove, + path="invalidField", + value={"some": "value"}, + ) + ] + ) + + # This should raise ValueError for invalid field name + with pytest.raises(ValueError, match="no match|did not yield"): + patch.patch(user) diff --git a/tests/test_path_validation.py b/tests/test_path_validation.py index c40a70c..a0b2227 100644 --- a/tests/test_path_validation.py +++ b/tests/test_path_validation.py @@ -108,8 +108,5 @@ def test_path_extraction(): == "userName" ) - # Test complex path with filter (should return None) - assert extract_field_name('emails[type eq "work"]') is None - # Test invalid URN path assert extract_field_name("urn:invalid") is None diff --git a/tests/test_utils.py b/tests/test_utils.py index 117ba68..2299839 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,3 +1,5 @@ +from scim2_models.rfc7643.enterprise_user import EnterpriseUser +from scim2_models.rfc7643.user import User from scim2_models.utils import to_camel @@ -13,3 +15,15 @@ def test_to_camel(): assert to_camel("Foo_Bar") == "fooBar" assert to_camel("$foo$") == "$foo$" + + +def test_get_extension_for_schema(): + """Test get_extension_model method on Resource.""" + user = User[EnterpriseUser]() + extension_class = user.get_extension_model( + "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User" + ) + assert extension_class == EnterpriseUser + + extension_class = user.get_extension_model("urn:unknown:schema") + assert extension_class is None