"""Tests for dynamo cli commands.""" import textwrap from pathlib import Path from typing import Any from unittest.mock import MagicMock, call, patch from uuid import UUID import pytest import typer from pdp import config from pdp.cli.commands import dynamo as ddb_cmd from pdp.cli.commands.dynamo import DEV_M2M_IDENTITY, QA_REFRESH_IDENTITY_UUID from pdp.fastapi.schemas.identity import Role @pytest.fixture() def identity_1_uuid() -> str: """Fixture for an identity's uuid.""" return "381b5726-7033-43da-aaa1-fb959d72524a" @pytest.fixture() def identity_2_uuid() -> str: """Fixture for another identity's uuid.""" return "f124a292-5d07-4b95-8d13-8b1b25f70477" @pytest.fixture() def identity_3_bad_uuid() -> str: """Fixture for an invalid identity uuid.""" return "not-a-true-uuid" @pytest.fixture() def identity_4_uuid() -> str: """Fixture for a 4th identity uuid that has detach operations.""" return "9b074270-5bfa-4a30-a6b4-2a6ebfa97951" @pytest.fixture() def identity_csv_filename( tmp_path: Path, identity_1_uuid: str, identity_2_uuid: str, identity_4_uuid: str, tenant_1_uuid_as_string: str, ) -> str: """CSV file for testing load_csv commands.""" d = tmp_path / "ddb_command_test" d.mkdir() p = d / "test.csv" input_str = textwrap.dedent( f"""\ identity_uuid,tenant_uuid,tenant_type,role,operation {identity_1_uuid},{tenant_1_uuid_as_string},account,settings_admin,attach {identity_1_uuid},{tenant_1_uuid_as_string},account,audience_development_admin,attach {identity_2_uuid},{tenant_1_uuid_as_string},company_brand,settings_admin,attach {identity_2_uuid},{tenant_1_uuid_as_string},company_brand,audience_development_admin,attach {identity_4_uuid},{tenant_1_uuid_as_string},company_brand,settings_admin,attach {identity_4_uuid},{tenant_1_uuid_as_string},company_brand,audience_development_admin,attach {identity_4_uuid},{tenant_1_uuid_as_string},company_brand,audience_development_admin,detach {identity_4_uuid},{tenant_1_uuid_as_string},company_brand,songwhip_read,detach {identity_4_uuid},{tenant_1_uuid_as_string},company_brand,no_operation {identity_4_uuid},{tenant_1_uuid_as_string},company_brand,empty_operation, {identity_4_uuid},{tenant_1_uuid_as_string},company_brand,unknown_operation,unknown_operation """ ) p.write_text(input_str) return str(p) @pytest.fixture() def identity_csv_filename_bad_identity_uuid( tmp_path: Path, tenant_1_uuid_as_string: str, identity_3_bad_uuid: str ) -> str: """CSV file for testing load_csv commands.""" d = tmp_path / "ddb_command_test" d.mkdir() p = d / "test.csv" input_str = textwrap.dedent( f"""\ identity_uuid,tenant_uuid,tenant_type,role,operation {identity_3_bad_uuid},{tenant_1_uuid_as_string},account,settings_admin,attach {identity_3_bad_uuid},{tenant_1_uuid_as_string},account,audience_development_admin,attach """ ) p.write_text(input_str) return str(p) @pytest.fixture() def identity_csv_with_spaces_filename( tmp_path: Path, identity_1_uuid: str, identity_2_uuid: str, identity_4_uuid: str, tenant_1_uuid_as_string: str, ) -> str: """CSV file for testing load_csv commands.""" d = tmp_path / "ddb_command_test" d.mkdir() p = d / "test.csv" input_str = textwrap.dedent( f"""\ identity_uuid ,tenant_uuid ,tenant_type , role,operation {identity_1_uuid}, {tenant_1_uuid_as_string}, account, settings_admin ,attach {identity_1_uuid}, {tenant_1_uuid_as_string}, account, audience_development_admin,attach {identity_2_uuid}, {tenant_1_uuid_as_string}, company_brand, settings_admin,attach {identity_2_uuid},{tenant_1_uuid_as_string},company_brand,audience_development_admin,attach {identity_4_uuid},{tenant_1_uuid_as_string},company_brand,settings_admin,attach {identity_4_uuid},{tenant_1_uuid_as_string}, company_brand,audience_development_admin , attach {identity_4_uuid},{tenant_1_uuid_as_string},company_brand,audience_development_admin {identity_4_uuid},{tenant_1_uuid_as_string},company_brand, songwhip_read,detach {identity_4_uuid},{tenant_1_uuid_as_string},company_brand,no_operation {identity_4_uuid},{tenant_1_uuid_as_string}, company_brand,empty_operation, {identity_4_uuid},{tenant_1_uuid_as_string},company_brand,unknown_operation, unknown_operation """ # noqa: E501 ) p.write_text(input_str) return str(p) @patch("pdp.cli.commands.dynamo.DynamoDbConnector") @patch("pdp.cli.commands.dynamo.set_env") def test_create_command( mock_set_env: MagicMock, mock_dynamodb_connector: MagicMock ) -> None: """Verifies the create command deletes and re-creates the DDB table.""" mock_set_env.return_value = None mock_connection = MagicMock() mock_dynamodb_connector.return_value = mock_connection mock_connection.client.list_tables.return_value = { "TableNames": [config.DYNAMODB_TABLE_IDENTITY] } ddb_cmd.create() mock_connection.client.delete_table.assert_called_with( TableName=config.DYNAMODB_TABLE_IDENTITY ) mock_connection.client.create_table.assert_called_with( TableName=config.DYNAMODB_TABLE_IDENTITY, AttributeDefinitions=[ {"AttributeName": config.IDENTITY_HASH_KEY, "AttributeType": "S"}, {"AttributeName": config.IDENTITY_RANGE_KEY, "AttributeType": "S"}, ], KeySchema=[ {"AttributeName": config.IDENTITY_HASH_KEY, "KeyType": "HASH"}, {"AttributeName": config.IDENTITY_RANGE_KEY, "KeyType": "RANGE"}, ], ProvisionedThroughput={"ReadCapacityUnits": 10, "WriteCapacityUnits": 5}, ) @patch("pdp.cli.commands.dynamo.DynamoDbConnector") @patch("pdp.cli.commands.dynamo.attach_and_detach_roles") @patch("pdp.cli.commands.dynamo.set_env") def test_seed_dynamodb( mock_set_env: MagicMock, mock_attach_and_detach_roles: MagicMock, mock_dynamodb_connector: MagicMock, ) -> None: """Verifies seed command reads seed.csv and calls logic.attach_and_detach_roles.""" mock_set_env.return_value = None mock_connection = MagicMock() mock_dynamodb_connector.return_value = mock_connection ddb_cmd.seed_dynamodb() calls = [ call( "b0d57450-551a-43a8-9a5d-9fa61cf3d1dd", UUID("0268c332-8e7c-4c4e-9bb3-04532eb51c82"), "account", roles_to_attach=[ Role(role="settings_admin"), Role(role="audience_development_analyst"), ], roles_to_detach=[], authenticated_identity_uuid=DEV_M2M_IDENTITY, identity_ddb_connector=mock_connection, ), call( "b0d57450-551a-43a8-9a5d-9fa61cf3d1dd", UUID("a2dbe1e9-e100-4ec1-88ae-75d4f8cec8f6"), "subaccount", roles_to_attach=[ Role(role="audience_development_client"), Role(role="settings_admin"), ], roles_to_detach=[], authenticated_identity_uuid=DEV_M2M_IDENTITY, identity_ddb_connector=mock_connection, ), ] mock_attach_and_detach_roles.assert_has_calls(calls) @patch("pdp.cli.commands.dynamo.bust_identity_caches") @patch("pdp.cli.commands.dynamo.RedisConnector") @patch("pdp.cli.commands.dynamo.DynamoDbConnector") @patch("pdp.cli.commands.dynamo.attach_and_detach_roles") @patch("pdp.cli.commands.dynamo.set_env") def test_load_csv( mock_set_env: MagicMock, mock_attach_and_detach_roles: MagicMock, mock_dynamodb_connector: MagicMock, mock_redis_connector: MagicMock, mock_bust_identity_caches: MagicMock, identity_csv_filename: str, identity_1_uuid: str, identity_2_uuid: str, identity_4_uuid: str, tenant_1_uuid: UUID, ) -> None: """Verifies command loads a CSV from an arbitrary file path.""" mock_set_env.return_value = None mock_connection = MagicMock() mock_dynamodb_connector.return_value = mock_connection ddb_cmd.load_csv(identity_csv_filename) assert_load_csv_calls( mock_attach_and_detach_roles, identity_1_uuid, identity_2_uuid, identity_4_uuid, tenant_1_uuid, mock_connection, ) mock_bust_identity_caches.assert_awaited_once_with( [UUID(identity_1_uuid), UUID(identity_2_uuid), UUID(identity_4_uuid)], redis_connector=mock_redis_connector(), ) @patch("pdp.cli.commands.dynamo.DynamoDbConnector") @patch("pdp.cli.commands.dynamo.attach_and_detach_roles") @patch("pdp.cli.commands.dynamo.set_env") def test_load_csv_bad_identity_uuid( mock_set_env: MagicMock, mock_attach_and_detach_roles: MagicMock, mock_dynamodb_connector: MagicMock, identity_csv_filename_bad_identity_uuid: str, ) -> None: """Verifies command loads a CSV from an arbitrary file path.""" mock_set_env.return_value = None mock_connection = MagicMock() mock_dynamodb_connector.return_value = mock_connection with pytest.raises(ValueError): ddb_cmd.load_csv(identity_csv_filename_bad_identity_uuid) assert mock_attach_and_detach_roles.call_count == 0 @patch("pdp.cli.commands.dynamo.DynamoDbConnector") @patch("pdp.cli.commands.dynamo.attach_and_detach_roles") @patch("pdp.cli.commands.dynamo.set_env") def test_load_spaces_csv( mock_set_env: MagicMock, mock_attach_and_detach_roles: MagicMock, mock_dynamodb_connector: MagicMock, identity_csv_with_spaces_filename: str, identity_1_uuid: str, identity_2_uuid: str, identity_4_uuid: str, tenant_1_uuid: UUID, ) -> None: """Verifies command loads a CSV with spaces from an arbitrary file path.""" mock_set_env.return_value = None mock_connection = MagicMock() mock_dynamodb_connector.return_value = mock_connection ddb_cmd.load_csv(identity_csv_with_spaces_filename) assert_load_csv_calls( mock_attach_and_detach_roles, identity_1_uuid, identity_2_uuid, identity_4_uuid, tenant_1_uuid, mock_connection, ) def assert_load_csv_calls( mock_attach_and_detach_roles: MagicMock, identity_1_uuid: str, identity_2_uuid: str, identity_4_uuid: str, tenant_1_uuid: UUID, mock_connection: MagicMock, ) -> None: """Assert valid updates for CSV entries.""" calls = [ call( identity_1_uuid, tenant_1_uuid, "account", roles_to_attach=[ Role(role="settings_admin"), Role(role="audience_development_admin"), ], roles_to_detach=[], authenticated_identity_uuid=DEV_M2M_IDENTITY, identity_ddb_connector=mock_connection, ), call( identity_2_uuid, tenant_1_uuid, "company_brand", roles_to_attach=[ Role(role="settings_admin"), Role(role="audience_development_admin"), ], roles_to_detach=[], authenticated_identity_uuid=DEV_M2M_IDENTITY, identity_ddb_connector=mock_connection, ), call( identity_4_uuid, tenant_1_uuid, "company_brand", roles_to_attach=[ Role(role="settings_admin"), Role(role="audience_development_admin"), ], roles_to_detach=[ Role(role="audience_development_admin"), Role(role="songwhip_read"), Role(role="no_operation"), Role(role="empty_operation"), Role(role="unknown_operation"), ], authenticated_identity_uuid=DEV_M2M_IDENTITY, identity_ddb_connector=mock_connection, ), ] mock_attach_and_detach_roles.assert_has_calls(calls) @pytest.mark.parametrize( "environment, allowed_environment, pp_identity_table, expect_exception", [ (config.QA_ENVIRONMENT, config.DEV_ENVIRONMENT, "dev_pp_identity", True), (config.QA_ENVIRONMENT, config.DEV_ENVIRONMENT, "dev_pp_identity", True), (config.PROD_ENVIRONMENT, config.QA_ENVIRONMENT, "qa_pp_identity", True), (config.PROD_ENVIRONMENT, config.QA_ENVIRONMENT, "qa_pp_identity", True), ( config.PROD_ENVIRONMENT, config.PROD_ENVIRONMENT, "prod_pp_identity", True, ), # prod should always fail ( config.QA_ENVIRONMENT, config.QA_ENVIRONMENT, "prod_pp_identity", True, ), # wrong QA DDB table name (config.QA_ENVIRONMENT, config.QA_ENVIRONMENT, "qa_pp_identity", False), (config.QA_ENVIRONMENT, config.QA_ENVIRONMENT, "pp_identity_refresh", False), (config.DEV_ENVIRONMENT, config.DEV_ENVIRONMENT, "dev_pp_identity", False), ], ) def test_set_env( environment: str, allowed_environment: str, pp_identity_table: str, expect_exception: bool, monkeypatch: Any, ) -> None: """Verify set_env fails for QA and PROD environments.""" monkeypatch.setattr(config, "ENVIRONMENT", environment) monkeypatch.setattr(config, "DYNAMODB_TABLE_IDENTITY", pp_identity_table) if expect_exception: with pytest.raises(typer.Exit): ddb_cmd.set_env(allowed_environments=(allowed_environment,)) else: try: ddb_cmd.set_env(allowed_environments=(allowed_environment,)) except Exception as exc: assert False, f"set_env raised an error in DEV environment. {exc}" @pytest.mark.parametrize( "environment", [config.QA_ENVIRONMENT, config.PROD_ENVIRONMENT] ) def test_drop_pp_identity_table_fails(environment: str, monkeypatch: Any) -> None: """Verify drop table fails for QA and PROD environments.""" monkeypatch.setattr(config, "ENVIRONMENT", environment) with pytest.raises(typer.Exit): ddb_cmd.drop_pp_identity_table(MagicMock()) def test_drop_pp_identity_table_fails_ok(monkeypatch: Any) -> None: """Verify drop table does not fail in DEV environment.""" try: monkeypatch.setattr(config, "ENVIRONMENT", config.DEV_ENVIRONMENT) ddb_cmd.drop_pp_identity_table(MagicMock()) except typer.Exit as exc: assert False, f"set_env raised an error in DEV environment. {exc}" @pytest.mark.parametrize( "environment", [config.QA_ENVIRONMENT, config.PROD_ENVIRONMENT] ) def test_create_pp_identity_table_fails(environment: str, monkeypatch: Any) -> None: """Verify create table fails for QA and PROD environments.""" monkeypatch.setattr(config, "ENVIRONMENT", environment) with pytest.raises(typer.Exit): ddb_cmd.create_pp_identity_table(MagicMock()) def test_create_pp_identity_table_fails_ok(monkeypatch: Any) -> None: """Verify create table does not fail in DEV environment.""" try: monkeypatch.setattr(config, "ENVIRONMENT", config.DEV_ENVIRONMENT) ddb_cmd.create_pp_identity_table(MagicMock()) except typer.Exit as exc: assert False, f"set_env raised an error in DEV environment. {exc}" @pytest.mark.parametrize( "environment, authenticated_identity_uuid, expected_identity, expect_exception", [ (config.DEV_ENVIRONMENT, "IGNORE_ME", DEV_M2M_IDENTITY, False), (config.DEV_ENVIRONMENT, None, DEV_M2M_IDENTITY, False), ( config.QA_ENVIRONMENT, "JENKINS_IDENTITY_UUID", "JENKINS_IDENTITY_UUID", False, ), (config.QA_ENVIRONMENT, None, None, True), ], ) def test_get_m2m_identity( environment: str, authenticated_identity_uuid: str, expected_identity: str, expect_exception: bool, monkeypatch: Any, ) -> None: """Verify the cli loads the authenticated_identity_uuid or aborts.""" monkeypatch.setattr(config, "ENVIRONMENT", environment) if authenticated_identity_uuid: monkeypatch.setenv(QA_REFRESH_IDENTITY_UUID, authenticated_identity_uuid) if expect_exception: with pytest.raises(typer.Exit): _ = ddb_cmd.get_m2m_identity() else: try: identity = ddb_cmd.get_m2m_identity() assert identity == expected_identity except Exception as exc: assert False, f"get_m2m_identity raised in env: {environment}. Error: {exc}"