File size: 3,747 Bytes
f783f69
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""Auth verification tests — forged JWTs must never yield a user_id."""

import asyncio
import time

import jwt as pyjwt
import pytest
from fastapi import HTTPException
from fastapi.security import HTTPAuthorizationCredentials

from app.services import auth

SECRET = "unit-test-secret-0123456789abcdef-0123456789"
SUB = "11111111-2222-3333-4444-555555555555"


def _make_token(sub=SUB, secret=SECRET, expires_delta=3600, headers=None):
    payload = {
        "sub": sub,
        "role": "authenticated",
        "exp": int(time.time()) + expires_delta,
    }
    return pyjwt.encode(payload, secret, algorithm="HS256", headers=headers or {})


def _creds(token: str) -> HTTPAuthorizationCredentials:
    return HTTPAuthorizationCredentials(scheme="Bearer", credentials=token)


@pytest.fixture(autouse=True)
def _reset_state(monkeypatch):
    monkeypatch.delenv("SUPABASE_JWT_SECRET", raising=False)
    auth._verified_cache.clear()
    yield
    auth._verified_cache.clear()


class TestLocalVerification:
    def test_valid_signed_token_returns_sub(self, monkeypatch):
        monkeypatch.setenv("SUPABASE_JWT_SECRET", SECRET)
        assert auth._verify_locally(_make_token()) == SUB

    def test_forged_signature_rejected(self, monkeypatch):
        monkeypatch.setenv("SUPABASE_JWT_SECRET", SECRET)
        forged = _make_token(secret="attacker-key-0123456789abcdef-0123456789")
        assert auth._verify_locally(forged) is None

    def test_expired_token_rejected(self, monkeypatch):
        monkeypatch.setenv("SUPABASE_JWT_SECRET", SECRET)
        expired = _make_token(expires_delta=-10)
        assert auth._verify_locally(expired) is None

    def test_missing_claims_rejected(self, monkeypatch):
        monkeypatch.setenv("SUPABASE_JWT_SECRET", SECRET)
        token = pyjwt.encode({"role": "authenticated"}, SECRET, algorithm="HS256")
        assert auth._verify_locally(token) is None


class TestGetUserId:
    def test_anonymous_when_no_credentials(self):
        assert asyncio.run(auth.get_user_id(None)) is None

    @pytest.mark.parametrize("bad", ["", "not-a-jwt", "a.b", "...."])
    def test_malformed_tokens_rejected_without_network(self, bad):
        # No SUPABASE_JWT_SECRET set -> would hit network path if it got there;
        # malformed shapes must short-circuit to None.
        assert asyncio.run(auth.get_user_id(_creds(bad))) is None

    def test_forged_token_rejected_with_secret_configured(self, monkeypatch):
        monkeypatch.setenv("SUPABASE_JWT_SECRET", SECRET)
        forged = f"{_make_token().split('.')[0]}.{_make_token().split('.')[1]}.garbage"
        assert asyncio.run(auth.get_user_id(_creds(forged))) is None

    def test_delegates_to_supabase_path_without_secret(self, monkeypatch):
        seen = {}

        async def fake_verify(token: str):
            seen["token"] = token
            return SUB

        monkeypatch.setattr(auth, "_verify_via_supabase", fake_verify)
        result = asyncio.run(auth.get_user_id(_creds("aaa.bbb.ccc")))
        assert result == SUB
        assert seen["token"] == "aaa.bbb.ccc"


class TestRequireUserId:
    def test_raises_401_on_unverifiable_token(self, monkeypatch):
        async def fake_verify(token: str):
            return None

        monkeypatch.setattr(auth, "_verify_via_supabase", fake_verify)
        with pytest.raises(HTTPException) as exc:
            asyncio.run(auth.require_user_id(_creds("x.y.z")))
        assert exc.value.status_code == 401

    def test_passes_through_verified_sub(self, monkeypatch):
        async def fake_verify(token: str):
            return SUB

        monkeypatch.setattr(auth, "_verify_via_supabase", fake_verify)
        assert asyncio.run(auth.require_user_id(_creds("x.y.z"))) == SUB