test_auth.py (4010B)
1 """T013 — Tests for auth (password hashing + JWT).""" 2 3 import time 4 5 import pytest 6 7 from app.auth import ( 8 create_jwt, 9 hash_password, 10 verify_jwt, 11 verify_password, 12 ) 13 14 15 class TestPasswordHashing: 16 """argon2id password hashing.""" 17 18 def test_hash_password_returns_string(self) -> None: 19 """Hash is a non-empty string.""" 20 hashed = hash_password("mysecret") 21 assert isinstance(hashed, str) 22 assert len(hashed) > 20 23 24 def test_verify_password_correct(self) -> None: 25 """Correct password verifies successfully.""" 26 hashed = hash_password("correct-horse") 27 assert verify_password("correct-horse", hashed) is True 28 29 def test_verify_password_wrong(self) -> None: 30 """Wrong password returns False.""" 31 hashed = hash_password("correct-horse") 32 assert verify_password("wrong-battery", hashed) is False 33 34 def test_different_hashes_for_same_input(self) -> None: 35 """Each hash is unique (salted).""" 36 h1 = hash_password("same-pass") 37 h2 = hash_password("same-pass") 38 assert h1 != h2 39 40 41 class TestJWT: 42 """JWT creation and verification.""" 43 44 def test_create_jwt_returns_string(self) -> None: 45 """Token is a non-empty string.""" 46 token = create_jwt({"sub": "user1"}) 47 assert isinstance(token, str) 48 assert len(token) > 20 49 50 def test_verify_jwt_decodes_valid_token(self) -> None: 51 """Valid token decodes to original payload.""" 52 token = create_jwt({"sub": "user1"}) 53 payload = verify_jwt(token) 54 assert payload["sub"] == "user1" 55 56 def test_verify_jwt_raises_on_expired(self) -> None: 57 """Expired token raises an exception.""" 58 token = create_jwt({"sub": "user1"}, expires_in_seconds=-1) 59 with pytest.raises(Exception): 60 verify_jwt(token) 61 62 def test_verify_jwt_raises_on_invalid(self) -> None: 63 """Tampered token raises an exception.""" 64 token = create_jwt({"sub": "user1"}) 65 with pytest.raises(Exception): 66 verify_jwt(token + "tampered") 67 68 69 class TestSecretManagement: 70 """Secret key loading and creation.""" 71 72 def test_get_secret_from_env(self, monkeypatch) -> None: 73 """_get_secret_key reads from THEO_JWT_SECRET env var.""" 74 monkeypatch.setenv("THEO_JWT_SECRET", "env-secret-123") 75 from app.auth import _get_secret_key 76 assert _get_secret_key() == "env-secret-123" 77 78 def test_load_or_create_secret_creates_file(self, tmp_path, monkeypatch) -> None: 79 """Creates .jwt_secret file when it doesn't exist.""" 80 monkeypatch.setenv("THEO_DATA_DIR", str(tmp_path)) 81 from app.auth import _load_or_create_secret 82 secret = _load_or_create_secret() 83 assert len(secret) == 64 # 32 bytes hex 84 secret_file = tmp_path / ".jwt_secret" 85 assert secret_file.exists() 86 assert secret_file.read_text().strip() == secret 87 88 def test_load_or_create_secret_reads_existing(self, tmp_path, monkeypatch) -> None: 89 """Reads existing .jwt_secret file.""" 90 monkeypatch.setenv("THEO_DATA_DIR", str(tmp_path)) 91 (tmp_path / ".jwt_secret").write_text("persisted-secret") 92 from app.auth import _load_or_create_secret 93 assert _load_or_create_secret() == "persisted-secret" 94 95 def test_create_jwt_uses_secret_key(self) -> None: 96 """JWT is signed with the secret key.""" 97 token = create_jwt({"sub": "test"}) 98 payload = verify_jwt(token) 99 assert payload["sub"] == "test" 100 assert "exp" in payload 101 102 def test_create_jwt_custom_expiry(self) -> None: 103 """Custom expiry is encoded in the token.""" 104 token = create_jwt({"sub": "test"}, expires_in_seconds=3600) 105 payload = verify_jwt(token) 106 assert payload["exp"] > int(time.time()) 107 108 def test_verify_jwt_raises_on_malformed(self) -> None: 109 """Non-JWT string raises.""" 110 with pytest.raises(Exception): 111 verify_jwt("not-a-jwt-token") 112