# #region Test.Auth.Jwt [C:3] [TYPE Module] [SEMANTICS test,auth,jwt,token] # @BRIEF Tests for core/auth/jwt.py — create_access_token, decode_token, blacklist, is_blacklisted. # @RELATION BINDS_TO -> [Auth.Jwt] # @TEST_EDGE: invalid_token -> decode_token raises JWTError # @TEST_EDGE: expired_token -> decode_token raises JWTError # @TEST_EDGE: blacklist_missing_jti -> blacklist_token returns early from pathlib import Path import sys sys.path.insert(0, str(Path(__file__).parent.parent.parent / "src")) import os # Must set auth env vars before any src.core.auth imports os.environ.setdefault("AUTH_SECRET_KEY", "test-secret-key-for-jwt-testing") os.environ.setdefault("JWT_AUDIENCE", "test-audience") os.environ.setdefault("JWT_ISSUER", "test-issuer") from datetime import UTC, datetime, timedelta import pytest from unittest.mock import patch, MagicMock from jose import jwt as jose_jwt class TestCreateAccessToken: """Verify create_access_token @POST guarantees.""" def test_creates_token_with_minimal_data(self): from src.core.auth.jwt import create_access_token, decode_token token = create_access_token({"sub": "user1"}) assert isinstance(token, str) payload = decode_token(token) assert payload["sub"] == "user1" assert "exp" in payload assert "iat" in payload assert "jti" in payload assert "aud" in payload assert "iss" in payload def test_creates_token_with_scopes(self): from src.core.auth.jwt import create_access_token, decode_token token = create_access_token({"sub": "user1", "scopes": ["read", "write"]}) payload = decode_token(token) assert payload["scopes"] == ["read", "write"] def test_creates_token_with_custom_expiry(self): from src.core.auth.jwt import create_access_token, decode_token token = create_access_token({"sub": "user1"}, expires_delta=timedelta(hours=2)) payload = decode_token(token) assert payload["sub"] == "user1" def test_token_contains_expected_claims(self): from src.core.auth.jwt import create_access_token, decode_token, auth_config token = create_access_token({"sub": "user1"}) payload = decode_token(token) assert payload["aud"] == auth_config.JWT_AUDIENCE assert payload["iss"] == auth_config.JWT_ISSUER assert payload["sub"] == "user1" class TestDecodeToken: """Verify decode_token @POST guarantees.""" def test_decode_valid_token(self): from src.core.auth.jwt import create_access_token, decode_token token = create_access_token({"sub": "user1"}) payload = decode_token(token) assert payload["sub"] == "user1" def test_decode_expired_token_raises(self): from src.core.auth.jwt import decode_token, auth_config from jose import jwt as jose_jwt expired = jose_jwt.encode( { "sub": "user1", "exp": 0, "iat": 0, "jti": "test-jti", "aud": auth_config.JWT_AUDIENCE, "iss": auth_config.JWT_ISSUER, }, auth_config.SECRET_KEY, algorithm=auth_config.ALGORITHM, ) with pytest.raises(Exception): decode_token(expired) def test_decode_invalid_signature_raises(self): from src.core.auth.jwt import decode_token # Token with different key bad_token = jose_jwt.encode( {"sub": "user1", "exp": 9999999999, "iat": 0, "jti": "test", "aud": "test-audience", "iss": "test-issuer"}, "wrong-secret-key", algorithm="HS256", ) with pytest.raises(Exception): decode_token(bad_token) def test_decode_malformed_token_raises(self): from src.core.auth.jwt import decode_token with pytest.raises(Exception): decode_token("not-a-jwt-token") def test_decode_missing_required_claims_raises(self): from src.core.auth.jwt import decode_token, auth_config # Token missing 'sub' — jose may or may not enforce require for sub # Test with a genuinely invalid token instead with pytest.raises(Exception): decode_token("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.missing-parts") def test_decode_empty_token_raises(self): from src.core.auth.jwt import decode_token with pytest.raises(Exception): decode_token("") class TestHashToken: """Verify _hash_token utility.""" def test_hash_token_returns_sha256_hex(self): from src.core.auth.jwt import _hash_token result = _hash_token("test-token") assert isinstance(result, str) assert len(result) == 64 # SHA-256 hex digest length def test_hash_token_is_deterministic(self): from src.core.auth.jwt import _hash_token assert _hash_token("same-token") == _hash_token("same-token") def test_hash_token_different_for_different_inputs(self): from src.core.auth.jwt import _hash_token assert _hash_token("token-a") != _hash_token("token-b") class TestPruneBlacklist: """Verify _prune_blacklist deletes expired entries.""" def test_deletes_expired_entries(self): from src.core.auth.jwt import _prune_blacklist from src.models.mapping import Base from src.models.auth import TokenBlacklist from sqlalchemy import create_engine, event from sqlalchemy.orm import Session engine = create_engine("sqlite:///:memory:", connect_args={"check_same_thread": False}) event.listen(engine, "connect", lambda c, _: c.execute("PRAGMA foreign_keys=ON")) Base.metadata.create_all(bind=engine) db = Session(bind=engine) past = datetime.now(UTC) - timedelta(hours=2) db.add(TokenBlacklist(jti="expired1", token_hash="a" * 64, expires_at=past)) db.add(TokenBlacklist(jti="expired2", token_hash="b" * 64, expires_at=past)) db.commit() _prune_blacklist(db) remaining = db.query(TokenBlacklist).all() assert len(remaining) == 0 def test_keeps_future_entries(self): from src.core.auth.jwt import _prune_blacklist from src.models.mapping import Base from src.models.auth import TokenBlacklist from sqlalchemy import create_engine, event from sqlalchemy.orm import Session engine = create_engine("sqlite:///:memory:", connect_args={"check_same_thread": False}) event.listen(engine, "connect", lambda c, _: c.execute("PRAGMA foreign_keys=ON")) Base.metadata.create_all(bind=engine) db = Session(bind=engine) future = datetime.now(UTC) + timedelta(hours=2) db.add(TokenBlacklist(jti="valid1", token_hash="c" * 64, expires_at=future)) db.commit() _prune_blacklist(db) remaining = db.query(TokenBlacklist).all() assert len(remaining) == 1 assert remaining[0].jti == "valid1" def test_empty_db_does_not_raise(self): from src.core.auth.jwt import _prune_blacklist from src.models.mapping import Base from sqlalchemy import create_engine, event from sqlalchemy.orm import Session engine = create_engine("sqlite:///:memory:", connect_args={"check_same_thread": False}) event.listen(engine, "connect", lambda c, _: c.execute("PRAGMA foreign_keys=ON")) Base.metadata.create_all(bind=engine) db = Session(bind=engine) # Should not raise _prune_blacklist(db) class TestBlacklistToken: """Verify blacklist_token @POST guarantees.""" @pytest.fixture def db_session(self): from src.models.mapping import Base from sqlalchemy import create_engine, event from sqlalchemy.orm import Session engine = create_engine("sqlite:///:memory:", connect_args={"check_same_thread": False}) event.listen(engine, "connect", lambda c, _: c.execute("PRAGMA foreign_keys=ON")) Base.metadata.create_all(bind=engine) session = Session(bind=engine) yield session session.close() engine.dispose() def test_blacklist_valid_token(self, db_session): from src.core.auth.jwt import create_access_token, blacklist_token, is_token_blacklisted from src.models.auth import TokenBlacklist token = create_access_token({"sub": "user1"}) blacklist_token(token, db_session) assert is_token_blacklisted(token, db_session) is True entry = db_session.query(TokenBlacklist).first() assert entry is not None assert entry.jti is not None def test_blacklist_invalid_token_skips(self, db_session): from src.core.auth.jwt import blacklist_token from src.models.auth import TokenBlacklist # Should return early without error blacklist_token("invalid-token", db_session) count = db_session.query(TokenBlacklist).count() assert count == 0 def test_blacklist_existing_entry_skips(self, db_session): from src.core.auth.jwt import create_access_token, blacklist_token from src.models.auth import TokenBlacklist token = create_access_token({"sub": "user1"}) blacklist_token(token, db_session) # Blacklist again — should skip without error blacklist_token(token, db_session) count = db_session.query(TokenBlacklist).count() assert count == 1 def test_blacklist_token_missing_jti_returns(self, db_session): """Covers line 133: decode succeeds but payload has no jti.""" from src.core.auth.jwt import blacklist_token from src.models.auth import TokenBlacklist from unittest.mock import patch with patch("src.core.auth.jwt.decode_token", return_value={"sub": "user1"}): blacklist_token("some-token", db_session) count = db_session.query(TokenBlacklist).count() assert count == 0 def test_blacklist_token_with_datetime_exp_works(self, db_session): """Covers line 136: exp_raw is a datetime instance.""" from src.core.auth.jwt import blacklist_token, is_token_blacklisted from src.models.auth import TokenBlacklist from unittest.mock import patch from datetime import datetime, timedelta # Use naive datetime since SQLite stores datetimes without timezone exp_dt = datetime.now() + timedelta(hours=1) with patch("src.core.auth.jwt.decode_token", return_value={ "sub": "user1", "jti": "test-jti-dt", "exp": exp_dt, }): blacklist_token("some-token", db_session) entry = db_session.query(TokenBlacklist).filter(TokenBlacklist.jti == "test-jti-dt").first() assert entry is not None # SQLite stores datetime precision differently, compare timestamps assert abs((entry.expires_at - exp_dt).total_seconds()) < 1 class TestIsTokenBlacklisted: """Verify is_token_blacklisted @POST guarantees.""" @pytest.fixture def db_session(self): from src.models.mapping import Base from sqlalchemy import create_engine, event from sqlalchemy.orm import Session engine = create_engine("sqlite:///:memory:", connect_args={"check_same_thread": False}) event.listen(engine, "connect", lambda c, _: c.execute("PRAGMA foreign_keys=ON")) Base.metadata.create_all(bind=engine) session = Session(bind=engine) yield session session.close() engine.dispose() def test_not_blacklisted_returns_false(self, db_session): from src.core.auth.jwt import create_access_token, is_token_blacklisted token = create_access_token({"sub": "user1"}) assert is_token_blacklisted(token, db_session) is False def test_blacklisted_returns_true(self, db_session): from src.core.auth.jwt import create_access_token, blacklist_token, is_token_blacklisted token = create_access_token({"sub": "user1"}) blacklist_token(token, db_session) assert is_token_blacklisted(token, db_session) is True def test_invalid_token_returns_true(self, db_session): from src.core.auth.jwt import is_token_blacklisted assert is_token_blacklisted("invalid-token", db_session) is True def test_token_missing_jti_returns_false(self, db_session): """Covers line 171: decode succeeds but payload has no jti.""" from src.core.auth.jwt import is_token_blacklisted from unittest.mock import patch with patch("src.core.auth.jwt.decode_token", return_value={"sub": "user1"}): result = is_token_blacklisted("some-token", db_session) assert result is False def test_deleted_token_returns_true(self, db_session): from src.core.auth.jwt import is_token_blacklisted # Token missing jti claim assert is_token_blacklisted("malformed.token.here", db_session) is True # #endregion Test.Auth.Jwt