feat(contract): add opt-in MAX configuration and lifecycle
CI / ruff · pytest (pull_request) Successful in 2m24s

Refs #81
This commit is contained in:
Pavel Eliseev
2026-10-04 15:58:25 +03:00
parent bde6ec6855
commit b3e103954f
4 changed files with 108 additions and 7 deletions
+10
View File
@@ -37,3 +37,13 @@ GITEA_URL=https://gitea.24studbank.ru
GITEA_REPO=MedITron/PTSRaiAgent GITEA_REPO=MedITron/PTSRaiAgent
GITEA_USER= GITEA_USER=
GITEA_TOKEN= GITEA_TOKEN=
# MAX notifications: keep disabled until the backend/channel PRs and a real bot are installed.
MAX_ENABLED=false
MAX_BOT_TOKEN=
MAX_BOT_USERNAME=
MAX_WEBHOOK_SECRET=
MAX_WEBHOOK_URL=
MAX_SITE_URL=
# Optional path to a trusted PEM CA bundle; never disable TLS verification.
MAX_CA_BUNDLE=
MAX_LINK_TTL_SECONDS=600
+33 -1
View File
@@ -1,10 +1,12 @@
"""Typed application settings. The only place that reads environment variables.""" """Typed application settings. The only place that reads environment variables."""
import re
from functools import lru_cache from functools import lru_cache
from pathlib import Path from pathlib import Path
from typing import Literal from typing import Literal
from urllib.parse import urlsplit
from pydantic import Field from pydantic import Field, SecretStr, model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict from pydantic_settings import BaseSettings, SettingsConfigDict
ROOT_DIR = Path(__file__).resolve().parent.parent ROOT_DIR = Path(__file__).resolve().parent.parent
@@ -43,6 +45,36 @@ class Settings(BaseSettings):
tg_bot_token: str = "" tg_bot_token: str = ""
doctor_notify_chat_id: int | None = None doctor_notify_chat_id: int | None = None
# MAX is opt-in. Its adapter is delivered in the related backend/channel changes.
max_enabled: bool = False
max_bot_token: SecretStr = SecretStr("")
max_bot_username: str = ""
max_webhook_secret: SecretStr = SecretStr("")
max_webhook_url: str = ""
max_site_url: str = ""
max_ca_bundle: Path | None = None
max_link_ttl_seconds: int = Field(default=600, ge=60, le=1800)
@model_validator(mode="after")
def validate_max(self) -> "Settings":
if not self.max_enabled:
return self
if not self.max_bot_token.get_secret_value().strip():
raise ValueError("MAX_BOT_TOKEN is required when MAX_ENABLED=true")
if not re.fullmatch(r"[A-Za-z0-9_]{1,128}", self.max_bot_username):
raise ValueError("MAX_BOT_USERNAME must be the bot nickname without @ or a URL")
if not re.fullmatch(r"[A-Za-z0-9_-]{32,256}", self.max_webhook_secret.get_secret_value()):
raise ValueError("MAX_WEBHOOK_SECRET must contain 32..256 URL-safe characters")
for name, value in (("MAX_SITE_URL", self.max_site_url), ("MAX_WEBHOOK_URL", self.max_webhook_url)):
url = urlsplit(value)
if url.scheme != "https" or not url.hostname or url.username or url.password or url.port not in (None, 443):
raise ValueError(f"{name} must use HTTPS on port 443 without credentials")
if url.query or (name == "MAX_WEBHOOK_URL" and url.fragment):
raise ValueError(f"{name} must not contain a query string (or a fragment for webhook)")
if self.max_ca_bundle is not None and not self.max_ca_bundle.is_file():
raise ValueError("MAX_CA_BUNDLE must point to an existing trusted CA bundle")
return self
# Virtual clock for the demo (ADR-005). # Virtual clock for the demo (ADR-005).
demo_clock: bool = True demo_clock: bool = True
tz: str = "Europe/Moscow" tz: str = "Europe/Moscow"
+14 -2
View File
@@ -30,12 +30,24 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
# In compose Postgres is required and healthy before the app starts; this branch is for running the # In compose Postgres is required and healthy before the app starts; this branch is for running the
# API (and its tests) without a database. Endpoints touching the DB will fail until it is reachable. # API (and its tests) without a database. Endpoints touching the DB will fail until it is reachable.
log.error("db init failed, continuing without db: %s", type(exc).__name__) log.error("db init failed, continuing without db: %s", type(exc).__name__)
if settings.max_enabled:
raise # MAX requires persistent bindings and a delivery queue.
# Lanes fill these in (#13, #11); main.py itself stays frozen (#34). # Lanes fill these in (#13, #11); main.py itself stays frozen (#34).
await scheduler.start() max_channel = None
try:
await telegram.start() await telegram.start()
if settings.max_enabled:
from app.channels import max_bot
max_channel = max_bot
await max_channel.start()
await scheduler.start()
yield yield
await telegram.stop() finally:
await scheduler.stop() await scheduler.stop()
if max_channel is not None:
await max_channel.stop()
await telegram.stop()
await dispose() await dispose()
+47
View File
@@ -0,0 +1,47 @@
"""MAX stays off without credentials; enabled deployments fail closed on incomplete settings."""
import pytest
from pydantic import ValidationError
from app.config import Settings
def configured(**overrides):
fields = {
"max_enabled": True,
"max_bot_token": "test-only-token",
"max_bot_username": "test_bot",
"max_webhook_secret": "test-only-secret-" * 3,
"max_webhook_url": "https://api.example.org/api/v1/webhooks/max",
"max_site_url": "https://patient.example.org/#/today",
}
return Settings(_env_file=None, **(fields | overrides))
def test_max_disabled_by_default():
assert not Settings(_env_file=None).max_enabled
def test_max_secrets_are_redacted():
settings = configured()
assert "test-only-token" not in repr(settings)
assert "test-only-secret" not in repr(settings)
@pytest.mark.parametrize(
"overrides",
[
{"max_bot_token": ""},
{"max_bot_username": "@test_bot"},
{"max_webhook_secret": "short"},
{"max_site_url": "http://patient.example.org"},
{"max_webhook_url": "http://api.example.org"},
{"max_webhook_url": "https://api.example.org:8443/hook"},
{"max_webhook_url": "https://user:password@api.example.org/hook"},
{"max_webhook_url": "https://api.example.org/hook#fragment"},
{"max_site_url": "https://patient.example.org?token=credential"},
],
)
def test_max_rejects_incomplete_or_unsafe_settings(overrides):
with pytest.raises(ValidationError):
configured(**overrides)