From f75a358d21a23fb402ee9d083b819e9993963df2 Mon Sep 17 00:00:00 2001 From: Pavel Eliseev Date: Sun, 4 Oct 2026 15:51:35 +0300 Subject: [PATCH] feat(contract): add opt-in MAX configuration and lifecycle Refs #81 --- .env.example | 10 ++++++++ app/config.py | 34 ++++++++++++++++++++++++++- app/main.py | 24 ++++++++++++++----- tests/test_contracts_max.py | 47 +++++++++++++++++++++++++++++++++++++ 4 files changed, 108 insertions(+), 7 deletions(-) create mode 100644 tests/test_contracts_max.py diff --git a/.env.example b/.env.example index 53e7253..9b90399 100644 --- a/.env.example +++ b/.env.example @@ -37,3 +37,13 @@ GITEA_URL=https://gitea.24studbank.ru GITEA_REPO=MedITron/PTSRaiAgent GITEA_USER= 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 diff --git a/app/config.py b/app/config.py index 0f0069c..0522046 100644 --- a/app/config.py +++ b/app/config.py @@ -1,10 +1,12 @@ """Typed application settings. The only place that reads environment variables.""" +import re from functools import lru_cache from pathlib import Path 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 ROOT_DIR = Path(__file__).resolve().parent.parent @@ -42,6 +44,36 @@ class Settings(BaseSettings): tg_bot_token: str = "" 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). demo_clock: bool = True tz: str = "Europe/Moscow" diff --git a/app/main.py b/app/main.py index fec6c61..1a90f4d 100644 --- a/app/main.py +++ b/app/main.py @@ -30,13 +30,25 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]: # 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. 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). - await scheduler.start() - await telegram.start() - yield - await telegram.stop() - await scheduler.stop() - await dispose() + max_channel = None + try: + 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 + finally: + await scheduler.stop() + if max_channel is not None: + await max_channel.stop() + await telegram.stop() + await dispose() app = FastAPI(title="PTSRaiAgent API", version="0.1.0", lifespan=lifespan) diff --git a/tests/test_contracts_max.py b/tests/test_contracts_max.py new file mode 100644 index 0000000..882c482 --- /dev/null +++ b/tests/test_contracts_max.py @@ -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)