diff --git a/README.rst b/README.rst index 81628f8..daf1012 100644 --- a/README.rst +++ b/README.rst @@ -118,11 +118,10 @@ Here is a simple example of track event. .. code:: python from castle.client import Client - from castle import events castle = Client.from_request(request) castle.track({ - 'event': '$login', + 'event': '$login.succeeded', 'user_id': 'user_id' }) @@ -148,11 +147,10 @@ background worker you can generate data for a worker: .. code:: python from castle.payload.prepare import PayloadPrepare - from castle import events payload = PayloadPrepare.call( { - 'event': $login, + 'event': '$login.succeeded', 'user_id': user.id, 'properties': { 'key': 'value' }, 'user_traits': { 'key': 'value' } diff --git a/castle/client.py b/castle/client.py index 98c5a05..11eea88 100644 --- a/castle/client.py +++ b/castle/client.py @@ -1,5 +1,8 @@ from castle.api_request import APIRequest from castle.commands.authenticate import CommandsAuthenticate +from castle.commands.filter import CommandsFilter +from castle.commands.log import CommandsLog +from castle.commands.risk import CommandsRisk from castle.commands.start_impersonation import CommandsStartImpersonation from castle.commands.end_impersonation import CommandsEndImpersonation from castle.commands.track import CommandsTrack @@ -21,11 +24,11 @@ def from_request(cls, request, options=None): return cls(options) @staticmethod - def failover_response_or_raise(options, exception): + def failover_response_or_raise(user_id, exception): if configuration.failover_strategy == FailoverStrategy.THROW.value: raise exception return FailoverPrepareResponse( - options.get('user_id'), None, exception.__class__.__name__ + user_id, None, exception.__class__.__name__ ).call() def __init__(self, options=None): @@ -49,7 +52,7 @@ def authenticate(self, options): response.update(failover=False, failover_reason=None) return response except (RequestError, InternalServerError) as exception: - return Client.failover_response_or_raise(options, exception) + return Client.failover_response_or_raise(options.get('user_id'), exception) else: return FailoverPrepareResponse( options.get('user_id'), @@ -57,6 +60,47 @@ def authenticate(self, options): 'Castle set to do not track.' ).call() + def filter(self, options): + if self.tracked(): + self._add_timestamp_if_necessary(options) + command = CommandsFilter(self.context).call(options) + try: + response = self.api.call(command) + response.update(failover=False, failover_reason=None) + return response + except (RequestError, InternalServerError) as exception: + return Client.failover_response_or_raise(options.get('user').get('id'), exception) + else: + return FailoverPrepareResponse( + options.get('user').get('id'), + 'allow', + 'Castle set to do not track.' + ).call() + + def log(self, options): + if not self.tracked(): + return None + self._add_timestamp_if_necessary(options) + + return self.api.call(CommandsLog(self.context).call(options)) + + def risk(self, options): + if self.tracked(): + self._add_timestamp_if_necessary(options) + command = CommandsRisk(self.context).call(options) + try: + response = self.api.call(command) + response.update(failover=False, failover_reason=None) + return response + except (RequestError, InternalServerError) as exception: + return Client.failover_response_or_raise(options.get('user').get('id'), exception) + else: + return FailoverPrepareResponse( + options.get('user').get('id'), + 'allow', + 'Castle set to do not track.' + ).call() + def start_impersonation(self, options): self._add_timestamp_if_necessary(options) response = self.api.call(CommandsStartImpersonation(self.context).call(options)) diff --git a/castle/commands/filter.py b/castle/commands/filter.py new file mode 100644 index 0000000..9717eeb --- /dev/null +++ b/castle/commands/filter.py @@ -0,0 +1,20 @@ +from castle.command import Command +from castle.utils.timestamp import UtilsTimestamp as generate_timestamp +from castle.context.merge import ContextMerge +from castle.context.sanitize import ContextSanitize +from castle.validators.present import ValidatorsPresent + + +class CommandsFilter(object): + def __init__(self, context): + self.context = context + + def call(self, options): + ValidatorsPresent.call(options, 'event') + context = ContextMerge.call(self.context, options.get('context')) + context = ContextSanitize.call(context) + if context: + options.update({'context': context}) + options.update({'sent_at': generate_timestamp.call()}) + + return Command(method='post', path='filter', data=options) diff --git a/castle/commands/log.py b/castle/commands/log.py new file mode 100644 index 0000000..05df3ab --- /dev/null +++ b/castle/commands/log.py @@ -0,0 +1,20 @@ +from castle.command import Command +from castle.utils.timestamp import UtilsTimestamp as generate_timestamp +from castle.context.merge import ContextMerge +from castle.context.sanitize import ContextSanitize +from castle.validators.present import ValidatorsPresent + + +class CommandsLog(object): + def __init__(self, context): + self.context = context + + def call(self, options): + ValidatorsPresent.call(options, 'event') + context = ContextMerge.call(self.context, options.get('context')) + context = ContextSanitize.call(context) + if context: + options.update({'context': context}) + options.update({'sent_at': generate_timestamp.call()}) + + return Command(method='post', path='log', data=options) diff --git a/castle/commands/risk.py b/castle/commands/risk.py new file mode 100644 index 0000000..85a1238 --- /dev/null +++ b/castle/commands/risk.py @@ -0,0 +1,20 @@ +from castle.command import Command +from castle.utils.timestamp import UtilsTimestamp as generate_timestamp +from castle.context.merge import ContextMerge +from castle.context.sanitize import ContextSanitize +from castle.validators.present import ValidatorsPresent + + +class CommandsRisk(object): + def __init__(self, context): + self.context = context + + def call(self, options): + ValidatorsPresent.call(options, 'event') + context = ContextMerge.call(self.context, options.get('context')) + context = ContextSanitize.call(context) + if context: + options.update({'context': context}) + options.update({'sent_at': generate_timestamp.call()}) + + return Command(method='post', path='risk', data=options) diff --git a/castle/test/client_test.py b/castle/test/client_test.py index 9dd2907..e1a58f7 100644 --- a/castle/test/client_test.py +++ b/castle/test/client_test.py @@ -109,7 +109,7 @@ def test_authenticate_tracked_true(self): status=200 ) client = Client.from_request(request(), {}) - options = {'event': '$login.authenticate', 'user_id': '1234'} + options = {'event': '$login.succeeded', 'user_id': '1234'} response_text.update(failover=False, failover_reason=None) self.assertEqual(client.authenticate(options), response_text) @@ -128,7 +128,7 @@ def test_authenticate_tracked_true_status_500(self): status=500 ) client = Client.from_request(request(), {}) - options = {'event': '$login.authenticate', 'user_id': '1234'} + options = {'event': '$login.succeeded', 'user_id': '1234'} self.assertEqual(client.authenticate(options), response_text) def test_authenticate_tracked_false(self): @@ -140,7 +140,7 @@ def test_authenticate_tracked_false(self): } client = Client.from_request(request(), {}) client.disable_tracking() - options = {'event': '$login.authenticate', 'user_id': '1234'} + options = {'event': '$login.succeeded', 'user_id': '1234'} self.assertEqual(client.authenticate(options), response_text) @responses.activate @@ -153,7 +153,7 @@ def test_track_tracked_true(self): status=200 ) client = Client.from_request(request(), {}) - options = {'event': '$login.authenticate', 'user_id': '1234'} + options = {'event': '$login.succeeded', 'user_id': '1234'} self.assertEqual(client.track(options), response_text) def test_track_tracked_false(self): @@ -161,6 +161,147 @@ def test_track_tracked_false(self): client.disable_tracking() self.assertEqual(client.track({}), None) + @responses.activate + def test_filter_tracked_true(self): + response_text = {'action': Verdict.ALLOW.value, 'user_id': '1234'} + responses.add( + responses.POST, + 'https://api.castle.io/v1/filter', + json=response_text, + status=200 + ) + client = Client.from_request(request(), {}) + options = { + 'request_token': '7e51335b-f4bc-4bc7-875d-b713fb61eb23-bf021a3022a1a302', + 'event': '$login', + 'status': '$succeeded', + 'user': {'id': '1234'} + } + response_text.update(failover=False, failover_reason=None) + self.assertEqual(client.filter(options), response_text) + + @responses.activate + def test_filter_tracked_true_status_500(self): + response_text = { + 'action': Verdict.ALLOW.value, + 'user_id': '1234', + 'failover': True, + 'failover_reason': 'InternalServerError' + } + responses.add( + responses.POST, + 'https://api.castle.io/v1/filter', + json='filter', + status=500 + ) + client = Client.from_request(request(), {}) + options = { + 'request_token': '7e51335b-f4bc-4bc7-875d-b713fb61eb23-bf021a3022a1a302', + 'event': '$login', + 'status': '$succeeded', + 'user': {'id': '1234'} + } + self.assertEqual(client.filter(options), response_text) + + def test_filter_tracked_false(self): + response_text = { + 'action': Verdict.ALLOW.value, + 'user_id': '1234', + 'failover': True, + 'failover_reason': 'Castle set to do not track.' + } + client = Client.from_request(request(), {}) + client.disable_tracking() + options = { + 'request_token': '7e51335b-f4bc-4bc7-875d-b713fb61eb23-bf021a3022a1a302', + 'event': '$login', + 'status': '$succeeded', + 'user': {'id': '1234'} + } + self.assertEqual(client.filter(options), response_text) + + @responses.activate + def test_log_tracked_true(self): + response_text = 'log' + responses.add( + responses.POST, + 'https://api.castle.io/v1/log', + json=response_text, + status=200 + ) + client = Client.from_request(request(), {}) + options = { + 'request_token': '7e51335b-f4bc-4bc7-875d-b713fb61eb23-bf021a3022a1a302', + 'event': '$login', + 'status': '$succeeded', + 'user': {'id': '1234'} + } + self.assertEqual(client.log(options), response_text) + + def test_log_tracked_false(self): + client = Client.from_request(request(), {}) + client.disable_tracking() + self.assertEqual(client.log({}), None) + + @responses.activate + def test_risk_tracked_true(self): + response_text = {'action': Verdict.ALLOW.value, 'user_id': '1234'} + responses.add( + responses.POST, + 'https://api.castle.io/v1/risk', + json=response_text, + status=200 + ) + client = Client.from_request(request(), {}) + options = { + 'request_token': '7e51335b-f4bc-4bc7-875d-b713fb61eb23-bf021a3022a1a302', + 'event': '$login', + 'status': '$succeeded', + 'user': {'id': '1234'} + } + response_text.update(failover=False, failover_reason=None) + self.assertEqual(client.risk(options), response_text) + + @responses.activate + def test_risk_tracked_true_status_500(self): + response_text = { + 'action': Verdict.ALLOW.value, + 'user_id': '1234', + 'failover': True, + 'failover_reason': 'InternalServerError' + } + responses.add( + responses.POST, + 'https://api.castle.io/v1/risk', + json='risk', + status=500 + ) + client = Client.from_request(request(), {}) + options = { + 'request_token': '7e51335b-f4bc-4bc7-875d-b713fb61eb23-bf021a3022a1a302', + 'event': '$login', + 'status': '$succeeded', + 'user': {'id': '1234'} + } + self.assertEqual(client.risk(options), response_text) + + def test_risk_tracked_false(self): + response_text = { + 'action': Verdict.ALLOW.value, + 'user_id': '1234', + 'failover': True, + 'failover_reason': 'Castle set to do not track.' + } + client = Client.from_request(request(), {}) + client.disable_tracking() + options = { + 'request_token': '7e51335b-f4bc-4bc7-875d-b713fb61eb23-bf021a3022a1a302', + 'event': '$login', + 'status': '$succeeded', + 'user': {'id': '1234'} + } + self.assertEqual(client.risk(options), response_text) + def test_disable_tracking(self): client = Client.from_request(request(), {}) client.disable_tracking() @@ -184,7 +325,7 @@ def test_tracked_when_do_not_track_true(self): def test_failover_strategy_not_throw(self): options = {'user_id': '1234'} self.assertEqual( - Client.failover_response_or_raise(options, Exception()), + Client.failover_response_or_raise(options.get('user_id'), Exception()), { 'action': Verdict.ALLOW.value, 'user_id': '1234', diff --git a/castle/test/commands/filter_test.py b/castle/test/commands/filter_test.py new file mode 100644 index 0000000..3912068 --- /dev/null +++ b/castle/test/commands/filter_test.py @@ -0,0 +1,98 @@ +from castle.test import mock, unittest +from castle.command import Command +from castle.commands.filter import CommandsFilter +from castle.exceptions import InvalidParametersError +from castle.utils import clone + + +def default_options(): + """Default options include all required fields.""" + return { + 'request_token': '7e51335b-f4bc-4bc7-875d-b713fb61eb23-bf021a3022a1a302', + 'event': '$registration', + 'status': '', + 'user': { + 'id': '1234' + } + } + + +def default_options_plus(**extra): + """Default options plus the given extra fields.""" + options = default_options() + options.update(extra) + return options + + +def default_command_with_data(**data): + """What we expect the filter command to look like.""" + return Command( + method='post', + path='filter', + data=dict(sent_at=mock.sentinel.timestamp, **data) + ) + + +class CommandsFilterTestCase(unittest.TestCase): + + def setUp(self): + # patch timestamp to return a known value + timestamp_patcher = mock.patch( + 'castle.commands.filter.timestamp') + self.mock_timestamp = timestamp_patcher.start() + self.mock_timestamp.return_value = mock.sentinel.timestamp + self.addCleanup(timestamp_patcher.stop) + + def test_init(self): + context = mock.sentinel.test_init_context + obj = CommandsFilter(context) + self.assertEqual(obj.context, context) + + def test_build(self): + context = {'test': '1'} + options = default_options_plus(context={'spam': True}) + + # expect the original context to have been merged with the context specified in the options + expected_data = clone(options) + expected_data.update(context={'test': '1', 'spam': True}) + expected = default_command_with_data(**expected_data) + + self.assertEqual(CommandsFilter( + context).build(options), expected) + + def test_build_no_event(self): + context = {} + options = default_options() + options.pop('event') + + with self.assertRaises(InvalidParametersError): + CommandsFilter(context).build(options) + + def test_build_no_user_id(self): + context = {} + options = default_options() + options.pop('user_id') + + expected = default_command_with_data(**options) + + self.assertEqual(CommandsFilter(context).build(options), expected) + + def test_build_properties_allowed(self): + context = {} + options = default_options_plus(properties={'test': '1'}) + options.update({'context': context}) + + expected = default_command_with_data(**options) + + self.assertEqual(CommandsFilter( + context).build(options), expected) + + def test_build_user_traits_allowed(self): + context = {} + options = default_options_plus(user_traits={'email': 'a@b.com'}) + options.update({'context': context}) + + expected = default_command_with_data(**options) + + self.assertEqual(CommandsFilter( + context).build(options), expected) diff --git a/castle/test/commands/log_test.py b/castle/test/commands/log_test.py new file mode 100644 index 0000000..ec1f518 --- /dev/null +++ b/castle/test/commands/log_test.py @@ -0,0 +1,98 @@ +from castle.test import mock, unittest +from castle.command import Command +from castle.commands.log import CommandsLog +from castle.exceptions import InvalidParametersError +from castle.utils import clone + + +def default_options(): + """Default options include all required fields.""" + return { + 'request_token': '7e51335b-f4bc-4bc7-875d-b713fb61eb23-bf021a3022a1a302', + 'event': '$registration', + 'status': '', + 'user': { + 'id': '1234' + } + } + + +def default_options_plus(**extra): + """Default options plus the given extra fields.""" + options = default_options() + options.update(extra) + return options + + +def default_command_with_data(**data): + """What we expect the log command to look like.""" + return Command( + method='post', + path='log', + data=dict(sent_at=mock.sentinel.timestamp, **data) + ) + + +class CommandsLogTestCase(unittest.TestCase): + + def setUp(self): + # patch timestamp to return a known value + timestamp_patcher = mock.patch( + 'castle.commands.log.timestamp') + self.mock_timestamp = timestamp_patcher.start() + self.mock_timestamp.return_value = mock.sentinel.timestamp + self.addCleanup(timestamp_patcher.stop) + + def test_init(self): + context = mock.sentinel.test_init_context + obj = CommandsLog(context) + self.assertEqual(obj.context, context) + + def test_build(self): + context = {'test': '1'} + options = default_options_plus(context={'spam': True}) + + # expect the original context to have been merged with the context specified in the options + expected_data = clone(options) + expected_data.update(context={'test': '1', 'spam': True}) + expected = default_command_with_data(**expected_data) + + self.assertEqual(CommandsLog( + context).build(options), expected) + + def test_build_no_event(self): + context = {} + options = default_options() + options.pop('event') + + with self.assertRaises(InvalidParametersError): + CommandsLog(context).build(options) + + def test_build_no_user_id(self): + context = {} + options = default_options() + options.pop('user_id') + + expected = default_command_with_data(**options) + + self.assertEqual(CommandsLog(context).build(options), expected) + + def test_build_properties_allowed(self): + context = {} + options = default_options_plus(properties={'test': '1'}) + options.update({'context': context}) + + expected = default_command_with_data(**options) + + self.assertEqual(CommandsLog( + context).build(options), expected) + + def test_build_user_traits_allowed(self): + context = {} + options = default_options_plus(user_traits={'email': 'a@b.com'}) + options.update({'context': context}) + + expected = default_command_with_data(**options) + + self.assertEqual(CommandsLog( + context).build(options), expected) diff --git a/castle/test/commands/risk_test.py b/castle/test/commands/risk_test.py new file mode 100644 index 0000000..7a3c67d --- /dev/null +++ b/castle/test/commands/risk_test.py @@ -0,0 +1,98 @@ +from castle.test import mock, unittest +from castle.command import Command +from castle.commands.risk import CommandsRisk +from castle.exceptions import InvalidParametersError +from castle.utils import clone + + +def default_options(): + """Default options include all required fields.""" + return { + 'request_token': '7e51335b-f4bc-4bc7-875d-b713fb61eb23-bf021a3022a1a302', + 'event': '$login', + 'status': '$succeeded', + 'user': { + 'id': '1234' + } + } + + +def default_options_plus(**extra): + """Default options plus the given extra fields.""" + options = default_options() + options.update(extra) + return options + + +def default_command_with_data(**data): + """What we expect the risk command to look like.""" + return Command( + method='post', + path='risk', + data=dict(sent_at=mock.sentinel.timestamp, **data) + ) + + +class CommandsRiskTestCase(unittest.TestCase): + + def setUp(self): + # patch timestamp to return a known value + timestamp_patcher = mock.patch( + 'castle.commands.risk.timestamp') + self.mock_timestamp = timestamp_patcher.start() + self.mock_timestamp.return_value = mock.sentinel.timestamp + self.addCleanup(timestamp_patcher.stop) + + def test_init(self): + context = mock.sentinel.test_init_context + obj = CommandsRisk(context) + self.assertEqual(obj.context, context) + + def test_build(self): + context = {'test': '1'} + options = default_options_plus(context={'spam': True}) + + # expect the original context to have been merged with the context specified in the options + expected_data = clone(options) + expected_data.update(context={'test': '1', 'spam': True}) + expected = default_command_with_data(**expected_data) + + self.assertEqual(CommandsRisk( + context).build(options), expected) + + def test_build_no_event(self): + context = {} + options = default_options() + options.pop('event') + + with self.assertRaises(InvalidParametersError): + CommandsRisk(context).build(options) + + def test_build_no_user_id(self): + context = {} + options = default_options() + options.pop('user_id') + + expected = default_command_with_data(**options) + + self.assertEqual(CommandsRisk(context).build(options), expected) + + def test_build_properties_allowed(self): + context = {} + options = default_options_plus(properties={'test': '1'}) + options.update({'context': context}) + + expected = default_command_with_data(**options) + + self.assertEqual(CommandsRisk( + context).build(options), expected) + + def test_build_user_traits_allowed(self): + context = {} + options = default_options_plus(user_traits={'email': 'a@b.com'}) + options.update({'context': context}) + + expected = default_command_with_data(**options) + + self.assertEqual(CommandsRisk( + context).build(options), expected)