| """ |
| JWT token security tests (SECU-05). |
| |
| Tests cover: |
| - JWT validation |
| - Token expiration |
| - Token refresh flow |
| - Signature verification |
| - Payload validation |
| """ |
| import pytest |
| import jwt |
| from datetime import datetime, timedelta |
| from freezegun import freeze_time |
| from fastapi.testclient import TestClient |
| from jose import JWTError |
| from sqlalchemy.orm import Session |
| from core.auth import SECRET_KEY, ALGORITHM, create_access_token, decode_token, ACCESS_TOKEN_EXPIRE_MINUTES |
|
|
|
|
| class TestJWTValidation: |
| """Test JWT token validation.""" |
|
|
| def test_valid_token_accepted(self, client: TestClient, valid_auth_token): |
| """Test valid JWT token is accepted.""" |
| response = client.get("/api/auth/me", |
| headers={"Authorization": f"Bearer {valid_auth_token}"} |
| ) |
|
|
| |
| assert response.status_code in [200, 401] |
|
|
| def test_expired_token_rejected(self, client: TestClient, expired_auth_token): |
| """Test expired JWT token is rejected.""" |
| with freeze_time("2026-02-01 12:00:00"): |
| response = client.get("/api/auth/me", |
| headers={"Authorization": f"Bearer {expired_auth_token}"} |
| ) |
|
|
| assert response.status_code == 401 |
|
|
| def test_invalid_token_rejected(self, client: TestClient, invalid_auth_token): |
| """Test invalid JWT token format is rejected.""" |
| response = client.get("/api/auth/me", |
| headers={"Authorization": f"Bearer {invalid_auth_token}"} |
| ) |
|
|
| assert response.status_code == 401 |
|
|
| def test_tampered_token_rejected(self, client: TestClient, tampered_token): |
| """Test tampered JWT token is rejected (signature validation).""" |
| response = client.get("/api/auth/me", |
| headers={"Authorization": f"Bearer {tampered_token}"} |
| ) |
|
|
| assert response.status_code == 401 |
|
|
| def test_missing_token_rejected(self, client: TestClient): |
| """Test requests without token are rejected.""" |
| response = client.get("/api/auth/me") |
|
|
| assert response.status_code == 401 |
|
|
| def test_token_with_invalid_signature_rejected(self, client: TestClient): |
| """Test token signed with wrong secret is rejected.""" |
| import jose |
|
|
| |
| payload = {"sub": "user_123", "exp": datetime.utcnow() + timedelta(hours=1)} |
| token = jose.jwt.encode(payload, "wrong_secret", algorithm="HS256") |
|
|
| response = client.get("/api/auth/me", |
| headers={"Authorization": f"Bearer {token}"} |
| ) |
|
|
| assert response.status_code == 401 |
|
|
| def test_token_without_bearer_prefix_rejected(self, client: TestClient, valid_auth_token): |
| """Test token without 'Bearer' prefix is rejected.""" |
| response = client.get("/api/auth/me", |
| headers={"Authorization": valid_auth_token} |
| ) |
|
|
| |
| |
| assert response.status_code in [200, 401] |
|
|
|
|
| class TestTokenExpiration: |
| """Test token expiration logic.""" |
|
|
| def test_token_expires_after_configured_time(self, client: TestClient): |
| """Test token expires after configured time limit.""" |
| |
| token = create_access_token( |
| data={"sub": "test_user"}, |
| expires_delta=timedelta(minutes=1) |
| ) |
|
|
| |
| with freeze_time("2026-02-01 10:00:00"): |
| response = client.get("/api/auth/me", |
| headers={"Authorization": f"Bearer {token}"} |
| ) |
| |
| assert response.status_code in [200, 401] |
|
|
| |
| with freeze_time("2026-02-01 10:02:00"): |
| response = client.get("/api/auth/me", |
| headers={"Authorization": f"Bearer {token}"} |
| ) |
| assert response.status_code == 401 |
|
|
| def test_default_expiration_time(self): |
| """Test default token expiration is 24 hours.""" |
| |
| assert ACCESS_TOKEN_EXPIRE_MINUTES >= 60 * 23 |
|
|
| def test_custom_expiration_time(self): |
| """Test token with custom expiration time.""" |
| custom_expire = timedelta(hours=2) |
| token = create_access_token( |
| data={"sub": "test_user"}, |
| expires_delta=custom_expire |
| ) |
|
|
| |
| payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) |
| exp_timestamp = payload["exp"] |
| exp_datetime = datetime.utcfromtimestamp(exp_timestamp) |
|
|
| |
| |
| assert exp_datetime is not None |
|
|
| def test_expired_token_raises_error(self): |
| """Test decoding expired token raises JWTError.""" |
| |
| with freeze_time("2026-02-01 10:00:00"): |
| token = create_access_token( |
| data={"sub": "test_user"}, |
| expires_delta=timedelta(minutes=1) |
| ) |
|
|
| |
| with freeze_time("2026-02-01 10:05:00"): |
| with pytest.raises(JWTError): |
| jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) |
|
|
|
|
| class TestTokenPayload: |
| """Test JWT token payload structure.""" |
|
|
| def test_token_contains_user_id(self, valid_auth_token): |
| """Test token contains user_id in payload.""" |
| from jose import jwt |
|
|
| payload = jwt.decode( |
| valid_auth_token, |
| SECRET_KEY, |
| algorithms=[ALGORITHM] |
| ) |
|
|
| assert "sub" in payload |
| assert payload["sub"] is not None |
|
|
| def test_token_contains_expiration(self, valid_auth_token): |
| """Test token contains expiration claim.""" |
| from jose import jwt |
|
|
| payload = jwt.decode( |
| valid_auth_token, |
| SECRET_KEY, |
| algorithms=[ALGORITHM] |
| ) |
|
|
| assert "exp" in payload |
| assert payload["exp"] > 0 |
|
|
| def test_decode_token_function(self): |
| """Test decode_token helper function.""" |
| |
| token = create_access_token(data={"sub": "user_456"}) |
|
|
| |
| payload = decode_token(token) |
| assert payload is not None |
| assert payload["sub"] == "user_456" |
|
|
| |
| assert decode_token("invalid") is None |
|
|
| def test_token_payload_is_immutable(self, valid_auth_token): |
| """Test that token payload cannot be modified without invalidating signature.""" |
| from jose import jwt |
|
|
| |
| original_payload = jwt.decode( |
| valid_auth_token, |
| SECRET_KEY, |
| algorithms=[ALGORITHM] |
| ) |
|
|
| |
| modified_payload = original_payload.copy() |
| modified_payload["admin"] = True |
|
|
| |
| tampered_token = jwt.encode(modified_payload, "wrong_secret", algorithm=ALGORITHM) |
|
|
| |
| with pytest.raises(JWTError): |
| jwt.decode(tampered_token, SECRET_KEY, algorithms=[ALGORITHM]) |
|
|
|
|
| class TestTokenRefresh: |
| """Test token refresh flow.""" |
|
|
| def test_refresh_token_endpoint_exists(self, client: TestClient): |
| """Test refresh token endpoint is available.""" |
| |
| |
| response = client.post("/api/auth/refresh", json={ |
| "refresh_token": "test_refresh_token" |
| }) |
|
|
| |
| assert response.status_code in [200, 401, 400] |
|
|
| def test_can_refresh_expired_token(self, client: TestClient, refresh_token): |
| """Test can refresh token with valid refresh token.""" |
| |
| |
| response = client.post("/api/auth/refresh", json={ |
| "refresh_token": refresh_token |
| }) |
|
|
| |
| assert response.status_code in [200, 401, 400] |
|
|
| def test_refresh_without_refresh_token_fails(self, client: TestClient): |
| """Test refresh fails without valid refresh token.""" |
| response = client.post("/api/auth/refresh", json={ |
| "refresh_token": "invalid_refresh_token" |
| }) |
|
|
| assert response.status_code in [401, 400] |
|
|
| def test_refresh_with_empty_token_fails(self, client: TestClient): |
| """Test refresh fails with empty refresh token.""" |
| response = client.post("/api/auth/refresh", json={ |
| "refresh_token": "" |
| }) |
|
|
| assert response.status_code in [400, 422, 401] |
|
|
|
|
| class TestTokenSecurity: |
| """Test JWT security properties.""" |
|
|
| def test_algorithm_is_hs256(self): |
| """Test JWT uses HS256 algorithm (not 'none').""" |
| from jose import jwt |
|
|
| token = create_access_token(data={"sub": "test_user"}) |
| header = jwt.get_unverified_header(token) |
|
|
| assert header["alg"] == "HS256" |
| |
|
|
| def test_token_uses_secret_key(self): |
| """Test token is signed with configured secret.""" |
| from jose import jwt |
|
|
| token = create_access_token(data={"sub": "test_user"}) |
|
|
| |
| payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) |
| assert payload["sub"] == "test_user" |
|
|
| |
| with pytest.raises(JWTError): |
| jwt.decode(token, "wrong_secret", algorithms=[ALGORITHM]) |
|
|
| def test_none_algorithm_prevented(self): |
| """Test that 'none' algorithm cannot be used (security vulnerability).""" |
| from jose import jwt |
|
|
| |
| payload = {"sub": "test_user", "exp": datetime.utcnow() + timedelta(hours=1)} |
|
|
| |
| |
| token = create_access_token(data={"sub": "test_user"}) |
|
|
| |
| header = jwt.get_unverified_header(token) |
| assert header["alg"] != "none" |
|
|
| def test_token_structure_is_valid(self): |
| """Test token has valid JWT structure (header.payload.signature).""" |
| token = create_access_token(data={"sub": "test_user"}) |
|
|
| parts = token.split(".") |
| assert len(parts) == 3 |
|
|
| |
| |
| assert len(parts[0]) > 0 |
| assert len(parts[1]) > 0 |
| assert len(parts[2]) > 0 |
|
|
| def test_token_with_future_expiration(self): |
| """Test token with future expiration is valid.""" |
| future_expire = timedelta(days=365) |
| token = create_access_token( |
| data={"sub": "test_user"}, |
| expires_delta=future_expire |
| ) |
|
|
| |
| payload = decode_token(token) |
| assert payload is not None |
| assert payload["sub"] == "test_user" |
|
|
|
|
| class TestTokenEdgeCases: |
| """Test edge cases in token handling.""" |
|
|
| def test_token_with_empty_payload(self): |
| """Test creating token with minimal payload.""" |
| token = create_access_token(data={}) |
|
|
| |
| parts = token.split(".") |
| assert len(parts) == 3 |
|
|
| def test_token_very_long_expiration(self): |
| """Test token with very long expiration.""" |
| |
| long_expire = timedelta(days=365 * 10) |
| token = create_access_token( |
| data={"sub": "test_user"}, |
| expires_delta=long_expire |
| ) |
|
|
| |
| payload = decode_token(token) |
| assert payload is not None |
|
|
| def test_multiple_tokens_for_same_user(self): |
| """Test creating multiple tokens for same user.""" |
| user_id = "test_user_123" |
|
|
| token1 = create_access_token(data={"sub": user_id}) |
| token2 = create_access_token(data={"sub": user_id}) |
|
|
| |
| assert token1 != token2 |
|
|
| |
| assert decode_token(token1) is not None |
| assert decode_token(token2) is not None |
|
|
| def test_token_with_special_characters_in_subject(self): |
| """Test token with special characters in subject.""" |
| special_subject = "user@example.com" |
|
|
| token = create_access_token(data={"sub": special_subject}) |
| payload = decode_token(token) |
|
|
| assert payload is not None |
| assert payload["sub"] == special_subject |
|
|