"""Unit tests for app.core.security helpers.""" from __future__ import annotations import hashlib import os from datetime import UTC, datetime, timedelta from uuid import UUID, uuid4 import pytest from jose import JWTError os.environ.setdefault("DATABASE_URL", "postgresql://test:test@localhost/testdb") os.environ.setdefault("JWT_SECRET_KEY", "test-secret-key") from app.config import settings # noqa: E402 (env must be set before importing) from app.core import security # noqa: E402 def test_hash_password_roundtrip() -> None: password = "SuperStrong!123" hashed = security.hash_password(password) assert hashed assert hashed != password assert security.verify_password(password, hashed) assert not security.verify_password("wrong-password", hashed) def test_create_access_token_includes_required_claims_per_specs(monkeypatch: pytest.MonkeyPatch) -> None: """Ensure JWT claims stay aligned with SPECS.md section 2.""" monkeypatch.setattr(settings, "access_token_expire_minutes", 15) user_id = str(uuid4()) org_id = str(uuid4()) token = security.create_access_token(sub=user_id, org_id=org_id, org_role="member") payload = security.decode_access_token(token) # Required claims per SPECS.md assert payload["sub"] == user_id assert payload["org_id"] == org_id assert payload["org_role"] == "member" # Standard JWT claims assert payload["iss"] == settings.jwt_issuer assert payload["aud"] == settings.jwt_audience assert "jti" in payload UUID(payload["jti"]) # Should be valid UUID assert abs(payload["exp"] - payload["iat"] - 15 * 60) <= 1 def test_create_access_token_honors_custom_expiry() -> None: user_id = str(uuid4()) org_id = str(uuid4()) ttl = timedelta(hours=2) token = security.create_access_token( sub=user_id, org_id=org_id, org_role="admin", expires_delta=ttl, ) payload = security.decode_access_token(token) assert abs(payload["exp"] - payload["iat"] - int(ttl.total_seconds())) <= 1 def test_decode_access_token_rejects_tampered_signature() -> None: token = security.create_access_token(sub=str(uuid4()), org_id=str(uuid4()), org_role="viewer") tampered = token[:-1] + ("A" if token[-1] != "A" else "B") with pytest.raises(JWTError): security.decode_access_token(tampered) def test_decode_access_token_rejects_expired_token() -> None: token = security.create_access_token( sub=str(uuid4()), org_id=str(uuid4()), org_role="viewer", expires_delta=timedelta(seconds=-5), ) with pytest.raises(JWTError): security.decode_access_token(token) def test_decode_access_token_rejects_wrong_issuer(monkeypatch: pytest.MonkeyPatch) -> None: """Token created with different issuer should be rejected.""" from jose import jwt as jose_jwt payload = { "sub": str(uuid4()), "org_id": str(uuid4()), "org_role": "viewer", "iss": "wrong-issuer", "aud": settings.jwt_audience, "jti": str(uuid4()), "iat": datetime.now(UTC), "exp": datetime.now(UTC) + timedelta(minutes=15), } token = jose_jwt.encode(payload, settings.jwt_secret_key, algorithm=settings.jwt_algorithm) with pytest.raises(JWTError): security.decode_access_token(token) def test_decode_access_token_rejects_wrong_audience(monkeypatch: pytest.MonkeyPatch) -> None: """Token created with different audience should be rejected.""" from jose import jwt as jose_jwt payload = { "sub": str(uuid4()), "org_id": str(uuid4()), "org_role": "viewer", "iss": settings.jwt_issuer, "aud": "wrong-audience", "jti": str(uuid4()), "iat": datetime.now(UTC), "exp": datetime.now(UTC) + timedelta(minutes=15), } token = jose_jwt.encode(payload, settings.jwt_secret_key, algorithm=settings.jwt_algorithm) with pytest.raises(JWTError): security.decode_access_token(token) def test_create_access_token_generates_unique_jti() -> None: """Each token should have a unique jti claim.""" token1 = security.create_access_token(sub=str(uuid4()), org_id=str(uuid4()), org_role="viewer") token2 = security.create_access_token(sub=str(uuid4()), org_id=str(uuid4()), org_role="viewer") payload1 = security.decode_access_token(token1) payload2 = security.decode_access_token(token2) assert payload1["jti"] != payload2["jti"] def test_generate_refresh_token_is_random_and_urlsafe() -> None: token_one = security.generate_refresh_token() token_two = security.generate_refresh_token() assert token_one != token_two assert len(token_one) >= 43 allowed_chars = set("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_") assert set(token_one).issubset(allowed_chars) assert set(token_two).issubset(allowed_chars) def test_hash_token_matches_sha256_digest() -> None: raw = "refresh-token-value" assert security.hash_token(raw) == hashlib.sha256(raw.encode()).hexdigest() def test_get_refresh_token_expiry_uses_settings(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(settings, "refresh_token_expire_days", 45) fixed_now = datetime(2025, 5, 10, 15, 30, tzinfo=UTC) class _FixedDateTime: @staticmethod def now(tz: object | None = None) -> datetime: assert tz is security.UTC return fixed_now monkeypatch.setattr(security, "datetime", _FixedDateTime) expiry = security.get_refresh_token_expiry() assert expiry == fixed_now + timedelta(days=45) def test_token_payload_parses_uuid_fields() -> None: jti = str(uuid4()) payload = { "sub": str(uuid4()), "org_id": str(uuid4()), "org_role": "admin", "iss": "incidentops", "aud": "incidentops-api", "jti": jti, "iat": 1704067200, "exp": 1704068100, } token_payload = security.TokenPayload(payload) assert token_payload.user_id == UUID(payload["sub"]) assert token_payload.org_id == UUID(payload["org_id"]) assert token_payload.org_role == "admin" assert token_payload.issuer == "incidentops" assert token_payload.audience == "incidentops-api" assert token_payload.jti == UUID(jti) assert token_payload.issued_at == 1704067200 assert token_payload.expires_at == 1704068100 def test_token_payload_rejects_invalid_uuid() -> None: payload = { "sub": "not-a-uuid", "org_id": str(uuid4()), "org_role": "member", "iss": "incidentops", "aud": "incidentops-api", "jti": str(uuid4()), "iat": 1704067200, "exp": 1704068100, } with pytest.raises(ValueError): security.TokenPayload(payload) def test_token_payload_rejects_invalid_jti() -> None: payload = { "sub": str(uuid4()), "org_id": str(uuid4()), "org_role": "member", "iss": "incidentops", "aud": "incidentops-api", "jti": "not-a-uuid", "iat": 1704067200, "exp": 1704068100, } with pytest.raises(ValueError): security.TokenPayload(payload)