"""Tests for ResourcePolicyMetadata.""" import logging from pathlib import Path from typing import Any import pytest from pydantic import ValidationError from pdp.connectors.cerbos_policy_parser import ( CerbosPolicyParser, PolicyMetadataDatabase, ResourcePolicyMetadata, ) @pytest.mark.parametrize( "resource_type, requires_account_feature_controls", [ pytest.param("digital_audio", True, id="requires_account_feature_controls"), pytest.param("account", False, id="does_not_require_account_feature_controls"), ], ) def test_resource_policy_metadata_fields( resource_type: str, requires_account_feature_controls: bool ) -> None: metadata = ResourcePolicyMetadata( resource_type=resource_type, requires_account_feature_controls=requires_account_feature_controls, ) assert metadata.resource_type == resource_type assert ( metadata.requires_account_feature_controls == requires_account_feature_controls ) @pytest.mark.parametrize( "kwargs", [ pytest.param( {"requires_account_feature_controls": True}, id="missing_resource_type", ), pytest.param( {"resource_type": "account"}, id="missing_requires_account_feature_controls", ), ], ) def test_resource_policy_metadata_requires_all_fields(kwargs: dict[str, Any]) -> None: with pytest.raises(ValidationError): ResourcePolicyMetadata(**kwargs) # --- PolicyMetadataDatabase --- def test_policy_metadata_database_add_and_get() -> None: db = PolicyMetadataDatabase() metadata = ResourcePolicyMetadata( resource_type="digital_audio", requires_account_feature_controls=True, ) db.add_policy(metadata) assert db.get_policy("digital_audio") == metadata def test_policy_metadata_database_get_missing_returns_none() -> None: db = PolicyMetadataDatabase() assert db.get_policy("nonexistent") is None def test_policy_metadata_database_add_policy_duplicate_resource_type() -> None: """Adding a policy with the same resource type should overwrite the existing one.""" db = PolicyMetadataDatabase() db.add_policy( ResourcePolicyMetadata( resource_type="digital_audio", requires_account_feature_controls=True, ) ) db.add_policy( ResourcePolicyMetadata( resource_type="digital_audio", requires_account_feature_controls=False, ) ) assert db.requires_account_feature_controls("digital_audio") is False @pytest.mark.parametrize( "resource_type, expected", [ pytest.param("digital_audio", True, id="requires_feature_controls"), pytest.param("account", False, id="does_not_require_feature_controls"), pytest.param("nonexistent", False, id="missing_resource_type_returns_false"), ], ) def test_policy_metadata_database_requires_account_feature_controls( resource_type: str, expected: bool ) -> None: """Test PolicyMetadataDatabase.requires_account_feature_controls.""" db = PolicyMetadataDatabase() db.add_policy( ResourcePolicyMetadata( resource_type="digital_audio", requires_account_feature_controls=True, ) ) db.add_policy( ResourcePolicyMetadata( resource_type="account", requires_account_feature_controls=False, ) ) assert db.requires_account_feature_controls(resource_type) == expected def test_policy_metadata_database_json_round_trip() -> None: """Test serializing to JSON and deserializing back produces an equivalent db.""" db = PolicyMetadataDatabase() db.add_policy( ResourcePolicyMetadata( resource_type="digital_audio", requires_account_feature_controls=True, ) ) db.add_policy( ResourcePolicyMetadata( resource_type="account", requires_account_feature_controls=False, ) ) serialized = db.to_json() restored = PolicyMetadataDatabase.from_json(serialized) assert restored.requires_account_feature_controls("digital_audio") is True assert restored.requires_account_feature_controls("account") is False assert restored.get_policy("digital_audio") == db.get_policy("digital_audio") assert restored.get_policy("account") == db.get_policy("account") # --- CerbosPolicyParser --- @pytest.fixture def policy_dir(tmp_path: Path) -> Path: return tmp_path def _write_policy(policy_dir: Path, filename: str, content: str) -> Path: policy_file = policy_dir / filename policy_file.write_text(content) return policy_file _RESOURCE_POLICY_WITH_FEATURE_CONTROLS = """\ apiVersion: api.cerbos.dev/v1 resourcePolicy: version: default resource: digital_audio variables: import: - account_feature_controls rules: [] """ _RESOURCE_POLICY_WITHOUT_FEATURE_CONTROLS = """\ apiVersion: api.cerbos.dev/v1 resourcePolicy: version: default resource: account rules: [] """ _RESOURCE_POLICY_WITH_SOME_OTHER_VARIABLES = """\ apiVersion: api.cerbos.dev/v1 resourcePolicy: version: default resource: campaign variables: import: - some_other_variables rules: [] """ _RESOURCE_POLICY_WITH_MULTIPLE_VARIABLES = """\ apiVersion: api.cerbos.dev/v1 resourcePolicy: version: default resource: campaign variables: import: - some_other_variables - account_feature_controls rules: [] """ _RESOURCE_POLICY_IS_NOT_A_DICT = """\ apiVersion: api.cerbos.dev/v1 resourcePolicy: "NOT A DICT" """ _MISSING_RESOURCE_RESOURCE_POLICY_POLICY = """\ apiVersion: api.cerbos.dev/v1 resourcePolicy: version: default rules: [] """ _DERIVED_ROLES_POLICY = """\ apiVersion: api.cerbos.dev/v1 derivedRoles: name: content_roles definitions: [] """ _INVALID_YAML = "key: [unclosed bracket" def test_parse_resource_policy_with_feature_controls(policy_dir: Path) -> None: policy_file = _write_policy( policy_dir, "digital_audio.yml", _RESOURCE_POLICY_WITH_FEATURE_CONTROLS, ) parser = CerbosPolicyParser(policies_dir=str(policy_dir)) metadata = parser.parse_resource_policy(policy_file) assert metadata is not None assert metadata.resource_type == "digital_audio" assert metadata.requires_account_feature_controls is True def test_parse_resource_policy_without_feature_controls(policy_dir: Path) -> None: policy_file = _write_policy( policy_dir, "account.yml", _RESOURCE_POLICY_WITHOUT_FEATURE_CONTROLS, ) parser = CerbosPolicyParser(policies_dir=str(policy_dir)) metadata = parser.parse_resource_policy(policy_file) assert metadata is not None assert metadata.resource_type == "account" assert metadata.requires_account_feature_controls is False def test_parse_resource_policy_with_other_variables(policy_dir: Path) -> None: policy_file = _write_policy( policy_dir, "campaign.yml", _RESOURCE_POLICY_WITH_SOME_OTHER_VARIABLES, ) parser = CerbosPolicyParser(policies_dir=str(policy_dir)) metadata = parser.parse_resource_policy(policy_file) assert metadata is not None assert metadata.resource_type == "campaign" assert metadata.requires_account_feature_controls is False def test_parse_resource_policy_with_multiple_variables(policy_dir: Path) -> None: policy_file = _write_policy( policy_dir, "campaign.yml", _RESOURCE_POLICY_WITH_MULTIPLE_VARIABLES, ) parser = CerbosPolicyParser(policies_dir=str(policy_dir)) metadata = parser.parse_resource_policy(policy_file) assert metadata is not None assert metadata.resource_type == "campaign" assert metadata.requires_account_feature_controls is True @pytest.mark.parametrize( "filename, content", [ pytest.param("not_a_dict.yml", _RESOURCE_POLICY_IS_NOT_A_DICT, id="not_a_dict"), pytest.param( "missing_resource.yml", _MISSING_RESOURCE_RESOURCE_POLICY_POLICY, id="missing_resource_field", ), pytest.param( "derived_roles.yml", _DERIVED_ROLES_POLICY, id="non_resource_policy", ), pytest.param("empty.yml", "", id="empty_file"), ], ) def test_parse_resource_policy_returns_none( policy_dir: Path, filename: str, content: str ) -> None: policy_file = _write_policy(policy_dir, filename, content) parser = CerbosPolicyParser(policies_dir=str(policy_dir)) assert parser.parse_resource_policy(policy_file) is None def test_parse_resource_policy_bad_yaml_logs_warning( policy_dir: Path, caplog: pytest.LogCaptureFixture ) -> None: policy_file = _write_policy(policy_dir, "bad.yml", _INVALID_YAML) parser = CerbosPolicyParser(policies_dir=str(policy_dir)) with caplog.at_level(logging.WARNING, logger="pdp.connectors.cerbos_policy_parser"): result = parser.parse_resource_policy(policy_file) assert result is None assert any("bad.yml" in record.message for record in caplog.records) @pytest.mark.parametrize("extension", ["yaml", "yml"]) def test_build_database_happy_path(policy_dir: Path, extension: str) -> None: # Does require feature controls _write_policy( policy_dir, f"digital_audio.{extension}", _RESOURCE_POLICY_WITH_FEATURE_CONTROLS, ) # Does not require feature controls _write_policy( policy_dir, f"account.{extension}", _RESOURCE_POLICY_WITHOUT_FEATURE_CONTROLS, ) # Skipped files do not cause errors _write_policy( policy_dir, f"derived_roles.{extension}", _DERIVED_ROLES_POLICY, ) _write_policy( policy_dir, f"missing_resource.{extension}", _MISSING_RESOURCE_RESOURCE_POLICY_POLICY, ) _write_policy(policy_dir, f"empty.{extension}", "") # Glob pattern should skip non-yaml files, even if they have valid content _write_policy(policy_dir, "not_a_yaml.text", _RESOURCE_POLICY_WITH_FEATURE_CONTROLS) parser = CerbosPolicyParser(policies_dir=str(policy_dir)) db = parser.build_database() assert db.requires_account_feature_controls("digital_audio") is True assert db.requires_account_feature_controls("account") is False assert db.get_policy("content_roles") is None @pytest.mark.parametrize("extension", ["yaml", "yml"]) @pytest.mark.parametrize( "filename1, filename2, final_expected_requires_feature_controls", [ pytest.param( "campaign1", "campaign2", False, ), pytest.param( "campaign2", "campaign1", True, ), ], ) def test_build_database_multiple_policies_same_resource_type( policy_dir: Path, extension: str, filename1: str, filename2: str, final_expected_requires_feature_controls: bool, ) -> None: parser = CerbosPolicyParser(policies_dir=str(policy_dir)) _write_policy( policy_dir, f"{filename1}.{extension}", _RESOURCE_POLICY_WITH_MULTIPLE_VARIABLES, # requires feature controls ) db = parser.build_database() assert db.get_policy("campaign") is not None assert db.requires_account_feature_controls("campaign") is True _write_policy( policy_dir, f"{filename2}.{extension}", _RESOURCE_POLICY_WITH_SOME_OTHER_VARIABLES, # does not require feature controls ) db = parser.build_database() assert db.get_policy("campaign") is not None assert ( db.requires_account_feature_controls("campaign") is final_expected_requires_feature_controls ) def test_build_database_with_nonexistent_directory( tmp_path: Path, caplog: pytest.LogCaptureFixture ) -> None: nonexistent = tmp_path / "does_not_exist" parser = CerbosPolicyParser(policies_dir=str(nonexistent)) with caplog.at_level(logging.ERROR, logger="pdp.connectors.cerbos_policy_parser"): db = parser.build_database() assert db.to_json() == "{}" assert any("does_not_exist" in record.message for record in caplog.records) def test_build_database_with_empty_directory( policy_dir: Path, caplog: pytest.LogCaptureFixture ) -> None: parser = CerbosPolicyParser(policies_dir=str(policy_dir)) with caplog.at_level(logging.ERROR, logger="pdp.connectors.cerbos_policy_parser"): db = parser.build_database() assert db.to_json() == "{}" assert any("No policy files found" in record.message for record in caplog.records) def test_build_database_with_nested_resource_policies(policy_dir: Path) -> None: subdir = policy_dir / "nested" subdir.mkdir() _write_policy( policy_dir, "digital_audio.yml", _RESOURCE_POLICY_WITH_FEATURE_CONTROLS ) _write_policy(subdir, "account.yml", _RESOURCE_POLICY_WITHOUT_FEATURE_CONTROLS) parser = CerbosPolicyParser(policies_dir=str(policy_dir)) db = parser.build_database() assert db.requires_account_feature_controls("digital_audio") is True assert db.requires_account_feature_controls("account") is False def test_build_database_skips_bad_yaml_without_raising( policy_dir: Path, caplog: pytest.LogCaptureFixture ) -> None: _write_policy(policy_dir, "bad.yml", _INVALID_YAML) _write_policy(policy_dir, "account.yml", _RESOURCE_POLICY_WITHOUT_FEATURE_CONTROLS) parser = CerbosPolicyParser(policies_dir=str(policy_dir)) with caplog.at_level(logging.WARNING, logger="pdp.connectors.cerbos_policy_parser"): db = parser.build_database() assert db.get_policy("account") is not None assert any("bad.yml" in record.message for record in caplog.records)