diff --git a/.circleci/config.yml b/.circleci/config.yml index bd09816..33fe235 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -17,14 +17,9 @@ aliases: jobs: lint-sdk: docker: - - image: circleci/python:3.9 + - image: circleci/python:3.10 <<: *with-requests <<: *lint - python-3_4: - docker: - - image: circleci/python:3.4 - <<: *with-requests - <<: *job-defaults python-3_5: docker: - image: circleci/python:3.5 @@ -50,14 +45,18 @@ jobs: - image: circleci/python:3.9 <<: *with-requests <<: *job-defaults - + python-3_10: + docker: + - image: circleci/python:3.10 + <<: *with-requests + <<: *job-defaults workflows: main: jobs: - lint-sdk - - python-3_4 - python-3_5 - python-3_6 - python-3_7 - python-3_8 - python-3_9 + - python-3_10 diff --git a/.python-version b/.python-version index a5c4c76..7b59a5c 100644 --- a/.python-version +++ b/.python-version @@ -1 +1 @@ -3.9.0 +3.10.2 diff --git a/castle/client.py b/castle/client.py index 11eea88..e7dc93f 100644 --- a/castle/client.py +++ b/castle/client.py @@ -20,7 +20,8 @@ def from_request(cls, request, options=None): if options is None: options = {} - options.setdefault('context', ContextPrepare.call(request, options)) + options = options.copy() + options['context'] = ContextPrepare.call(request, options) return cls(options) @staticmethod diff --git a/castle/commands/filter.py b/castle/commands/filter.py index 9717eeb..4e7c03b 100644 --- a/castle/commands/filter.py +++ b/castle/commands/filter.py @@ -2,7 +2,6 @@ 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): @@ -10,7 +9,6 @@ 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: diff --git a/castle/commands/log.py b/castle/commands/log.py index 05df3ab..a8bdb61 100644 --- a/castle/commands/log.py +++ b/castle/commands/log.py @@ -2,7 +2,6 @@ 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): @@ -10,7 +9,6 @@ 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: diff --git a/castle/commands/risk.py b/castle/commands/risk.py index 85a1238..6e0601a 100644 --- a/castle/commands/risk.py +++ b/castle/commands/risk.py @@ -2,7 +2,6 @@ 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): @@ -10,7 +9,6 @@ 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: diff --git a/castle/configuration.py b/castle/configuration.py index 5206407..cea334d 100644 --- a/castle/configuration.py +++ b/castle/configuration.py @@ -25,7 +25,6 @@ "TE", "Upgrade-Insecure-Requests", "User-Agent", - "X-Castle-Client-Id", "X-Requested-With", ] diff --git a/castle/context/merge.py b/castle/context/merge.py index 85595fe..994b036 100644 --- a/castle/context/merge.py +++ b/castle/context/merge.py @@ -6,6 +6,8 @@ class ContextMerge(object): @staticmethod def call(initial_context, request_context): + if initial_context is None: + initial_context = {} source_copy = UtilsClone.call(initial_context) UtilsMerge.call(source_copy, request_context) return source_copy diff --git a/castle/core/process_response.py b/castle/core/process_response.py index 899beb1..193db7e 100644 --- a/castle/core/process_response.py +++ b/castle/core/process_response.py @@ -1,5 +1,7 @@ +from json import JSONDecodeError from castle.errors import BadRequestError, UnauthorizedError, ForbiddenError, NotFoundError, \ - UserUnauthorizedError, InvalidParametersError, APIError, InternalServerError + UserUnauthorizedError, InvalidParametersError, APIError, InternalServerError, \ + InvalidRequestTokenError from castle.logger import Logger RESPONSE_ERRORS = { @@ -7,8 +9,7 @@ 401: UnauthorizedError, 403: ForbiddenError, 404: NotFoundError, - 419: UserUnauthorizedError, - 422: InvalidParametersError + 419: UserUnauthorizedError } @@ -32,5 +33,16 @@ def verify(self): if self.response.status_code >= 500 and self.response.status_code <= 599: raise InternalServerError + if self.response.status_code == 422: + # attempt to unpack the error type from the response body + try: + body = self.response.json() + if isinstance(body, dict) and 'type' in body: + if body['type'] == 'invalid_request_token': + raise InvalidRequestTokenError(body['message']) + raise InvalidParametersError(body['message']) + except JSONDecodeError: + raise InvalidParametersError(self.response.text) + error = RESPONSE_ERRORS.get(self.response.status_code, APIError) raise error(self.response.text) diff --git a/castle/errors.py b/castle/errors.py index 2991059..62646b6 100644 --- a/castle/errors.py +++ b/castle/errors.py @@ -27,6 +27,10 @@ class InvalidParametersError(APIError): pass +class InvalidRequestTokenError(APIError): + pass + + class BadRequestError(APIError): pass diff --git a/castle/ips/extract.py b/castle/ips/extract.py index 0df38da..fb78384 100644 --- a/castle/ips/extract.py +++ b/castle/ips/extract.py @@ -57,6 +57,6 @@ def _ips_from(self, header): def _limit_proxy_depth(self, ips, ip_header): if ip_header in DEPTH_RELATED: - ips = ips[:len(ips)-self.trusted_proxy_depth] + ips = ips[:len(ips) - self.trusted_proxy_depth] return ips diff --git a/castle/test/__init__.py b/castle/test/__init__.py index df17350..0de82f1 100644 --- a/castle/test/__init__.py +++ b/castle/test/__init__.py @@ -16,9 +16,12 @@ 'castle.test.commands.approve_device_test', 'castle.test.commands.authenticate_test', 'castle.test.commands.end_impersonation_test', + 'castle.test.commands.filter_test', 'castle.test.commands.get_device_test', 'castle.test.commands.get_devices_for_user_test', + 'castle.test.commands.log_test', 'castle.test.commands.report_device_test', + 'castle.test.commands.risk_test', 'castle.test.commands.start_impersonation_test', 'castle.test.commands.track_test', 'castle.test.configuration_test', diff --git a/castle/test/api/get_devices_for_user_test.py b/castle/test/api/get_devices_for_user_test.py index f1dec5b..03d5029 100644 --- a/castle/test/api/get_devices_for_user_test.py +++ b/castle/test/api/get_devices_for_user_test.py @@ -16,7 +16,7 @@ def tearDown(self): @responses.activate def test_call(self): # pylint: disable=line-too-long - response_text = "{\"total_count\":2,\"data\":[{\"token\":\"token\",\"created_at\":\"2020-08-01T18:55:45.352Z\",\"last_seen_at\":\"2020-10-18T21:11:57.476Z\",\"user_id\":\"4\",\"approved_at\":\"2020-08-13T09:55:19.286Z\",\"escalated_at\":null,\"mitigated_at\":null,\"context\":{\"ip\":\"127.0.0.1\",\"location\":{\"country_code\":\"PL\",\"country\":\"Poland\"},\"user_agent\":{\"raw\":\"Mozilla\/5.0 (Macintosh; Intel Mac OS X 10_15_6) AppleWebKit\/537.36 (KHTML, like Gecko) Chrome\/86.0.4240.75 Safari\/537.36\",\"browser\":\"Chrome\",\"version\":\"86.0.4240\",\"os\":\"Mac OS X 10.15.6\",\"mobile\":false,\"platform\":\"Mac OS X\",\"device\":\"Mac\",\"family\":\"Chrome\"},\"properties\":{},\"type\":\"desktop\"},\"is_current_device\":false},{\"token\":\"token2\",\"created_at\":\"2020-08-13T11:26:47.401Z\",\"last_seen_at\":\"2020-10-18T18:37:22.855Z\",\"user_id\":\"4\",\"approved_at\":null,\"escalated_at\":null,\"mitigated_at\":null,\"context\":{\"ip\":\"127.0.0.1\",\"location\":{\"country_code\":\"PL\",\"country\":\"Poland\"},\"user_agent\":{\"raw\":\"Mozilla\/5.0 (Macintosh; Intel Mac OS X 10_15_6) AppleWebKit\/537.36 (KHTML, like Gecko) Chrome\/86.0.4240.75 Safari\/537.36\",\"browser\":\"Chrome\",\"version\":\"86.0.4240\",\"os\":\"Mac OS X 10.15.6\",\"mobile\":false,\"platform\":\"Mac OS X\",\"device\":\"Mac\",\"family\":\"Chrome\"},\"properties\":{},\"type\":\"desktop\"},\"is_current_device\":false}]}" + response_text = "{\"total_count\":2,\"data\":[{\"token\":\"token\",\"created_at\":\"2020-08-01T18:55:45.352Z\",\"last_seen_at\":\"2020-10-18T21:11:57.476Z\",\"user_id\":\"4\",\"approved_at\":\"2020-08-13T09:55:19.286Z\",\"escalated_at\":null,\"mitigated_at\":null,\"context\":{\"ip\":\"127.0.0.1\",\"location\":{\"country_code\":\"PL\",\"country\":\"Poland\"},\"user_agent\":{\"raw\":\"Mozilla\\/5.0 (Macintosh; Intel Mac OS X 10_15_6) AppleWebKit\\/537.36 (KHTML, like Gecko) Chrome\\/86.0.4240.75 Safari\\/537.36\",\"browser\":\"Chrome\",\"version\":\"86.0.4240\",\"os\":\"Mac OS X 10.15.6\",\"mobile\":false,\"platform\":\"Mac OS X\",\"device\":\"Mac\",\"family\":\"Chrome\"},\"properties\":{},\"type\":\"desktop\"},\"is_current_device\":false},{\"token\":\"token2\",\"created_at\":\"2020-08-13T11:26:47.401Z\",\"last_seen_at\":\"2020-10-18T18:37:22.855Z\",\"user_id\":\"4\",\"approved_at\":null,\"escalated_at\":null,\"mitigated_at\":null,\"context\":{\"ip\":\"127.0.0.1\",\"location\":{\"country_code\":\"PL\",\"country\":\"Poland\"},\"user_agent\":{\"raw\":\"Mozilla\\/5.0 (Macintosh; Intel Mac OS X 10_15_6) AppleWebKit\\/537.36 (KHTML, like Gecko) Chrome\\/86.0.4240.75 Safari\\/537.36\",\"browser\":\"Chrome\",\"version\":\"86.0.4240\",\"os\":\"Mac OS X 10.15.6\",\"mobile\":false,\"platform\":\"Mac OS X\",\"device\":\"Mac\",\"family\":\"Chrome\"},\"properties\":{},\"type\":\"desktop\"},\"is_current_device\":false}]}" responses.add( responses.GET, 'https://api.castle.io/v1/users/1234/devices', diff --git a/castle/test/commands/filter_test.py b/castle/test/commands/filter_test.py index 3912068..6236c49 100644 --- a/castle/test/commands/filter_test.py +++ b/castle/test/commands/filter_test.py @@ -1,8 +1,7 @@ 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 +from castle.utils.clone import UtilsClone def default_options(): @@ -38,7 +37,7 @@ class CommandsFilterTestCase(unittest.TestCase): def setUp(self): # patch timestamp to return a known value timestamp_patcher = mock.patch( - 'castle.commands.filter.timestamp') + 'castle.commands.filter.generate_timestamp.call') self.mock_timestamp = timestamp_patcher.start() self.mock_timestamp.return_value = mock.sentinel.timestamp self.addCleanup(timestamp_patcher.stop) @@ -48,36 +47,19 @@ def test_init(self): obj = CommandsFilter(context) self.assertEqual(obj.context, context) - def test_build(self): + def test_call(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 = UtilsClone.call(options) expected_data.update(context={'test': '1', 'spam': True}) expected = default_command_with_data(**expected_data) self.assertEqual(CommandsFilter( - context).build(options), expected) + context).call(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): + def test_call_properties_allowed(self): context = {} options = default_options_plus(properties={'test': '1'}) options.update({'context': context}) @@ -85,9 +67,9 @@ def test_build_properties_allowed(self): expected = default_command_with_data(**options) self.assertEqual(CommandsFilter( - context).build(options), expected) + context).call(options), expected) - def test_build_user_traits_allowed(self): + def test_call_user_traits_allowed(self): context = {} options = default_options_plus(user_traits={'email': 'a@b.com'}) options.update({'context': context}) @@ -95,4 +77,4 @@ def test_build_user_traits_allowed(self): expected = default_command_with_data(**options) self.assertEqual(CommandsFilter( - context).build(options), expected) + context).call(options), expected) diff --git a/castle/test/commands/log_test.py b/castle/test/commands/log_test.py index ec1f518..8965d00 100644 --- a/castle/test/commands/log_test.py +++ b/castle/test/commands/log_test.py @@ -1,8 +1,7 @@ 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 +from castle.utils.clone import UtilsClone def default_options(): @@ -38,7 +37,7 @@ class CommandsLogTestCase(unittest.TestCase): def setUp(self): # patch timestamp to return a known value timestamp_patcher = mock.patch( - 'castle.commands.log.timestamp') + 'castle.commands.log.generate_timestamp.call') self.mock_timestamp = timestamp_patcher.start() self.mock_timestamp.return_value = mock.sentinel.timestamp self.addCleanup(timestamp_patcher.stop) @@ -48,36 +47,19 @@ def test_init(self): obj = CommandsLog(context) self.assertEqual(obj.context, context) - def test_build(self): + def test_call(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 = UtilsClone.call(options) expected_data.update(context={'test': '1', 'spam': True}) expected = default_command_with_data(**expected_data) self.assertEqual(CommandsLog( - context).build(options), expected) + context).call(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): + def test_call_properties_allowed(self): context = {} options = default_options_plus(properties={'test': '1'}) options.update({'context': context}) @@ -85,9 +67,9 @@ def test_build_properties_allowed(self): expected = default_command_with_data(**options) self.assertEqual(CommandsLog( - context).build(options), expected) + context).call(options), expected) - def test_build_user_traits_allowed(self): + def test_call_user_traits_allowed(self): context = {} options = default_options_plus(user_traits={'email': 'a@b.com'}) options.update({'context': context}) @@ -95,4 +77,4 @@ def test_build_user_traits_allowed(self): expected = default_command_with_data(**options) self.assertEqual(CommandsLog( - context).build(options), expected) + context).call(options), expected) diff --git a/castle/test/commands/risk_test.py b/castle/test/commands/risk_test.py index 7a3c67d..42483bc 100644 --- a/castle/test/commands/risk_test.py +++ b/castle/test/commands/risk_test.py @@ -1,8 +1,7 @@ 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 +from castle.utils.clone import UtilsClone def default_options(): @@ -38,7 +37,7 @@ class CommandsRiskTestCase(unittest.TestCase): def setUp(self): # patch timestamp to return a known value timestamp_patcher = mock.patch( - 'castle.commands.risk.timestamp') + 'castle.commands.risk.generate_timestamp.call') self.mock_timestamp = timestamp_patcher.start() self.mock_timestamp.return_value = mock.sentinel.timestamp self.addCleanup(timestamp_patcher.stop) @@ -48,36 +47,19 @@ def test_init(self): obj = CommandsRisk(context) self.assertEqual(obj.context, context) - def test_build(self): + def test_call(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 = UtilsClone.call(options) expected_data.update(context={'test': '1', 'spam': True}) expected = default_command_with_data(**expected_data) self.assertEqual(CommandsRisk( - context).build(options), expected) + context).call(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): + def test_call_properties_allowed(self): context = {} options = default_options_plus(properties={'test': '1'}) options.update({'context': context}) @@ -85,9 +67,9 @@ def test_build_properties_allowed(self): expected = default_command_with_data(**options) self.assertEqual(CommandsRisk( - context).build(options), expected) + context).call(options), expected) - def test_build_user_traits_allowed(self): + def test_call_user_traits_allowed(self): context = {} options = default_options_plus(user_traits={'email': 'a@b.com'}) options.update({'context': context}) @@ -95,4 +77,4 @@ def test_build_user_traits_allowed(self): expected = default_command_with_data(**options) self.assertEqual(CommandsRisk( - context).build(options), expected) + context).call(options), expected) diff --git a/castle/test/context/merge_test.py b/castle/test/context/merge_test.py index 024177d..7c575e8 100644 --- a/castle/test/context/merge_test.py +++ b/castle/test/context/merge_test.py @@ -11,3 +11,10 @@ def test_call(self): params, {'foo': {'foo': 'foo'}, 'to_remove': None}), {'foo': {'foo': 'foo', 'nonfoo': 'nonbar'}} ) + + def test_call_no_base(self): + params = {'foo': {'foo': 'bar', 'nonfoo': 'nonbar'}} + self.assertEqual( + ContextMerge.call(None, params), + {'foo': {'foo': 'bar', 'nonfoo': 'nonbar'}} + ) diff --git a/castle/test/core/process_response_test.py b/castle/test/core/process_response_test.py index 41375ff..d8b8614 100644 --- a/castle/test/core/process_response_test.py +++ b/castle/test/core/process_response_test.py @@ -4,7 +4,7 @@ from castle.test import unittest from castle.core.process_response import CoreProcessResponse from castle.errors import BadRequestError, UnauthorizedError, ForbiddenError, NotFoundError, \ - UserUnauthorizedError, InvalidParametersError, InternalServerError + UserUnauthorizedError, InvalidParametersError, InternalServerError, InvalidRequestTokenError def response(status_code=200, body=None): @@ -79,6 +79,16 @@ def test_verify_422(self): with self.assertRaises(InvalidParametersError): CoreProcessResponse(response(status_code=422)).verify() + def test_verify_422_record_invalid(self): + with self.assertRaises(InvalidParametersError): + CoreProcessResponse( + response(status_code=422, body=b'{"type":"record_invalid","message":"validation failed"}')).verify() + + def test_verify_422_invalid_request_token(self): + with self.assertRaises(InvalidRequestTokenError): + CoreProcessResponse(response( + status_code=422, body=b'{"type":"invalid_request_token","message":"token invalid"}')).verify() + def test_verify_500(self): with self.assertRaises(InternalServerError): CoreProcessResponse(response(status_code=500)).verify() diff --git a/pylintrc b/pylintrc index 0c98018..eceeb25 100644 --- a/pylintrc +++ b/pylintrc @@ -8,4 +8,4 @@ class-rgx=[A-Z_][_a-zA-Z0-9]+$ ignored-modules = responses,requests [MESSAGES CONTROL] -disable=missing-docstring,too-many-instance-attributes,attribute-defined-outside-init,too-few-public-methods,dangerous-default-value,duplicate-code,bad-continuation,useless-object-inheritance,too-many-public-methods +disable=missing-docstring,too-many-instance-attributes,attribute-defined-outside-init,too-few-public-methods,dangerous-default-value,duplicate-code,bad-continuation,useless-object-inheritance,too-many-public-methods,R1735,C0209,W0707