diff --git a/ChangeLog.md b/ChangeLog.md index 62973e4..05a84f2 100644 --- a/ChangeLog.md +++ b/ChangeLog.md @@ -5,6 +5,15 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). +## [unreleased] - 2025-12-11 +### Added +- Added Data API configuration to request signatures +- Added Data API Demo to standalone demos + +### Fixed +- Fixed mypy errors failing lint check on ci build +- Fixed missing import on setuptools types for testing + ## [v0.3.13] - 2026-01-08 ### Added - Added SDK language and version to request metadata diff --git a/learnosity_sdk/request/init.py b/learnosity_sdk/request/init.py index 1f0ead1..a6ba66e 100644 --- a/learnosity_sdk/request/init.py +++ b/learnosity_sdk/request/init.py @@ -74,14 +74,14 @@ def generate(self, encode: bool = True) -> Union[str, Dict[str, Any]]: del output['domain'] # Stringify the request packet if necessary - if self.request is not None: + if isinstance(self.request, dict): output.update(self.request) elif self.service == 'events': output['security'] = self.security output['config'] = self.request elif self.service == 'assess': - if self.request is not None: + if isinstance(self.request, dict): output.update(self.request) elif self.service == 'data': # We ignore the encode param for data API @@ -193,7 +193,7 @@ def set_service_options(self) -> None: elif self.service == 'assess': self.sign_request_data = False - if self.request is not None and 'questionsApiActivity' in self.request: + if isinstance(self.request, dict) and 'questionsApiActivity' in self.request and isinstance(self.request['questionsApiActivity'], dict): questionsApi = self.request['questionsApiActivity'] if 'domain' in self.security: @@ -223,13 +223,13 @@ def set_service_options(self) -> None: self.request['questionsApiActivity'].update(questionsApi) elif self.service == 'items' or self.service == 'reports': - if self.request is not None and ('user_id' not in self.security and 'user_id' in self.request): + if isinstance(self.request, dict) and ('user_id' not in self.security and 'user_id' in self.request): self.security['user_id'] = self.request['user_id'] elif self.service == 'events': self.sign_request_data = False hashed_users = {} - users = self.request.get('users', []) if self.request is not None else [] + users = self.request.get('users', []) if isinstance(self.request, dict) else [] for user in users: concat = "{}{}".format(user, self.secret) hashed_users[user] = hashlib.sha256(concat.encode('utf-8')).hexdigest() @@ -244,8 +244,8 @@ def hash_list(self, l: Iterable[Any]) -> str: return '$02$' + signature def add_telemetry_data(self) -> None: - if self.request is not None and self.__telemetry_enabled: - if 'meta' in self.request: + if isinstance(self.request, dict) and self.__telemetry_enabled: + if 'meta' in self.request and isinstance(self.request['meta'], dict): self.request['meta']['sdk'] = self.get_sdk_meta() else: self.request['meta'] = { diff --git a/setup.py b/setup.py index 52f531f..ddde1a6 100644 --- a/setup.py +++ b/setup.py @@ -1,7 +1,7 @@ import setuptools # Loads __version__ using exec as setup.py can't import its own package -version = {} +version: dict[str, str] = {} version_file = 'learnosity_sdk/_version.py' exec(open(version_file).read(), { '__builtins__': None }, version) if '__version__' not in version: @@ -27,6 +27,7 @@ 'responses >=0.8.1', 'types-requests', 'types-Jinja2', + 'types-setuptools', 'mypy', ] diff --git a/tests/unit/test_init.py b/tests/unit/test_init.py index 0c7654b..259aa17 100644 --- a/tests/unit/test_init.py +++ b/tests/unit/test_init.py @@ -1,9 +1,23 @@ import collections -from typing import Dict, Optional +import json +from typing import Any, Dict, List, Optional, Tuple import unittest import learnosity_sdk.request + +def as_dict(value: object) -> Dict[str, Any]: + """ + Narrow ``value`` to ``Dict[str, Any]`` for both mypy and the test runner. + + Unlike ``typing.cast``, ``isinstance`` here is a real, always-executed + check: mismatches fail the test with a clear message instead of being + silently trusted. + """ + if not isinstance(value, dict): + raise AssertionError(f'Expected a dict, got {type(value).__name__}: {value!r}') + return value + ServiceTestSpec = collections.namedtuple( "ServiceTestSpec", [ "service", @@ -154,3 +168,257 @@ def _prepare_security(self, add_security: Optional[Dict[str, str]]=None) -> Dict if add_security is not None: security.update(add_security) return security + + +class TestRequestTypeHandling(unittest.TestCase): + """ + Tests for the ``isinstance(self.request, dict)`` guards added to ``Init``. + + These guards make request handling robust when ``self.request`` is ``None`` + or a non-dict value (e.g. a JSON array passed as a string), and when nested + keys such as ``questionsApiActivity`` are not dicts. Before the guards these + inputs raised at runtime: + * an events request that parsed to a list raised + ``AttributeError: 'list' object has no attribute 'get'`` + * a non-dict ``questionsApiActivity`` raised + ``AttributeError: 'str' object has no attribute 'keys'`` + The tests below lock in the defensive behaviour so it cannot regress. + """ + + key = 'yis0TYCu7U9V4o7M' + secret = '74c5fd430cf1242a527f6223aebd42d30464be22' + domain = 'localhost' + timestamp = '20140626-0528' + + def setUp(self) -> None: + # Behaviour under test is independent of telemetry. Disable it for + # determinism and restore the default (enabled) afterwards so test + # ordering (pytest-randomly) cannot leak state between tests. + learnosity_sdk.request.Init.disable_telemetry() + self.addCleanup(learnosity_sdk.request.Init.enable_telemetry) + + def _security(self, add: Optional[Dict[str, str]] = None) -> Dict[str, str]: + security = { + 'consumer_key': self.key, + 'domain': self.domain, + 'timestamp': self.timestamp, + } + if add is not None: + security.update(add) + return security + + def test_none_request_does_not_raise(self) -> None: + """A ``None`` request must be handled by every service without raising.""" + cases: List[Tuple[str, Dict[str, str]]] = [ + ('items', self._security()), + ('reports', self._security()), + ('events', self._security()), + ('assess', self._security({'user_id': '$ANONYMIZED_USER_ID'})), + ('questions', self._security({'user_id': '$ANONYMIZED_USER_ID'})), + ] + for service, security in cases: + with self.subTest(service=service): + init = learnosity_sdk.request.Init( + service, security, self.secret, request=None) + # A signature must still be produced and be well-formed. + self.assertTrue(init.generate_signature().startswith('$02$')) + + def test_events_non_dict_request_is_ignored(self) -> None: + """ + An events request that parses to a non-dict (JSON array) must not add + hashed users to the security packet and must not raise. Regression for + ``AttributeError: 'list' object has no attribute 'get'``. + """ + init = learnosity_sdk.request.Init( + 'events', self._security(), self.secret, request='[1, 2, 3]') + self.assertNotIn('users', init.security) + + def test_non_dict_request_is_ignored_in_generate(self) -> None: + """ + For 'questions' and 'assess', a request that parses to a non-dict must + be skipped by ``generate()`` rather than merged into the output. + """ + for service in ('questions', 'assess'): + with self.subTest(service=service): + init = learnosity_sdk.request.Init( + service, self._security({'user_id': '$ANONYMIZED_USER_ID'}), + self.secret, request='[1, 2, 3]') + output = init.generate(encode=False) + # A request passed as a string is always JSON-encoded on output. + parsed = json.loads(output) if isinstance(output, str) else output + self.assertIsInstance(parsed, dict) + self.assertNotIn(1, parsed.values()) + + def test_questions_generate_merges_dict_request(self) -> None: + """ + 'questions' ``generate()`` must merge a dict request into the output + (and strip ``domain`` from the security packet). + """ + init = learnosity_sdk.request.Init( + 'questions', self._security({'user_id': '$ANONYMIZED_USER_ID'}), + self.secret, request={'foo': 'bar'}) + output = as_dict(init.generate(encode=False)) + self.assertEqual(output['foo'], 'bar') + self.assertNotIn('domain', output) + + def test_assess_generate_merges_dict_request(self) -> None: + """'assess' ``generate()`` must merge a dict request into the output.""" + init = learnosity_sdk.request.Init( + 'assess', self._security({'user_id': '$ANONYMIZED_USER_ID'}), + self.secret, request={'foo': 'bar'}) + output = as_dict(init.generate(encode=False)) + self.assertEqual(output['foo'], 'bar') + + def test_assess_questions_api_activity_is_signed(self) -> None: + """ + When an 'assess' request contains a ``questionsApiActivity`` dict, it + must be replaced with a signed activity (consumer_key, timestamp, + user_id, signature) while preserving any additional keys. + """ + init = learnosity_sdk.request.Init( + 'assess', self._security({'user_id': '$ANONYMIZED_USER_ID'}), + self.secret, request={'questionsApiActivity': {'foo': 'bar'}}) + + request = as_dict(init.request) + activity = request['questionsApiActivity'] + self.assertEqual(activity['consumer_key'], self.key) + self.assertEqual(activity['user_id'], '$ANONYMIZED_USER_ID') + self.assertEqual(activity['timestamp'], self.timestamp) + self.assertTrue(activity['signature'].startswith('$02$')) + # Extra keys supplied by the caller must be retained. + self.assertEqual(activity['foo'], 'bar') + + def test_assess_questions_api_activity_non_dict_is_ignored(self) -> None: + """ + A non-dict ``questionsApiActivity`` must be left untouched and must not + raise. Regression for ``AttributeError: 'str' object has no attribute + 'keys'``. + """ + init = learnosity_sdk.request.Init( + 'assess', self._security({'user_id': '$ANONYMIZED_USER_ID'}), + self.secret, request={'questionsApiActivity': 'not-a-dict'}) + + request = as_dict(init.request) + self.assertEqual(request['questionsApiActivity'], 'not-a-dict') + + def test_assess_activity_uses_activity_domain_and_strips_stale_keys(self) -> None: + """ + When the security packet has no ``domain``, the domain from + ``questionsApiActivity`` is used for signing; stale identity keys + supplied by the caller are stripped and replaced, while other keys are + preserved. + """ + security = { + 'consumer_key': self.key, + 'timestamp': self.timestamp, + 'user_id': '$ANONYMIZED_USER_ID', + } + init = learnosity_sdk.request.Init( + 'assess', security, self.secret, + request={'questionsApiActivity': { + 'domain': 'custom.learnosity.com', + 'consumer_key': 'STALE', + 'signature': 'STALE_SIG', + 'extra': 'keep-me', + }}) + + request = as_dict(init.request) + activity = request['questionsApiActivity'] + # Stale identity keys are replaced with freshly generated values. + self.assertEqual(activity['consumer_key'], self.key) + self.assertTrue(activity['signature'].startswith('$02$')) + self.assertNotEqual(activity['signature'], 'STALE_SIG') + # Non-identity keys supplied by the caller survive. + self.assertEqual(activity['extra'], 'keep-me') + + def test_assess_activity_falls_back_to_default_domain(self) -> None: + """ + When neither the security packet nor the activity supplies a domain, + the activity is still signed using the default assess domain. + """ + security = { + 'consumer_key': self.key, + 'timestamp': self.timestamp, + 'user_id': '$ANONYMIZED_USER_ID', + } + init = learnosity_sdk.request.Init( + 'assess', security, self.secret, + request={'questionsApiActivity': {'extra': 'keep-me'}}) + + request = as_dict(init.request) + activity = request['questionsApiActivity'] + self.assertTrue(activity['signature'].startswith('$02$')) + self.assertEqual(activity['extra'], 'keep-me') + + def test_items_user_id_copied_from_request(self) -> None: + """ + For 'items'/'reports', when security lacks ``user_id`` but the request + provides one, it must be copied into the security packet. + """ + for service in ('items', 'reports'): + with self.subTest(service=service): + init = learnosity_sdk.request.Init( + service, self._security(), self.secret, + request={'user_id': 'req-user', 'items': ['item_1']}) + self.assertEqual(init.security['user_id'], 'req-user') + + def test_items_user_id_in_security_not_overwritten(self) -> None: + """An existing security ``user_id`` must take precedence over the request.""" + init = learnosity_sdk.request.Init( + 'items', self._security({'user_id': 'sec-user'}), self.secret, + request={'user_id': 'req-user', 'items': ['item_1']}) + self.assertEqual(init.security['user_id'], 'sec-user') + + +class TestTelemetryMetaHandling(unittest.TestCase): + """ + Tests for the ``meta`` guards in ``Init.add_telemetry_data()``. + + The SDK meta block must attach safely whether or not the request already + contains a ``meta`` key and regardless of that key's type. A non-dict + ``meta`` previously raised ``TypeError: 'str' object does not support item + assignment``. + """ + + key = 'yis0TYCu7U9V4o7M' + secret = '74c5fd430cf1242a527f6223aebd42d30464be22' + + def setUp(self) -> None: + learnosity_sdk.request.Init.enable_telemetry() + self.addCleanup(learnosity_sdk.request.Init.enable_telemetry) + + def _security(self) -> Dict[str, str]: + return { + 'consumer_key': self.key, + 'domain': 'localhost', + 'timestamp': '20140626-0528', + } + + def test_sdk_meta_added_when_no_meta_present(self) -> None: + """When telemetry is enabled and no ``meta`` exists, one is created.""" + init = learnosity_sdk.request.Init( + 'items', self._security(), self.secret, request={'items': ['item_1']}) + request = as_dict(init.request) + self.assertIn('sdk', request['meta']) + + def test_existing_meta_dict_is_preserved(self) -> None: + """An existing ``meta`` dict must keep its keys and gain the sdk block.""" + init = learnosity_sdk.request.Init( + 'items', self._security(), self.secret, + request={'items': ['item_1'], 'meta': {'existing': 'value'}}) + request = as_dict(init.request) + self.assertEqual(request['meta']['existing'], 'value') + self.assertIn('sdk', request['meta']) + + def test_non_dict_meta_is_replaced(self) -> None: + """ + A non-dict ``meta`` must be replaced with a dict holding the sdk block. + Regression for ``TypeError: 'str' object does not support item + assignment``. + """ + init = learnosity_sdk.request.Init( + 'items', self._security(), self.secret, + request={'items': ['item_1'], 'meta': 'not-a-dict'}) + request = as_dict(init.request) + self.assertIsInstance(request['meta'], dict) + self.assertIn('sdk', request['meta'])