feat: project skeleton
- infra (k8s, kind, helm, docker) backbone is implemented - security: implementation + unit tests are done
This commit is contained in:
228
tests/core/test_security.py
Normal file
228
tests/core/test_security.py
Normal file
@@ -0,0 +1,228 @@
|
||||
"""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)
|
||||
Reference in New Issue
Block a user