"""Test logic for devices.""" from importlib import reload from unittest.mock import call import boto3 from botocore.exceptions import ClientError from flask import g from moto import mock_sns from owsrequest.context import RequestContext from owsresponse import response import pytest from pythonfeatures import pythonfeatures from requests.structures import CaseInsensitiveDict from users import app, config, constants from users.logic import devices from users.models import ( devices as devices_model, identities as identities_model, ows_notifications, profiles as profiles_model, ) @pytest.mark.parametrize( ( 'get_identity_response', 'get_device_response', 'expected_response_status', 'expected_response_message', ), [ # 200 ok, updated (response.Response(), response.Response(message={'foo': 'bar'}), 200, {'foo': 'bar'}), # 404, identity not found. ( response.create_not_found_response(message='Identity not found'), response.Response(message={'foo': 'bar'}), 404, {'code': 'not_found_error', 'message': 'Identity not found'}, ), ], ) def test_get_push_notification_device( mocker, get_identity_response, get_device_response, expected_response_status, expected_response_message, ): """Test test_get_push_notification_device.""" orchard_identity_id = 'abcde1234' mocker.patch.object( identities_model, 'get_identity', return_value=get_identity_response, autospec=True ) mocker.patch.object( devices_model, 'get_push_notification_device', return_value=get_device_response, autospec=True, ) result = devices.get_push_notification_device(orchard_identity_id) assert result.status == expected_response_status if result: assert result.message == expected_response_message else: assert result.errors == expected_response_message if get_identity_response: devices_model.get_push_notification_device.assert_called_with(orchard_identity_id) @pytest.mark.parametrize( ( 'get_device_response', 'get_subscriptions_response', 'expected_response_status', 'num_deleted_devices', 'num_subscriptions_deleted', ), [ # 404, no devices (response.Response(message=[]), None, 404, 0, 0), # 404, without expected device (response.Response(message=[{'device_id': 'abc'}]), None, 404, 0, 0), # 204, with expected device ( response.Response( message=[ {'device_id': 'xyz', 'endpoint_arn': 'fake-arn-1'}, {'device_id': 'abc', 'endpoint_arn': 'fake-arn-2'}, ] ), [ {'Endpoint': {'endpoint_arn': 'fake-arn-1'}}, {'Endpoint': {'endpoint_arn': 'fake-arn-2'}}, {'Endpoint': {'endpoint_arn': 'fake-arn-3'}}, ], 204, 1, 1, ), # 204, with multiple devices deleted ( response.Response( message=[ {'device_id': 'xyz', 'endpoint_arn': 'fake-arn-1'}, {'device_id': 'xyz', 'endpoint_arn': 'fake-arn-2'}, ] ), [ {'Endpoint': {'endpoint_arn': 'fake-arn-1'}}, {'Endpoint': {'endpoint_arn': 'fake-arn-2'}}, {'Endpoint': {'endpoint_arn': 'fake-arn-3'}}, ], 204, 2, 2, ), ], ) def test_delete_push_notification_device( mocker, context, get_device_response, get_subscriptions_response, expected_response_status, num_deleted_devices, num_subscriptions_deleted, ): """Test logic layer on device delete.""" orchard_identity_id = 'abc' device_id = 'xyz' mocker.patch.object( devices_model, 'get_push_notification_device', return_value=get_device_response ) delete_mock = mocker.patch.object( devices_model, 'delete_push_notification_device', return_value=response.Response(status=204) ) unregister_mock = mocker.patch.object( devices, 'unregister_device', return_value={}, autospec=True ) get_subscriptions = mocker.patch.object( devices, '_get_sns_subscriptions', return_value=get_subscriptions_response ) delete_subscriptions = mocker.patch.object(devices, '_delete_sns_subscriptions') with context: result = devices.delete_push_notification_device(orchard_identity_id, device_id) assert result.status == expected_response_status deleted = result.status == 204 if deleted: assert get_subscriptions.called assert delete_mock.call_count == num_deleted_devices assert unregister_mock.call_count == num_deleted_devices assert delete_subscriptions.call_count == num_deleted_devices assert num_subscriptions_deleted == len(delete_subscriptions.call_args_list) @pytest.mark.parametrize( ( 'orchard_identity_id', 'brand', 'subscriptions', ), [ ('test_identity', 'theorchard', [1, 2, 3]), ('test_identity', 'theorchard', []), ('test_identity', 'awal', [1, 2, 3]), ('test_identity', 'sme', [1, 2, 3]), ], ) def test_get_sns_subscriptions(orchard_identity_id, brand, subscriptions, mocker, context): """Test fetching SNS subscriptions by identity id.""" sns_client = mocker.patch.object(devices, 'SNS_CLIENT') sns_call = mocker.patch.object( sns_client, 'list_subscriptions_by_topic', return_value={'Subscriptions': subscriptions} ) config.SNS_ARN_PREFIX = 'foo' config.ENVIRONMENT = 'test' expected_topic_name = devices._sns_topic_name(orchard_identity_id, brand) with context: result = devices._get_sns_subscriptions(orchard_identity_id, brand) assert sns_call.call_args_list == [call(TopicArn=f'foo:{expected_topic_name}')] assert result == subscriptions def test_delete_sns_subscriptions(mocker, context): """Test deleting SNS subscription via subscription list.""" sns_client = mocker.patch.object(devices, 'SNS_CLIENT') sns_call = mocker.patch.object(sns_client, 'unsubscribe') with context: devices._delete_sns_subscriptions([{'SubscriptionArn': '123'}, {'SubscriptionArn': '456'}]) assert sns_call.call_args_list == [call(SubscriptionArn='123'), call(SubscriptionArn='456')] @mock_sns def test_unregister_device(mocker, context): """Test logic layer of SNS endpoint delete.""" reload(devices) orchard_identity_id = 'abc' device_data = {'push_token': 'test123', 'platform_type': 'ios'} client = boto3.client('sns', region_name=config.AWS_REGION) mock_application = client.create_platform_application( Name='test-application', Platform='ios', Attributes={'PlatformCredential': 'key123'} ) config.PUSH_NOTIFICATION_ARN['ios_orchard'] = mock_application.get('PlatformApplicationArn') with context: mocker.spy(context.g.ows.log, 'warning') endpoint_arn = devices.register_device( orchard_identity_id, device_data, constants.AUTH0_ORCHARD_ORG_NAME ) result = client.get_endpoint_attributes(EndpointArn=endpoint_arn) assert result['ResponseMetadata']['HTTPStatusCode'] == 200 result = devices.unregister_device(endpoint_arn) assert result['ResponseMetadata']['HTTPStatusCode'] == 200 with pytest.raises(client.exceptions.NotFoundException): client.get_endpoint_attributes(EndpointArn=endpoint_arn) @mock_sns @pytest.mark.parametrize( ('identity_id', 'device_data', 'brand', 'app_name'), [ ( 'abc-123', {'push_token': 'test123', 'platform_type': 'ios'}, constants.AUTH0_ORCHARD_ORG_NAME, 'Orchard_Go', ), ( 'abc-123', {'push_token': 'test123', 'platform_type': 'ios'}, constants.SONY_BRAND, 'prod-sme', ), ( 'abc-123', {'push_token': 'test123', 'platform_type': 'android'}, constants.SONY_BRAND, 'prod-sme', ), ], ) def test_register_device(identity_id, device_data, brand, app_name, mocker, context): """Test test_register_device.""" reload(devices) # create a test application client = boto3.client('sns', region_name=config.AWS_REGION) platform = device_data['platform_type'] mock_application = client.create_platform_application( Name=app_name, Platform=platform, Attributes={'PlatformCredential': 'key123'} ) arn_key = f'{platform}_{brand}' config.PUSH_NOTIFICATION_ARN[arn_key] = mock_application.get('PlatformApplicationArn') with context: mocker.spy(context.g.ows.log, 'warning') endpoint_arn = devices.register_device(identity_id, device_data, brand) assert endpoint_arn def test_register_device_dupe_token(mocker, context): """Test test_register_device.""" reload(devices) orchard_identity_id = 'abc' device_data = {'push_token': 'test123', 'platform_type': 'ios'} sns_client = mocker.patch.object(devices, 'SNS_CLIENT') error_response = { 'Error': { 'Code': 'InvalidParameter', 'Message': 'An error occurred (InvalidParameter) when calling the ' 'CreatePlatformEndpoint operation: Invalid parameter: Token Reason: Endpoint ' 'arn:aws:sns:us-east-1:437795906767:endpoint/APNS/PROD_Orchard_Go/75beebe0-5c3c-' '3448-be55-1ad34412c1c6 already exists with the same Token, but different attributes.', } } operation = 'CreatePlatformEndpoint' mocker.patch.object( sns_client, 'create_platform_endpoint', side_effect=ClientError(error_response, operation) ) get_device = response.Response( { 'push_token': 'test123', 'endpoint_arn': 'arn:aws:sns:us-east-1:437795906767:endpoint/APNS/PROD_Orchard_Go/75beebe0', # noqa: E501 'platform_type': 'ios', } ) mocker.patch.object(devices_model, 'get_device_by_push_token', return_value=get_device) with context: mocker.spy(context.g.ows.log, 'warning') device_data['foo'] = 'bar' endpoint_arn = devices.register_device( orchard_identity_id, device_data, constants.AUTH0_ORCHARD_ORG_NAME ) assert endpoint_arn assert sns_client.create_platform_endpoint.called assert devices_model.get_device_by_push_token.called assert sns_client.set_endpoint_attributes.called @pytest.mark.parametrize( ( 'optional_params', 'get_identity_response', 'subscribe_profiles_response', 'create_device_response', 'register_device_response', 'expected_response_status', 'expected_response_message', ), [ # 200 ok, updated ( {'device_id': 'xyz1234', 'localization': 'en'}, response.Response(message={'id': 'bar', 'default_brand': constants.ORCHARD_BRAND}), response.Response(status=200), response.Response(message={'foo': 'bar'}), 'test_endpoint_arn_123', 200, {'foo': 'bar'}, ), # 200 ok, updated, no localization ( {'device_id': 'xyz1234'}, response.Response(message={'id': 'bar', 'default_brand': constants.ORCHARD_BRAND}), response.Response(status=200), response.Response(message={'foo': 'bar'}), 'test_endpoint_arn_123', 200, {'foo': 'bar'}, ), # 200 ok, ignore brand parameter for identity.defaultBrand ( {'device_id': 'xyz1234', 'brand': 'awal'}, response.Response(message={'id': 'bar', 'default_brand': constants.ORCHARD_BRAND}), response.Response(status=200), response.Response(message={'foo': 'bar'}), 'test_endpoint_arn_123', 200, {'foo': 'bar'}, ), # 500 something went wrong talking to ows-notifications, alert! ( {}, response.Response(message={'id': 'bar', 'default_brand': constants.ORCHARD_BRAND}), response.Response(status=500, errors='sad puppy'), None, None, 500, 'sad puppy', ), # 200 ok, updated, no localization, no device ( {}, response.Response(message={'id': 'bar', 'default_brand': constants.ORCHARD_BRAND}), response.Response(status=200), response.Response(message={'foo': 'bar'}), 'test_endpoint_arn_123', 200, {'foo': 'bar'}, ), # 404, identity not found. ( {}, response.create_not_found_response(message='Identity not found'), response.Response(status=200), response.Response(message={'foo': 'bar'}), 'test_endpoint_arn_123', 404, {'code': 'not_found_error', 'message': 'Identity not found'}, ), # 400, brand knr is not supported ( {'device_id': 'xyz1234', 'brand': constants.KNR_BRAND}, response.Response(message={'id': 'bar', 'default_brand': constants.KNR_BRAND}), response.Response(status=400), response.Response(message={'foo': 'bar'}), None, 400, { 'code': 'bad_params', 'message': f'Identity id bar can not register device. ' f'Brand {constants.KNR_BRAND} is not supported for device registration.', }, ), # 200, brand theorchard is supported for registration ( {'device_id': 'xyz1234', 'brand': constants.ORCHARD_BRAND}, response.Response(message={'id': 'bar', 'default_brand': constants.ORCHARD_BRAND}), response.Response(status=200), response.Response(message={'foo': 'bar'}), 'test_endpoint_arn_123', 200, {'foo': 'bar'}, ), # 200, brand awal is supported for registration ( {'device_id': 'xyz1234', 'brand': constants.AWAL_BRAND}, response.Response(message={'id': 'bar', 'default_brand': constants.AWAL_BRAND}), response.Response(status=200), response.Response(message={'foo': 'bar'}), 'test_endpoint_arn_123', 200, {'foo': 'bar'}, ), # 200, brand sme is supported for registration ( {'device_id': 'xyz1234', 'brand': constants.SONY_BRAND}, response.Response(message={'id': 'bar', 'default_brand': constants.SONY_BRAND}), response.Response(status=200), response.Response(message={'foo': 'bar'}), 'test_endpoint_arn_123', 200, {'foo': 'bar'}, ), ], ) def test_add_push_notification_device( mocker, optional_params, get_identity_response, subscribe_profiles_response, create_device_response, register_device_response, expected_response_status, expected_response_message, context, ): """Test test_add_push_notification_device.""" orchard_identity_id = 'abcde1234' required_params = { 'push_token': 'foo-2345', 'platform_type': 'ios', } if get_identity_response: brand = get_identity_response.message['default_brand'] sns_client = mocker.patch.object(devices, 'SNS_CLIENT') sns_call = mocker.patch.object(sns_client, 'create_topic', return_value={'TopicArn': 'foo'}) correlation_id = 'abc123' payload = {**required_params, **optional_params} mocker.patch.object(identities_model, 'get_identity', return_value=get_identity_response) mocker.patch.object(devices, 'delete_stale_registrations') mocker.patch.object(devices_model, 'get_device_by_push_token') mocker.patch.object( devices_model, 'create_push_notification_device', return_value=create_device_response ) mocker.patch.object( devices, 'register_device', return_value=register_device_response, autospec=True ) mocker.patch.object( devices, 'add_profile_subscriptions', return_value=subscribe_profiles_response ) with context, app.app.test_request_context(): mocker.spy(context.g.ows.log, 'info') result = devices.add_push_notification_device(orchard_identity_id, payload, correlation_id) assert result.status == expected_response_status if result: assert result.message == expected_response_message sns_topic = ( f'test-{brand}-push-notifications-bar' if brand != constants.ORCHARD_BRAND else 'test-push-notifications-bar' ) assert sns_call.call_args_list == [ call( Attributes={ 'ApplicationSuccessFeedbackRoleArn': '', 'ApplicationSuccessFeedbackSampleRate': '100', 'ApplicationFailureFeedbackRoleArn': '', 'KmsMasterKeyId': 'alias/aws/sns', }, Name=sns_topic, ) ] else: assert result.errors == expected_response_message if get_identity_response: resolved_id = get_identity_response.message['id'] if brand not in config.SUPPORTED_DEVICE_REGISTRATION_BRANDS: assert not devices.delete_stale_registrations.called assert not devices.register_device.called assert not devices.add_profile_subscriptions.called else: if payload.get('device_id'): devices.delete_stale_registrations.assert_called_with( resolved_id, payload.get('device_id'), payload.get('platform_type'), payload.get('push_token'), brand if brand != constants.ORCHARD_BRAND else constants.AUTH0_ORCHARD_ORG_NAME, ) else: assert not devices.delete_stale_registrations.called if subscribe_profiles_response.status == 200: devices.register_device.assert_called_with( resolved_id, payload, brand if brand != constants.ORCHARD_BRAND else constants.AUTH0_ORCHARD_ORG_NAME, ) device_data = { 'endpoint_arn': register_device_response, 'device_id': payload.get('device_id'), 'brand': brand, } if 'localization' in optional_params: identity_data = {'localization': optional_params['localization']} device_data['localization'] = optional_params['localization'] else: identity_data = {} devices_model.create_push_notification_device.assert_called_with( resolved_id, payload['push_token'], payload['platform_type'], device_data, identity_data, ) else: assert not devices.register_device.called devices.add_profile_subscriptions.assert_called_with( resolved_id, correlation_id ) @pytest.mark.parametrize( ('registrations', 'deleted'), [ # no registrations ([], False), # registered to same identity ( [({'device_id': 'xyz', 'push_token': 'abc', 'platform_type': 'ios'}, {'id': '123'})], False, ), # registered to same identity by auth0 id ( [ ( {'device_id': 'xyz', 'push_token': 'abc', 'platform_type': 'ios'}, {'id': '456', 'auth0_user_id': '123'}, ) ], True, ), # registered to same identity, different token ( [({'device_id': 'xyz', 'push_token': 'def', 'platform_type': 'ios'}, {'id': '123'})], True, ), # registered to same identity by auth0 id, different token ( [ ( {'device_id': 'xyz', 'push_token': 'def', 'platform_type': 'ios'}, {'id': '456', 'auth0_user_id': '123'}, ) ], True, ), # registered to different identity, same token ( [({'device_id': 'xyz', 'push_token': 'abc', 'platform_type': 'ios'}, {'id': '456'})], True, ), # registered to different identity, different token ( [({'device_id': 'xyz', 'push_token': 'def', 'platform_type': 'ios'}, {'id': '456'})], True, ), ], ) def test_delete_stale_registrations(mocker, registrations, deleted): """Test stale device delete flow.""" identity_id = '123' device_id = 'xyz' platform_type = 'ios' push_token = 'abc' mocker.patch.object( devices_model, 'get_registrations', return_value=response.Response(registrations) ) delete_mock = mocker.patch.object(devices, 'delete_push_notification_device') devices.delete_stale_registrations(identity_id, device_id, platform_type, push_token) if deleted: assert delete_mock.called_with(registrations[0][1]['id'], registrations[0][0]['device_id']) else: assert not delete_mock.called @pytest.mark.parametrize( ('profiles_response', 'expected_calls', 'notifications_responses', 'expected_result'), [ # one profile, ok ( response.Response(message=[{'profile_id': 123, 'profile_type': 'InsightsProfile'}]), [ call(123, 'InsightsProfile', 'push_notifications', 'social_spike', False, 'xyz'), call( 123, 'InsightsProfile', 'push_notifications', 'playlist_placements', False, 'xyz', ), call(123, 'InsightsProfile', 'push_notifications', 'trending_tracks', False, 'xyz'), ], response.Response(), 200, ), # notification response throws a 500 ( response.Response( message=[{'profile_id': 123, 'profile_type': 'InsightsProfile'}], status=200 ), [ call(123, 'InsightsProfile', 'push_notifications', 'social_spike', False, 'xyz'), ], response.Response(status=500, errors={'message': 'uh-oh'}), 500, ), # 2 profiles, 2nd notification call fails critically ( response.Response( message=[ {'profile_id': 123, 'profile_type': 'InsightsProfile'}, {'profile_id': 678, 'profile_type': 'InsightsProfile'}, ], status=200, ), [ call(123, 'InsightsProfile', 'push_notifications', 'social_spike', False, 'xyz'), call( 123, 'InsightsProfile', 'push_notifications', 'playlist_placements', False, 'xyz', ), call(123, 'InsightsProfile', 'push_notifications', 'trending_tracks', False, 'xyz'), call(678, 'InsightsProfile', 'push_notifications', 'social_spike', False, 'xyz'), ], [ response.Response(status=200), response.Response(status=200), response.Response(status=200), response.Response(status=500, errors={'message': 'uh-oh'}), ], 500, ), # 2 profiles, 1st notification call fails critically ( response.Response( message=[ {'profile_id': 123, 'profile_type': 'InsightsProfile'}, {'profile_id': 678, 'profile_type': 'InsightsProfile'}, ], status=200, ), [ call(123, 'InsightsProfile', 'push_notifications', 'social_spike', False, 'xyz'), ], response.Response(status=500, errors={'message': 'uh-oh'}), 500, ), # Many profiles, filter out non-enabled types ( response.Response( message=[ {'profile_id': 111, 'profile_type': 'OrchAdminProfile'}, {'profile_id': 222, 'profile_type': 'InsightsProfile'}, {'profile_id': 333, 'profile_type': 'ArtistProfile'}, {'profile_id': 444, 'profile_type': 'LabelProfile'}, {'profile_id': 555, 'profile_type': 'SettingsProfile'}, ] ), [ call(222, 'InsightsProfile', 'push_notifications', 'social_spike', False, 'xyz'), call( 222, 'InsightsProfile', 'push_notifications', 'playlist_placements', False, 'xyz', ), call(222, 'InsightsProfile', 'push_notifications', 'trending_tracks', False, 'xyz'), call(333, 'ArtistProfile', 'push_notifications', 'social_spike', False, 'xyz'), call( 333, 'ArtistProfile', 'push_notifications', 'playlist_placements', False, 'xyz' ), call(333, 'ArtistProfile', 'push_notifications', 'trending_tracks', False, 'xyz'), call(444, 'LabelProfile', 'push_notifications', 'social_spike', False, 'xyz'), call( 444, 'LabelProfile', 'push_notifications', 'playlist_placements', False, 'xyz' ), call(444, 'LabelProfile', 'push_notifications', 'trending_tracks', False, 'xyz'), ], response.Response(), 200, ), ], ) def test_add_profile_subscriptions( fixture_client, mocker, profiles_response, expected_calls, notifications_responses, expected_result, ): """Test add_profile_subscriptions.""" identity_id = 'abc' correlation_id = 'xyz' with app.app.test_request_context(): g.request_context = RequestContext( CaseInsensitiveDict({'Orchard-Identity-Id': identity_id}) ) mocker.patch.object(profiles_model, 'get_profiles', return_value=profiles_response) mocker.patch.object( pythonfeatures, 'get_all_features', return_value=response.Response(message='control') ) notification_calls = mocker.patch.object( ows_notifications, 'create_notification_subscription' ) if isinstance(notifications_responses, list): notification_calls.side_effect = notifications_responses else: notification_calls.return_value = notifications_responses result = devices.add_profile_subscriptions(identity_id, correlation_id) assert result.status == expected_result assert notification_calls.call_args_list == expected_calls