File size: 4,840 Bytes
54286b5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""
Unit Tests β€” Auth: hashing.py and jwt.py
No HTTP client or database required.
"""

from datetime import timedelta
from unittest.mock import MagicMock

import pytest
from fastapi import HTTPException

from backend.auth.hashing import hash_password, verify_password
from backend.auth.jwt import (
    create_access_token,
    create_refresh_token,
    decode_token,
    extract_token_from_request,
)


# ── U-1 through U-4: Password Hashing ────────────────────────────────────────

class TestHashPassword:
    async def test_hash_returns_nonempty_string(self):
        h = hash_password("mysecret")
        assert isinstance(h, str) and len(h) > 0

    async def test_hash_does_not_store_plaintext(self):
        h = hash_password("mysecret")
        assert "mysecret" not in h

    async def test_two_hashes_of_same_password_differ(self):
        # bcrypt uses random salt β†’ different hashes for same input
        h1 = hash_password("same")
        h2 = hash_password("same")
        assert h1 != h2

    async def test_verify_correct_password(self):
        h = hash_password("correct_horse_battery")
        assert verify_password("correct_horse_battery", h) is True

    async def test_verify_wrong_password(self):
        h = hash_password("correct")
        assert verify_password("wrong", h) is False

    async def test_verify_empty_against_hash(self):
        h = hash_password("nonempty")
        assert verify_password("", h) is False


# ── U-5 through U-9: JWT Operations ──────────────────────────────────────────

class TestJWT:
    async def test_create_access_token_returns_string(self):
        token = create_access_token({"sub": "user-id-123"})
        assert isinstance(token, str) and len(token) > 0

    async def test_decode_access_token_returns_correct_sub(self):
        token = create_access_token({"sub": "user-abc"})
        payload = decode_token(token, expected_type="access")
        assert payload["sub"] == "user-abc"

    async def test_decode_token_has_type_access(self):
        token = create_access_token({"sub": "x"})
        payload = decode_token(token)
        assert payload["type"] == "access"

    async def test_expired_token_raises_401(self):
        token = create_access_token({"sub": "x"}, expires_delta=timedelta(seconds=-1))
        with pytest.raises(HTTPException) as exc_info:
            decode_token(token)
        assert exc_info.value.status_code == 401

    async def test_refresh_token_type(self):
        token = create_refresh_token({"sub": "user-r"})
        payload = decode_token(token, expected_type="refresh")
        assert payload["type"] == "refresh"
        assert payload["sub"] == "user-r"

    async def test_refresh_used_as_access_raises_401(self):
        refresh = create_refresh_token({"sub": "user-r"})
        with pytest.raises(HTTPException) as exc_info:
            decode_token(refresh, expected_type="access")
        assert exc_info.value.status_code == 401

    async def test_tampered_token_raises_401(self):
        import base64, json
        token = create_access_token({"sub": "real-user"})
        header, _, sig = token.split(".")
        fake_payload = base64.urlsafe_b64encode(
            json.dumps({"sub": "attacker", "type": "access"}).encode()
        ).rstrip(b"=").decode()
        tampered = f"{header}.{fake_payload}.{sig}"
        with pytest.raises(HTTPException) as exc_info:
            decode_token(tampered)
        assert exc_info.value.status_code == 401


# ── U-10: extract_token_from_request ─────────────────────────────────────────

class TestExtractToken:
    async def test_extracts_from_cookie(self):
        req = MagicMock()
        req.cookies = {"access_token": "cookie-token"}
        req.headers = {}
        result = extract_token_from_request(req)
        assert result == "cookie-token"

    async def test_extracts_from_bearer_header(self):
        req = MagicMock()
        req.cookies = {}
        req.headers = {"Authorization": "Bearer header-token"}
        result = extract_token_from_request(req)
        assert result == "header-token"

    async def test_returns_none_when_no_token(self):
        req = MagicMock()
        req.cookies = {}
        req.headers = {}
        result = extract_token_from_request(req)
        assert result is None

    async def test_cookie_takes_priority_over_header(self):
        req = MagicMock()
        req.cookies = {"access_token": "cookie-tok"}
        req.headers = {"Authorization": "Bearer header-tok"}
        result = extract_token_from_request(req)
        assert result == "cookie-tok"