diff --git a/backend/app/config.py b/backend/app/config.py index 604286f..e86022b 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -24,6 +24,9 @@ class Settings(BaseSettings): # ── Redis ───────────────────────────────────────────────────────── REDIS_URL: str = "redis://redis:6379/0" + # Logical DB indices on the same Redis instance (PATH in URL is overridden). + REDIS_TOKEN_BLACKLIST_DB: int = 1 + REDIS_CACHE_DB: int = 2 # ── CORS ───────────────────────────────────────────────────────── # Comma-separated list of allowed origins, or a JSON array. diff --git a/backend/app/infrastructure/redis_client.py b/backend/app/infrastructure/redis_client.py index cde4e21..1c8f148 100644 --- a/backend/app/infrastructure/redis_client.py +++ b/backend/app/infrastructure/redis_client.py @@ -1,17 +1,22 @@ -"""Redis client singleton. +"""Redis client factories. -Provides a lazily-initialised Redis connection that is reused across -the application. The connection URL is read from ``settings.REDIS_URL``. +``settings.REDIS_URL`` selects the default logical database (usually ``0``). +Token blacklist and application cache use separate logical DBs on the same +Redis instance (``REDIS_TOKEN_BLACKLIST_DB``, ``REDIS_CACHE_DB``) so keys never +collide and TTL policies can differ per workload. Usage:: - from app.infrastructure.redis_client import get_redis + from app.infrastructure.redis_client import get_redis, get_redis_blacklist - r = get_redis() - r.set("key", "value", ex=300) + get_redis().set("key", "value", ex=300) + get_redis_blacklist().setex("blacklist:…", ttl, "1") """ +from __future__ import annotations + import logging +from urllib.parse import urlparse, urlunparse import redis @@ -19,16 +24,43 @@ from app.config import settings logger = logging.getLogger(__name__) -_redis_client: redis.Redis | None = None +_clients: dict[str, redis.Redis] = {} + + +def _redis_url_with_db(base_url: str, db_index: int) -> str: + """Return *base_url* with its path replaced by ``/{db_index}``.""" + parsed = urlparse(base_url) + path = f"/{db_index}" + return urlunparse( + (parsed.scheme, parsed.netloc, path, "", "", ""), + ) + + +def _get_client(url: str) -> redis.Redis: + if url not in _clients: + _clients[url] = redis.from_url(url, decode_responses=True) + logger.info("Redis client connected to %s", url) + return _clients[url] def get_redis() -> redis.Redis: - """Return a shared Redis client, creating it on first call.""" - global _redis_client - if _redis_client is None: - _redis_client = redis.from_url( - settings.REDIS_URL, - decode_responses=True, - ) - logger.info("Redis client connected to %s", settings.REDIS_URL) - return _redis_client + """Default Redis connection (URL from ``settings.REDIS_URL``).""" + return _get_client(settings.REDIS_URL) + + +def get_redis_blacklist() -> redis.Redis: + """Redis DB used for JWT revocation (``jti`` keys with TTL).""" + url = _redis_url_with_db( + settings.REDIS_URL, + settings.REDIS_TOKEN_BLACKLIST_DB, + ) + return _get_client(url) + + +def get_redis_cache() -> redis.Redis: + """Redis DB reserved for shared cache (scores, queues, etc.).""" + url = _redis_url_with_db( + settings.REDIS_URL, + settings.REDIS_CACHE_DB, + ) + return _get_client(url) diff --git a/backend/requirements.txt b/backend/requirements.txt index 5674c00..1cd7569 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -26,3 +26,4 @@ docxtpl>=0.18.0 pytest pytest-asyncio httpx +fakeredis>=2.23.0 diff --git a/backend/tests/test_redis_client.py b/backend/tests/test_redis_client.py new file mode 100644 index 0000000..1990482 --- /dev/null +++ b/backend/tests/test_redis_client.py @@ -0,0 +1,61 @@ +"""Redis URL helpers and blacklist DB wiring (FASE 0.1).""" + +import time +import uuid +from unittest.mock import MagicMock + +import pytest + +from app.config import settings +from app.infrastructure import redis_client as rc + + +@pytest.fixture +def fakeredis(): + import fakeredis as fr + + return fr.FakeRedis(decode_responses=True) + + +def test_redis_url_with_db_replaces_index(): + assert ( + rc._redis_url_with_db("redis://localhost:6379/0", 2) + == "redis://localhost:6379/2" + ) + + +def test_get_redis_blacklist_uses_configured_db(monkeypatch): + monkeypatch.setattr(rc, "_clients", {}) + captured: list[str] = [] + + def fake_from_url(url, **kwargs): + captured.append(url) + m = MagicMock() + m.setex = MagicMock() + return m + + monkeypatch.setattr("redis.from_url", fake_from_url) + monkeypatch.setattr(settings, "REDIS_URL", "redis://redis:6379/0") + monkeypatch.setattr(settings, "REDIS_TOKEN_BLACKLIST_DB", 9) + + c = rc.get_redis_blacklist() + c.setex("k", 10, "v") + assert captured == ["redis://redis:6379/9"] + c.setex.assert_called_once_with("k", 10, "v") + + +def test_redis_set_get_ttl(fakeredis, monkeypatch): + """Integration-style check against FakeRedis (no real server).""" + monkeypatch.setattr(rc, "_clients", {}) + monkeypatch.setattr( + "app.infrastructure.redis_client.get_redis_blacklist", + lambda: fakeredis, + ) + from app.auth import blacklist_token, is_token_blacklisted + + jti = str(uuid.uuid4()) + exp = time.time() + 3600 + blacklist_token(jti, exp) + assert is_token_blacklisted(jti) is True + ttl = fakeredis.ttl(f"blacklist:{jti}") + assert 0 < ttl <= 3600 diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 93584b1..ab41e76 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -80,6 +80,8 @@ services: MINIO_BUCKET: ${MINIO_BUCKET:-evidence} MINIO_SECURE: ${MINIO_SECURE:-false} REDIS_URL: redis://redis:6379/0 + REDIS_TOKEN_BLACKLIST_DB: ${REDIS_TOKEN_BLACKLIST_DB:-1} + REDIS_CACHE_DB: ${REDIS_CACHE_DB:-2} CORS_ORIGINS: ${CORS_ORIGINS:-} AEGIS_ENV: ${AEGIS_ENV:-production} ADMIN_USERNAME: ${ADMIN_USERNAME:-admin} diff --git a/docker-compose.yml b/docker-compose.yml index eae3e04..cf5e993 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -69,7 +69,7 @@ services: interval: 5s timeout: 3s retries: 5 - restart: unless-stopped + restart: always # ── FastAPI Backend ──────────────────────────────────────────────────────── backend: @@ -89,6 +89,8 @@ services: ACCESS_TOKEN_EXPIRE_MINUTES: 60 # Redis REDIS_URL: redis://redis:6379/0 + REDIS_TOKEN_BLACKLIST_DB: "1" + REDIS_CACHE_DB: "2" # MinIO MINIO_ENDPOINT: minio:9000 MINIO_ACCESS_KEY: minioadmin