- infra (k8s, kind, helm, docker) backbone is implemented - security: implementation + unit tests are done
229 lines
7.1 KiB
Python
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)
|