File size: 4,163 Bytes
62516b8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
from __future__ import annotations

import asyncio
from datetime import datetime, timedelta, timezone

import jwt
import pytest
from fastapi import HTTPException

from src.auth.jwt import decode_and_verify_jwt, require_jwt
from src.config import settings


def _build_token(
    *,
    secret: str,
    iss: str,
    aud: str,
    sub: str = "client:test-client",
    expires_in_seconds: int = 300,
    scope: str = "material:write lkpd:write lkpd:read",
) -> str:
    now = datetime.now(timezone.utc)
    payload = {
        "iss": iss,
        "aud": aud,
        "sub": sub,
        "iat": int(now.timestamp()),
        "exp": int((now + timedelta(seconds=expires_in_seconds)).timestamp()),
        "scope": scope,
    }
    return jwt.encode(payload, secret, algorithm="HS256")


def _set_jwt_settings(monkeypatch: pytest.MonkeyPatch) -> None:
    monkeypatch.setattr(settings, "jwt_enabled", True)
    monkeypatch.setattr(settings, "jwt_secret", "x" * 32)
    monkeypatch.setattr(settings, "jwt_issuer", "my-backend")
    monkeypatch.setattr(settings, "jwt_audience", "rtm-class-ai")
    monkeypatch.setattr(settings, "jwt_clock_skew_seconds", 0)
    monkeypatch.setattr(settings, "jwt_denylist_enabled", False)


def test_decode_and_verify_jwt_valid_token(monkeypatch: pytest.MonkeyPatch) -> None:
    _set_jwt_settings(monkeypatch)
    token = _build_token(
        secret=settings.jwt_secret,
        iss=settings.jwt_issuer,
        aud=settings.jwt_audience,
    )

    payload = decode_and_verify_jwt(token)

    assert payload["iss"] == settings.jwt_issuer
    assert payload["aud"] == settings.jwt_audience
    assert payload["sub"] == "client:test-client"


def test_decode_and_verify_jwt_wrong_issuer(monkeypatch: pytest.MonkeyPatch) -> None:
    _set_jwt_settings(monkeypatch)
    token = _build_token(
        secret=settings.jwt_secret,
        iss="not-backend",
        aud=settings.jwt_audience,
    )

    with pytest.raises(HTTPException) as exc_info:
        decode_and_verify_jwt(token)

    assert exc_info.value.status_code == 401
    assert exc_info.value.detail == "Invalid token issuer."


def test_decode_and_verify_jwt_wrong_audience(monkeypatch: pytest.MonkeyPatch) -> None:
    _set_jwt_settings(monkeypatch)
    token = _build_token(
        secret=settings.jwt_secret,
        iss=settings.jwt_issuer,
        aud="other-service",
    )

    with pytest.raises(HTTPException) as exc_info:
        decode_and_verify_jwt(token)

    assert exc_info.value.status_code == 401
    assert exc_info.value.detail == "Invalid token audience."


def test_decode_and_verify_jwt_expired_token(monkeypatch: pytest.MonkeyPatch) -> None:
    _set_jwt_settings(monkeypatch)
    token = _build_token(
        secret=settings.jwt_secret,
        iss=settings.jwt_issuer,
        aud=settings.jwt_audience,
        expires_in_seconds=-1,
    )

    with pytest.raises(HTTPException) as exc_info:
        decode_and_verify_jwt(token)

    assert exc_info.value.status_code == 401
    assert exc_info.value.detail == "Invalid or expired token."


def test_require_jwt_missing_scope(monkeypatch: pytest.MonkeyPatch) -> None:
    _set_jwt_settings(monkeypatch)
    token = _build_token(
        secret=settings.jwt_secret,
        iss=settings.jwt_issuer,
        aud=settings.jwt_audience,
        scope="lkpd:read",
    )
    dependency = require_jwt(["material:write"])

    with pytest.raises(HTTPException) as exc_info:
        asyncio.run(dependency(authorization=f"Bearer {token}"))

    assert exc_info.value.status_code == 403
    assert exc_info.value.detail == "Insufficient scope."


def test_require_jwt_invalid_subject(monkeypatch: pytest.MonkeyPatch) -> None:
    _set_jwt_settings(monkeypatch)
    token = _build_token(
        secret=settings.jwt_secret,
        iss=settings.jwt_issuer,
        aud=settings.jwt_audience,
        sub="service:backend",
    )
    dependency = require_jwt(["material:write"])

    with pytest.raises(HTTPException) as exc_info:
        asyncio.run(dependency(authorization=f"Bearer {token}"))

    assert exc_info.value.status_code == 401
    assert exc_info.value.detail == "Invalid token subject."