Spaces:
Configuration error
Configuration error
| """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 | |