Закрыли часть проблем с безопасностью + мелкие починки
This commit is contained in:
@@ -31,6 +31,10 @@ class JWKSValidator:
|
||||
self._loaded_at = 0.0
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
@property
|
||||
def has_keys(self) -> bool:
|
||||
return bool(self._keys)
|
||||
|
||||
async def refresh(self) -> None:
|
||||
async with self._lock:
|
||||
discovery_url = (
|
||||
|
||||
@@ -113,6 +113,21 @@ async def refresh_settings_cache(app: FastAPI) -> None:
|
||||
await asyncio.sleep(30)
|
||||
|
||||
|
||||
async def refresh_jwks_cache(app: FastAPI) -> None:
|
||||
retry_delay = 5
|
||||
refresh_delay = max(30, app.state.settings.jwks_cache_ttl_seconds)
|
||||
delay = refresh_delay if app.state.jwks.has_keys else retry_delay
|
||||
while True:
|
||||
await asyncio.sleep(delay)
|
||||
try:
|
||||
await app.state.jwks.refresh()
|
||||
except Exception:
|
||||
log.warning("jwks.refresh_failed")
|
||||
delay = retry_delay
|
||||
else:
|
||||
delay = refresh_delay
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
settings = get_settings()
|
||||
@@ -142,12 +157,14 @@ async def lifespan(app: FastAPI):
|
||||
await app.state.jwks.refresh()
|
||||
except Exception:
|
||||
structlog.get_logger().warning("jwks.warmup_failed")
|
||||
jwks_task = asyncio.create_task(refresh_jwks_cache(app))
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
settings_task.cancel()
|
||||
with suppress(asyncio.CancelledError):
|
||||
await settings_task
|
||||
for task in (settings_task, jwks_task):
|
||||
task.cancel()
|
||||
with suppress(asyncio.CancelledError):
|
||||
await task
|
||||
await app.state.http.aclose()
|
||||
await app.state.redis.aclose()
|
||||
await app.state.redis_rt.aclose()
|
||||
@@ -462,7 +479,7 @@ async def ready(request: Request, db: Session):
|
||||
components[name] = "ok"
|
||||
except Exception:
|
||||
components[name] = "failed"
|
||||
components["jwks"] = "ok" if request.app.state.jwks._keys else "failed"
|
||||
components["jwks"] = "ok" if request.app.state.jwks.has_keys else "failed"
|
||||
try:
|
||||
safety_response = await request.app.state.http.get(
|
||||
f"{str(request.app.state.settings.message_safety_url).rstrip('/')}/health/ready",
|
||||
|
||||
@@ -1,14 +1,22 @@
|
||||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
from alembic.config import Config
|
||||
from alembic.script import ScriptDirectory
|
||||
from pydantic import SecretStr
|
||||
|
||||
from app.main import EXPECTED_API_DB_REVISION, app, otp_settings, websocket_token
|
||||
from app.main import (
|
||||
EXPECTED_API_DB_REVISION,
|
||||
app,
|
||||
otp_settings,
|
||||
refresh_jwks_cache,
|
||||
websocket_token,
|
||||
)
|
||||
from app.services import SettingsSnapshot
|
||||
|
||||
EXPECTED_PATHS = {
|
||||
@@ -65,6 +73,39 @@ def test_readiness_expected_revision_matches_alembic_head() -> None:
|
||||
assert EXPECTED_API_DB_REVISION == scripts.get_current_head()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_jwks_refresh_loop_recovers_after_startup_race(monkeypatch) -> None:
|
||||
class FakeJWKS:
|
||||
has_keys = False
|
||||
refresh_calls = 0
|
||||
|
||||
async def refresh(self) -> None:
|
||||
self.refresh_calls += 1
|
||||
self.has_keys = True
|
||||
|
||||
jwks = FakeJWKS()
|
||||
test_app = SimpleNamespace(
|
||||
state=SimpleNamespace(
|
||||
jwks=jwks,
|
||||
settings=SimpleNamespace(jwks_cache_ttl_seconds=300),
|
||||
)
|
||||
)
|
||||
delays: list[int] = []
|
||||
|
||||
async def fake_sleep(delay: int) -> None:
|
||||
delays.append(delay)
|
||||
if len(delays) > 1:
|
||||
raise asyncio.CancelledError
|
||||
|
||||
monkeypatch.setattr("app.main.asyncio.sleep", fake_sleep)
|
||||
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await refresh_jwks_cache(test_app)
|
||||
|
||||
assert jwks.refresh_calls == 1
|
||||
assert delays == [5, 300]
|
||||
|
||||
|
||||
def test_websocket_route_is_registered() -> None:
|
||||
assert any(getattr(route, "path", None) == "/api/v1/realtime" for route in app.routes)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user