mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-08-27 05:00:18 +00:00
* feat: add simplex-support-bot-light * feat: package simplex-support-bot-light for docker * docs: document simplex-support-bot-light * fix: make a missing config actionable and bounded * docs: note the attached compose Ctrl+C behaviour * refactor: use the library for startup and custom data * refactor: drop the display-name workaround * style: shorten the startup comment * build: build the core and the library into the image * docs: correct where the image type rule comes from * fix: log what the core said a command got wrong * build: run the container as the operator's uid * docs: keep the container uid in .env * docs: tighten the README
237 lines
7.6 KiB
Python
237 lines
7.6 KiB
Python
"""The monitoring endpoint: real sockets, no libsimplex."""
|
|
|
|
import asyncio
|
|
|
|
import pytest
|
|
from simplex_chat.core import ChatAPIError
|
|
|
|
from support_bot_light import health
|
|
from support_bot_light.config import Config, ConfigError, Health
|
|
from support_bot_light.context import BotContext
|
|
from tests.conftest import ROSTER_GROUP_ID, USER_ID
|
|
|
|
CONFIG = Config("Support", "./x", "hi", "Invite roster", "owner")
|
|
|
|
|
|
class ProbeApi:
|
|
"""The one call the probe makes, with the outcomes it has to distinguish."""
|
|
|
|
def __init__(self, error: bool = False, delay: float = 0.0):
|
|
self.error = error
|
|
self.delay = delay
|
|
self.calls = 0
|
|
|
|
async def api_list_members(self, group_id: int) -> list[dict]:
|
|
self.calls += 1
|
|
assert group_id == ROSTER_GROUP_ID
|
|
if self.delay:
|
|
await asyncio.sleep(self.delay)
|
|
if self.error:
|
|
raise ChatAPIError("core is unhappy", {"type": "chatCmdError"})
|
|
return []
|
|
|
|
|
|
def context(api) -> BotContext:
|
|
return BotContext(api=api, user_id=USER_ID, roster_group_id=ROSTER_GROUP_ID, config=CONFIG)
|
|
|
|
|
|
async def request(server: asyncio.Server, line: str) -> str:
|
|
"""Send one request line to a running endpoint and read the whole reply."""
|
|
port = server.sockets[0].getsockname()[1]
|
|
reader, writer = await asyncio.open_connection("127.0.0.1", port)
|
|
try:
|
|
writer.write(f"{line}\r\nHost: localhost\r\n\r\n".encode())
|
|
await writer.drain()
|
|
return (await reader.read()).decode("latin-1")
|
|
finally:
|
|
writer.close()
|
|
await writer.wait_closed()
|
|
|
|
|
|
async def endpoint(api) -> asyncio.Server:
|
|
# Port 0: the OS picks a free one, so tests never collide.
|
|
return await health.serve(context(api), Health(host="127.0.0.1", port=0))
|
|
|
|
|
|
async def test_reports_ok_while_the_core_answers():
|
|
api = ProbeApi()
|
|
server = await endpoint(api)
|
|
try:
|
|
reply = await request(server, "GET /health HTTP/1.1")
|
|
finally:
|
|
server.close()
|
|
await server.wait_closed()
|
|
assert reply.startswith("HTTP/1.1 200 OK")
|
|
assert reply.endswith('{"status":"ok"}\n')
|
|
assert api.calls == 1
|
|
|
|
|
|
async def test_reports_unavailable_when_the_core_errors():
|
|
server = await endpoint(ProbeApi(error=True))
|
|
try:
|
|
reply = await request(server, "GET /health HTTP/1.1")
|
|
finally:
|
|
server.close()
|
|
await server.wait_closed()
|
|
assert reply.startswith("HTTP/1.1 503 Service Unavailable")
|
|
|
|
|
|
async def test_a_slow_core_times_out_rather_than_hanging(monkeypatch):
|
|
monkeypatch.setattr(health, "PROBE_TIMEOUT", 0.05)
|
|
api = ProbeApi(delay=5)
|
|
server = await endpoint(api)
|
|
try:
|
|
reply = await asyncio.wait_for(request(server, "GET /health HTTP/1.1"), 2)
|
|
finally:
|
|
server.close()
|
|
await server.wait_closed()
|
|
assert reply.startswith("HTTP/1.1 503")
|
|
|
|
|
|
async def test_a_concurrent_request_does_not_start_a_second_probe(monkeypatch):
|
|
monkeypatch.setattr(health, "PROBE_TIMEOUT", 0.5)
|
|
api = ProbeApi(delay=0.3)
|
|
server = await endpoint(api)
|
|
try:
|
|
first = asyncio.create_task(request(server, "GET /health HTTP/1.1"))
|
|
await asyncio.sleep(0.05)
|
|
second = await request(server, "GET /health HTTP/1.1")
|
|
assert (await first).startswith("HTTP/1.1 200 OK")
|
|
finally:
|
|
server.close()
|
|
await server.wait_closed()
|
|
assert second.startswith("HTTP/1.1 200 OK") # it waits on the same probe
|
|
assert api.calls == 1
|
|
|
|
|
|
async def test_polling_a_stalled_core_never_starts_a_second_query(monkeypatch):
|
|
# Each abandoned query keeps a worker in the loop's default executor, which
|
|
# the receive loop also uses: a query per poll would take the bot's own
|
|
# traffic down with the core.
|
|
monkeypatch.setattr(health, "PROBE_TIMEOUT", 0.05)
|
|
api = ProbeApi(delay=3)
|
|
server = await endpoint(api)
|
|
try:
|
|
for _ in range(5):
|
|
reply = await request(server, "GET /health HTTP/1.1")
|
|
assert reply.startswith("HTTP/1.1 503")
|
|
assert api.calls == 1 # one query outstanding, not five
|
|
finally:
|
|
server.close()
|
|
await server.wait_closed()
|
|
|
|
|
|
async def test_the_next_poll_after_recovery_starts_a_fresh_query(monkeypatch):
|
|
monkeypatch.setattr(health, "PROBE_TIMEOUT", 0.05)
|
|
api = ProbeApi(delay=0.2)
|
|
server = await endpoint(api)
|
|
try:
|
|
assert (await request(server, "GET /health HTTP/1.1")).startswith("HTTP/1.1 503")
|
|
await asyncio.sleep(0.3) # the abandoned query completes
|
|
api.delay = 0 # the core recovers
|
|
assert (await request(server, "GET /health HTTP/1.1")).startswith("HTTP/1.1 200")
|
|
finally:
|
|
server.close()
|
|
await server.wait_closed()
|
|
assert api.calls == 2
|
|
|
|
|
|
async def test_a_core_that_raises_anything_reports_unavailable():
|
|
# A malformed reply or a missing controller is what this exists to report,
|
|
# and neither arrives as a chat error.
|
|
class Broken(ProbeApi):
|
|
async def api_list_members(self, group_id: int) -> list[dict]:
|
|
self.calls += 1
|
|
raise RuntimeError("controller not initialized")
|
|
|
|
api = Broken()
|
|
server = await endpoint(api)
|
|
try:
|
|
reply = await request(server, "GET /health HTTP/1.1")
|
|
finally:
|
|
server.close()
|
|
await server.wait_closed()
|
|
assert reply.startswith("HTTP/1.1 503")
|
|
|
|
|
|
async def test_an_oversized_request_line_is_answered():
|
|
api = ProbeApi()
|
|
server = await endpoint(api)
|
|
try:
|
|
reply = await request(server, "GET /" + "x" * (health.MAX_REQUEST_BYTES + 10))
|
|
finally:
|
|
server.close()
|
|
await server.wait_closed()
|
|
assert reply.startswith("HTTP/1.1 400")
|
|
assert api.calls == 0
|
|
|
|
|
|
async def test_head_is_answered_without_a_body():
|
|
api = ProbeApi()
|
|
server = await endpoint(api)
|
|
try:
|
|
reply = await request(server, "HEAD /health HTTP/1.1")
|
|
finally:
|
|
server.close()
|
|
await server.wait_closed()
|
|
assert reply.startswith("HTTP/1.1 200 OK")
|
|
assert "{" not in reply
|
|
assert api.calls == 1
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("line", "status"),
|
|
[
|
|
("GET / HTTP/1.1", "404"),
|
|
("GET /healthz HTTP/1.1", "404"),
|
|
("POST /health HTTP/1.1", "405"),
|
|
("nonsense", "404"),
|
|
],
|
|
)
|
|
async def test_only_get_on_the_health_path_is_answered(line, status):
|
|
api = ProbeApi()
|
|
server = await endpoint(api)
|
|
try:
|
|
reply = await request(server, line)
|
|
finally:
|
|
server.close()
|
|
await server.wait_closed()
|
|
assert reply.startswith(f"HTTP/1.1 {status}")
|
|
assert api.calls == 0
|
|
|
|
|
|
async def test_a_query_string_still_matches_the_path():
|
|
api = ProbeApi()
|
|
server = await endpoint(api)
|
|
try:
|
|
reply = await request(server, "GET /health?from=monitor HTTP/1.1")
|
|
finally:
|
|
server.close()
|
|
await server.wait_closed()
|
|
assert reply.startswith("HTTP/1.1 200 OK")
|
|
|
|
|
|
async def test_a_configured_port_already_in_use_stops_the_bot():
|
|
api = ProbeApi()
|
|
taken = await endpoint(api)
|
|
port = taken.sockets[0].getsockname()[1]
|
|
try:
|
|
with pytest.raises(ConfigError, match="cannot listen"):
|
|
await health.serve(context(api), Health("127.0.0.1", port, configured=True))
|
|
finally:
|
|
taken.close()
|
|
await taken.wait_closed()
|
|
|
|
|
|
async def test_the_default_port_being_in_use_does_not_stop_the_bot():
|
|
# Nothing asked for port 8080; an unrelated service on it is not a reason to
|
|
# refuse to answer chats.
|
|
api = ProbeApi()
|
|
taken = await endpoint(api)
|
|
port = taken.sockets[0].getsockname()[1]
|
|
try:
|
|
assert await health.serve(context(api), Health("127.0.0.1", port)) is None
|
|
finally:
|
|
taken.close()
|
|
await taken.wait_closed()
|