"""API contracts for the web IM channel-config surface (Task 5). Covers the contract from https://github.com/HKUDS/Vibe-Trading/issues/1519: ``GET /channels/config``, ``PUT /channels/config/{name}`` and ``POST /channels/{name}/test`` — secret masking, the 422-before-write ordering (bad credentials never reach disk), hot-apply modes (hot_swapped / reset / deferred), per-channel lock serialization, and the never-persist guarantee of the test endpoint. No secret value may appear in any response body. """ from __future__ import annotations import asyncio import json import socket from pathlib import Path from typing import Any import httpx import pytest import yaml from fastapi.testclient import TestClient import api_server from src.api import channels_config_routes as routes from src.api import state as api_state from src.channels import email_probe from src.channels.dingtalk import DINGTALK_AVAILABLE, DingTalkChannel CLIENT_ID = "ding-client-id-1234567890" STORED_SECRET = "stored-secret-abcdefghij" UNSAVED_SECRET = "unsaved-secret-xyz987654321" EMAIL_IMAP_PASSWORD = "email-imap-password-987654321" EMAIL_SMTP_PASSWORD = "email-smtp-password-123456789" WS_TOKEN = "ws-token-secret-abcdefghij" WS_ISSUE_SECRET = "ws-issue-secret-987654321" FEISHU_APP_ID = "cli_feishu_app_id_1234567890" FEISHU_STORED_SECRET = "feishu-stored-secret-abcdefghij" # Captured before any test monkeypatches httpx, so repeated injections in a # single test still wrap the real client (test_dingtalk_connection_test idiom). _REAL_ASYNC_CLIENT = httpx.AsyncClient class FakeSessionService: """SessionService placeholder so a runtime build never touches real state.""" class FakeManager: """Records ``reload_channel`` calls; optional delay exposes lock overlap.""" def __init__(self, *, delay: float = 0.0, fail: bool = False) -> None: self.reload_calls: list[tuple[str, dict | None]] = [] self.active = 0 self.max_active = 0 self._delay = delay self._fail = fail async def reload_channel(self, name: str, section: dict | None) -> dict[str, Any]: if self._fail: raise RuntimeError("reload boom") self.reload_calls.append((name, section)) self.active += 1 self.max_active = max(self.max_active, self.active) try: if self._delay: await asyncio.sleep(self._delay) finally: self.active -= 1 return {"loaded": bool(section and section.get("enabled"))} class FakeRuntime: """Stands in for ChannelRuntime: an authoritative running flag + manager.""" def __init__(self, *, running: bool, manager: FakeManager | None = None) -> None: self._running = running self.manager = manager if manager is not None else FakeManager() self.start_calls: list[bool] = [] self.stop_calls: list[bool] = [] def status(self) -> dict[str, Any]: return {"running": self._running, "channels": {}} async def start(self, *, start_manager: bool = True) -> None: self.start_calls.append(start_manager) async def stop(self) -> None: self.stop_calls.append(True) self._running = False def _write_agent_config( tmp_path: Path, channels: dict[str, Any], *, suffix: str = ".json" ) -> Path: path = tmp_path / f"agent{suffix}" payload = {"channels": channels} if suffix == ".json": path.write_text(json.dumps(payload), encoding="utf-8") else: path.write_text(yaml.safe_dump(payload), encoding="utf-8") return path def _client( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, *, channels: dict[str, Any] | None = None, runtime: Any = None, suffix: str = ".json", ) -> tuple[TestClient, Path]: """Point the config root at *tmp_path* and mount the app test client.""" config_path = _write_agent_config(tmp_path, channels or {}, suffix=suffix) monkeypatch.setenv("VIBE_TRADING_HOME", str(tmp_path)) monkeypatch.setattr(api_server, "_channel_runtime", runtime) monkeypatch.setattr(api_server, "_channel_bus", None) monkeypatch.setattr(api_server, "_channel_manager", None) monkeypatch.setattr(api_server, "_get_session_service", lambda: FakeSessionService()) return TestClient(api_server.app, client=("127.0.0.1", 50000)), config_path @pytest.fixture(autouse=True) def _fresh_locks(): """Give every test unbound locks (asyncio.Lock binds to a loop on contention).""" routes._channel_locks.clear() routes._runtime_lock = asyncio.Lock() yield routes._channel_locks.clear() routes._runtime_lock = asyncio.Lock() def _dingtalk_section(**overrides: Any) -> dict[str, Any]: section: dict[str, Any] = { "enabled": False, "client_id": CLIENT_ID, "client_secret": STORED_SECRET, "allow_from": ["*"], } section.update(overrides) return section def _email_section(**overrides: Any) -> dict[str, Any]: section: dict[str, Any] = { "enabled": False, "imap_host": "imap.example.com", "imap_username": "bot@example.com", "imap_password": EMAIL_IMAP_PASSWORD, "smtp_host": "smtp.example.com", "smtp_username": "sender@example.com", "smtp_password": EMAIL_SMTP_PASSWORD, } section.update(overrides) return section def _feishu_section(**overrides: Any) -> dict[str, Any]: section: dict[str, Any] = { "enabled": False, "app_id": FEISHU_APP_ID, "app_secret": FEISHU_STORED_SECRET, } section.update(overrides) return section def _free_port() -> int: """Return a loopback port the OS just handed out (free at bind time).""" with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: sock.bind(("127.0.0.1", 0)) return int(sock.getsockname()[1]) def _inject_mock_transport( monkeypatch: pytest.MonkeyPatch, handler: Any ) -> list[httpx.Request]: """Route the probe's fresh ``httpx.AsyncClient`` through a MockTransport.""" requests: list[httpx.Request] = [] def recording_handler(request: httpx.Request) -> httpx.Response: requests.append(request) return handler(request) transport = httpx.MockTransport(recording_handler) real_client_cls = _REAL_ASYNC_CLIENT def factory(*args: Any, **kwargs: Any) -> httpx.AsyncClient: kwargs["transport"] = transport return real_client_cls(*args, **kwargs) monkeypatch.setattr(httpx, "AsyncClient", factory) return requests # --------------------------------------------------------------------------- # # GET /channels/config # --------------------------------------------------------------------------- # def test_get_masks_secrets_and_reports_writable(tmp_path: Path, monkeypatch) -> None: client, path = _client( tmp_path, monkeypatch, channels={"dingtalk": _dingtalk_section()} ) response = client.get("/channels/config") assert response.status_code == 200 payload = response.json() assert payload["config_path"] == path.name assert payload["writable"] is True assert payload["runtime_running"] is False entry = payload["channels"]["dingtalk"] assert entry["display_name"] == "DingTalk" assert entry["supports_test"] is True assert entry["sdk_available"] is DINGTALK_AVAILABLE assert entry["values"]["client_id"] == CLIENT_ID assert entry["values"]["enabled"] is False assert "client_secret" not in entry["values"] assert entry["secrets"]["client_secret"] == {"set": True, "masked": "****ghij"} keys = {field["key"] for field in entry["fields"]} assert keys >= {"client_id", "client_secret", "allow_from"} assert "enabled" not in keys client_id_hint = next(f for f in entry["fields"] if f["key"] == "client_id") assert client_id_hint["help_key"] == "settings.channels.fields.dingtalk.client_id" assert client_id_hint["secret"] is False assert STORED_SECRET not in response.text # GET is read-only: it must not build the runtime singleton. assert api_server._channel_runtime is None def test_get_feishu_masks_secrets_and_reports_test_support( tmp_path: Path, monkeypatch ) -> None: client, _ = _client(tmp_path, monkeypatch, channels={"feishu": _feishu_section()}) response = client.get("/channels/config") assert response.status_code == 200 entry = response.json()["channels"]["feishu"] assert entry["display_name"] == "Feishu" assert entry["supports_test"] is True assert entry["values"]["app_id"] == FEISHU_APP_ID assert entry["values"]["enabled"] is False assert "app_secret" not in entry["values"] assert entry["secrets"]["app_secret"] == {"set": True, "masked": "****ghij"} keys = [field["key"] for field in entry["fields"]] assert keys[:2] == ["app_id", "app_secret"] assert "enabled" not in keys app_id_hint = next(f for f in entry["fields"] if f["key"] == "app_id") assert app_id_hint["help_key"] == "settings.channels.fields.feishu.app_id" assert app_id_hint["secret"] is False assert FEISHU_STORED_SECRET not in response.text def test_get_excludes_non_channel_helper_modules(tmp_path: Path, monkeypatch) -> None: client, _ = _client(tmp_path, monkeypatch) payload = client.get("/channels/config").json() assert "targets" not in payload["channels"] assert "config_meta" not in payload["channels"] assert "dingtalk" in payload["channels"] def test_get_reports_not_writable_for_yaml_config(tmp_path: Path, monkeypatch) -> None: client, path = _client( tmp_path, monkeypatch, channels={"dingtalk": _dingtalk_section()}, suffix=".yaml", ) response = client.get("/channels/config") assert response.status_code == 200 payload = response.json() assert payload["writable"] is False assert payload["config_path"] == path.name assert payload["channels"]["dingtalk"]["values"]["client_id"] == CLIENT_ID assert STORED_SECRET not in response.text def test_get_reports_runtime_running_from_live_status(tmp_path: Path, monkeypatch) -> None: client, _ = _client( tmp_path, monkeypatch, channels={"dingtalk": _dingtalk_section()}, runtime=FakeRuntime(running=True), ) payload = client.get("/channels/config").json() assert payload["runtime_running"] is True def test_get_backfills_adapter_defaults_for_unconfigured_channel( tmp_path: Path, monkeypatch ) -> None: """An empty on-disk section still yields the adapter's default values. Regression (found by live QQ E2E): a never-configured channel returned empty ``values``, so the generic form seeded "" into typed fields (``msg_format`` Literal, ``download_chunk_size`` int) and any Test/Save died on validation_error before the fix. """ client, _ = _client(tmp_path, monkeypatch, channels={}) qq = client.get("/channels/config").json()["channels"]["qq"] assert qq["values"]["msg_format"] == "plain" assert qq["values"]["download_chunk_size"] == 262144 assert qq["values"]["download_max_bytes"] == 209715200 assert qq["values"]["app_id"] == "" assert qq["values"]["enabled"] is False # Secret masking still reflects stored state only: nothing is configured. assert qq["secrets"]["secret"] == {"set": False, "masked": ""} def test_pristine_form_roundtrip_validates_for_typed_fields( tmp_path: Path, monkeypatch ) -> None: """GET values echoed back as a form patch must pass Test validation. Mirrors the frontend's ``buildPatch`` for a pristine (never-configured) QQ form: every non-secret field is sent back as the string/list/bool the generic widgets hold. Before the defaults backfill this produced ``validation_error: msg_format, download_chunk_size, download_max_bytes``. """ client, _ = _client(tmp_path, monkeypatch, channels={}) entry = client.get("/channels/config").json()["channels"]["qq"] patch: dict[str, Any] = {} for field in entry["fields"]: if field["secret"]: continue value = entry["values"].get(field["key"]) if field["type"] == "list": patch[field["key"]] = value if isinstance(value, list) else [] elif field["type"] == "bool": patch[field["key"]] = bool(value) else: patch[field["key"]] = "" if value is None else str(value) patch["app_id"] = "1905653354" patch["secret"] = "form-typed-secret-xyz987654321" def handler(request: httpx.Request) -> httpx.Response: return httpx.Response(200, json={"access_token": "probe-token-do-not-leak"}) requests = _inject_mock_transport(monkeypatch, handler) response = client.post("/channels/qq/test", json={"config": patch}) assert response.status_code == 200 body = response.json() assert body["ok"] is True, body assert body["code"] == "ok" assert len(requests) == 1 assert "form-typed-secret-xyz987654321" not in response.text def test_feishu_pristine_form_roundtrip_validates_for_typed_fields( tmp_path: Path, monkeypatch ) -> None: """Same roundtrip for Feishu's typed fields (Literal domain/group_policy, ``None``-default done_emoji, bools) through the ``tenant_access_token`` probe.""" client, _ = _client(tmp_path, monkeypatch, channels={}) entry = client.get("/channels/config").json()["channels"]["feishu"] patch: dict[str, Any] = {} for field in entry["fields"]: if field["secret"]: continue value = entry["values"].get(field["key"]) if field["type"] == "list": patch[field["key"]] = value if isinstance(value, list) else [] elif field["type"] == "bool": patch[field["key"]] = bool(value) else: patch[field["key"]] = "" if value is None else str(value) patch["app_id"] = "cli_round_trip_app" patch["app_secret"] = "form-typed-feishu-secret-xyz987654321" def handler(request: httpx.Request) -> httpx.Response: assert "open.feishu.cn" in str(request.url) return httpx.Response( 200, json={"tenant_access_token": "probe-token-do-not-leak"} ) requests = _inject_mock_transport(monkeypatch, handler) response = client.post("/channels/feishu/test", json={"config": patch}) assert response.status_code == 200 body = response.json() assert body["ok"] is True, body assert body["code"] == "ok" assert len(requests) == 1 assert "form-typed-feishu-secret-xyz987654321" not in response.text assert "probe-token-do-not-leak" not in response.text def test_email_pristine_form_roundtrip_normalizes_nullable_text_fields( tmp_path: Path, monkeypatch ) -> None: """The generic form must not turn nullable Email fields into invalid blanks.""" client, path = _client( tmp_path, monkeypatch, channels={"email": _email_section()} ) entry = client.get("/channels/config").json()["channels"]["email"] patch: dict[str, Any] = {} for field in entry["fields"]: if field["secret"]: continue value = entry["values"].get(field["key"]) if field["type"] == "list": patch[field["key"]] = value if isinstance(value, list) else [] elif field["type"] == "bool": patch[field["key"]] = bool(value) else: patch[field["key"]] = "" if value is None else str(value) patch["max_attachments_per_email"] = "4" response = client.put("/channels/config/email", json={"config": patch}) assert response.status_code == 200, response.text on_disk = json.loads(path.read_text(encoding="utf-8"))["channels"]["email"] assert on_disk["max_attachments_per_email"] == "4" assert on_disk["post_action"] is None assert on_disk["post_action_move_mailbox"] is None assert EMAIL_IMAP_PASSWORD not in response.text assert EMAIL_SMTP_PASSWORD not in response.text def test_display_config_path_relativizes_home() -> None: home = Path.home() assert routes._display_config_path(home / "sub" / "agent.json") == "~/sub/agent.json" assert routes._display_config_path(Path("/not-under-home/agent.json")) == "agent.json" # --------------------------------------------------------------------------- # # PUT /channels/config/{name} # --------------------------------------------------------------------------- # def test_put_writes_file_and_hot_swaps_running_runtime(tmp_path: Path, monkeypatch) -> None: runtime = FakeRuntime(running=True) client, path = _client( tmp_path, monkeypatch, channels={"dingtalk": _dingtalk_section()}, runtime=runtime ) response = client.put( "/channels/config/dingtalk", json={"config": {"client_id": "new-client-id"}} ) assert response.status_code == 200 body = response.json() assert body["applied"] == "hot_swapped" assert body["channel"]["values"]["client_id"] == "new-client-id" assert body["channel"]["secrets"]["client_secret"]["set"] is True on_disk = json.loads(path.read_text(encoding="utf-8"))["channels"]["dingtalk"] assert on_disk["client_id"] == "new-client-id" # A secret absent from the patch keeps its stored value. assert on_disk["client_secret"] == STORED_SECRET assert runtime.manager.reload_calls == [ ("dingtalk", {**on_disk}), ] assert STORED_SECRET not in response.text def test_put_empty_secret_string_keeps_stored_value(tmp_path: Path, monkeypatch) -> None: client, path = _client( tmp_path, monkeypatch, channels={"dingtalk": _dingtalk_section()} ) response = client.put( "/channels/config/dingtalk", json={"config": {"client_secret": ""}} ) assert response.status_code == 200 on_disk = json.loads(path.read_text(encoding="utf-8"))["channels"]["dingtalk"] assert on_disk["client_secret"] == STORED_SECRET assert STORED_SECRET not in response.text def test_put_enable_with_bad_credentials_is_blocked_before_write( tmp_path: Path, monkeypatch ) -> None: client, path = _client( tmp_path, monkeypatch, channels={"dingtalk": _dingtalk_section()} ) before = path.read_bytes() def handler(request: httpx.Request) -> httpx.Response: return httpx.Response(401, json={"message": "invalid appKey/appSecret"}) requests = _inject_mock_transport(monkeypatch, handler) response = client.put("/channels/config/dingtalk", json={"config": {"enabled": True}}) assert response.status_code == 422 detail = response.json()["detail"] assert detail["code"] == "invalid_credentials" assert detail["fields"] == [] assert len(requests) == 1 # The security property: bad credentials never reach disk. assert path.read_bytes() == before assert api_server._channel_runtime is None assert STORED_SECRET not in response.text def test_put_feishu_merge_patch_writes_section(tmp_path: Path, monkeypatch) -> None: client, path = _client( tmp_path, monkeypatch, channels={"feishu": _feishu_section()} ) response = client.put( "/channels/config/feishu", json={"config": {"domain": "lark", "react_emoji": "OK"}}, ) assert response.status_code == 200 assert response.json()["applied"] == "deferred" on_disk = json.loads(path.read_text(encoding="utf-8"))["channels"]["feishu"] assert on_disk["domain"] == "lark" assert on_disk["react_emoji"] == "OK" # A secret absent from the patch keeps its stored value. assert on_disk["app_secret"] == FEISHU_STORED_SECRET assert FEISHU_STORED_SECRET not in response.text def test_put_feishu_enable_with_bad_credentials_is_blocked_before_write( tmp_path: Path, monkeypatch ) -> None: """Feishu rejects bad credentials with HTTP 200 + an error body; the enable-transition probe must still block the write with 422.""" client, path = _client( tmp_path, monkeypatch, channels={"feishu": _feishu_section()} ) before = path.read_bytes() def handler(request: httpx.Request) -> httpx.Response: return httpx.Response(200, json={"code": 10014, "msg": "app secret invalid"}) requests = _inject_mock_transport(monkeypatch, handler) response = client.put("/channels/config/feishu", json={"config": {"enabled": True}}) assert response.status_code == 422 detail = response.json()["detail"] assert detail["code"] == "invalid_credentials" assert detail["fields"] == [] assert len(requests) == 1 assert "open.feishu.cn" in str(requests[0].url) assert path.read_bytes() == before assert api_server._channel_runtime is None assert FEISHU_STORED_SECRET not in response.text def test_put_enable_rejection_carries_scrubbed_message(tmp_path: Path, monkeypatch) -> None: client, path = _client( tmp_path, monkeypatch, channels={"dingtalk": _dingtalk_section()} ) before = path.read_bytes() def handler(request: httpx.Request) -> httpx.Response: return httpx.Response(401, text=f"appSecret {STORED_SECRET} is invalid") requests = _inject_mock_transport(monkeypatch, handler) response = client.put("/channels/config/dingtalk", json={"config": {"enabled": True}}) assert response.status_code == 422 detail = response.json()["detail"] assert detail["code"] == "invalid_credentials" assert isinstance(detail["message"], str) assert detail["message"] assert len(requests) == 1 assert path.read_bytes() == before assert STORED_SECRET not in response.text def test_put_enable_probe_exception_becomes_422_network(tmp_path: Path, monkeypatch) -> None: client, path = _client( tmp_path, monkeypatch, channels={"dingtalk": _dingtalk_section()} ) before = path.read_bytes() async def raising_probe(self) -> dict[str, Any]: raise RuntimeError("boom") monkeypatch.setattr(DingTalkChannel, "test_connection", raising_probe) response = client.put("/channels/config/dingtalk", json={"config": {"enabled": True}}) assert response.status_code == 422 detail = response.json()["detail"] assert detail["code"] == "network" assert "boom" in detail["message"] assert path.read_bytes() == before def test_put_enable_skip_verify_writes_without_probe(tmp_path: Path, monkeypatch) -> None: client, path = _client( tmp_path, monkeypatch, channels={"dingtalk": _dingtalk_section()} ) def handler(request: httpx.Request) -> httpx.Response: # pragma: no cover raise AssertionError("probe must not run with skip_verify") _inject_mock_transport(monkeypatch, handler) response = client.put( "/channels/config/dingtalk", json={"config": {"enabled": True}, "skip_verify": True}, ) assert response.status_code == 200 assert response.json()["applied"] == "deferred" on_disk = json.loads(path.read_text(encoding="utf-8"))["channels"]["dingtalk"] assert on_disk["enabled"] is True def test_put_validation_error_blocks_write(tmp_path: Path, monkeypatch) -> None: client, path = _client( tmp_path, monkeypatch, channels={"dingtalk": _dingtalk_section()} ) before = path.read_bytes() response = client.put( "/channels/config/dingtalk", json={"config": {"allow_from": "not-a-list"}} ) assert response.status_code == 422 detail = response.json()["detail"] assert detail["code"] == "validation_error" assert "allow_from" in detail["fields"] assert path.read_bytes() == before assert STORED_SECRET not in response.text def test_put_clear_flag_removes_stored_secret(tmp_path: Path, monkeypatch) -> None: client, path = _client( tmp_path, monkeypatch, channels={"dingtalk": _dingtalk_section()} ) response = client.put( "/channels/config/dingtalk", json={"config": {}, "clear_client_secret": True} ) assert response.status_code == 200 on_disk = json.loads(path.read_text(encoding="utf-8"))["channels"]["dingtalk"] assert "client_secret" not in on_disk assert on_disk["client_id"] == CLIENT_ID entry = response.json()["channel"] assert entry["secrets"].get("client_secret", {"set": False})["set"] is False assert STORED_SECRET not in response.text def test_put_rejects_unknown_config_keys(tmp_path: Path, monkeypatch) -> None: client, path = _client( tmp_path, monkeypatch, channels={"dingtalk": _dingtalk_section()} ) before = path.read_bytes() response = client.put( "/channels/config/dingtalk", json={"config": {"definitely_unknown_key": 1, "__proto__": 1}}, ) assert response.status_code == 422 detail = response.json()["detail"] assert detail["code"] == "validation_error" assert {"definitely_unknown_key", "__proto__"} <= set(detail["fields"]) assert path.read_bytes() == before def test_put_accepts_manager_override_keys(tmp_path: Path, monkeypatch) -> None: client, path = _client( tmp_path, monkeypatch, channels={"dingtalk": _dingtalk_section()} ) response = client.put( "/channels/config/dingtalk", json={"config": {"send_progress": True}} ) assert response.status_code == 200 on_disk = json.loads(path.read_text(encoding="utf-8"))["channels"]["dingtalk"] assert on_disk["send_progress"] is True def test_put_yaml_config_returns_config_not_writable(tmp_path: Path, monkeypatch) -> None: client, path = _client( tmp_path, monkeypatch, channels={"dingtalk": _dingtalk_section()}, suffix=".yaml", ) before = path.read_bytes() response = client.put( "/channels/config/dingtalk", json={"config": {"client_id": "x"}} ) assert response.status_code == 400 assert response.json()["detail"] == {"code": "config_not_writable"} assert path.read_bytes() == before def test_put_with_stopped_runtime_resets_singleton(tmp_path: Path, monkeypatch) -> None: runtime = FakeRuntime(running=False) client, _ = _client( tmp_path, monkeypatch, channels={"dingtalk": _dingtalk_section()}, runtime=runtime ) resets: list[bool] = [] monkeypatch.setattr(api_state, "reset_channel_runtime", lambda: resets.append(True)) response = client.put( "/channels/config/dingtalk", json={"config": {"client_id": "stopped-id"}} ) assert response.status_code == 200 assert response.json()["applied"] == "reset" assert resets == [True] assert runtime.manager.reload_calls == [] def test_put_without_runtime_defers(tmp_path: Path, monkeypatch) -> None: client, _ = _client(tmp_path, monkeypatch, channels={"dingtalk": _dingtalk_section()}) resets: list[bool] = [] monkeypatch.setattr(api_state, "reset_channel_runtime", lambda: resets.append(True)) response = client.put( "/channels/config/dingtalk", json={"config": {"client_id": "deferred-id"}} ) assert response.status_code == 200 assert response.json()["applied"] == "deferred" assert resets == [] assert api_server._channel_runtime is None def test_put_reload_failure_falls_back_to_reset_and_restart( tmp_path: Path, monkeypatch ) -> None: runtime = FakeRuntime(running=True, manager=FakeManager(fail=True)) client, _ = _client( tmp_path, monkeypatch, channels={"dingtalk": _dingtalk_section()}, runtime=runtime ) new_runtime = FakeRuntime(running=False) def _stub_get_channel_runtime() -> FakeRuntime: # Mirror the real accessor's caching contract: the rebuilt singleton is # observable on the host module, which is what `_hot_apply` leaves behind. monkeypatch.setattr(api_server, "_channel_runtime", new_runtime) return new_runtime monkeypatch.setattr(api_server, "_get_channel_runtime", _stub_get_channel_runtime) response = client.put( "/channels/config/dingtalk", json={"config": {"client_id": "fallback-id"}} ) assert response.status_code == 200 assert response.json()["applied"] == "reset" # The orphaned runtime is stopped before the reset + restart... assert runtime.stop_calls == [True] # ...and the old runtime is never restarted in its orphaned state. assert runtime.start_calls == [] assert new_runtime.start_calls == [True] assert api_server._channel_runtime is new_runtime def test_put_reload_failure_survives_a_wedged_stop(tmp_path: Path, monkeypatch) -> None: class WedgedStopRuntime(FakeRuntime): """A runtime whose stop() fails, standing in for a wedged adapter.""" async def stop(self) -> None: raise RuntimeError("wedged") runtime = WedgedStopRuntime(running=True, manager=FakeManager(fail=True)) client, _ = _client( tmp_path, monkeypatch, channels={"dingtalk": _dingtalk_section()}, runtime=runtime ) new_runtime = FakeRuntime(running=False) monkeypatch.setattr(api_server, "_get_channel_runtime", lambda: new_runtime) response = client.put( "/channels/config/dingtalk", json={"config": {"client_id": "fallback-id"}} ) assert response.status_code == 200 assert response.json()["applied"] == "reset" # A failed stop must not block the rebuild. assert new_runtime.start_calls == [True] def test_concurrent_puts_serialize_per_channel(tmp_path: Path, monkeypatch) -> None: runtime = FakeRuntime(running=True, manager=FakeManager(delay=0.05)) _client( tmp_path, monkeypatch, channels={"dingtalk": _dingtalk_section()}, runtime=runtime ) async def scenario() -> list[httpx.Response]: transport = httpx.ASGITransport(app=api_server.app) async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as ac: return list( await asyncio.gather( ac.put("/channels/config/dingtalk", json={"config": {"client_id": "a"}}), ac.put("/channels/config/dingtalk", json={"config": {"client_id": "b"}}), ) ) responses = asyncio.run(scenario()) assert [r.status_code for r in responses] == [200, 200] assert len(runtime.manager.reload_calls) == 2 # The per-channel lock kept the two hot swaps from overlapping. assert runtime.manager.max_active == 1 # --------------------------------------------------------------------------- # # POST /channels/{name}/test # --------------------------------------------------------------------------- # def test_post_test_uses_unsaved_creds_and_never_writes(tmp_path: Path, monkeypatch) -> None: client, path = _client( tmp_path, monkeypatch, channels={"dingtalk": _dingtalk_section()} ) before = path.read_bytes() seen: list[dict[str, Any]] = [] def handler(request: httpx.Request) -> httpx.Response: seen.append(json.loads(request.content.decode("utf-8"))) return httpx.Response(200, json={"accessToken": "token-do-not-leak"}) _inject_mock_transport(monkeypatch, handler) response = client.post( "/channels/dingtalk/test", json={"config": {"client_id": "unsaved-id", "client_secret": UNSAVED_SECRET}}, ) assert response.status_code == 200 body = response.json() assert body["ok"] is True assert body["code"] == "ok" assert body["tested_saved_config"] is False assert body["sdk_available"] is DINGTALK_AVAILABLE assert seen == [{"appKey": "unsaved-id", "appSecret": UNSAVED_SECRET}] # The test endpoint never persists anything. assert path.read_bytes() == before assert UNSAVED_SECRET not in response.text assert STORED_SECRET not in response.text assert "token-do-not-leak" not in response.text def test_post_test_without_body_uses_saved_config(tmp_path: Path, monkeypatch) -> None: client, path = _client( tmp_path, monkeypatch, channels={"dingtalk": _dingtalk_section()} ) before = path.read_bytes() seen: list[dict[str, Any]] = [] def handler(request: httpx.Request) -> httpx.Response: seen.append(json.loads(request.content.decode("utf-8"))) return httpx.Response(200, json={"accessToken": "token-do-not-leak"}) _inject_mock_transport(monkeypatch, handler) response = client.post("/channels/dingtalk/test", json={}) assert response.status_code == 200 body = response.json() assert body["tested_saved_config"] is True assert body["ok"] is True assert seen == [{"appKey": CLIENT_ID, "appSecret": STORED_SECRET}] assert path.read_bytes() == before assert STORED_SECRET not in response.text def test_post_test_reports_invalid_credentials(tmp_path: Path, monkeypatch) -> None: client, path = _client( tmp_path, monkeypatch, channels={"dingtalk": _dingtalk_section()} ) before = path.read_bytes() def handler(request: httpx.Request) -> httpx.Response: return httpx.Response(401, text=f"appSecret {STORED_SECRET} is invalid") _inject_mock_transport(monkeypatch, handler) response = client.post("/channels/dingtalk/test", json={}) assert response.status_code == 200 body = response.json() assert body["ok"] is False assert body["code"] == "invalid_credentials" assert body["tested_saved_config"] is True # The echoed secret is scrubbed (adapter layer + route defense in depth). assert STORED_SECRET not in response.text assert path.read_bytes() == before @pytest.mark.parametrize("body", [b"null", b"[1, 2]", b'"oops"']) def test_post_test_non_object_json_200_returns_envelope_not_500( tmp_path: Path, monkeypatch, body: bytes ) -> None: """A token endpoint answering 200 with non-object JSON must not 500. Regression for the shared probe's ``.get`` on a non-dict body: the ``/test`` route does not wrap ``test_connection`` in try/except, so an ``AttributeError`` here surfaced as a bare 500 instead of an honest ``invalid_credentials`` envelope. """ client, path = _client( tmp_path, monkeypatch, channels={"dingtalk": _dingtalk_section()} ) before = path.read_bytes() def handler(request: httpx.Request) -> httpx.Response: return httpx.Response(200, content=body) requests = _inject_mock_transport(monkeypatch, handler) response = client.post("/channels/dingtalk/test", json={}) assert response.status_code == 200 payload = response.json() assert payload["ok"] is False assert payload["code"] == "invalid_credentials" assert len(requests) == 1 assert path.read_bytes() == before assert STORED_SECRET not in response.text def test_post_test_rejects_unknown_config_keys(tmp_path: Path, monkeypatch) -> None: """POST /test shares PUT's unknown-key rejection instead of silently dropping the keys through pydantic ``extra="ignore"`` into the probe.""" client, path = _client( tmp_path, monkeypatch, channels={"dingtalk": _dingtalk_section()} ) before = path.read_bytes() def handler(request: httpx.Request) -> httpx.Response: # pragma: no cover raise AssertionError("an unknown key must be rejected before any probe") requests = _inject_mock_transport(monkeypatch, handler) response = client.post( "/channels/dingtalk/test", json={"config": {"definitely_unknown_key": 1, "__proto__": 1}}, ) assert response.status_code == 422 detail = response.json()["detail"] assert detail["code"] == "validation_error" assert {"definitely_unknown_key", "__proto__"} <= set(detail["fields"]) assert requests == [] assert path.read_bytes() == before def test_post_test_honors_pending_secret_clear(tmp_path: Path, monkeypatch) -> None: client, path = _client( tmp_path, monkeypatch, channels={"dingtalk": _dingtalk_section()} ) before = path.read_bytes() def handler(request: httpx.Request) -> httpx.Response: # pragma: no cover raise AssertionError("a cleared credential must not reach the transport") requests = _inject_mock_transport(monkeypatch, handler) response = client.post( "/channels/dingtalk/test", json={"config": {}, "clear_client_secret": True} ) assert response.status_code == 200 body = response.json() assert body["ok"] is False assert body["code"] == "invalid_credentials" assert body["detail"] == "missing credentials" # The stored secret was excluded from the probe, so nothing was sent. assert requests == [] assert path.read_bytes() == before def test_post_test_unsupported_channel_short_circuits(tmp_path: Path, monkeypatch) -> None: client, path = _client( tmp_path, monkeypatch, channels={"telegram": {"enabled": False}} ) before = path.read_bytes() def handler(request: httpx.Request) -> httpx.Response: # pragma: no cover raise AssertionError("no network call may happen for an unsupported channel") requests = _inject_mock_transport(monkeypatch, handler) response = client.post("/channels/telegram/test", json={}) assert response.status_code == 200 body = response.json() assert body["ok"] is False assert body["code"] == "unsupported" assert body["tested_saved_config"] is True assert isinstance(body["sdk_available"], bool) assert requests == [] assert path.read_bytes() == before # --------------------------------------------------------------------------- # # Unknown channels / helper modules # --------------------------------------------------------------------------- # @pytest.mark.parametrize("name", ["nonexistent", "targets", "config_meta"]) def test_put_and_test_404_unknown_and_helper_modules( tmp_path: Path, monkeypatch, name: str ) -> None: client, path = _client( tmp_path, monkeypatch, channels={"dingtalk": _dingtalk_section()} ) before = path.read_bytes() put = client.put(f"/channels/config/{name}", json={"config": {}}) post = client.post(f"/channels/{name}/test", json={}) assert put.status_code == 404 assert post.status_code == 404 assert path.read_bytes() == before # --------------------------------------------------------------------------- # # Secret hygiene sweep # --------------------------------------------------------------------------- # def test_no_secret_value_in_any_response_body(tmp_path: Path, monkeypatch) -> None: runtime = FakeRuntime(running=True) client, _ = _client( tmp_path, monkeypatch, channels={"dingtalk": _dingtalk_section()}, runtime=runtime ) def handler(request: httpx.Request) -> httpx.Response: return httpx.Response(401, text=f"bad secret {STORED_SECRET}") _inject_mock_transport(monkeypatch, handler) responses = [ client.get("/channels/config"), # Enable transition with a probe that echoes the secret in its 401 body. client.put("/channels/config/dingtalk", json={"config": {"enabled": True}}), client.post("/channels/dingtalk/test", json={}), client.put("/channels/config/dingtalk", json={"config": {"client_id": "ok-id"}}), ] assert [r.status_code for r in responses] == [200, 422, 200, 200] for response in responses: assert STORED_SECRET not in response.text # --------------------------------------------------------------------------- # # Email + WebSocket: guided-channel parity (hot config backend support) # --------------------------------------------------------------------------- # def test_put_websocket_minimal_section_is_not_a_type_error( tmp_path: Path, monkeypatch ) -> None: """Regression: the ephemeral websocket build needs the gateway kwarg. ``WebSocketChannel.__init__`` requires a keyword-only ``gateway``, which ``_build_ephemeral`` did not pass — so every PUT of a websocket section died as a 422 ``TypeError`` validation envelope before anything else ran. """ port = _free_port() client, path = _client(tmp_path, monkeypatch, channels={}) response = client.put( "/channels/config/websocket", json={"config": {"enabled": False, "host": "127.0.0.1", "port": port}}, ) assert response.status_code != 422 assert response.status_code == 200 on_disk = json.loads(path.read_text(encoding="utf-8"))["channels"]["websocket"] assert on_disk["host"] == "127.0.0.1" assert on_disk["port"] == port def test_post_websocket_test_returns_probe_code_not_unsupported( tmp_path: Path, monkeypatch ) -> None: client, path = _client(tmp_path, monkeypatch, channels={}) before = path.read_bytes() response = client.post("/channels/websocket/test", json={}) assert response.status_code == 200 body = response.json() assert body["code"] in {"ok", "invalid_credentials", "network"} assert body["code"] != "unsupported" assert body["tested_saved_config"] is True # The test endpoint never persists anything. assert path.read_bytes() == before def test_post_email_test_empty_config_reports_missing_credentials( tmp_path: Path, monkeypatch ) -> None: client, path = _client(tmp_path, monkeypatch, channels={}) before = path.read_bytes() response = client.post("/channels/email/test", json={}) assert response.status_code == 200 body = response.json() assert body["ok"] is False assert body["code"] == "invalid_credentials" assert body["detail"] == ( "missing credentials: imap_host, imap_username, imap_password, " "smtp_host, smtp_username, smtp_password" ) assert body["sdk_available"] is True assert path.read_bytes() == before def test_get_email_and_websocket_entries_have_help_keys_and_masked_secrets( tmp_path: Path, monkeypatch ) -> None: client, _ = _client( tmp_path, monkeypatch, channels={ "email": _email_section(), "websocket": { "enabled": False, "token": WS_TOKEN, "token_issue_secret": WS_ISSUE_SECRET, }, }, ) response = client.get("/channels/config") assert response.status_code == 200 payload = response.json() email_entry = payload["channels"]["email"] assert email_entry["supports_test"] is True hints = {field["key"]: field for field in email_entry["fields"]} assert "enabled" not in hints assert ( hints["imap_host"]["help_key"] == "settings.channels.fields.email.imap_host" ) assert hints["imap_password"]["secret"] is True assert hints["imap_password"]["type"] == "password" assert email_entry["values"]["imap_host"] == "imap.example.com" assert "imap_password" not in email_entry["values"] assert "smtp_password" not in email_entry["values"] assert email_entry["secrets"]["imap_password"]["set"] is True assert email_entry["secrets"]["imap_password"]["masked"].startswith("****") assert email_entry["secrets"]["smtp_password"]["set"] is True ws_entry = payload["channels"]["websocket"] assert ws_entry["supports_test"] is True ws_hints = {field["key"]: field for field in ws_entry["fields"]} assert "enabled" not in ws_hints assert ws_hints["token"]["help_key"] == "settings.channels.fields.websocket.token" assert ws_hints["token"]["secret"] is True assert ws_hints["token_issue_secret"]["secret"] is True # ssl_certfile/ssl_keyfile are filesystem paths, not secret material. assert ws_hints["ssl_certfile"]["secret"] is False assert ws_hints["ssl_keyfile"]["secret"] is False assert "token" not in ws_entry["values"] assert ws_entry["secrets"]["token"] == {"set": True, "masked": "****ghij"} assert ws_entry["secrets"]["token_issue_secret"]["set"] is True for secret in ( EMAIL_IMAP_PASSWORD, EMAIL_SMTP_PASSWORD, WS_TOKEN, WS_ISSUE_SECRET, ): assert secret not in response.text def test_put_email_enable_probe_failure_blocks_write( tmp_path: Path, monkeypatch ) -> None: client, path = _client(tmp_path, monkeypatch, channels={"email": _email_section()}) before = path.read_bytes() async def failing_probe(config: Any) -> dict[str, Any]: return { "ok": False, "code": "invalid_credentials", "detail": "imap: login failed", "sdk_available": True, } monkeypatch.setattr(email_probe, "test_connection", failing_probe) response = client.put("/channels/config/email", json={"config": {"enabled": True}}) assert response.status_code == 422 detail = response.json()["detail"] assert detail["code"] == "invalid_credentials" assert detail["fields"] == [] assert detail["message"] == "imap: login failed" # The security property: bad credentials never reach disk. assert path.read_bytes() == before assert EMAIL_IMAP_PASSWORD not in response.text def test_put_email_enable_skip_verify_bypasses_probe( tmp_path: Path, monkeypatch ) -> None: client, path = _client(tmp_path, monkeypatch, channels={"email": _email_section()}) async def exploding_probe(config: Any) -> dict[str, Any]: # pragma: no cover raise AssertionError("probe must not run with skip_verify") monkeypatch.setattr(email_probe, "test_connection", exploding_probe) response = client.put( "/channels/config/email", json={"config": {"enabled": True}, "skip_verify": True}, ) assert response.status_code == 200 on_disk = json.loads(path.read_text(encoding="utf-8"))["channels"]["email"] assert on_disk["enabled"] is True # --------------------------------------------------------------------------- # # Hint-authoritative secret resolution (websocket token-shaped non-secrets) # --------------------------------------------------------------------------- # def _websocket_section(**overrides: Any) -> dict[str, Any]: section: dict[str, Any] = { "enabled": False, "websocket_requires_token": True, "token_ttl_s": 300, "token_issue_path": "/issue", "token": "s3cret-value", } section.update(overrides) return section def test_get_websocket_token_shaped_non_secrets_stay_in_values( tmp_path: Path, monkeypatch ) -> None: client, _ = _client( tmp_path, monkeypatch, channels={"websocket": _websocket_section()} ) response = client.get("/channels/config") assert response.status_code == 200 entry = response.json()["channels"]["websocket"] assert entry["values"]["websocket_requires_token"] is True assert entry["values"]["token_ttl_s"] == 300 assert entry["values"]["token_issue_path"] == "/issue" assert "token" not in entry["values"] assert entry["secrets"]["token"] == {"set": True, "masked": "****alue"} assert "s3cret-value" not in response.text def test_put_websocket_form_patch_preserves_requires_token( tmp_path: Path, monkeypatch ) -> None: """The exact security-flip regression: a form save must not write False. Before hint-authoritative secret resolution, ``websocket_requires_token`` was regex-masked out of GET ``values``; the bool widget then seeded ``Boolean(undefined) = false`` and buildPatch always includes bool fields, so ANY save silently disabled the WS handshake token requirement. """ client, path = _client( tmp_path, monkeypatch, channels={"websocket": _websocket_section()} ) patch: dict[str, Any] = { "enabled": False, "host": "127.0.0.1", "port": _free_port(), "unix_socket_path": "", "path": "/", "token_issue_path": "/issue", "token_ttl_s": "600", "websocket_requires_token": True, "allow_from": ["*"], "streaming": True, "max_message_bytes": "37748736", "ping_interval_s": "20", "ping_timeout_s": "20", "ssl_certfile": "", "ssl_keyfile": "", } response = client.put("/channels/config/websocket", json={"config": patch}) assert response.status_code == 200 on_disk = json.loads(path.read_text(encoding="utf-8"))["channels"]["websocket"] assert on_disk["websocket_requires_token"] is True # The writer persists the form patch verbatim; the adapter coerces types. assert on_disk["token_ttl_s"] == "600" assert on_disk["token_issue_path"] == "/issue" # A secret absent from the patch keeps its stored value. assert on_disk["token"] == "s3cret-value" assert "s3cret-value" not in response.text def test_put_websocket_clears_non_secret_token_issue_path( tmp_path: Path, monkeypatch ) -> None: """A cleared non-secret text field persists as "" (no longer dropped).""" client, path = _client( tmp_path, monkeypatch, channels={"websocket": _websocket_section()} ) response = client.put( "/channels/config/websocket", json={"config": {"token_issue_path": ""}} ) assert response.status_code == 200 on_disk = json.loads(path.read_text(encoding="utf-8"))["channels"]["websocket"] assert on_disk["token_issue_path"] == "" assert on_disk["token"] == "s3cret-value" def test_email_test_normalizes_blank_nullable_fields_without_persisting(tmp_path, monkeypatch): client, path = _client(tmp_path, monkeypatch, channels={"email": _email_section()}) before = path.read_bytes() seen = [] async def successful_probe(config): seen.append(config.post_action) return {"ok": True, "code": "ok", "detail": "connected", "sdk_available": True} monkeypatch.setattr(email_probe, "test_connection", successful_probe) response = client.post("/channels/email/test", json={"config": {"post_action": "", "max_attachments_per_email": 7}}) assert response.status_code == 200 and response.json()["ok"] is True assert seen == [None] assert path.read_bytes() == before response = client.post("/channels/email/test", json={"config": {"post_action": "invalid"}}) assert response.status_code == 200 and response.json()["ok"] is False assert response.json()["code"] == "invalid_credentials" and seen == [None] assert response.json()["detail"].startswith("validation_error:") def test_email_authentication_trust_anchor_is_editable_in_generic_form(tmp_path, monkeypatch): client, path = _client(tmp_path, monkeypatch, channels={"email": _email_section()}) entry = client.get("/channels/config").json()["channels"]["email"] field = next(field for field in entry["fields"] if field["key"] == "trusted_authserv_id") assert field["help_key"] == "settings.channels.fields.email.trusted_authserv_id" response = client.put("/channels/config/email", json={"config": {"trusted_authserv_id": "mx.example.test"}}) assert response.status_code == 200 assert json.loads(path.read_text())["channels"]["email"]["trusted_authserv_id"] == "mx.example.test"