File size: 5,891 Bytes
276ef31
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
"""Hardening tests: password hashing, JWT, CSRF, device fingerprint, encryption."""

from __future__ import annotations

import hashlib
from datetime import datetime, timedelta, timezone

import jwt
import pytest
from passlib.context import CryptContext

from app.core.crypto import decrypt, encrypt
from app.core.security import (
    create_access_token,
    create_refresh_token,
    decode_token,
    device_fingerprint,
    hash_csrf,
    hash_password,
    new_csrf_token,
    verify_password,
)

_pwd = CryptContext(schemes=["bcrypt"], deprecated="auto")


class TestPasswordHashing:
    def test_bcrypt_adaptive_algorithm(self):
        """Verify bcrypt is used (adaptive, salt included)."""
        h = hash_password("test-password-123")
        assert h.startswith("$2b$"), "should be bcrypt $2b$"
        # bcrypt includes salt in the hash string
        parts = h.split("$")
        assert len(parts) == 4
        assert parts[1] == "2b"

    def test_hash_is_deterministic_with_salt(self):
        """Each hash should be different (different salt) but all verify."""
        h1 = hash_password("same-pass")
        h2 = hash_password("same-pass")
        assert h1 != h2, "different salts should produce different hashes"
        assert verify_password("same-pass", h1)
        assert verify_password("same-pass", h2)

    def test_wrong_password_fails(self):
        h = hash_password("real-pass")
        assert not verify_password("wrong-pass", h)

    def test_empty_password(self):
        h = hash_password("")
        assert verify_password("", h)

    def test_long_password(self):
        long = "a" * 1000
        h = hash_password(long)
        assert verify_password(long, h)


class TestJWT:
    def test_access_token_creation_and_decoding(self):
        token = create_access_token(sub="user123", role="user")
        payload = decode_token(token)
        assert payload["sub"] == "user123"
        assert payload["role"] == "user"
        assert payload["type"] == "access"
        assert "jti" in payload
        assert "iat" in payload
        assert "exp" in payload

    def test_access_token_expiry(self, settings):
        """Access token should expire after jwt_access_ttl_min."""
        short_ttl = 1 / 60  # 1 second
        # Override for test
        token = create_access_token(sub="x", role="user")
        payload = decode_token(token)
        exp = datetime.fromtimestamp(payload["exp"], tz=timezone.utc)
        expected = datetime.now(tz=timezone.utc) + timedelta(minutes=settings.jwt_access_ttl_min)
        # Allow 5 sec tolerance
        assert abs((exp - expected).total_seconds()) < 5

    def test_refresh_token_has_session_id(self):
        token, exp = create_refresh_token(sub="user123", session_id="sess_abc")
        payload = decode_token(token)
        assert payload["type"] == "refresh"
        assert payload["sid"] == "sess_abc"
        assert payload["sub"] == "user123"

    def test_jwt_algorithm_hs256(self, settings):
        token = create_access_token(sub="x", role="user")
        # Parse without verification to check header
        header = jwt.get_unverified_header(token)
        assert header["alg"] == "HS256"

    def test_expired_token_raises(self):
        from app.core.security import decode_token
        expired = jwt.encode(
            {"sub": "x", "exp": 0, "iat": 0, "type": "access", "jti": "test"},
            "secret",
            algorithm="HS256",
        )
        # Manually decode to test expiry
        with pytest.raises(jwt.ExpiredSignatureError):
            decode_token(expired)

    def test_wrong_token_type_rejected(self):
        """Refresh token used as access should be rejected."""
        token, _ = create_refresh_token(sub="x", session_id="s")
        payload = decode_token(token)
        assert payload["type"] == "refresh"

    def test_token_has_unique_jti(self):
        t1 = create_access_token(sub="x", role="user")
        t2 = create_access_token(sub="x", role="user")
        p1 = decode_token(t1)
        p2 = decode_token(t2)
        assert p1["jti"] != p2["jti"]


class TestCSRF:
    def test_token_generation(self):
        t = new_csrf_token()
        assert len(t) == 43  # 32 bytes url-safe base64

    def test_hash_is_deterministic(self):
        t = "some-random-token-value"
        h1 = hash_csrf(t)
        h2 = hash_csrf(t)
        assert h1 == h2
        assert len(h1) == 64  # SHA256 hex

    def test_different_tokens_different_hashes(self):
        assert hash_csrf("token-a") != hash_csrf("token-b")


class TestDeviceFingerprint:
    def test_fingerprint_format(self):
        fp = device_fingerprint("Mozilla/5.0", "192.168.1.1")
        assert len(fp) == 32
        assert all(c in "0123456789abcdef" for c in fp)

    def test_different_inputs_different_fingerprints(self):
        fp1 = device_fingerprint("Chrome", "1.2.3.4")
        fp2 = device_fingerprint("Firefox", "1.2.3.4")
        assert fp1 != fp2

    def test_same_inputs_same_fingerprint(self):
        fp1 = device_fingerprint("Chrome", "1.2.3.4")
        fp2 = device_fingerprint("Chrome", "1.2.3.4")
        assert fp1 == fp2


class TestFernetEncryption:
    def test_encrypt_decrypt_roundtrip(self):
        original = "sk-proj-OpenAIKey12345"
        encrypted = encrypt(original)
        assert encrypted != original
        assert encrypted.startswith("gAAAAA")  # Fernet prefix
        decrypted = decrypt(encrypted)
        assert decrypted == original

    def test_empty_string(self):
        assert encrypt("") == ""
        assert decrypt("") == ""

    def test_invalid_ciphertext(self):
        assert decrypt("not-valid") == ""

    def test_api_key_presence(self):
        """ModelOut.hasApiKey should reflect key presence, not value."""
        encrypted = encrypt("sk-test-123")
        assert encrypted  # Not empty
        assert encrypted != "sk-test-123"