From c95361ffd6ca10caaf3b145d141d7c8cd0ac3304 Mon Sep 17 00:00:00 2001 From: Giovanni Cimolin da Silva Date: Mon, 27 Jan 2025 21:37:38 -0300 Subject: [PATCH 1/2] test: Improve test coverage --- knox/crypto.py | 34 ++++++++- tests/test_crypto.py | 57 ++++++++++++++ tests/test_models.py | 86 +++++++++++++++++++++ tests/test_settings.py | 122 ++++++++++++++++++++++++++++++ tests/{tests.py => test_views.py} | 46 +++++++---- 5 files changed, 330 insertions(+), 15 deletions(-) create mode 100644 tests/test_crypto.py create mode 100644 tests/test_models.py create mode 100644 tests/test_settings.py rename tests/{tests.py => test_views.py} (96%) diff --git a/knox/crypto.py b/knox/crypto.py index 02e70ffe..86779a00 100644 --- a/knox/crypto.py +++ b/knox/crypto.py @@ -7,6 +7,17 @@ def create_token_string() -> str: + """ + Creates a secure random token string using hexadecimal encoding. + + The token length is determined by knox_settings.AUTH_TOKEN_CHARACTER_LENGTH. + Since each byte is represented by 2 hexadecimal characters, the number of + random bytes generated is half the desired character length. + + Returns: + str: A hexadecimal string of length AUTH_TOKEN_CHARACTER_LENGTH containing + random bytes. + """ return binascii.hexlify( generate_bytes(int(knox_settings.AUTH_TOKEN_CHARACTER_LENGTH / 2)) ).decode() @@ -14,8 +25,16 @@ def create_token_string() -> str: def make_hex_compatible(token: str) -> bytes: """ + Converts a string token into a hex-compatible bytes object. + We need to make sure that the token, that is send is hex-compatible. When a token prefix is used, we cannot guarantee that. + + Args: + token (str): The token string to convert. + + Returns: + bytes: The hex-compatible bytes representation of the token. """ return binascii.unhexlify(binascii.hexlify(bytes(token, 'utf-8'))) @@ -23,8 +42,19 @@ def make_hex_compatible(token: str) -> bytes: def hash_token(token: str) -> str: """ Calculates the hash of a token. - Token must contain an even number of hex digits or - a binascii.Error exception will be raised. + + Uses the hash algorithm specified in knox_settings.SECURE_HASH_ALGORITHM. + The token is first converted to a hex-compatible format before hashing. + + Args: + token (str): The token string to hash. + + Returns: + str: The hexadecimal representation of the token's hash digest. + + Example: + >>> hash_token("abc123") + 'a123f...' # The actual hash will be longer """ digest = hash_func() digest.update(make_hex_compatible(token)) diff --git a/tests/test_crypto.py b/tests/test_crypto.py new file mode 100644 index 00000000..bc85015e --- /dev/null +++ b/tests/test_crypto.py @@ -0,0 +1,57 @@ +from django.test import TestCase +from unittest.mock import patch +from knox.settings import knox_settings +from knox.crypto import create_token_string, make_hex_compatible, hash_token + + +class CryptoUtilsTestCase(TestCase): + def test_create_token_string(self): + """ + Verify token string creation has correct length and contains only hex characters. + """ + with patch('os.urandom') as mock_urandom: + mock_urandom.return_value = b'abcdef1234567890' + expected_length = knox_settings.AUTH_TOKEN_CHARACTER_LENGTH + token = create_token_string() + self.assertEqual(len(token), expected_length) + hex_chars = set('0123456789abcdef') + self.assertTrue(all(c in hex_chars for c in token.lower())) + + def test_make_hex_compatible_with_valid_input(self): + """ + Ensure standard strings are correctly converted to hex-compatible bytes. + """ + test_token = "test123" + result = make_hex_compatible(test_token) + self.assertIsInstance(result, bytes) + expected = b'test123' + self.assertEqual(result, expected) + + def test_make_hex_compatible_with_empty_string(self): + """ + Verify empty string input returns empty bytes. + """ + test_token = "" + result = make_hex_compatible(test_token) + self.assertEqual(result, b'') + + def test_make_hex_compatible_with_special_characters(self): + """ + Check hex compatibility conversion handles special characters correctly. + """ + test_token = "test@#$%" + result = make_hex_compatible(test_token) + self.assertIsInstance(result, bytes) + expected = b'test@#$%' + self.assertEqual(result, expected) + + def test_hash_token_with_valid_token(self): + """ + Verify hash output is correct length and contains valid hex characters. + """ + test_token = "abcdef1234567890" + result = hash_token(test_token) + self.assertIsInstance(result, str) + self.assertEqual(len(result), 128) + hex_chars = set('0123456789abcdef') + self.assertTrue(all(c in hex_chars for c in result.lower())) diff --git a/tests/test_models.py b/tests/test_models.py new file mode 100644 index 00000000..f9e3d159 --- /dev/null +++ b/tests/test_models.py @@ -0,0 +1,86 @@ +from django.test import TestCase +from django.contrib.auth import get_user_model +from django.utils import timezone +from datetime import timedelta +from freezegun import freeze_time + +from knox.settings import CONSTANTS, knox_settings +from knox.models import AuthToken + + +class AuthTokenTests(TestCase): + """ + Auth token model tests. + """ + + def setUp(self): + self.User = get_user_model() + self.user = self.User.objects.create_user( + username='testuser', + password='testpass123' + ) + + def test_token_creation(self): + """ + Test that tokens are created correctly with expected format. + """ + token_creation = timezone.now() + with freeze_time(token_creation): + instance, token = AuthToken.objects.create(user=self.user) + self.assertIsNotNone(token) + self.assertTrue(token.startswith(knox_settings.TOKEN_PREFIX)) + self.assertEqual( + len(instance.token_key), + CONSTANTS.TOKEN_KEY_LENGTH, + ) + self.assertEqual(instance.user, self.user) + self.assertEqual( + instance.expiry, + token_creation + timedelta(hours=10) + ) + + def test_token_creation_with_expiry(self): + """ + Test token creation with explicit expiry time. + """ + expiry_time = timedelta(hours=10) + before_creation = timezone.now() + instance, _ = AuthToken.objects.create( + user=self.user, + expiry=expiry_time + ) + self.assertIsNotNone(instance.expiry) + self.assertTrue(before_creation < instance.expiry) + self.assertTrue( + (instance.expiry - before_creation - expiry_time).total_seconds() < 1 + ) + + def test_token_string_representation(self): + """ + Test the string representation of AuthToken. + """ + instance, _ = AuthToken.objects.create(user=self.user) + expected_str = f'{instance.digest} : {self.user}' + self.assertEqual(str(instance), expected_str) + + def test_multiple_tokens_for_user(self): + """ + Test that a user can have multiple valid tokens. + """ + token1, _ = AuthToken.objects.create(user=self.user) + token2, _ = AuthToken.objects.create(user=self.user) + user_tokens = self.user.auth_token_set.all() + self.assertEqual(user_tokens.count(), 2) + self.assertNotEqual(token1.digest, token2.digest) + + def test_token_with_custom_prefix(self): + """ + Test token creation with custom prefix. + """ + custom_prefix = "TEST_" + instance, token = AuthToken.objects.create( + user=self.user, + prefix=custom_prefix + ) + self.assertTrue(token.startswith(custom_prefix)) + self.assertTrue(instance.token_key.startswith(custom_prefix)) diff --git a/tests/test_settings.py b/tests/test_settings.py new file mode 100644 index 00000000..337b44e3 --- /dev/null +++ b/tests/test_settings.py @@ -0,0 +1,122 @@ +from datetime import timedelta +from unittest import mock +import hashlib +from django.test import override_settings +from django.core.signals import setting_changed + +from knox.settings import ( + CONSTANTS, + knox_settings, + reload_api_settings, + IMPORT_STRINGS, +) + + +class TestKnoxSettings: + @override_settings(REST_KNOX={ + 'AUTH_TOKEN_CHARACTER_LENGTH': 32, + 'TOKEN_TTL': timedelta(hours=5), + 'AUTO_REFRESH': True, + }) + def test_override_settings(self): + """ + Test that settings can be overridden. + """ + assert knox_settings.AUTH_TOKEN_CHARACTER_LENGTH == 32 + assert knox_settings.TOKEN_TTL == timedelta(hours=5) + assert knox_settings.AUTO_REFRESH is True + # Default values should remain unchanged + assert knox_settings.AUTH_HEADER_PREFIX == 'Token' + + def test_constants_immutability(self): + """ + Test that CONSTANTS cannot be modified. + """ + with self.assertRaises(Exception): + CONSTANTS.TOKEN_KEY_LENGTH = 20 + + with self.assertRaises(Exception): + CONSTANTS.DIGEST_LENGTH = 256 + + def test_constants_values(self): + """ + Test that CONSTANTS have correct values. + """ + assert CONSTANTS.TOKEN_KEY_LENGTH == 15 + assert CONSTANTS.DIGEST_LENGTH == 128 + assert CONSTANTS.MAXIMUM_TOKEN_PREFIX_LENGTH == 10 + + def test_reload_api_settings(self): + """ + Test settings reload functionality. + """ + new_settings = { + 'TOKEN_TTL': timedelta(hours=2), + 'AUTH_HEADER_PREFIX': 'Bearer', + } + + reload_api_settings( + setting='REST_KNOX', + value=new_settings + ) + + assert knox_settings.TOKEN_TTL == timedelta(hours=2) + assert knox_settings.AUTH_HEADER_PREFIX == 'Bearer' + + def test_token_prefix_length_validation(self): + """ + Test that TOKEN_PREFIX length is validated. + """ + with self.assertRaises(ValueError, match="Illegal TOKEN_PREFIX length"): + reload_api_settings( + setting='REST_KNOX', + value={'TOKEN_PREFIX': 'x' * 11} # Exceeds MAXIMUM_TOKEN_PREFIX_LENGTH + ) + + def test_import_strings(self): + """ + Test that import strings are properly handled. + """ + assert 'SECURE_HASH_ALGORITHM' in IMPORT_STRINGS + assert 'USER_SERIALIZER' in IMPORT_STRINGS + + @override_settings(REST_KNOX={ + 'SECURE_HASH_ALGORITHM': 'hashlib.md5' + }) + def test_hash_algorithm_import(self): + """ + Test that hash algorithm is properly imported. + """ + assert knox_settings.SECURE_HASH_ALGORITHM == hashlib.md5 + + def test_setting_changed_signal(self): + """ + Test that setting_changed signal properly triggers reload. + """ + new_settings = { + 'TOKEN_TTL': timedelta(hours=3), + } + + setting_changed.send( + sender=None, + setting='REST_KNOX', + value=new_settings + ) + + assert knox_settings.TOKEN_TTL == timedelta(hours=3) + + @mock.patch('django.conf.settings') + def test_custom_token_model(self, mock_settings): + """ + Test custom token model setting. + """ + custom_model = 'custom_app.CustomToken' + mock_settings.KNOX_TOKEN_MODEL = custom_model + + # Reload settings + reload_api_settings( + setting='REST_KNOX', + value={} + ) + + assert knox_settings.TOKEN_MODEL == custom_model diff --git a/tests/tests.py b/tests/test_views.py similarity index 96% rename from tests/tests.py rename to tests/test_views.py index d4355b87..abbd112d 100644 --- a/tests/tests.py +++ b/tests/test_views.py @@ -22,6 +22,9 @@ def get_basic_auth_header(username, password): + """ + Create a basic auth header (test helper). + """ return 'Basic %s' % base64.b64encode( (f'{username}:{password}').encode('ascii')).decode() @@ -57,9 +60,11 @@ def get_basic_auth_header(username, password): token_prefix_too_long_knox["TOKEN_PREFIX"] = token_prefix_too_long -class AuthTestCase(TestCase): - +class BaseTestCase(TestCase): def setUp(self): + """ + Creates test users. + """ self.username = 'john.doe' self.email = 'john.doe@example.com' self.password = 'hunter2' @@ -70,6 +75,12 @@ def setUp(self): self.password2 = 'hunter2' self.user2 = User.objects.create_user(self.username2, self.email2, self.password2) + +class LoginViewTestCase(BaseTestCase): + """ + Tests the functionality of the login view. + """ + def test_login_creates_keys(self): self.assertEqual(AuthToken.objects.count(), 0) url = reverse('knox_login') @@ -95,7 +106,6 @@ def test_login_returns_serialized_token(self): self.assertNotIn(username_field, response.data) def test_login_returns_serialized_token_and_username_field(self): - with override_settings(REST_KNOX=user_serializer_knox): reload(views) self.assertEqual(AuthToken.objects.count(), 0) @@ -113,7 +123,6 @@ def test_login_returns_serialized_token_and_username_field(self): self.assertIn(username_field, response.data['user']) def test_login_returns_configured_expiry_datetime_format(self): - with override_settings(REST_KNOX=expiry_datetime_format_knox): reload(views) self.assertEqual(AuthToken.objects.count(), 0) @@ -137,6 +146,12 @@ def test_login_returns_configured_expiry_datetime_format(self): ) ) + +class LogoutViewsTestCase(BaseTestCase): + """ + Tests the functionality of the logout views. + """ + def test_logout_deletes_keys(self): self.assertEqual(AuthToken.objects.count(), 0) for _ in range(2): @@ -163,7 +178,7 @@ def test_logout_all_deletes_keys(self): def test_logout_all_deletes_only_targets_keys(self): self.assertEqual(AuthToken.objects.count(), 0) for _ in range(10): - instance, token = AuthToken.objects.create(user=self.user) + _, token = AuthToken.objects.create(user=self.user) AuthToken.objects.create(user=self.user2) self.assertEqual(AuthToken.objects.count(), 20) @@ -173,6 +188,12 @@ def test_logout_all_deletes_only_targets_keys(self): self.assertEqual(AuthToken.objects.count(), 10, 'tokens from other users should not be affected by logout all') + +class TokenAuthenticationTestCase(BaseTestCase): + """ + Tests the functionality of the `TokenAuthentication` class. + """ + def test_expired_tokens_login_fails(self): self.assertEqual(AuthToken.objects.count(), 0) instance, token = AuthToken.objects.create( @@ -186,7 +207,7 @@ def test_expired_tokens_deleted(self): self.assertEqual(AuthToken.objects.count(), 0) for _ in range(10): # -1 TTL gives an expired token - instance, token = AuthToken.objects.create( + _, token = AuthToken.objects.create( user=self.user, expiry=timedelta(seconds=-1)) self.assertEqual(AuthToken.objects.count(), 10) @@ -198,7 +219,7 @@ def test_expired_tokens_deleted(self): def test_update_token_key(self): self.assertEqual(AuthToken.objects.count(), 0) - instance, token = AuthToken.objects.create(self.user) + _, token = AuthToken.objects.create(self.user) rf = APIRequestFactory() request = rf.get('/') request.META = {'HTTP_AUTHORIZATION': f'Token {token}'} @@ -244,7 +265,7 @@ def test_invalid_token_length_returns_401_code(self): self.assertEqual(response.data, {"detail": "Invalid token."}) def test_invalid_odd_length_token_returns_401_code(self): - instance, token = AuthToken.objects.create(self.user) + _, token = AuthToken.objects.create(self.user) odd_length_token = token + '1' self.client.credentials(HTTP_AUTHORIZATION=('Token %s' % odd_length_token)) response = self.client.post(root_url, {}, format='json') @@ -252,11 +273,13 @@ def test_invalid_odd_length_token_returns_401_code(self): self.assertEqual(response.data, {"detail": "Invalid token."}) def test_token_expiry_is_extended_with_auto_refresh_activated(self): + """ + """ ttl = knox_settings.TOKEN_TTL original_time = datetime(2018, 7, 25, 0, 0, 0, 0) with freeze_time(original_time): - instance, token = AuthToken.objects.create(user=self.user) + _, token = AuthToken.objects.create(user=self.user) self.client.credentials(HTTP_AUTHORIZATION=('Token %s' % token)) five_hours_later = original_time + timedelta(hours=5) @@ -359,7 +382,7 @@ def handler(sender, username, **kwargs): token_expired.connect(handler) - instance, token = AuthToken.objects.create( + _, token = AuthToken.objects.create( user=self.user, expiry=timedelta(seconds=-1), ) @@ -369,7 +392,6 @@ def handler(sender, username, **kwargs): self.assertTrue(self.signal_was_called) def test_exceed_token_amount_per_user(self): - with override_settings(REST_KNOX=token_user_limit_knox): reload(views) for _ in range(5): @@ -387,7 +409,6 @@ def test_exceed_token_amount_per_user(self): {"error": "Maximum amount of tokens allowed per user exceeded."}) def test_does_not_exceed_on_expired_keys(self): - with override_settings(REST_KNOX=token_user_limit_knox): reload(views) for _ in range(9): @@ -408,7 +429,6 @@ def test_does_not_exceed_on_expired_keys(self): {"error": "Maximum amount of tokens allowed per user exceeded."}) def test_invalid_prefix_return_401(self): - with override_settings(REST_KNOX=auth_header_prefix_knox): reload(auth) instance, token = AuthToken.objects.create(user=self.user) From 08b5e4f88ec7bf29b7b99f189667d9d348bf15a4 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 28 Jan 2025 00:41:04 +0000 Subject: [PATCH 2/2] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- tests/test_crypto.py | 6 ++++-- tests/test_models.py | 7 ++++--- tests/test_settings.py | 10 ++++------ 3 files changed, 12 insertions(+), 11 deletions(-) diff --git a/tests/test_crypto.py b/tests/test_crypto.py index bc85015e..ac1efd9a 100644 --- a/tests/test_crypto.py +++ b/tests/test_crypto.py @@ -1,7 +1,9 @@ -from django.test import TestCase from unittest.mock import patch + +from django.test import TestCase + +from knox.crypto import create_token_string, hash_token, make_hex_compatible from knox.settings import knox_settings -from knox.crypto import create_token_string, make_hex_compatible, hash_token class CryptoUtilsTestCase(TestCase): diff --git a/tests/test_models.py b/tests/test_models.py index f9e3d159..37c74b4c 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -1,11 +1,12 @@ -from django.test import TestCase +from datetime import timedelta + from django.contrib.auth import get_user_model +from django.test import TestCase from django.utils import timezone -from datetime import timedelta from freezegun import freeze_time -from knox.settings import CONSTANTS, knox_settings from knox.models import AuthToken +from knox.settings import CONSTANTS, knox_settings class AuthTokenTests(TestCase): diff --git a/tests/test_settings.py b/tests/test_settings.py index 337b44e3..e47cd0d0 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -1,14 +1,12 @@ +import hashlib from datetime import timedelta from unittest import mock -import hashlib -from django.test import override_settings + from django.core.signals import setting_changed +from django.test import override_settings from knox.settings import ( - CONSTANTS, - knox_settings, - reload_api_settings, - IMPORT_STRINGS, + CONSTANTS, IMPORT_STRINGS, knox_settings, reload_api_settings, )