Talent-Lens / tests /unit /test_auth.py
riezqidr's picture
fix(security): enforce RBAC and close five denial-of-service vectors
ca3a272
Raw
History Blame Contribute Delete
6.55 kB
"""Authentication: JWT verification, principal extraction, and route protection."""
from __future__ import annotations
import uuid
from datetime import timedelta
import pytest
PROTECTED_ROUTE = "/api/v1/auth/me"
# --------------------------------------------------------------------------- #
# Token verification #
# --------------------------------------------------------------------------- #
def test_valid_token_yields_a_principal(make_token):
from app.security import decode_access_token
principal = decode_access_token(make_token())
assert principal.user_id == uuid.UUID("33333333-3333-3333-3333-333333333333")
assert principal.tenant_id == uuid.UUID("11111111-1111-1111-1111-111111111111")
assert "recruiter" in principal.roles
def test_expired_token_is_rejected(make_token):
from app.exceptions import AuthenticationError
from app.security import decode_access_token
expired = make_token(expires_in=timedelta(minutes=-5))
with pytest.raises(AuthenticationError):
decode_access_token(expired)
def test_token_signed_with_wrong_secret_is_rejected(make_token):
from app.exceptions import AuthenticationError
from app.security import decode_access_token
forged = make_token(secret="attacker-key")
with pytest.raises(AuthenticationError):
decode_access_token(forged)
def test_token_with_wrong_issuer_is_rejected(make_token):
from app.exceptions import AuthenticationError
from app.security import decode_access_token
with pytest.raises(AuthenticationError):
decode_access_token(make_token(issuer="https://evil.example/auth"))
def test_token_with_wrong_audience_is_rejected(make_token):
from app.exceptions import AuthenticationError
from app.security import decode_access_token
with pytest.raises(AuthenticationError):
decode_access_token(make_token(audience="some-other-service"))
def test_token_missing_tenant_claim_is_rejected(make_token):
"""Tenancy is not optional — a token without `tenant_id` cannot be trusted."""
from app.exceptions import AuthenticationError
from app.security import decode_access_token
with pytest.raises(AuthenticationError):
decode_access_token(make_token(drop_claims=("tenant_id",)))
def test_malformed_token_is_rejected():
from app.exceptions import AuthenticationError
from app.security import decode_access_token
with pytest.raises(AuthenticationError):
decode_access_token("not-a-jwt")
def test_algorithm_confusion_none_is_rejected():
"""A token with alg=none must never authenticate."""
import jwt
from app.exceptions import AuthenticationError
from app.security import decode_access_token
unsigned = jwt.encode(
{"sub": str(uuid.uuid4()), "tenant_id": str(uuid.uuid4())},
key="",
algorithm="none",
)
with pytest.raises(AuthenticationError):
decode_access_token(unsigned)
# --------------------------------------------------------------------------- #
# Route protection #
# --------------------------------------------------------------------------- #
async def test_protected_route_requires_a_token(client):
response = await client.get(PROTECTED_ROUTE)
assert response.status_code == 401
assert response.json()["error"] == "UNAUTHENTICATED"
async def test_protected_route_rejects_a_bad_scheme(client, make_token):
response = await client.get(
PROTECTED_ROUTE, headers={"Authorization": f"Basic {make_token()}"}
)
assert response.status_code == 401
async def test_protected_route_accepts_a_valid_token(client, auth_headers):
response = await client.get(PROTECTED_ROUTE, headers=auth_headers)
assert response.status_code == 200
body = response.json()
assert body["user_id"] == "33333333-3333-3333-3333-333333333333"
assert body["tenant_id"] == "11111111-1111-1111-1111-111111111111"
async def test_caller_cannot_override_tenant_via_query_param(client, make_token):
"""Identity comes from the token alone — never from caller-supplied input."""
headers = {"Authorization": f"Bearer {make_token()}"}
response = await client.get(
PROTECTED_ROUTE,
headers=headers,
params={"tenant_id": "22222222-2222-2222-2222-222222222222"},
)
assert response.status_code == 200
assert response.json()["tenant_id"] == "11111111-1111-1111-1111-111111111111"
async def test_health_is_not_protected(client):
assert (await client.get("/health")).status_code == 200
# --------------------------------------------------------------------------- #
# Authorization (RBAC) — regression for roles being decorative #
# --------------------------------------------------------------------------- #
async def test_read_only_role_cannot_upload_a_resume(client, make_token, minimal_pdf_bytes):
"""A `viewer` is authenticated but must not be able to ingest documents."""
headers = {"Authorization": f"Bearer {make_token(roles=['viewer'])}"}
response = await client.post(
"/api/v1/resumes",
headers=headers,
files={"file": ("jane.pdf", minimal_pdf_bytes, "application/pdf")},
)
assert response.status_code == 403
assert response.json()["error"] == "FORBIDDEN"
async def test_read_only_role_cannot_create_a_job(client, make_token):
headers = {"Authorization": f"Bearer {make_token(roles=['viewer'])}"}
response = await client.post(
"/api/v1/jobs",
headers=headers,
json={"title": "Engineer", "description_raw": "Python and Postgres."},
)
assert response.status_code == 403
async def test_token_with_no_roles_cannot_write(client, make_token):
headers = {"Authorization": f"Bearer {make_token(roles=[])}"}
response = await client.post(
"/api/v1/jobs",
headers=headers,
json={"title": "Engineer", "description_raw": "Python and Postgres."},
)
assert response.status_code == 403
async def test_unknown_role_cannot_write(client, make_token):
"""An unrecognized role grants nothing — the allowlist is closed."""
headers = {"Authorization": f"Bearer {make_token(roles=['superuser'])}"}
response = await client.post(
"/api/v1/jobs",
headers=headers,
json={"title": "Engineer", "description_raw": "Python and Postgres."},
)
assert response.status_code == 403