Files
blog.pyaqa.ru/tests/unit/infrastructure/test_config.py
Sergey Vanyushkin 184b95969c
Some checks failed
ci/woodpecker/pr/lint Pipeline failed
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/type Pipeline was successful
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)
2026-05-02 11:21:45 +03:00

248 lines
8.4 KiB
Python

"""Tests for infrastructure config."""
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 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(
_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.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"