Files
incidentops/tests/core/test_security.py
minhtrannhat 359291eec7 feat: project skeleton
- infra (k8s, kind, helm, docker) backbone is implemented
- security: implementation + unit tests are done
2025-11-21 12:00:00 +00:00

229 lines
7.1 KiB
Python

"""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)