"""Тесты паролей, JWT и шифрования секретов. Без БД и без сети.""" from __future__ import annotations import uuid from datetime import UTC, datetime, timedelta import jwt import pytest from app.core import crypto from app.core.config import settings from app.core.security import ( ALGORITHM, TokenError, create_access_token, decode_access_token, generate_refresh_token, hash_password, hash_refresh_token, verify_password, ) class TestPasswords: def test_verifies_correct_password(self) -> None: assert verify_password("correct horse battery", hash_password("correct horse battery")) def test_rejects_wrong_password(self) -> None: assert not verify_password("wrong", hash_password("correct horse battery")) def test_hash_is_salted(self) -> None: """Одинаковые пароли обязаны давать разные хеши.""" assert hash_password("same") != hash_password("same") def test_rejects_garbage_hash_without_raising(self) -> None: """Повреждённый хеш в БД не должен ронять вход с 500.""" assert not verify_password("anything", "not-a-valid-argon2-hash") class TestAccessToken: def test_roundtrip_carries_identity(self) -> None: user_id = uuid.uuid4() payload = decode_access_token(create_access_token(user_id, "cashier")) assert payload["sub"] == str(user_id) assert payload["role"] == "cashier" def test_rejects_expired_token(self) -> None: expired = jwt.encode( { "sub": str(uuid.uuid4()), "role": "admin", "type": "access", "exp": datetime.now(UTC) - timedelta(minutes=1), }, settings.secret_key, algorithm=ALGORITHM, ) with pytest.raises(TokenError): decode_access_token(expired) def test_rejects_token_signed_with_other_key(self) -> None: forged = jwt.encode( { "sub": str(uuid.uuid4()), "role": "admin", "type": "access", "exp": datetime.now(UTC) + timedelta(hours=1), }, "attacker-key", algorithm=ALGORITHM, ) with pytest.raises(TokenError): decode_access_token(forged) def test_refresh_token_is_not_accepted_as_access(self) -> None: """Ключевая проверка: подмена типа токена не должна давать доступ.""" refresh_shaped = jwt.encode( { "sub": str(uuid.uuid4()), "role": "admin", "type": "refresh", "exp": datetime.now(UTC) + timedelta(days=7), }, settings.secret_key, algorithm=ALGORITHM, ) with pytest.raises(TokenError, match="access"): decode_access_token(refresh_shaped) class TestRefreshToken: def test_tokens_are_unique(self) -> None: assert generate_refresh_token()[0] != generate_refresh_token()[0] def test_hash_matches_raw_value(self) -> None: raw, stored = generate_refresh_token() assert hash_refresh_token(raw) == stored def test_raw_token_is_not_recoverable_from_hash(self) -> None: raw, stored = generate_refresh_token() assert raw not in stored class TestCrypto: def test_roundtrip(self) -> None: secret = "license-key-abc-123" assert crypto.decrypt(crypto.encrypt(secret)) == secret def test_ciphertext_hides_plaintext(self) -> None: assert "license-key" not in crypto.encrypt("license-key-abc-123") def test_tampered_ciphertext_is_rejected(self) -> None: token = crypto.encrypt("license-key-abc-123") tampered = token[:-4] + ("AAAA" if not token.endswith("AAAA") else "BBBB") with pytest.raises(crypto.DecryptionError): crypto.decrypt(tampered) @pytest.mark.parametrize( ("value", "expected"), [("", ""), ("abc", "***"), ("abcd", "****"), ("abcdefgh", "****efgh")], ) def test_mask(self, value: str, expected: str) -> None: assert crypto.mask(value) == expected