diff --git a/README.rst b/README.rst index 9fe681a..a39b938 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': events.LOGIN_SUCCEEDED, + 'event': '$login', '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': events.LOGIN_SUCCEEDED, + 'event': '$login', 'user_id': user.id, 'properties': { 'key': 'value' }, 'user_traits': { 'key': 'value' } @@ -172,7 +170,7 @@ and use it later in a way Events -------------- -List of Recognized Events can be found `here `_ or in the `docs `_. +List of Recognized Events can be found in the `docs `_. Device management ----------------- @@ -234,7 +232,7 @@ error `__. Webhooks -------- -Castle uses webhooks to notify about ``$incident.confirmed`` or `$review.opened` events. +Castle uses webhooks to notify about ``$incident.confirmed`` or ``$review.opened`` events. Each webhook has ``X-Castle-Signature`` header that allows verifying webhook's source. .. code:: python diff --git a/castle/api/review.py b/castle/api/review.py deleted file mode 100644 index d9c3b27..0000000 --- a/castle/api/review.py +++ /dev/null @@ -1,9 +0,0 @@ -from castle.api_request import APIRequest -from castle.commands.review import CommandsReview -from castle.configuration import configuration - - -class APIReview(object): - @staticmethod - def call(review_id, config=configuration): - return APIRequest(config).call(CommandsReview.call(review_id)) diff --git a/castle/client.py b/castle/client.py index 7e0e9d2..b09954f 100644 --- a/castle/client.py +++ b/castle/client.py @@ -1,11 +1,12 @@ from castle.api_request import APIRequest from castle.commands.authenticate import CommandsAuthenticate -from castle.commands.identify import CommandsIdentify from castle.commands.start_impersonation import CommandsStartImpersonation from castle.commands.end_impersonation import CommandsEndImpersonation from castle.commands.track import CommandsTrack from castle.configuration import configuration from castle.context.prepare import ContextPrepare +from castle.options.merge import OptionsMerge +from castle.options.get_default import OptionsGetDefault from castle.errors import InternalServerError, RequestError, ImpersonationFailed from castle.failover.prepare_response import FailoverPrepareResponse from castle.failover.strategy import FailoverStrategy @@ -18,8 +19,11 @@ def from_request(cls, request, options=None): if options is None: options = {} - options.setdefault('context', ContextPrepare.call(request, options)) - return cls(options) + default_options = OptionsGetDefault(request, options.get('cookies')).call() + options_with_default_opts = OptionsMerge.call(options, default_options) + + options_with_default_opts.setdefault('context', ContextPrepare.call(options)) + return cls(options_with_default_opts) @staticmethod def failover_response_or_raise(options, exception): @@ -32,6 +36,8 @@ def failover_response_or_raise(options, exception): def __init__(self, options=None): if options is None: options = {} + + self.default_options = options self.do_not_track = options.get('do_not_track', False) self.timestamp = options.get('timestamp') self.context = options.get('context') @@ -42,47 +48,51 @@ def _add_timestamp_if_necessary(self, options): options.setdefault('timestamp', self.timestamp) def authenticate(self, options): + options_with_default_opts = OptionsMerge.call(options, self.default_options) + if self.tracked(): - self._add_timestamp_if_necessary(options) - command = CommandsAuthenticate(self.context).call(options) + self._add_timestamp_if_necessary(options_with_default_opts) + command = CommandsAuthenticate(self.context).call(options_with_default_opts) 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, exception) + return Client.failover_response_or_raise(options_with_default_opts, exception) else: return FailoverPrepareResponse( - options.get('user_id'), + options_with_default_opts.get('user_id'), 'allow', 'Castle set to do not track.' ).call() - def identify(self, options): - if not self.tracked(): - return None - self._add_timestamp_if_necessary(options) - return self.api.call(CommandsIdentify(self.context).call(options)) - def start_impersonation(self, options): - self._add_timestamp_if_necessary(options) - response = self.api.call(CommandsStartImpersonation(self.context).call(options)) + options_with_default_opts = OptionsMerge.call(options, self.default_options) + + self._add_timestamp_if_necessary(options_with_default_opts) + response = self.api.call(CommandsStartImpersonation( + self.context).call(options_with_default_opts)) if not response.get('success'): raise ImpersonationFailed return response def end_impersonation(self, options): - self._add_timestamp_if_necessary(options) - response = self.api.call(CommandsEndImpersonation(self.context).call(options)) + options_with_default_opts = OptionsMerge.call(options, self.default_options) + + self._add_timestamp_if_necessary(options_with_default_opts) + response = self.api.call(CommandsEndImpersonation( + self.context).call(options_with_default_opts)) if not response.get('success'): raise ImpersonationFailed return response def track(self, options): + options_with_default_opts = OptionsMerge.call(options, self.default_options) + if not self.tracked(): return None - self._add_timestamp_if_necessary(options) - return self.api.call(CommandsTrack(self.context).call(options)) + self._add_timestamp_if_necessary(options_with_default_opts) + return self.api.call(CommandsTrack(self.context).call(options_with_default_opts)) def disable_tracking(self): self.do_not_track = True diff --git a/castle/commands/end_impersonation.py b/castle/commands/end_impersonation.py index 4bed078..dd006e6 100644 --- a/castle/commands/end_impersonation.py +++ b/castle/commands/end_impersonation.py @@ -10,11 +10,10 @@ def __init__(self, context): self.context = context def call(self, options): - ValidatorsPresent.call(options, 'user_id') + ValidatorsPresent.call(options, 'user_id', 'headers') context = ContextMerge.call(self.context, options.get('context')) context = ContextSanitize.call(context) - ValidatorsPresent.call(context, 'user_agent', 'ip') if context: options.update({'context': context}) diff --git a/castle/commands/identify.py b/castle/commands/identify.py deleted file mode 100644 index 43ba523..0000000 --- a/castle/commands/identify.py +++ /dev/null @@ -1,20 +0,0 @@ -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.not_supported import ValidatorsNotSupported - - -class CommandsIdentify(object): - def __init__(self, context): - self.context = context - - def call(self, options): - ValidatorsNotSupported.call(options, 'properties') - 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='identify', data=options) diff --git a/castle/commands/review.py b/castle/commands/review.py deleted file mode 100644 index d90c7b0..0000000 --- a/castle/commands/review.py +++ /dev/null @@ -1,15 +0,0 @@ -from castle.command import Command -from castle.validators.present import ValidatorsPresent - - -class CommandsReview(object): - - @staticmethod - def call(review_id): - ValidatorsPresent.call({'review_id': review_id}, 'review_id') - - return Command( - method='get', - path="reviews/{review_id}".format(review_id=review_id), - data=None - ) diff --git a/castle/commands/start_impersonation.py b/castle/commands/start_impersonation.py index 83001e5..909e22e 100644 --- a/castle/commands/start_impersonation.py +++ b/castle/commands/start_impersonation.py @@ -10,11 +10,10 @@ def __init__(self, context): self.context = context def call(self, options): - ValidatorsPresent.call(options, 'user_id') + ValidatorsPresent.call(options, 'user_id', 'headers') context = ContextMerge.call(self.context, options.get('context')) context = ContextSanitize.call(context) - ValidatorsPresent.call(context, 'user_agent', 'ip') if context: options.update({'context': context}) diff --git a/castle/context/get_default.py b/castle/context/get_default.py index ed20f97..c9a54cb 100644 --- a/castle/context/get_default.py +++ b/castle/context/get_default.py @@ -1,53 +1,18 @@ from castle.version import VERSION -from castle.headers.filter import HeadersFilter -from castle.client_id.extract import ClientIdExtract -from castle.headers.extract import HeadersExtract -from castle.ips.extract import IPsExtract __version__ = VERSION class ContextGetDefault(object): - def __init__(self, request, cookies): - self.cookies = self._fetch_cookies(request, cookies) - self.pre_headers = HeadersFilter(request).call() - def call(self): + @staticmethod + def call(): context = dict({ - 'client_id': self._client_id(), 'active': True, - 'headers': self._headers(), - 'ip': self._ip(), 'library': { 'name': 'castle-python', 'version': __version__ } }) - context.update(self._optional_defaults()) - - return context - - def _ip(self): - return IPsExtract(self.pre_headers).call() - def _client_id(self): - return ClientIdExtract(self.pre_headers, self.cookies).call() - - def _headers(self): - return HeadersExtract(self.pre_headers).call() - - def _optional_defaults(self): - context = dict() - if 'Accept-Language' in self.pre_headers: - context['locale'] = self.pre_headers.get('Accept-Language') - if 'User-Agent' in self.pre_headers: - context['user_agent'] = self.pre_headers.get('User-Agent') return context - - @staticmethod - def _fetch_cookies(request, cookies): - if cookies: - return cookies - if hasattr(request, 'COOKIES') and request.COOKIES: - return request.COOKIES - return None diff --git a/castle/context/prepare.py b/castle/context/prepare.py index 5545cfe..854feaf 100644 --- a/castle/context/prepare.py +++ b/castle/context/prepare.py @@ -5,9 +5,8 @@ class ContextPrepare(object): @staticmethod - def call(request, options=None): + def call(options=None): if options is None: options = {} - default_context = ContextGetDefault( - request, options.get('cookies')).call() + default_context = ContextGetDefault.call() return ContextMerge.call(default_context, options.get('context', {})) diff --git a/castle/events.py b/castle/events.py deleted file mode 100644 index d9310af..0000000 --- a/castle/events.py +++ /dev/null @@ -1,42 +0,0 @@ -# Record when a user succesfully logs in. -LOGIN_SUCCEEDED = '$login.succeeded' -# Record when a user failed to log in. -LOGIN_FAILED = '$login.failed' -# Record when a user logs out. -LOGOUT_SUCCEEDED = '$logout.succeeded' -# Record when a user updated their profile (including password, email, phone, etc). -PROFILE_UPDATE_SUCCEEDED = '$profile_update.succeeded' -# Record errors when updating profile. -PROFILE_UPDATE_FAILED = '$profile_update.failed' -# Capture account creation, both when a user signs up as well as when created manually -# by an administrator. -REGISTRATION_SUCCEEDED = '$registration.succeeded' -# Record when an account failed to be created. -REGISTRATION_FAILED = '$registration.failed' -# The user completed all of the steps in the password reset process and the password was -# successfully reset.Password resets do not required knowledge of the current password. -PASSWORD_RESET_SUCCEEDED = '$password_reset.succeeded' -# Use to record when a user failed to reset their password. -PASSWORD_RESET_FAILED = '$password_reset.failed' -# The user successfully requested a password reset. -PASSWORD_RESET_REQUEST_SUCCCEEDED = '$password_reset_request.succeeded' -# The user failed to request a password reset. -PASSWORD_RESET_REQUEST_FAILED = '$password_reset_request.failed' -# User account has been reset. -INCIDENT_MITIGATED = '$incident.mitigated' -# User confirmed malicious activity. -REVIEW_ESCALATED = '$review.escalated' -# User confirmed safe activity. -REVIEW_RESOLVED = '$review.resolved' -# Record when a user is prompted with additional verification, such as two-factor -# authentication or a captcha. -CHALLENGE_REQUESTED = '$challenge.requested' -# Record when additional verification was successful. -CHALLENGE_SUCCEEDED = '$challenge.succeeded' -# Record when additional verification failed. -CHALLENGE_FAILED = '$challenge.failed' -# Record when a user attempts an in-app transaction, such as a purchase or withdrawal. -TRANSACTION_ATTEMPTED = '$transaction.attempted' -# Record when a user session is extended, or use any time you want -# to re-authenticate a user mid-session. -SESSION_EXTENDED = '$session.extended' diff --git a/castle/client_id/__init__.py b/castle/fingerprint/__init__.py similarity index 100% rename from castle/client_id/__init__.py rename to castle/fingerprint/__init__.py diff --git a/castle/client_id/extract.py b/castle/fingerprint/extract.py similarity index 86% rename from castle/client_id/extract.py rename to castle/fingerprint/extract.py index d555552..aa25250 100644 --- a/castle/client_id/extract.py +++ b/castle/fingerprint/extract.py @@ -1,4 +1,4 @@ -class ClientIdExtract(object): +class FingerprintExtract(object): def __init__(self, headers, cookies=None): self.headers = headers self.cookies = cookies or dict() diff --git a/castle/options/__init__.py b/castle/options/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/castle/options/get_default.py b/castle/options/get_default.py new file mode 100644 index 0000000..58ab389 --- /dev/null +++ b/castle/options/get_default.py @@ -0,0 +1,39 @@ +from castle.version import VERSION +from castle.headers.filter import HeadersFilter +from castle.fingerprint.extract import FingerprintExtract +from castle.headers.extract import HeadersExtract +from castle.ips.extract import IPsExtract + +__version__ = VERSION + + +class OptionsGetDefault(object): + def __init__(self, request, cookies): + self.cookies = self._fetch_cookies(request, cookies) + self.pre_headers = HeadersFilter(request).call() + + def call(self): + context = dict({ + 'fingerprint': self._fingerprint(), + 'headers': self._headers(), + 'ip': self._ip(), + }) + + return context + + def _ip(self): + return IPsExtract(self.pre_headers).call() + + def _fingerprint(self): + return FingerprintExtract(self.pre_headers, self.cookies).call() + + def _headers(self): + return HeadersExtract(self.pre_headers).call() + + @staticmethod + def _fetch_cookies(request, cookies): + if cookies: + return cookies + if hasattr(request, 'COOKIES') and request.COOKIES: + return request.COOKIES + return None diff --git a/castle/options/merge.py b/castle/options/merge.py new file mode 100644 index 0000000..4d088db --- /dev/null +++ b/castle/options/merge.py @@ -0,0 +1,11 @@ +from castle.utils.merge import UtilsMerge +from castle.utils.clone import UtilsClone + + +class OptionsMerge(object): + + @staticmethod + def call(initial_options, request_options): + source_copy = UtilsClone.call(initial_options) + UtilsMerge.call(source_copy, request_options) + return source_copy diff --git a/castle/payload/prepare.py b/castle/payload/prepare.py index 03f74aa..88ff83d 100644 --- a/castle/payload/prepare.py +++ b/castle/payload/prepare.py @@ -2,7 +2,8 @@ from castle.utils.timestamp import UtilsTimestamp as generate_timestamp from castle.context.prepare import ContextPrepare -from castle.utils.merge import UtilsMerge +from castle.options.get_default import OptionsGetDefault +from castle.options.merge import OptionsMerge class PayloadPrepare(object): @@ -12,12 +13,16 @@ def call(payload_options, request, options=None): if options is None: options = {} - context = ContextPrepare.call(request, UtilsMerge.call(payload_options, options)) + default_options = OptionsGetDefault(request, options.get('cookies')).call() + options_with_default_opts = OptionsMerge.call(options, default_options) + options_for_payload = OptionsMerge.call(payload_options, options_with_default_opts) - payload_options.setdefault('context', context) - payload_options.setdefault('timestamp', generate_timestamp.call()) + context = ContextPrepare.call(options_for_payload) - if 'traits' in payload_options: + options_for_payload.setdefault('context', context) + options_for_payload.setdefault('timestamp', generate_timestamp.call()) + + if 'traits' in options_for_payload: warnings.warn('use user_traits instead of traits key', DeprecationWarning) - return payload_options + return options_for_payload diff --git a/castle/test/__init__.py b/castle/test/__init__.py index 513f6e6..eeb637d 100644 --- a/castle/test/__init__.py +++ b/castle/test/__init__.py @@ -9,9 +9,7 @@ 'castle.test.api.get_device_test', 'castle.test.api.get_devices_for_user_test', 'castle.test.api.report_device_test', - 'castle.test.api.review_test', 'castle.test.api_request_test', - 'castle.test.client_id.extract_test', 'castle.test.client_test', 'castle.test.command_test', 'castle.test.commands.approve_device_test', @@ -19,9 +17,7 @@ 'castle.test.commands.end_impersonation_test', 'castle.test.commands.get_device_test', 'castle.test.commands.get_devices_for_user_test', - 'castle.test.commands.identify_test', 'castle.test.commands.report_device_test', - 'castle.test.commands.review_test', 'castle.test.commands.start_impersonation_test', 'castle.test.commands.track_test', 'castle.test.configuration_test', @@ -34,11 +30,14 @@ 'castle.test.core.send_request_test', 'castle.test.failover.prepare_response_test', 'castle.test.failover.strategy_test', + 'castle.test.fingerprint.extract_test', 'castle.test.headers.extract_test', 'castle.test.headers.filter_test', 'castle.test.headers.format_test', 'castle.test.ips.extract_test', 'castle.test.logger_test', + 'castle.test.options.get_default_test', + 'castle.test.options.merge_test', 'castle.test.payload.prepare_test', 'castle.test.secure_mode_test', 'castle.test.session_test', diff --git a/castle/test/api/review_test.py b/castle/test/api/review_test.py deleted file mode 100644 index 29a27a6..0000000 --- a/castle/test/api/review_test.py +++ /dev/null @@ -1,27 +0,0 @@ -import json -import responses - -from castle.test import unittest -from castle.api.review import APIReview -from castle.configuration import configuration - - -class APIReviewTestCase(unittest.TestCase): - def setUp(self): - configuration.api_secret = 'test' - - def tearDown(self): - configuration.api_secret = None - - @responses.activate - def test_call(self): - # pylint: disable=line-too-long - response_text = "{\"id\":\"56b32fa0-880b-0135-74d6-00e650213316\",\"reviewed\":false,\"created_at\":\"2017-09-15T11:59:57.211Z\",\"user_id\":\"1\",\"context\":{\"ip\":\"8.8.8.8\",\"location\":{\"country_code\":\"US\",\"country\":\"United States\",\"region\":null,\"region_code\":null,\"city\":null,\"lat\":37.751,\"lon\":-97.822},\"user_agent\":{\"raw\":\"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/60.0.3112.113 Safari/537.36\",\"browser\":\"Chrome\",\"version\":\"60.0.3112\",\"os\":\"Mac OS X 10.12.6\",\"mobile\":false,\"platform\":\"Mac OS X\",\"device\":\"Unknown\",\"family\":\"Chrome\"}}}" - responses.add( - responses.GET, - 'https://api.castle.io/v1/reviews/1234', - body=response_text, - status=200 - ) - review_id = '1234' - self.assertEqual(APIReview.call(review_id), json.loads(response_text)) diff --git a/castle/test/client_id/extract_test.py b/castle/test/client_id/extract_test.py deleted file mode 100644 index c3585cb..0000000 --- a/castle/test/client_id/extract_test.py +++ /dev/null @@ -1,43 +0,0 @@ -from castle.test import unittest - -from castle.client_id.extract import ClientIdExtract - - -def client_id(): - return 'cookies' - - -def client_id_environ(): - return 'environ' - - -def cookies(): - return {'__cid': client_id()} - - -def environ(): - return {'X-Castle-Client-Id': client_id_environ()} - - -class ClientIdExtractTestCase(unittest.TestCase): - def test_extract_client_id_from_cookiesand_environ(self): - self.assertEqual( - ClientIdExtract(environ(), cookies()).call(), - client_id_environ() - ) - - def test_extract_client_id_from_cookies(self): - self.assertEqual( - ClientIdExtract({}, cookies()).call(), - client_id() - ) - - def test_extract_client_id_from_environ(self): - self.assertEqual(ClientIdExtract( - environ(), {}).call(), client_id_environ()) - - def test_extract_client_id_unavailable(self): - self.assertEqual(ClientIdExtract({}, {}).call(), '') - - def test_extract_client_id_no_cookies(self): - self.assertEqual(ClientIdExtract({}).call(), '') diff --git a/castle/test/client_test.py b/castle/test/client_test.py index f3c5d19..a7c4c79 100644 --- a/castle/test/client_test.py +++ b/castle/test/client_test.py @@ -30,17 +30,20 @@ def tearDown(self): def test_init(self): context = { 'active': True, - 'client_id': '1234', + 'library': {'name': 'castle-python', 'version': VERSION} + } + client = Client.from_request(request(), {}) + default_options = { + 'fingerprint': '1234', 'headers': { - 'User-Agent': 'test', 'X-Forwarded-For': '217.144.192.112', + 'User-Agent': 'test', 'X-Castle-Client-Id': '1234' }, 'ip': '217.144.192.112', - 'library': {'name': 'castle-python', 'version': VERSION}, - 'user_agent': 'test' + 'context': {'active': True, 'library': {'name': 'castle-python', 'version': '5.0.1'}} } - client = Client.from_request(request(), {}) + self.assertEqual(client.default_options, default_options) self.assertEqual(client.do_not_track, False) self.assertEqual(client.context, context) self.assertIsInstance(client.api, APIRequest) @@ -99,24 +102,6 @@ def test_end_impersonation_failed(self): with self.assertRaises(ImpersonationFailed): client.end_impersonation(options) - @responses.activate - def test_identify_tracked_true(self): - response_text = 'identify' - responses.add( - responses.POST, - 'https://api.castle.io/v1/identify', - json=response_text, - status=200 - ) - client = Client.from_request(request(), {}) - options = {'event': '$login.authenticate', 'user_id': '1234'} - self.assertEqual(client.identify(options), response_text) - - def test_identify_tracked_false(self): - client = Client.from_request(request(), {}) - client.disable_tracking() - self.assertEqual(client.identify({}), None) - @responses.activate def test_authenticate_tracked_true(self): response_text = {'action': Verdict.ALLOW.value, 'user_id': '1234'} diff --git a/castle/test/commands/end_impersonation_test.py b/castle/test/commands/end_impersonation_test.py index 0594fb4..ad669ca 100644 --- a/castle/test/commands/end_impersonation_test.py +++ b/castle/test/commands/end_impersonation_test.py @@ -8,7 +8,8 @@ def default_options(): """Default options include all required fields.""" return {'properties': {'impersonator': 'admin'}, 'user_id': '1234', - 'context': {'ip': '127.0.0.1', 'user_agent': 'Chrome'}} + 'context': {'ip': '127.0.0.1'}, + 'headers': {'random': 'header'}} def default_options_plus(**extra): @@ -44,20 +45,20 @@ def test_init(self): def test_call(self): context = {'lang': 'es'} options = default_options_plus( - context={'local time': '8:53pm', 'ip': '127.0.0.1', 'user_agent': 'Chrome'} + context={'local time': '8:53pm', 'ip': '127.0.0.1'} ) # expect the original context to have been merged with the context specified in the options expected_data = UtilsClone.call(options) expected_data.update( context={'lang': 'es', 'local time': '8:53pm', - 'ip': '127.0.0.1', 'user_agent': 'Chrome'} + 'ip': '127.0.0.1'} ) expected = default_command_with_data(**expected_data) self.assertEqual(CommandsEndImpersonation(context).call(options), expected) - def test_call_no_event(self): + def test_call_no_user_id(self): context = {} options = default_options() options.pop('user_id') @@ -65,18 +66,10 @@ def test_call_no_event(self): with self.assertRaises(InvalidParametersError): CommandsEndImpersonation(context).call(options) - def test_call_no_context_ip(self): + def test_call_no_headers(self): context = {} options = default_options() - options['context'].pop('ip') - - with self.assertRaises(InvalidParametersError): - CommandsEndImpersonation(context).call(options) - - def test_call_no_context_user_agent(self): - context = {} - options = default_options() - options['context'].pop('user_agent') + options.pop('headers') with self.assertRaises(InvalidParametersError): CommandsEndImpersonation(context).call(options) diff --git a/castle/test/commands/identify_test.py b/castle/test/commands/identify_test.py deleted file mode 100644 index 5c29489..0000000 --- a/castle/test/commands/identify_test.py +++ /dev/null @@ -1,76 +0,0 @@ -from castle.test import mock, unittest -from castle.command import Command -from castle.commands.identify import CommandsIdentify -from castle.errors import InvalidParametersError -from castle.utils.clone import UtilsClone - - -def default_options(): - """Default options include all required fields.""" - return {'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 identify command to look like.""" - return Command( - method='post', - path='identify', - data=dict(sent_at=mock.sentinel.timestamp, **data) - ) - - -class CommandsIdentifyTestCase(unittest.TestCase): - def setUp(self): - # patch timestamp to return a known value - timestamp_patcher = mock.patch('castle.commands.identify.generate_timestamp.call') - 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 = CommandsIdentify(context) - self.assertEqual(obj.context, context) - - def test_call(self): - context = {'test': '1'} - options = default_options_plus(context={'color': 'blue'}) - - # expect the original context to have been merged with the context specified in the options - expected_data = UtilsClone.call(options) - expected_data.update(context={'test': '1', 'color': 'blue'}) - expected = default_command_with_data(**expected_data) - - self.assertEqual(CommandsIdentify(context).call(options), expected) - - def test_call_no_user_id(self): - context = {} - options = default_options() - options.pop('user_id') - - expected = default_command_with_data(**options) - - self.assertEqual(CommandsIdentify(context).call(options), expected) - - def test_call_properties_not_allowed(self): - context = {'test': '1'} - options = default_options_plus(properties={'hair': 'blonde'}) - - with self.assertRaises(InvalidParametersError): - CommandsIdentify(context).call(options) - - def test_call_user_traits_allowed(self): - context = {} - options = default_options_plus(user_traits={'email': 'identity@its.me.com'}) - options.update({'context': context}) - - expected = default_command_with_data(**options) - - self.assertEqual(CommandsIdentify(context).call(options), expected) diff --git a/castle/test/commands/review_test.py b/castle/test/commands/review_test.py deleted file mode 100644 index bb4b5b2..0000000 --- a/castle/test/commands/review_test.py +++ /dev/null @@ -1,21 +0,0 @@ -from castle.test import unittest -from castle.command import Command -from castle.commands.review import CommandsReview -from castle.errors import InvalidParametersError - - -def review_id(): - return '1234' - - -class CommandsReviewTestCase(unittest.TestCase): - def test_call_no_review_id(self): - with self.assertRaises(InvalidParametersError): - CommandsReview.call('') - - def test_call(self): - command = CommandsReview.call(review_id()) - self.assertIsInstance(command, Command) - self.assertEqual(command.method, 'get') - self.assertEqual(command.path, "reviews/1234") - self.assertEqual(command.data, None) diff --git a/castle/test/commands/start_impersonation_test.py b/castle/test/commands/start_impersonation_test.py index 9730756..106cdd9 100644 --- a/castle/test/commands/start_impersonation_test.py +++ b/castle/test/commands/start_impersonation_test.py @@ -8,7 +8,8 @@ def default_options(): """Default options include all required fields.""" return {'properties': {'impersonator': 'admin'}, 'user_id': '1234', - 'context': {'ip': '127.0.0.1', 'user_agent': 'Chrome'}} + 'context': {'ip': '127.0.0.1', 'user_agent': 'Chrome'}, + 'headers': {'random': 'header'}} def default_options_plus(**extra): @@ -57,7 +58,7 @@ def test_call(self): self.assertEqual(CommandsStartImpersonation(context).call(options), expected) - def test_call_no_event(self): + def test_call_no_user_id(self): context = {} options = default_options() options.pop('user_id') @@ -65,18 +66,10 @@ def test_call_no_event(self): with self.assertRaises(InvalidParametersError): CommandsStartImpersonation(context).call(options) - def test_call_no_context_ip(self): + def test_call_no_headers(self): context = {} options = default_options() - options['context'].pop('ip') - - with self.assertRaises(InvalidParametersError): - CommandsStartImpersonation(context).call(options) - - def test_call_no_context_user_agent(self): - context = {} - options = default_options() - options['context'].pop('user_agent') + options.pop('headers') with self.assertRaises(InvalidParametersError): CommandsStartImpersonation(context).call(options) diff --git a/castle/test/context/get_default_test.py b/castle/test/context/get_default_test.py index fcb33f8..458968e 100644 --- a/castle/test/context/get_default_test.py +++ b/castle/test/context/get_default_test.py @@ -4,53 +4,12 @@ from castle.context.get_default import ContextGetDefault -def client_id(): - return 'abcd' - - -def cookies(): - return {'__cid': client_id()} - - -def request_ip(): - return '5.5.5.5' - - -def environ(): - return { - 'HTTP_X_FORWARDED_FOR': request_ip(), - 'HTTP_COOKIE': "__cid={client_id()};other=efgh", - 'HTTP-Accept-Language': 'en', - 'HTTP-User-Agent': 'test' - } - - -def request(env): - req = mock.Mock() - req.ip = request_ip() - req.environ = env - return req - - class ContextGetDefaultTestCase(unittest.TestCase): def test_default_context(self): - context = ContextGetDefault( - request(environ()), cookies()).call() - self.assertEqual(context['client_id'], client_id()) + context = ContextGetDefault.call() self.assertEqual(context['active'], True) - self.assertEqual( - context['headers'], - { - 'X-Forwarded-For': request_ip(), - 'Accept-Language': 'en', - 'User-Agent': 'test', - 'Cookie': True - } - ) - self.assertEqual(context['ip'], request_ip()) self.assertDictEqual( context['library'], {'name': 'castle-python', 'version': __version__} ) - self.assertEqual(context['user_agent'], 'test') diff --git a/castle/test/context/prepare_test.py b/castle/test/context/prepare_test.py index 0835e0a..48afede 100644 --- a/castle/test/context/prepare_test.py +++ b/castle/test/context/prepare_test.py @@ -19,21 +19,7 @@ class ContextPrepareTestCase(unittest.TestCase): def test_call(self): context = { 'active': True, - 'client_id': '1234', - 'headers': { - 'User-Agent': 'test', - 'X-Forwarded-For': '217.144.192.112', - 'X-Castle-Client-Id': '1234' - }, - 'ip': '217.144.192.112', - 'library': {'name': 'castle-python', 'version': VERSION}, - 'user_agent': 'test' + 'library': {'name': 'castle-python', 'version': VERSION} } - result_context = ContextPrepare.call(request(), {}) + result_context = ContextPrepare.call({}) self.assertEqual(result_context, context) - - def test_setup_client_id_from_cookies(self): - cookies = {'__cid': '1234'} - options = {'cookies': cookies} - result_context = ContextPrepare.call(request(), options) - self.assertEqual(result_context['client_id'], '1234') diff --git a/castle/test/fingerprint/extract_test.py b/castle/test/fingerprint/extract_test.py new file mode 100644 index 0000000..67986cf --- /dev/null +++ b/castle/test/fingerprint/extract_test.py @@ -0,0 +1,43 @@ +from castle.test import unittest + +from castle.fingerprint.extract import FingerprintExtract + + +def fingerprint(): + return 'cookies' + + +def fingerprint_environ(): + return 'environ' + + +def cookies(): + return {'__cid': fingerprint()} + + +def environ(): + return {'X-Castle-Client-Id': fingerprint_environ()} + + +class FingerprintExtractTestCase(unittest.TestCase): + def test_extract_fingerprint_from_cookiesand_environ(self): + self.assertEqual( + FingerprintExtract(environ(), cookies()).call(), + fingerprint_environ() + ) + + def test_extract_fingerprint_from_cookies(self): + self.assertEqual( + FingerprintExtract({}, cookies()).call(), + fingerprint() + ) + + def test_extract_fingerprint_from_environ(self): + self.assertEqual(FingerprintExtract( + environ(), {}).call(), fingerprint_environ()) + + def test_extract_fingerprint_unavailable(self): + self.assertEqual(FingerprintExtract({}, {}).call(), '') + + def test_extract_fingerprint_no_cookies(self): + self.assertEqual(FingerprintExtract({}).call(), '') diff --git a/castle/test/options/get_default_test.py b/castle/test/options/get_default_test.py new file mode 100644 index 0000000..20f6eff --- /dev/null +++ b/castle/test/options/get_default_test.py @@ -0,0 +1,50 @@ +from castle.test import unittest, mock + +from castle.version import VERSION as __version__ +from castle.options.get_default import OptionsGetDefault + + +def fingerprint(): + return 'abcd' + + +def cookies(): + return {'__cid': fingerprint()} + + +def request_ip(): + return '5.5.5.5' + + +def environ(): + return { + 'HTTP_X_FORWARDED_FOR': request_ip(), + 'HTTP_COOKIE': "__cid={fingerprint()};other=efgh", + 'HTTP-Accept-Language': 'en', + 'HTTP-User-Agent': 'test' + } + + +def request(env): + req = mock.Mock() + req.ip = request_ip() + req.environ = env + return req + + +class OptionsGetDefaultTestCase(unittest.TestCase): + + def test_default_context(self): + opts = OptionsGetDefault( + request(environ()), cookies()).call() + self.assertEqual(opts['fingerprint'], fingerprint()) + self.assertEqual( + opts['headers'], + { + 'X-Forwarded-For': request_ip(), + 'Accept-Language': 'en', + 'User-Agent': 'test', + 'Cookie': True + } + ) + self.assertEqual(opts['ip'], request_ip()) diff --git a/castle/test/options/merge_test.py b/castle/test/options/merge_test.py new file mode 100644 index 0000000..ef71792 --- /dev/null +++ b/castle/test/options/merge_test.py @@ -0,0 +1,13 @@ +from castle.test import unittest +from castle.options.merge import OptionsMerge + + +class OptionsMergeTestCase(unittest.TestCase): + + def test_call(self): + params = {'foo': {'foo': 'bar', 'nonfoo': 'nonbar'}, 'to_remove': 'ok'} + self.assertEqual( + OptionsMerge.call( + params, {'foo': {'foo': 'foo'}, 'to_remove': None}), + {'foo': {'foo': 'foo', 'nonfoo': 'nonbar'}} + ) diff --git a/castle/test/payload/prepare_test.py b/castle/test/payload/prepare_test.py index 4b8ddfc..5329e02 100644 --- a/castle/test/payload/prepare_test.py +++ b/castle/test/payload/prepare_test.py @@ -17,15 +17,38 @@ def request(): def ctx(): return { 'active': True, - 'client_id': '1234', + 'library': {'name': 'castle-python', 'version': VERSION} + } + + +def res(): + return { + 'foo': 'bar', + 'fingerprint': '1234', + 'headers': { + 'X-Forwarded-For': '217.144.192.112', + 'User-Agent': 'test', + 'X-Castle-Client-Id': '1234' + }, + 'ip': '217.144.192.112', + 'context': {'active': True, 'library': {'name': 'castle-python', 'version': '5.0.1'}}, + 'timestamp': '2018-01-02T03:04:05.678' + } + + +def resWithDeprecation(): + return { + 'foo': 'bar', + 'traits': {}, + 'fingerprint': '1234', 'headers': { + 'X-Forwarded-For': '217.144.192.112', 'User-Agent': 'test', - 'X-Castle-Client-Id': '1234', - 'X-Forwarded-For': '217.144.192.112' + 'X-Castle-Client-Id': '1234' }, 'ip': '217.144.192.112', - 'library': {'name': 'castle-python', 'version': VERSION}, - 'user_agent': 'test' + 'context': {'active': True, 'library': {'name': 'castle-python', 'version': '5.0.1'}}, + 'timestamp': '2018-01-02T03:04:05.678' } @@ -39,10 +62,10 @@ def setUp(self): def test_call(self): options = PayloadPrepare.call({'foo': 'bar'}, request()) - self.assertEqual( - options, {'foo': 'bar', 'timestamp': '2018-01-02T03:04:05.678', 'context': ctx()}) + + self.assertEqual(options, res()) def test_call_with_deprecation(self): options = PayloadPrepare.call({'foo': 'bar', 'traits': {}}, request()) - self.assertEqual( - options, {'foo': 'bar', 'timestamp': '2018-01-02T03:04:05.678', 'traits': {}, 'context': ctx()}) + + self.assertEqual(options, resWithDeprecation())