signal-bot/tests/manage_fixture.py
Krzysztof kuhy Rudnicki 0b539fc327 feat: the management backend, absorbing the tagger
A second process rather than more routes on the bot's own app. This one can
rewrite .env, and the bot's port already serves /_selftest, which posts for
real into a real group; keeping them apart means the surface that can
reconfigure the bot only listens while somebody has the page open.

The tagger's seven routes move here unchanged, out of tool/ and into the
coverage gate -- which is the reason an unconditional ImportError once sat
in tagger.py through two releases. The media route is still indexed by
integer, and that is not a style choice: a filename from a client is a path,
and the directory holds private photographs. FastAPI refuses a name with 422
before the handler runs, which the tests assert.

guard.py is the part that was not asked for. Binding to loopback stops the
network, not the browser already running on this machine, and two attacks
reach a loopback port from an ordinary web page: a cross-origin form, which
can only send urlencoded, multipart or plain text, so requiring
application/json refuses it with no token to keep; and DNS rebinding, whose
request still carries the attacker's hostname in Host. Forty lines instead
of authentication, which is the right trade for something started by hand
and stopped again a few minutes later.

assets.py branches on whether the page has been built, because StaticFiles
raises at construction on a missing directory -- mounting it unconditionally
would take the app down on a fresh clone, and in CI, where pytest never runs
pnpm build. Both branches are tested; "not built" answers with the command.

The container restart goes through signal_bot.external, wrapped in
asyncio.to_thread: it is synchronous with a 60s default, and blocking the
loop would freeze the page showing the restart.

100% branch coverage across all eleven modules, no omits, no pragmas.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01416TegJ3sa8xL5qGGgrygP
2026-08-31 14:38:24 +02:00

117 lines
4.0 KiB
Python

# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
"""A whole management app, pointed entirely at a temp directory.
Every path the app touches is a field on ManageDeps, so nothing here can
reach the real .env or the real album. The bot at the other end is an
httpx.MockTransport, so no test needs a container, a network or a phone.
Deliberately a builder rather than a fixture: the tests want apps whose bot
is up, down, or answering nonsense, and saying which belongs in the test.
"""
from __future__ import annotations
import json
from typing import TYPE_CHECKING
import httpx
from fastapi.testclient import TestClient
from signal_bot.config import Config
from signal_bot.manage.app import build_app
from signal_bot.manage.deps import ManageDeps
from signal_bot.pack import MEDIA_DIR, PEOPLE_FILE, TAGS_FILE
from signal_bot.pack_album import Album
from tests.conftest import WATCHED_GROUP
from tests.pack_fixture import PIXEL
if TYPE_CHECKING:
from collections.abc import Callable
from pathlib import Path
PORT = 8140
# The guard refuses anything that is not loopback, so every client in the
# suite has to say so -- which is also the only way the refusal gets tested.
BASE_URL = f"http://127.0.0.1:{PORT}"
# Invented, and short on purpose: the personal-data gate blocks anything
# matching group.<16+ base64 chars>, so a realistic-looking fixture id would
# make this repository refuse every future commit.
FIXTURE_GROUP = "group.FIXTURE-ALPHA"
ENV_TEXT = """\
# The account, in +E.164 form.
PHONE_NUMBER=+15550000000
# Empty means every group this account is in.
GROUP_IDS=
COOLDOWN_SECONDS=10
"""
HEALTHY = {"status": "ok", "version": "0.0.0", "pope": "Petrus", "papacy": "nr 1"}
def album_at(root: Path) -> Album:
"""A two-item album with one person, on disk under ``root``."""
media = root / MEDIA_DIR
media.mkdir(parents=True)
(media / "one.png").write_bytes(PIXEL)
(media / "two.png").write_bytes(PIXEL)
(root / PEOPLE_FILE).write_text(
json.dumps({"groups": [WATCHED_GROUP], "people": [{"key": "p1", "triggers": ["!alfa"]}]}),
encoding="utf-8",
)
(root / TAGS_FILE).write_text(json.dumps({"one.png": ["p1"]}), encoding="utf-8")
return Album(root)
def bot_is_up(request: httpx.Request) -> httpx.Response:
"""A container and a bot that both answer normally."""
if request.url.path == "/healthz":
return httpx.Response(200, json=HEALTHY)
if request.url.path.endswith("/_selftest"):
return httpx.Response(200, json={"status": "delivered", "group": FIXTURE_GROUP})
return httpx.Response(200, json=[{"id": FIXTURE_GROUP, "name": "alfa"}, {"name": "no id here"}])
def bot_is_down(_request: httpx.Request) -> httpx.Response:
"""Nothing at the other end at all."""
msg = "connection refused"
raise httpx.ConnectError(msg)
def answers_junk(_request: httpx.Request) -> httpx.Response:
"""Something is listening, but it is not speaking JSON."""
return httpx.Response(200, text="<html>not json</html>")
def deps_at(
tmp_path: Path,
handler: Callable[[httpx.Request], httpx.Response] = bot_is_up,
*,
built: bool = False,
) -> ManageDeps:
"""Everything a management app needs, all of it under ``tmp_path``."""
env_path = tmp_path / ".env"
env_path.write_text(ENV_TEXT, encoding="utf-8")
env_path.chmod(0o600)
dist = tmp_path / "dist"
if built:
(dist / "assets").mkdir(parents=True)
(dist / "assets" / "app.js").write_text("// built", encoding="utf-8")
(dist / "index.html").write_text("<title>built</title>", encoding="utf-8")
return ManageDeps(
config=Config(phone_number="+15550000000"),
env_path=env_path,
dist_dir=dist,
album=album_at(tmp_path / "pack"),
client=httpx.AsyncClient(transport=httpx.MockTransport(handler)),
port=PORT,
)
def client_for(deps: ManageDeps) -> TestClient:
"""A test client that talks to the app the way a browser on this box does."""
return TestClient(build_app(deps), base_url=BASE_URL)