Files
Auto-Trader/agent/tests/test_websocket_connection_test.py
T
shadowinlife de467063fd feat(channels): guided Web UI config for Email and WebSocket
Extend the contract-driven channel config surface from #1520 to the Email
and WebSocket adapters: both can now be configured, connection-tested and
hot-applied from Settings with no restart and no hand-edited JSON — the
"one hints dict entry" path #1519 designed for the long tail of adapters.

Backend
- Email: standalone IMAP + SMTP credential probe (email_probe.py) — login,
  mailbox select, SMTP connect/(STARTTLS)/login — classified into the frozen
  ok|invalid_credentials|network codes with secret-scrubbed, side-prefixed
  details; the adapter's test_connection delegates to it.
- WebSocket: local-server probe (websocket_probe.py) — TLS cert/key material
  validation plus a read-only bind of every resolved address (SO_REUSEADDR
  gated to POSIX, matching asyncio's create_server default); an address held
  by the already-running server reads as the expected state, not a failure.
  The config routes now build the WebSocket adapter with the gateway services
  it requires, so a WebSocket save or test no longer fails validation.
- config_meta: hand-written field hints for both channels. A hand-written
  hint's secret flag is now authoritative for the keys it covers (the regex
  fail-safe still masks every unhinted key), so token-shaped non-secrets such
  as websocket_requires_token stay visible and editable instead of being
  masked — a form save can no longer silently flip the stored token requirement.
- Email polling skips stale-config delete/move post-actions once a stop or hot
  swap begins; the already-fetched batch is still delivered.

Frontend
- Localized labels/help and guided setup for both channels across all nine
  locales; secrets are masked and never round-trip to the browser.

Adapter fix found during a live end-to-end smoke test against a real 163.com
mailbox: NetEase (163/126/yeah.net) accepts LOGIN but rejects the first SELECT
with "Unsafe Login" until the client sends an RFC 2971 IMAP ID, so the Email
channel could not read the most common Chinese mailboxes. The probe and the
polling loop now send a static, best-effort IMAP ID after login
(utils.send_imap_id) — it carries no user data or secrets and is ignored by
servers that do not support it. Verified end to end: an inbound mail from an
allow-listed sender passed SPF/DKIM, reached the agent, and an auto-reply was
delivered back over SMTP; on the same run the anti-spoofing check rejected the
mailbox's own SPF-failing service mail.

Follow-up to #1519 (extends #1520).

Signed-off-by: shadowinlife <shadowinlife@gmail.com>
2026-09-22 15:13:45 +08:00

216 lines
6.8 KiB
Python

"""Connection-test contract for the WebSocket channel.
WebSocket is a *server* channel: there are no remote credentials, so the
standalone probe validates local secret material (SSL cert/key) and address
availability (read-only TCP bind). The contract codes are
``ok | invalid_credentials | network`` plus an ``sdk_available`` flag. All
checks run against loopback only — no real network I/O — and the probe must
never write to the filesystem (no mkdir, no unlink of a stale unix socket).
"""
from __future__ import annotations
import asyncio
import json
import socket
from pathlib import Path
from typing import Any
from src.channels.bus.queue import MessageBus
from src.channels.websocket import WebSocketChannel
from src.channelsui.gateway_services import build_gateway_services
WS_TOKEN = "ws-token-secret-1234567890"
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 _listening_socket(port: int) -> socket.socket:
"""Hold *port* with a real listening socket (accepts TCP handshakes)."""
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.bind(("127.0.0.1", port))
sock.listen(1)
return sock
def _bound_socket(port: int) -> socket.socket:
"""Hold *port* bound but NOT listening (connect attempts are refused)."""
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.bind(("127.0.0.1", port))
return sock
def _make_channel(**overrides: Any) -> WebSocketChannel:
config: dict[str, Any] = {"host": "127.0.0.1", "port": _free_port()}
config.update(overrides)
return WebSocketChannel(config, MessageBus(), gateway=build_gateway_services())
def _run(coro: Any) -> Any:
return asyncio.run(coro)
# --------------------------------------------------------------------------- #
# Success
# --------------------------------------------------------------------------- #
def test_success_on_free_loopback_port() -> None:
assert WebSocketChannel.supports_connection_test is True
result = _run(_make_channel().test_connection())
assert result["ok"] is True
assert result["code"] == "ok"
# websockets is a core dependency and the adapter module imported it.
assert result["sdk_available"] is True
assert "detail" not in result
# --------------------------------------------------------------------------- #
# SSL material
# --------------------------------------------------------------------------- #
def test_ssl_certfile_missing_reports_invalid_credentials(tmp_path: Path) -> None:
channel = _make_channel(
ssl_certfile=str(tmp_path / "missing.pem"),
ssl_keyfile=str(tmp_path / "missing.key"),
)
result = _run(channel.test_connection())
assert result["ok"] is False
assert result["code"] == "invalid_credentials"
assert result["detail"].startswith("ssl: ")
def test_ssl_garbage_material_reports_invalid_credentials(tmp_path: Path) -> None:
cert = tmp_path / "cert.pem"
key = tmp_path / "key.pem"
cert.write_bytes(b"not a cert")
key.write_bytes(b"not a key")
channel = _make_channel(ssl_certfile=str(cert), ssl_keyfile=str(key))
result = _run(channel.test_connection())
assert result["ok"] is False
assert result["code"] == "invalid_credentials"
assert result["detail"].startswith("ssl: ")
def test_ssl_half_configured_reports_both_or_neither(tmp_path: Path) -> None:
cert = tmp_path / "cert.pem"
cert.write_bytes(b"not a cert")
channel = _make_channel(ssl_certfile=str(cert), ssl_keyfile="")
result = _run(channel.test_connection())
assert result["ok"] is False
assert result["code"] == "invalid_credentials"
assert result["detail"] == (
"ssl: ssl_certfile and ssl_keyfile must both be set for WSS, "
"or both left empty"
)
def test_ssl_failure_detail_never_carries_token(tmp_path: Path) -> None:
channel = _make_channel(
token=WS_TOKEN,
ssl_certfile=str(tmp_path / "missing.pem"),
ssl_keyfile=str(tmp_path / "missing.key"),
)
result = _run(channel.test_connection())
assert result["ok"] is False
assert WS_TOKEN not in json.dumps(result)
# --------------------------------------------------------------------------- #
# TCP bind / EADDRINUSE semantics
# --------------------------------------------------------------------------- #
def test_address_in_use_by_live_listener_reports_ok() -> None:
"""The core hot-config scenario: Test clicked while the channel runs."""
port = _free_port()
listener = _listening_socket(port)
try:
result = _run(_make_channel(port=port).test_connection())
finally:
listener.close()
assert result["ok"] is True
assert result["code"] == "ok"
assert "already in use" in result["detail"]
def test_address_in_use_without_listener_reports_network() -> None:
port = _free_port()
holder = _bound_socket(port)
try:
result = _run(_make_channel(port=port).test_connection())
finally:
holder.close()
assert result["ok"] is False
assert result["code"] == "network"
assert "not accepting connections" in result["detail"]
def test_unresolvable_host_reports_network() -> None:
channel = _make_channel(host="no-such-host.invalid")
result = _run(channel.test_connection())
assert result["ok"] is False
assert result["code"] == "network"
assert result["detail"].startswith("bind: ")
# --------------------------------------------------------------------------- #
# Unix socket: TCP bind skipped, no filesystem side effects
# --------------------------------------------------------------------------- #
def test_unix_socket_path_skips_tcp_bind_entirely(tmp_path: Path) -> None:
"""host/port are ignored by start() when a unix socket is configured."""
port = _free_port()
listener = _listening_socket(port)
try:
channel = _make_channel(
unix_socket_path=str(tmp_path / "ws.sock"), host="127.0.0.1", port=port
)
result = _run(channel.test_connection())
finally:
listener.close()
# The occupied port must not even be probed: ok with no bind note.
assert result["ok"] is True
assert result["code"] == "ok"
assert "detail" not in result
def test_probe_creates_nothing_on_the_filesystem(tmp_path: Path) -> None:
nested = tmp_path / "nested"
socket_path = nested / "ws.sock"
channel = _make_channel(unix_socket_path=str(socket_path))
result = _run(channel.test_connection())
assert result["ok"] is True
assert not nested.exists()
assert not socket_path.exists()
def test_result_envelope_is_json_serializable() -> None:
result = _run(_make_channel().test_connection())
assert json.loads(json.dumps(result)) == result