import uuid from unittest.mock import ANY import pytest import application from oto import response from oto.adaptors.flask import flaskify from owsrequest import context from owsrequest.context import RequestContext from pytest_mock import MockerFixture from python_pdp_sdk import ( AuthorizationBackend, UnauthenticatedException, UnauthorizedException, ) from product.auth import ( Tenant, assert_authorization, assert_authorization_for_tenant, get_tenant, is_authorized_for_tenant, only_for_identity, verify_profile_headers, ) from product.constants import error ben_identity = "10436b38-5e11-472d-b6a4-bf1ee2b1b438" jess_identity = "7f418dad-780b-4ab3-a4a2-2deba190f503" TENANT_UUID = uuid.UUID("7c8b382c-fc37-4179-9115-2165b1a93bed") @pytest.mark.parametrize( "allowed_identity, jwt_identity, expected_status_code", [ pytest.param( ben_identity, ben_identity, 200, id="allowed identity and jwt identity are one and the same, so authorized", ), pytest.param( ben_identity, jess_identity, 403, id="jwt identity is not the same as the allowed identity, so not authorized", ), pytest.param( [ben_identity], jess_identity, 403, id="jwt identity is not in the list of allowed identity, so not authorized", ), pytest.param( [ben_identity, jess_identity], jess_identity, 200, id="jwt identity is in the list of allowed identity, so authorized", ), ], ) def test_only_for_identity_success( allowed_identity: str | list[str], jwt_identity: str, expected_status_code: int, mocker: MockerFixture, ) -> None: """ Tests that if the request context has the correct identity, we return the decorated function result. """ mock_context = mocker.MagicMock(spec=RequestContext) mock_context.jwt_identity_id = jwt_identity success = response.Response(status=200) @only_for_identity(allowed_identity) def to_be_decorated(): return flaskify(success) mocker.patch.object( context, "get_request_context_from_headers", return_value=mock_context, ) with application.app.test_request_context(): result = to_be_decorated() assert result assert result.status_code == expected_status_code @pytest.mark.parametrize( "profile_id, profile_type, required, allowed_types, status", ( ("1", "LabelProfile", True, None, 200), # success, required (None, None, False, None, 200), # success, not required (None, "LabelProfile", False, None, 400), # incomplete headers (None, None, True, None, 403), # absent required headers ("1", "LabelProfile", False, ("ContentProfile",), 403), # type is not allowed ), ) def test_verify_profile_headers( profile_id: str | None, profile_type: str | None, required: bool, allowed_types: tuple[str, ...] | None, status: int, ): """Check 'verify_profile_headers' with various inputs.""" result = verify_profile_headers(profile_id, profile_type, required, allowed_types) assert result.status == status @pytest.fixture def patched_migration_authorization_backend( mocker: MockerFixture, mock_authorization_backend: AuthorizationBackend, ) -> AuthorizationBackend: """Patch the `migration_authorization_backend` reference imported into `product.auth`. `product/auth.py` does `from product.api import migration_authorization_backend`, which binds a local name in the `product.auth` module. Tests must therefore patch `product.auth.migration_authorization_backend` rather than `product.api.migration_authorization_backend`. """ mocker.patch("product.auth.migration_authorization_backend", mock_authorization_backend) return mock_authorization_backend class TestIsAuthorizedForTenant: @pytest.mark.parametrize( "backend_result, expected", [ (True, True), (False, False), ], ) def test_returns_backend_result( self, patched_migration_authorization_backend: AuthorizationBackend, backend_result: bool, expected: bool, ) -> None: patched_migration_authorization_backend.is_authorized.return_value = backend_result assert is_authorized_for_tenant(TENANT_UUID) is expected def test_forwards_staging_compatible_defaults( self, patched_migration_authorization_backend: AuthorizationBackend, ) -> None: """Defaults must match ows-product-staging.product_staging.api.auth.""" patched_migration_authorization_backend.is_authorized.return_value = True is_authorized_for_tenant(TENANT_UUID) patched_migration_authorization_backend.is_authorized.assert_called_once_with( action="bulk_create", resource_id=0, resource_type="digital_audio", resource_getter=ANY, tenant={ "tenant_type": "account", "tenant_uuid": str(TENANT_UUID), }, ) def test_merges_tenant_attributes( self, patched_migration_authorization_backend: AuthorizationBackend, ) -> None: patched_migration_authorization_backend.is_authorized.return_value = True is_authorized_for_tenant( TENANT_UUID, tenant_attributes={"tenant_hierarchy": ["a", "b", "c"]}, ) tenant = patched_migration_authorization_backend.is_authorized.call_args.kwargs["tenant"] assert tenant["tenant_hierarchy"] == ["a", "b", "c"] assert tenant["tenant_uuid"] == str(TENANT_UUID) assert tenant["tenant_type"] == "account" def test_overrides_default_kwargs( self, patched_migration_authorization_backend: AuthorizationBackend, ) -> None: patched_migration_authorization_backend.is_authorized.return_value = True is_authorized_for_tenant( TENANT_UUID, tenant_type="subaccount", resource_type="physical_audio", action="view", ) call_kwargs = patched_migration_authorization_backend.is_authorized.call_args.kwargs assert call_kwargs["action"] == "view" assert call_kwargs["resource_type"] == "physical_audio" assert call_kwargs["tenant"]["tenant_type"] == "subaccount" def test_propagates_unauthenticated_exception( self, patched_migration_authorization_backend: AuthorizationBackend, ) -> None: """The predicate must not swallow 401-class errors — handler layer maps them.""" backend = patched_migration_authorization_backend backend.is_authorized.side_effect = UnauthenticatedException() with pytest.raises(UnauthenticatedException): is_authorized_for_tenant(TENANT_UUID) class TestAssertAuthorizationForTenant: def test_returns_200_when_authorized( self, patched_migration_authorization_backend: AuthorizationBackend, ) -> None: patched_migration_authorization_backend.is_authorized.return_value = True result = assert_authorization_for_tenant(TENANT_UUID) assert result.status == 200 def test_returns_403_when_backend_denies( self, patched_migration_authorization_backend: AuthorizationBackend, ) -> None: patched_migration_authorization_backend.is_authorized.return_value = False result = assert_authorization_for_tenant(TENANT_UUID) assert result.status == 403 def test_returns_401_on_unauthenticated_exception( self, patched_migration_authorization_backend: AuthorizationBackend, ) -> None: backend = patched_migration_authorization_backend backend.is_authorized.side_effect = UnauthenticatedException() result = assert_authorization_for_tenant(TENANT_UUID) assert result.status == 401 assert result.errors["code"] == error.ERROR_CODE_AUTHORIZATION def test_returns_403_on_unauthorized_exception( self, patched_migration_authorization_backend: AuthorizationBackend, ) -> None: """A transport-level UnauthorizedException from the SDK surfaces as 403, not 500.""" patched_migration_authorization_backend.is_authorized.side_effect = UnauthorizedException() result = assert_authorization_for_tenant(TENANT_UUID) assert result.status == 403 class TestAssertAuthorization: """PDP-first auth with grass-header fallback.""" def test_returns_true_when_pdp_allows( self, patched_migration_authorization_backend: AuthorizationBackend, mocker: MockerFixture, ) -> None: patched_migration_authorization_backend.is_authorized.return_value = True mock_get_tenant = mocker.patch( "product.auth.get_tenant", return_value=Tenant(tenant_uuid=TENANT_UUID, tenant_type="account"), ) result = assert_authorization(product_id=1, vendor_id=42, subaccount_id=None) assert result is True mock_get_tenant.assert_called_once_with(product_id=1, subaccount_id=None) def test_falls_back_to_grass_and_returns_true_when_no_tenant( self, mocker: MockerFixture, ) -> None: mocker.patch("product.auth.get_tenant", return_value=None) mock_verify_grass = mocker.patch( "product.auth.flask_request.verify_grass_access", return_value=True ) with application.app.test_request_context(): result = assert_authorization(product_id=1, vendor_id=42, subaccount_id=None) assert result is True mock_verify_grass.assert_called_once_with(ANY, vendor=42, subaccount=None) def test_falls_back_to_grass_when_pdp_denies( self, patched_migration_authorization_backend: AuthorizationBackend, mocker: MockerFixture, ) -> None: patched_migration_authorization_backend.is_authorized.return_value = False mocker.patch( "product.auth.get_tenant", return_value=Tenant(tenant_uuid=TENANT_UUID, tenant_type="account"), ) mocker.patch("product.auth.flask_request.verify_grass_access", return_value=True) with application.app.test_request_context(): result = assert_authorization(product_id=1, vendor_id=42, subaccount_id=None) assert result is True def test_returns_false_when_both_pdp_and_grass_deny( self, patched_migration_authorization_backend: AuthorizationBackend, mocker: MockerFixture, ) -> None: patched_migration_authorization_backend.is_authorized.return_value = False mocker.patch( "product.auth.get_tenant", return_value=Tenant(tenant_uuid=TENANT_UUID, tenant_type="account"), ) mocker.patch("product.auth.flask_request.verify_grass_access", return_value=False) with application.app.test_request_context(): result = assert_authorization(product_id=1, vendor_id=42, subaccount_id=None) assert result is False def test_propagates_unauthenticated_exception( self, patched_migration_authorization_backend: AuthorizationBackend, mocker: MockerFixture, ) -> None: backend = patched_migration_authorization_backend backend.is_authorized.side_effect = UnauthenticatedException() mocker.patch( "product.auth.get_tenant", return_value=Tenant(tenant_uuid=TENANT_UUID, tenant_type="account"), ) with pytest.raises(UnauthenticatedException): assert_authorization(product_id=1, vendor_id=42, subaccount_id=None) VENDOR_UUID = "a1b2c3d4-e5f6-7890-abcd-ef1234567890" SUBACCOUNT_UUID = "b2c3d4e5-f6a7-8901-bcde-f12345678901" class TestGetTenant: def _mock_db(self, mocker: MockerFixture, row): mock_session = mocker.MagicMock() mock_session.execute.return_value.fetchone.return_value = row mock_cm = mocker.MagicMock() mock_cm.__enter__.return_value = mock_session mocker.patch("product.auth.mysql.db_session", return_value=mock_cm) def test_returns_none_when_product_not_found(self, mocker: MockerFixture) -> None: self._mock_db(mocker, None) assert get_tenant(product_id=1, subaccount_id=None) is None def test_returns_subaccount_tenant_when_subaccount_id_set(self, mocker: MockerFixture) -> None: row = mocker.MagicMock(vendor_uuid=VENDOR_UUID, subaccount_uuid=SUBACCOUNT_UUID) self._mock_db(mocker, row) result = get_tenant(product_id=1, subaccount_id=42) assert result is not None assert result.tenant_type == "subaccount" assert result.tenant_uuid == uuid.UUID(SUBACCOUNT_UUID) def test_returns_account_tenant_when_subaccount_id_is_none(self, mocker: MockerFixture) -> None: row = mocker.MagicMock(vendor_uuid=VENDOR_UUID, subaccount_uuid=SUBACCOUNT_UUID) self._mock_db(mocker, row) result = get_tenant(product_id=1, subaccount_id=None) assert result is not None assert result.tenant_type == "account" assert result.tenant_uuid == uuid.UUID(VENDOR_UUID) def test_returns_account_tenant_when_row_has_no_subaccount_uuid( self, mocker: MockerFixture ) -> None: row = mocker.MagicMock(vendor_uuid=VENDOR_UUID, subaccount_uuid=None) self._mock_db(mocker, row) result = get_tenant(product_id=1, subaccount_id=42) assert result is not None assert result.tenant_type == "account" assert result.tenant_uuid == uuid.UUID(VENDOR_UUID) def test_returns_none_when_row_has_no_uuids(self, mocker: MockerFixture) -> None: row = mocker.MagicMock(vendor_uuid=None, subaccount_uuid=None) self._mock_db(mocker, row) assert get_tenant(product_id=1, subaccount_id=None) is None