File size: 5,099 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
"""Pure unit tests for security primitives (no DB needed)."""

from __future__ import annotations

import hashlib
from datetime import datetime, timedelta, timezone

import jwt
import pytest

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,
)


# ---- Password Hashing ----

class TestPasswordHash:
    def test_bcrypt_starts_with_2b(self):
        h = hash_password("hello-world")
        assert h.startswith("$2b$")

    def test_salts_are_unique(self):
        h1 = hash_password("same")
        h2 = hash_password("same")
        assert h1 != h2
        assert verify_password("same", h1)
        assert verify_password("same", h2)

    def test_wrong_password_rejected(self):
        h = hash_password("correct")
        assert not verify_password("wrong", h)

    def test_verify_graceful_on_bad_hash(self):
        assert not verify_password("x", "not-a-valid-hash")


# ---- JWT ----

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

    def test_access_ttl(self):
        token = create_access_token(sub="x", role="admin")
        payload = decode_token(token)
        exp = datetime.fromtimestamp(payload["exp"], tz=timezone.utc)
        expected = datetime.now(tz=timezone.utc) + timedelta(minutes=30)
        assert abs((exp - expected).total_seconds()) < 10

    def test_session_id_in_access(self):
        token = create_access_token(sub="x", role="user", session_id="sess1")
        payload = decode_token(token)
        assert payload["sid"] == "sess1"

    def test_refresh_token_has_sid_and_type(self):
        token, exp = create_refresh_token(sub="x", session_id="sess1")
        payload = decode_token(token)
        assert payload["type"] == "refresh"
        assert payload["sid"] == "sess1"
        assert payload["sub"] == "x"

    def test_refresh_ttl_14_days(self):
        token, exp = create_refresh_token(sub="x", session_id="s")
        now = datetime.now(tz=timezone.utc)
        expected = now + timedelta(days=14)
        assert abs((exp - expected).total_seconds()) < 10

    def test_jti_uniqueness(self):
        jtis = {decode_token(create_access_token(sub="x", role="user"))["jti"] for _ in range(10)}
        assert len(jtis) == 10

    def test_expired_token_raises(self):
        expired = jwt.encode(
            {"sub": "x", "exp": 0, "iat": 0, "type": "access", "jti": "dead"},
            "test-jwt-secret-for-testing-only",
            algorithm="HS256",
        )
        with pytest.raises(jwt.ExpiredSignatureError):
            decode_token(expired)

    def test_invalid_signature_rejected(self):
        tampered = jwt.encode(
            {"sub": "x", "exp": 9999999999, "iat": 0, "type": "access", "jti": "bad"},
            "different-secret",
            algorithm="HS256",
        )
        with pytest.raises(jwt.InvalidTokenError):
            decode_token(tampered)


# ---- CSRF ----

class TestCSRF:
    def test_new_token_length(self):
        t = new_csrf_token()
        assert len(t) == 43

    def test_hash_consistency(self):
        t = "test-token"
        assert hash_csrf(t) == hash_csrf(t)
        assert len(hash_csrf(t)) == 64

    def test_different_inputs_different_outputs(self):
        assert hash_csrf("a") != hash_csrf("b")


# ---- Device Fingerprint ----

class TestDeviceFingerprint:
    def test_length_and_hex(self):
        fp = device_fingerprint("UA", "1.2.3.4")
        assert len(fp) == 32
        int(fp, 16)

    def test_deterministic(self):
        assert device_fingerprint("A", "B") == device_fingerprint("A", "B")

    def test_different_ua_different_fp(self):
        assert device_fingerprint("A", "1") != device_fingerprint("B", "1")

    def test_different_ip_different_fp(self):
        assert device_fingerprint("A", "1") != device_fingerprint("A", "2")


# ---- Fernet Encryption ----

class TestEncryption:
    def test_roundtrip(self):
        original = "sk-proj-test-key-12345"
        encrypted = encrypt(original)
        assert encrypted != original
        assert encrypted.startswith("gAAAAA")
        assert decrypt(encrypted) == original

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

    def test_bad_ciphertext_returns_empty(self):
        assert decrypt("invalid-data") == ""

    def test_different_keys_different_ciphertexts(self):
        """Same plaintext should produce different ciphertexts (Fernet includes IV)."""
        pt = "same-value"
        c1 = encrypt(pt)
        c2 = encrypt(pt)
        assert c1 != c2
        assert decrypt(c1) == pt
        assert decrypt(c2) == pt