"""Тесты примитивов безопасности: почта, пароли, токены.""" import base64 import threading import unittest from unittest import mock from splitapi import security class NormalizeEmailTests(unittest.TestCase): def test_lowercases_and_strips(self): self.assertEqual(security.normalize_email(" User@Example.COM "), "user@example.com") def test_accepts_plus_and_dots(self): self.assertEqual(security.normalize_email("a.b+tag@mail.example.org"), "a.b+tag@mail.example.org") def test_idn_domain_is_converted_to_punycode(self): self.assertEqual(security.normalize_email("ivan@почта.рф"), "ivan@xn--80a1acny.xn--p1ai") def test_rejects_garbage(self): for bad in ["", "plain", "a@b", "@example.com", "a@@example.com", "a b@example.com", "a@exa mple.com", "a@example..com", "a@-example.com", "a@example.c", None, 42]: with self.subTest(bad=bad): self.assertIsNone(security.normalize_email(bad)) def test_rejects_newline_inside_address(self): """re.match с $ пропускал перевод строки перед концом строки: нужен fullmatch.""" for bad in ["a\n@example.com", "user@example\n.com", "a@example.com\n\n", "a@exam\nple.com"]: with self.subTest(bad=bad): result = security.normalize_email(bad) self.assertTrue(result is None or "\n" not in result) self.assertIsNone(security.normalize_email("a\n@example.com")) self.assertIsNone(security.normalize_email("user@example\n.com")) def test_rejects_characters_dangerous_for_legacy_mail_systems(self): for bad in ["user%evil.com@example.com", "a|cmd@example.com", "a/b@example.com", "a!b@example.com", "a#b@example.com", "a$b@example.com", "a&b@example.com", "a*b@example.com", "a=b@example.com", "a?b@example.com", "a`b@example.com"]: with self.subTest(bad=bad): self.assertIsNone(security.normalize_email(bad)) def test_keeps_apostrophe_in_real_names(self): self.assertEqual(security.normalize_email("O'Brien@Example.com"), "o'brien@example.com") def test_rejects_header_injection(self): for bad in ["a@example.com\r\nBcc: evil@x.org", "a@example.com\nX: y", "a\x00@example.com"]: with self.subTest(bad=bad): self.assertIsNone(security.normalize_email(bad)) def test_rejects_too_long(self): self.assertIsNone(security.normalize_email("a" * 65 + "@example.com")) self.assertIsNone(security.normalize_email("a@" + "b" * 250 + ".com")) class PasswordPolicyTests(unittest.TestCase): def test_accepts_reasonable_password(self): self.assertIsNone(security.validate_password("correct horse battery", "u@example.com")) def test_too_short(self): self.assertEqual(security.validate_password("short1", "u@example.com"), "password_too_short") def test_too_long_is_rejected_to_bound_hashing_cost(self): self.assertEqual(security.validate_password("x" * 129, "u@example.com"), "password_too_long") def test_not_a_string(self): self.assertEqual(security.validate_password(None, "u@example.com"), "password_too_short") def test_equal_to_email_or_local_part(self): self.assertEqual(security.validate_password("someone@example.com", "someone@example.com"), "password_weak") self.assertEqual(security.validate_password("someoneelse1", "someoneelse1@example.com"), "password_weak") def test_common_passwords_rejected(self): for pw in ["password", "12345678", "qwertyuiop", "Password1"]: with self.subTest(pw=pw): self.assertEqual(security.validate_password(pw, "u@example.com"), "password_weak") def test_unicode_password_length_counts_characters(self): self.assertIsNone(security.validate_password("пароль-на-русском", "u@example.com")) def test_lone_surrogates_are_rejected_instead_of_crashing_the_hasher(self): """json.loads пропускает "\\ud800": такая строка не кодируется в UTF-8 и раньше давала 500.""" for bad in ["\ud800" * 8, "valid-prefix-\udfff", "ok-password-" + "\udc00"]: with self.subTest(bad=bad): self.assertEqual(security.validate_password(bad, "u@example.com"), "password_invalid") class PasswordHashTests(unittest.TestCase): def test_roundtrip(self): stored = security.hash_password("s3cret-password") self.assertTrue(security.verify_password(stored, "s3cret-password")) self.assertFalse(security.verify_password(stored, "S3cret-password")) def test_hash_has_parameters_and_is_salted(self): a, b = security.hash_password("same-password"), security.hash_password("same-password") self.assertNotEqual(a, b) self.assertTrue(a.startswith("scrypt$")) self.assertNotIn("same-password", a) def test_unicode_normalization_is_consistent(self): stored = security.hash_password("éclair-1234") # e + combining acute self.assertTrue(security.verify_password(stored, "éclair-1234")) # precomposed é def test_malformed_stored_hash_never_verifies(self): for bad in ["", "garbage", "scrypt$1$2", "scrypt$x$8$1$AAAA$BBBB", "md5$1$2$3$4$5"]: with self.subTest(bad=bad): self.assertFalse(security.verify_password(bad, "whatever-password")) def test_needs_rehash_detects_weaker_parameters(self): weak = security.hash_password("pw-for-rehash", n=2 ** 12) self.assertTrue(security.needs_rehash(weak)) self.assertFalse(security.needs_rehash(security.hash_password("pw-for-rehash"))) def test_dummy_verify_takes_same_path_and_returns_false(self): self.assertFalse(security.verify_dummy("any-password-here")) def test_waiting_for_a_hash_slot_times_out_instead_of_hanging(self): stored = "$".join(["scrypt", "1024", "8", "1", base64.b64encode(b"s" * 16).decode(), base64.b64encode(b"d" * 32).decode()]) with mock.patch.object(security, "_slots", threading.BoundedSemaphore(0)), mock.patch.object(security, "SLOT_WAIT", 0.05): with self.assertRaises(security.HashBusy): security.hash_password("some-password-1") with self.assertRaises(security.HashBusy): security.verify_password(stored, "some-password-1") def test_hash_slot_is_released_after_use(self): for _ in range(8): # больше, чем слотов: утечка слота зависла бы security.hash_password("another-password", n=2**10) class TokenTests(unittest.TestCase): def test_tokens_are_random_and_long(self): raw1, h1 = security.new_token() raw2, h2 = security.new_token() self.assertNotEqual(raw1, raw2) self.assertGreaterEqual(len(raw1), 43) self.assertNotEqual(h1, h2) def test_hash_is_sha256_hex_and_deterministic(self): raw, digest = security.new_token() self.assertEqual(security.hash_token(raw), digest) self.assertRegex(digest, r"^[0-9a-f]{64}$") self.assertNotIn(raw, digest) def test_hash_token_rejects_non_strings(self): self.assertIsNone(security.hash_token(None)) self.assertIsNone(security.hash_token(123)) self.assertIsNone(security.hash_token("x" * 300)) if __name__ == "__main__": unittest.main()