feat(auth): implement Keycloak authentication with RBAC and pagination
Major changes: - Add Keycloak integration via token introspection endpoint - Implement RBAC system with roles: admin, user, guest - Add role-based permissions for post operations - Add pagination support (default limit: 10) to list endpoints - Add published_only filter with admin-only override for unpublished posts Security improvements: - Remove hardcoded default secrets (SECRET_KEY, KEYCLOAK_CLIENT_SECRET) - Update .env.example with proper security placeholders - Add comprehensive RBAC unit tests Infrastructure: - Add httpx dependency for HTTP client - Add KeycloakAuthClient with token caching (TTL: 60s) - Add role-based dependencies (RequireAdmin, RequireUser, etc.) - Update DI container with Keycloak provider Endpoints updated: - GET /posts: filter by published status (admin can see all) - Add pagination params (limit, offset) to list endpoints - Enforce RBAC on post operations Tests: - Add 16 auth infrastructure tests - Add 13 RBAC role tests - Update existing tests for new required settings Breaking changes: - SECRET_KEY and KEYCLOAK_CLIENT_SECRET now required (no defaults)
This commit is contained in:
@@ -1,37 +1,247 @@
|
||||
"""Tests for infrastructure config."""
|
||||
|
||||
from app.infrastructure.config import Settings
|
||||
import pytest
|
||||
|
||||
from app.infrastructure.config import (
|
||||
AppConfig,
|
||||
DBConfig,
|
||||
Environment,
|
||||
KCConfig,
|
||||
SecurityConfig,
|
||||
Settings,
|
||||
)
|
||||
|
||||
|
||||
class TestSettings:
|
||||
"""Test Settings with composition pattern."""
|
||||
|
||||
def test_default_values(self) -> None:
|
||||
"""Test default settings values by creating settings without env file."""
|
||||
# Create settings with no env file to test defaults
|
||||
s = Settings(_env_file=None)
|
||||
assert s.app_name == "Blog API"
|
||||
assert s.debug is False
|
||||
assert s.host == "0.0.0.0"
|
||||
assert s.port == 8000
|
||||
assert s.database_url == "sqlite:///./blog.db"
|
||||
assert s.database_echo is False
|
||||
# Create settings with required secrets and no env file
|
||||
s = Settings(
|
||||
_env_file=None,
|
||||
security=SecurityConfig(secret_key="test-secret-key"),
|
||||
kc=KCConfig(client_secret="test-client-secret"),
|
||||
)
|
||||
assert s.app.name == "Blog API"
|
||||
assert s.app.debug is False
|
||||
assert s.app.host == "0.0.0.0"
|
||||
assert s.app.port == 8000
|
||||
assert s.database_url == "sqlite+aiosqlite:///./blog.db"
|
||||
assert s.db.echo is False
|
||||
assert s.security.secret_key == "test-secret-key"
|
||||
assert s.kc.client_secret == "test-client-secret"
|
||||
assert s.environment == Environment.DEV
|
||||
|
||||
def test_custom_values(self) -> None:
|
||||
"""Test custom settings values."""
|
||||
s = Settings(
|
||||
app_name="Test API",
|
||||
debug=True,
|
||||
host="localhost",
|
||||
port=9000,
|
||||
database_url="postgresql://test",
|
||||
secret_key="test-secret",
|
||||
_env_file=None,
|
||||
environment=Environment.PROD,
|
||||
app=AppConfig(
|
||||
name="Test API",
|
||||
debug=True,
|
||||
host="localhost",
|
||||
port=9000,
|
||||
),
|
||||
db=DBConfig(url="postgresql+asyncpg://user:pass@host/db"),
|
||||
security=SecurityConfig(secret_key="test-secret"),
|
||||
kc=KCConfig(client_secret="test-client-secret"),
|
||||
)
|
||||
assert s.app_name == "Test API"
|
||||
assert s.debug is True
|
||||
assert s.host == "localhost"
|
||||
assert s.port == 9000
|
||||
assert s.database_url == "postgresql://test"
|
||||
assert s.secret_key == "test-secret"
|
||||
assert s.app.name == "Test API"
|
||||
assert s.app.debug is True
|
||||
assert s.app.host == "localhost"
|
||||
assert s.app.port == 9000
|
||||
assert s.database_url == "postgresql+asyncpg://user:pass@host/db"
|
||||
assert s.security.secret_key == "test-secret"
|
||||
assert s.kc.client_secret == "test-client-secret"
|
||||
assert s.environment == Environment.PROD
|
||||
|
||||
def test_model_config(self) -> None:
|
||||
"""Test settings model config."""
|
||||
assert "env_file" in Settings.model_config
|
||||
|
||||
def test_is_dev_property(self) -> None:
|
||||
"""Test is_dev property."""
|
||||
s = Settings(
|
||||
_env_file=None,
|
||||
environment=Environment.DEV,
|
||||
security=SecurityConfig(secret_key="test"),
|
||||
kc=KCConfig(client_secret="test"),
|
||||
)
|
||||
assert s.is_dev is True
|
||||
assert s.is_prod is False
|
||||
|
||||
def test_is_prod_property(self) -> None:
|
||||
"""Test is_prod property."""
|
||||
s = Settings(
|
||||
_env_file=None,
|
||||
environment=Environment.PROD,
|
||||
security=SecurityConfig(secret_key="test"),
|
||||
kc=KCConfig(client_secret="test"),
|
||||
)
|
||||
assert s.is_prod is True
|
||||
assert s.is_dev is False
|
||||
|
||||
def test_prod_requires_security_secret(self) -> None:
|
||||
"""Test that prod mode requires security secret_key."""
|
||||
with pytest.raises(ValueError, match="SECURITY_SECRET_KEY"):
|
||||
Settings(
|
||||
_env_file=None,
|
||||
environment=Environment.PROD,
|
||||
security=SecurityConfig(secret_key=""),
|
||||
kc=KCConfig(client_secret="test"),
|
||||
)
|
||||
|
||||
def test_prod_requires_kc_secret(self) -> None:
|
||||
"""Test that prod mode requires KC client_secret."""
|
||||
with pytest.raises(ValueError, match="KC_CLIENT_SECRET"):
|
||||
Settings(
|
||||
_env_file=None,
|
||||
environment=Environment.PROD,
|
||||
security=SecurityConfig(secret_key="test"),
|
||||
kc=KCConfig(client_secret=""),
|
||||
)
|
||||
|
||||
def test_database_url_dev_default(self) -> None:
|
||||
"""Test default database URL in dev mode."""
|
||||
s = Settings(
|
||||
_env_file=None,
|
||||
environment=Environment.DEV,
|
||||
security=SecurityConfig(secret_key="test"),
|
||||
kc=KCConfig(client_secret="test"),
|
||||
)
|
||||
assert s.database_url == "sqlite+aiosqlite:///./blog.db"
|
||||
|
||||
def test_database_url_prod_builds_postgres(self) -> None:
|
||||
"""Test that database URL builds from components in prod."""
|
||||
s = Settings(
|
||||
_env_file=None,
|
||||
environment=Environment.PROD,
|
||||
db=DBConfig(
|
||||
url=None, # Force building from components
|
||||
host="db.example.com",
|
||||
port=5433,
|
||||
user="admin",
|
||||
password="secret",
|
||||
name="mydb",
|
||||
),
|
||||
security=SecurityConfig(secret_key="test"),
|
||||
kc=KCConfig(client_secret="test"),
|
||||
)
|
||||
assert (
|
||||
s.database_url
|
||||
== "postgresql+asyncpg://admin:secret@db.example.com:5433/mydb"
|
||||
)
|
||||
|
||||
def test_database_url_override(self) -> None:
|
||||
"""Test that explicit database URL overrides auto-building."""
|
||||
s = Settings(
|
||||
_env_file=None,
|
||||
environment=Environment.PROD,
|
||||
db=DBConfig(
|
||||
url="postgresql+asyncpg://custom/url",
|
||||
host="ignored",
|
||||
user="ignored",
|
||||
),
|
||||
security=SecurityConfig(secret_key="test"),
|
||||
kc=KCConfig(client_secret="test"),
|
||||
)
|
||||
assert s.database_url == "postgresql+asyncpg://custom/url"
|
||||
|
||||
|
||||
class TestAppConfig:
|
||||
"""Test AppConfig."""
|
||||
|
||||
def test_default_values(self) -> None:
|
||||
"""Test AppConfig default values."""
|
||||
cfg = AppConfig()
|
||||
assert cfg.name == "Blog API"
|
||||
assert cfg.debug is False
|
||||
assert cfg.host == "0.0.0.0"
|
||||
assert cfg.port == 8000
|
||||
|
||||
|
||||
class TestDBConfig:
|
||||
"""Test DBConfig."""
|
||||
|
||||
def test_default_values(self) -> None:
|
||||
"""Test DBConfig default values."""
|
||||
cfg = DBConfig()
|
||||
assert cfg.url is None
|
||||
assert cfg.echo is False
|
||||
assert cfg.host == "localhost"
|
||||
assert cfg.port == 5432
|
||||
assert cfg.user == "postgres"
|
||||
assert cfg.password == "postgres"
|
||||
assert cfg.name == "blog"
|
||||
|
||||
def test_postgres_url_validation(self) -> None:
|
||||
"""Test URL validation for postgres."""
|
||||
cfg = DBConfig(url="postgresql+asyncpg://user:pass@host/db")
|
||||
assert cfg.url == "postgresql+asyncpg://user:pass@host/db"
|
||||
|
||||
def test_sqlite_url_validation(self) -> None:
|
||||
"""Test URL validation for sqlite."""
|
||||
cfg = DBConfig(url="sqlite+aiosqlite:///./test.db")
|
||||
assert cfg.url == "sqlite+aiosqlite:///./test.db"
|
||||
|
||||
def test_invalid_url_validation(self) -> None:
|
||||
"""Test URL validation rejects invalid URLs."""
|
||||
with pytest.raises(ValueError, match="sqlite+.*postgresql+"):
|
||||
DBConfig(url="mysql://invalid")
|
||||
|
||||
|
||||
class TestKCConfig:
|
||||
"""Test KCConfig."""
|
||||
|
||||
def test_default_values(self) -> None:
|
||||
"""Test KCConfig default values."""
|
||||
cfg = KCConfig(client_secret="test-secret")
|
||||
assert cfg.server_url == "http://localhost:8080"
|
||||
assert cfg.realm == "blog"
|
||||
assert cfg.client_id == "blog-api"
|
||||
assert cfg.client_secret == "test-secret"
|
||||
assert cfg.token_cache_ttl == 60
|
||||
|
||||
def test_is_configured_with_secret(self) -> None:
|
||||
"""Test is_configured returns True when secret is set."""
|
||||
cfg = KCConfig(client_secret="test-secret")
|
||||
assert cfg.is_configured is True
|
||||
|
||||
def test_is_configured_without_secret(self) -> None:
|
||||
"""Test is_configured returns False when secret is empty."""
|
||||
cfg = KCConfig(client_secret="")
|
||||
assert cfg.is_configured is False
|
||||
|
||||
|
||||
class TestSecurityConfig:
|
||||
"""Test SecurityConfig."""
|
||||
|
||||
def test_default_values(self) -> None:
|
||||
"""Test SecurityConfig default values."""
|
||||
cfg = SecurityConfig(secret_key="test-key")
|
||||
assert cfg.secret_key == "test-key"
|
||||
assert cfg.access_token_expire_minutes == 30
|
||||
|
||||
def test_is_configured_with_secret(self) -> None:
|
||||
"""Test is_configured returns True when secret is set."""
|
||||
cfg = SecurityConfig(secret_key="test-secret")
|
||||
assert cfg.is_configured is True
|
||||
|
||||
def test_is_configured_without_secret(self) -> None:
|
||||
"""Test is_configured returns False when secret is empty."""
|
||||
cfg = SecurityConfig(secret_key="")
|
||||
assert cfg.is_configured is False
|
||||
|
||||
|
||||
class TestEnvironment:
|
||||
"""Test Environment enum."""
|
||||
|
||||
def test_dev_value(self) -> None:
|
||||
"""Test DEV environment value."""
|
||||
assert Environment.DEV.value == "dev"
|
||||
|
||||
def test_prod_value(self) -> None:
|
||||
"""Test PROD environment value."""
|
||||
assert Environment.PROD.value == "prod"
|
||||
|
||||
Reference in New Issue
Block a user