"""Test AttachAndDetachProcessor.""" import logging import textwrap from pathlib import Path from unittest.mock import MagicMock, call, patch from uuid import UUID import httpx import pytest import tenacity from pydantic import ValidationError from backfill.connectors.ows_pdp.models.attach_detach_roles_request import ( AttachDetachRolesRequest, ) from backfill.connectors.ows_pdp.models.role import Role from backfill.connectors.ows_pdp.models.tenant_type import TenantType from backfill.connectors.ows_pdp.ows_pdp import OwsPdpClient from backfill.job_processors.attach_and_detach_processor import AttachAndDetachProcessor @pytest.fixture() def identity_csv_filename( tmp_path: Path, ) -> str: """CSV file.""" d = tmp_path / "attach_and_detach_job" d.mkdir() p = d / "test.csv" input_str = textwrap.dedent( """\ identity_uuid,tenant_uuid,tenant_type,role,operation a499edee-d52c-4dc1-8e52-ec51745d09d9,e71530a0-1198-11f0-aefe-3e17271fba6f,account,settings_admin,attach a499edee-d52c-4dc1-8e52-ec51745d09d9,e71530a0-1198-11f0-aefe-3e17271fba6f,account,fansifter_can_view_fan_data,attach ac7a78ef-e5bc-4a5e-90d4-b5fc472c205d,e71530a0-1198-11f0-aefe-3e17271fba6f,account,settings_admin,attach ac7a78ef-e5bc-4a5e-90d4-b5fc472c205d,e71530a0-1198-11f0-aefe-3e17271fba6f,account,fansifter_can_view_fan_data,attach bfdc933c-c32a-4658-ac27-964cbfc91f46,e71530a0-1198-11f0-aefe-3e17271fba6f,account,settings_admin,attach bfdc933c-c32a-4658-ac27-964cbfc91f46,e71530a0-1198-11f0-aefe-3e17271fba6f,account,fansifter_can_view_fan_data,attach bfdc933c-c32a-4658-ac27-964cbfc91f46,e71530a0-1198-11f0-aefe-3e17271fba6f,account,fansifter_can_view_fan_data,detach bfdc933c-c32a-4658-ac27-964cbfc91f46,e71530a0-1198-11f0-aefe-3e17271fba6f,account,songwhip_read,detach bfdc933c-c32a-4658-ac27-964cbfc91f46,cc88020e-1afa-437c-82a2-27c90e881408,subaccount,songwhip_read,detach a499edee-d52c-4dc1-8e52-ec51745d09d9,ef3d6c1e-9e67-41e7-b818-6f8406a069a2,company_brand,settings_admin,attach """ ) p.write_text(input_str) return str(p) @pytest.fixture() def happy_path_identities() -> dict[str, dict[str, AttachDetachRolesRequest]]: return { "a499edee-d52c-4dc1-8e52-ec51745d09d9": { "e71530a0-1198-11f0-aefe-3e17271fba6f": AttachDetachRolesRequest( tenant_type=TenantType.ACCOUNT, tenant_uuid="e71530a0-1198-11f0-aefe-3e17271fba6f", roles_to_attach=[ Role(role="settings_admin"), Role(role="fansifter_can_view_fan_data"), ], roles_to_detach=[], ), "ef3d6c1e-9e67-41e7-b818-6f8406a069a2": AttachDetachRolesRequest( tenant_type=TenantType.COMPANY_BRAND, tenant_uuid="ef3d6c1e-9e67-41e7-b818-6f8406a069a2", roles_to_attach=[ Role(role="settings_admin"), ], roles_to_detach=[], ), }, "ac7a78ef-e5bc-4a5e-90d4-b5fc472c205d": { "e71530a0-1198-11f0-aefe-3e17271fba6f": AttachDetachRolesRequest( tenant_type=TenantType.ACCOUNT, tenant_uuid="e71530a0-1198-11f0-aefe-3e17271fba6f", roles_to_attach=[ Role(role="settings_admin"), Role(role="fansifter_can_view_fan_data"), ], roles_to_detach=[], ), }, "bfdc933c-c32a-4658-ac27-964cbfc91f46": { "cc88020e-1afa-437c-82a2-27c90e881408": AttachDetachRolesRequest( tenant_type=TenantType.SUBACCOUNT, tenant_uuid="cc88020e-1afa-437c-82a2-27c90e881408", roles_to_attach=[], roles_to_detach=[Role(role="songwhip_read")], ), "e71530a0-1198-11f0-aefe-3e17271fba6f": AttachDetachRolesRequest( tenant_type=TenantType.ACCOUNT, tenant_uuid="e71530a0-1198-11f0-aefe-3e17271fba6f", roles_to_attach=[ Role(role="settings_admin"), Role(role="fansifter_can_view_fan_data"), ], roles_to_detach=[ Role(role="fansifter_can_view_fan_data"), Role(role="songwhip_read"), ], ), }, } @pytest.fixture() def identity_csv_bad_row_filename( tmp_path: Path, ) -> str: """CSV file with a bad row.""" d = tmp_path / "bad_row" d.mkdir() p = d / "badrow.csv" input_str = textwrap.dedent( """\ identity_uuid,tenant_uuid,tenant_type,role,operation a499edee-d52c-4dc1-8e52-ec51745d09d9,e71530a0-1198-11f0-aefe-3e17271fba6f,account,settings_admin,attach ac7a78ef-e5bc-4a5e-90d4-b5fc472c205d,not-a-uuid,account,settings_admin,attach ac7a78ef-e5bc-4a5e-90d4-b5fc472c205d,e71530a0-1198-11f0-aefe-3e17271fba6f,account,fansifter_can_view_fan_data,attach """ ) p.write_text(input_str) return str(p) @pytest.fixture() def mock_ows_pdp_client() -> MagicMock: return MagicMock(spec=OwsPdpClient) @pytest.fixture() def processor() -> AttachAndDetachProcessor: """Return AttachAndDetachProcessor.""" return AttachAndDetachProcessor() @patch.object(AttachAndDetachProcessor, "_apply_changes") @patch.object(AttachAndDetachProcessor, "_load_csv") def test_process( mock__load_csv: MagicMock, mock__apply_changes: MagicMock, identity_csv_filename: str, mock_ows_pdp_client: MagicMock, happy_path_identities: dict[str, dict[str, AttachDetachRolesRequest]], processor: AttachAndDetachProcessor, backfill_uuid: str, ) -> None: """Test process, happy path.""" mock__load_csv.return_value = happy_path_identities processor.process( identity_csv_filename, mock_ows_pdp_client, backfill_uuid=backfill_uuid ) mock__load_csv.assert_called_once_with( identity_csv_filename, backfill_uuid=backfill_uuid ) mock__apply_changes.assert_called_once_with( happy_path_identities, identity_csv_filename, mock_ows_pdp_client, backfill_uuid=backfill_uuid, ) @patch.object(AttachAndDetachProcessor, "_apply_changes") @patch.object(AttachAndDetachProcessor, "_load_csv") def test_process__exception_raised( mock__load_csv: MagicMock, mock__apply_changes: MagicMock, identity_csv_filename: str, mock_ows_pdp_client: MagicMock, processor: AttachAndDetachProcessor, backfill_uuid: str, ) -> None: """Test process, _load_csv raises exception.""" mock__load_csv.side_effect = Exception("bad row") with pytest.raises(Exception) as exc: processor.process( identity_csv_filename, mock_ows_pdp_client, backfill_uuid=backfill_uuid ) assert exc.value.args == ("bad row",) mock__load_csv.assert_called_once_with( identity_csv_filename, backfill_uuid=backfill_uuid ) mock__apply_changes.assert_not_called() def test__load_csv( identity_csv_filename: str, happy_path_identities: dict[str, dict[str, AttachDetachRolesRequest]], processor: AttachAndDetachProcessor, caplog: pytest.LogCaptureFixture, backfill_uuid: str, ) -> None: """Test load csv, happy path.""" with caplog.at_level(logging.INFO): actual = processor._load_csv(identity_csv_filename, backfill_uuid=backfill_uuid) assert actual == happy_path_identities assert len(caplog.records) == 10, "each row in the file is a log message" for record in caplog.records: assert "Loaded identity from csv" == record.message def test__load_csv_missing_file( processor: AttachAndDetachProcessor, caplog: pytest.LogCaptureFixture, backfill_uuid: str, ) -> None: """Test _load_csv when csv file is missing.""" with pytest.raises(Exception) as exc: processor._load_csv("fictional.csv", backfill_uuid=backfill_uuid) assert exc.value.args == ("missing file %s", "fictional.csv") assert "Missing file" in caplog.text def test__load_csv_bad_row( identity_csv_bad_row_filename: str, processor: AttachAndDetachProcessor, caplog: pytest.LogCaptureFixture, backfill_uuid: str, ) -> None: """Test _load_csv when csv file contains a bad row.""" with pytest.raises(Exception) as exc: with caplog.at_level(logging.INFO): processor._load_csv( identity_csv_bad_row_filename, backfill_uuid=backfill_uuid ) assert isinstance(exc.value, ValidationError) assert len(caplog.records) == 1, "Only first line was loaded successfully" assert caplog.records[0].message == "Loaded identity from csv" assert hasattr(caplog.records[0], "resources") assert caplog.records[0].resources == { "csv_file": identity_csv_bad_row_filename, "identity_uuid": "a499edee-d52c-4dc1-8e52-ec51745d09d9", "tenant_uuid": "e71530a0-1198-11f0-aefe-3e17271fba6f", "role": "settings_admin", "operation": "OPERATION_ATTACH", } assert caplog.records[0].correlation_id == backfill_uuid # type: ignore[attr-defined] def test__apply_changes( identity_csv_filename: str, mock_ows_pdp_client: MagicMock, happy_path_identities: dict[str, dict[str, AttachDetachRolesRequest]], processor: AttachAndDetachProcessor, caplog: pytest.LogCaptureFixture, backfill_uuid: str, ) -> None: """Test _apply_changes, happy path.""" with caplog.at_level(logging.DEBUG): processor._apply_changes( happy_path_identities, identity_csv_filename, mock_ows_pdp_client, backfill_uuid=backfill_uuid, ) mock_ows_pdp_client.attach_detach_roles_by_identity_tenant.assert_called() mock_ows_pdp_client.attach_detach_roles_by_identity_tenant.assert_has_calls( [ call( UUID("a499edee-d52c-4dc1-8e52-ec51745d09d9"), happy_path_identities["a499edee-d52c-4dc1-8e52-ec51745d09d9"][ "e71530a0-1198-11f0-aefe-3e17271fba6f" ], ), call( UUID("a499edee-d52c-4dc1-8e52-ec51745d09d9"), happy_path_identities["a499edee-d52c-4dc1-8e52-ec51745d09d9"][ "ef3d6c1e-9e67-41e7-b818-6f8406a069a2" ], ), call( UUID("ac7a78ef-e5bc-4a5e-90d4-b5fc472c205d"), happy_path_identities["ac7a78ef-e5bc-4a5e-90d4-b5fc472c205d"][ "e71530a0-1198-11f0-aefe-3e17271fba6f" ], ), call( UUID("bfdc933c-c32a-4658-ac27-964cbfc91f46"), happy_path_identities["bfdc933c-c32a-4658-ac27-964cbfc91f46"][ "cc88020e-1afa-437c-82a2-27c90e881408" ], ), call( UUID("bfdc933c-c32a-4658-ac27-964cbfc91f46"), happy_path_identities["bfdc933c-c32a-4658-ac27-964cbfc91f46"][ "e71530a0-1198-11f0-aefe-3e17271fba6f" ], ), ], any_order=True, ) info_level_logs = [ record for record in caplog.records if record.levelname == "INFO" and record.message == "Changes applied for identity" ] assert len(info_level_logs) == 3, "Info-level logging for identity-level" debug_level_logs = [ record for record in caplog.records if record.levelname == "DEBUG" and record.message == "Changes applied for identity+tenant" ] assert len(debug_level_logs) == 5, ( "Debug-level logging identity+tenant changes that succeeded" ) def test__apply_changes_partial_errors( identity_csv_filename: str, mock_ows_pdp_client: MagicMock, happy_path_identities: dict[str, dict[str, AttachDetachRolesRequest]], processor: AttachAndDetachProcessor, caplog: pytest.LogCaptureFixture, backfill_uuid: str, ) -> None: """Test _apply_changes, errors from ows-pdp.""" mock_ows_pdp_client.attach_detach_roles_by_identity_tenant.side_effect = [ (MagicMock()), (httpx.DecodingError("cant parse this")), (tenacity.RetryError(MagicMock())), (httpx.RequestError("failed")), (MagicMock()), ] with caplog.at_level(logging.DEBUG): processor._apply_changes( happy_path_identities, identity_csv_filename, mock_ows_pdp_client, backfill_uuid=backfill_uuid, ) mock_ows_pdp_client.attach_detach_roles_by_identity_tenant.assert_called() mock_ows_pdp_client.attach_detach_roles_by_identity_tenant.assert_has_calls( [ call( UUID("a499edee-d52c-4dc1-8e52-ec51745d09d9"), happy_path_identities["a499edee-d52c-4dc1-8e52-ec51745d09d9"][ "e71530a0-1198-11f0-aefe-3e17271fba6f" ], ), call( UUID("a499edee-d52c-4dc1-8e52-ec51745d09d9"), happy_path_identities["a499edee-d52c-4dc1-8e52-ec51745d09d9"][ "ef3d6c1e-9e67-41e7-b818-6f8406a069a2" ], ), call( UUID("ac7a78ef-e5bc-4a5e-90d4-b5fc472c205d"), happy_path_identities["ac7a78ef-e5bc-4a5e-90d4-b5fc472c205d"][ "e71530a0-1198-11f0-aefe-3e17271fba6f" ], ), call( UUID("bfdc933c-c32a-4658-ac27-964cbfc91f46"), happy_path_identities["bfdc933c-c32a-4658-ac27-964cbfc91f46"][ "cc88020e-1afa-437c-82a2-27c90e881408" ], ), call( UUID("bfdc933c-c32a-4658-ac27-964cbfc91f46"), happy_path_identities["bfdc933c-c32a-4658-ac27-964cbfc91f46"][ "e71530a0-1198-11f0-aefe-3e17271fba6f" ], ), ], any_order=True, ) error_level_logs = [ record for record in caplog.records if record.levelname == "ERROR" and "Changes not applied for identity+tenant" in record.message ] assert len(error_level_logs) == 3, ( "Error-level logging for identity+tenant changes that failed." ) info_level_logs = [ record for record in caplog.records if record.levelname == "INFO" and record.message == "Changes applied for identity" ] assert len(info_level_logs) == 3, "Info-level logging for identity-level" debug_level_logs = [ record for record in caplog.records if record.levelname == "DEBUG" and record.message == "Changes applied for identity+tenant" ] assert len(debug_level_logs) == 2, ( "Debug-level logging identity+tenant changes that succeeded" ) def test__apply_changes_no_changes( processor: AttachAndDetachProcessor, mock_ows_pdp_client: MagicMock, caplog: pytest.LogCaptureFixture, backfill_uuid: str, ) -> None: """Test _apply_changes returns immediately when no identities.""" with caplog.at_level(logging.INFO): processor._apply_changes( {}, "doesn't matter", mock_ows_pdp_client, backfill_uuid=backfill_uuid ) mock_ows_pdp_client.attach_detach_roles_by_identity_tenant.assert_not_called() assert len(caplog.records) == 1 assert "No changes to apply" in caplog.text