feat(contract): add opt-in MAX configuration and lifecycle
CI / ruff · pytest (pull_request) Successful in 2m24s
CI / ruff · pytest (pull_request) Successful in 2m24s
Refs #81
This commit is contained in:
@@ -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
@@ -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
@@ -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()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
Reference in New Issue
Block a user