signal-bot/tests/test_manage_routes.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

204 lines
7.5 KiB
Python

# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
"""Status, config, .env and the two actions."""
from __future__ import annotations
from typing import TYPE_CHECKING
import httpx
from signal_bot import external
from tests.manage_fixture import (
ENV_TEXT,
FIXTURE_GROUP,
answers_junk,
bot_is_down,
client_for,
deps_at,
)
if TYPE_CHECKING:
from pathlib import Path
import pytest
def test_a_healthy_bot_is_reported_with_what_it_says_about_itself(
tmp_path: Path,
) -> None:
with client_for(deps_at(tmp_path)) as client:
body = client.get("/api/status").json()
assert body["reachable"] is True
assert body["bot"]["status"] == "ok"
def test_the_bot_being_down_is_a_state_and_never_an_error(tmp_path: Path) -> None:
# The single most important thing this page can tell you, so it must not
# be the thing that stops the page rendering.
with client_for(deps_at(tmp_path, bot_is_down)) as client:
answer = client.get("/api/status")
assert answer.status_code == 200
assert answer.json() == {"reachable": False, "bot": {}}
def test_something_listening_that_is_not_the_bot_is_also_just_down(
tmp_path: Path,
) -> None:
with client_for(deps_at(tmp_path, answers_junk)) as client:
assert client.get("/api/status").json()["reachable"] is False
def test_groups_are_listed_so_an_id_is_picked_and_never_typed(
tmp_path: Path,
) -> None:
with client_for(deps_at(tmp_path)) as client:
groups = client.get("/api/groups").json()["groups"]
# The entry with no id is dropped: it cannot be picked, so offering it
# would only produce a group setting that fails at the next startup.
assert groups == [{"id": FIXTURE_GROUP, "name": "alfa"}]
def test_an_unreachable_container_lists_no_groups_rather_than_failing(
tmp_path: Path,
) -> None:
with client_for(deps_at(tmp_path, bot_is_down)) as client:
assert client.get("/api/groups").json()["groups"] == []
def test_a_group_listing_that_is_not_json_lists_no_groups(tmp_path: Path) -> None:
with client_for(deps_at(tmp_path, answers_junk)) as client:
assert client.get("/api/groups").json()["groups"] == []
def test_the_configuration_is_shown_with_the_number_cut_down(
tmp_path: Path,
) -> None:
with client_for(deps_at(tmp_path)) as client:
body = client.get("/api/config").json()
# Enough to recognise, not enough to be the number. This repository
# leaked one into a public place once and the fix was to delete it.
assert body["phone_number"] == "…0000"
assert "+1555" not in body["phone_number"]
assert body["bind"] == "127.0.0.1:8137"
assert body["report_at"] == "21:37"
def test_the_env_view_says_whether_group_settings_can_be_edited_now(
tmp_path: Path,
) -> None:
with client_for(deps_at(tmp_path)) as client:
body = client.get("/api/env").json()
assert body["groups_known"] is True
assert "GROUP_IDS" in body["needs_groups"]
assert {"key": "COOLDOWN_SECONDS", "value": "10", "editable": True} in body["keys"]
def test_group_settings_go_read_only_when_the_container_is_unreachable(
tmp_path: Path,
) -> None:
with client_for(deps_at(tmp_path, bot_is_down)) as client:
assert client.get("/api/env").json()["groups_known"] is False
def test_an_ordinary_edit_is_written_and_asks_for_a_restart(tmp_path: Path) -> None:
deps = deps_at(tmp_path)
with client_for(deps) as client:
answer = client.put("/api/env", json={"values": {"COOLDOWN_SECONDS": "30"}})
assert answer.json() == {"status": "written", "restart_required": True}
written = deps.env_path.read_text(encoding="utf-8")
assert "COOLDOWN_SECONDS=30" in written
assert "# Empty means every group this account is in." in written
def test_a_read_only_key_is_refused_by_name(tmp_path: Path) -> None:
deps = deps_at(tmp_path)
with client_for(deps) as client:
# The value never matters: a read-only key is refused before
# anything is parsed, so it cannot be smuggled past by making the
# rest of the file invalid.
answer = client.put("/api/env", json={"values": {"PHONE_NUMBER": "anything"}})
assert answer.status_code == 403
assert "PHONE_NUMBER" in answer.json()["detail"]
assert deps.env_path.read_text(encoding="utf-8") == ENV_TEXT
def test_a_group_edit_is_refused_while_no_listing_has_been_read(
tmp_path: Path,
) -> None:
deps = deps_at(tmp_path, bot_is_down)
with client_for(deps) as client:
answer = client.put("/api/env", json={"values": {"GROUP_IDS": "group.x"}})
assert answer.status_code == 403
assert deps.env_path.read_text(encoding="utf-8") == ENV_TEXT
def test_an_edit_the_bot_would_refuse_at_boot_leaves_the_file_alone(
tmp_path: Path,
) -> None:
deps = deps_at(tmp_path)
with client_for(deps) as client:
answer = client.put("/api/env", json={"values": {"REPORT_HOUR": "99"}})
assert answer.status_code == 400
assert "REPORT_HOUR" in answer.json()["detail"]
# Byte-identical: validation happens before anything is written.
assert deps.env_path.read_text(encoding="utf-8") == ENV_TEXT
def test_a_selftest_is_proxied_to_the_bot_rather_than_reimplemented(
tmp_path: Path,
) -> None:
with client_for(deps_at(tmp_path)) as client:
answer = client.post("/api/selftest", json={"group": "alfa", "text": "!kot"})
assert answer.json()["status"] == "delivered"
def test_the_bots_own_refusal_is_passed_through_unchanged(tmp_path: Path) -> None:
def refuses(request: httpx.Request) -> httpx.Response:
if request.url.path.endswith("/_selftest"):
return httpx.Response(403, json={"status": "refused", "reason": "not listed"})
return httpx.Response(200, json=[])
# SELFTEST_GROUPS stays fail-closed in exactly one place, and rewording
# its refusal here would make two sentences for one rule.
with client_for(deps_at(tmp_path, refuses)) as client:
answer = client.post("/api/selftest", json={"group": "other", "text": "!kot"})
assert answer.status_code == 403
assert answer.json()["detail"]["reason"] == "not listed"
def test_a_bot_that_does_not_answer_is_a_bad_gateway(tmp_path: Path) -> None:
with client_for(deps_at(tmp_path, bot_is_down)) as client:
answer = client.post("/api/selftest", json={"group": "alfa", "text": "!kot"})
assert answer.status_code == 502
def test_restarting_the_container_reports_what_actually_happened(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
seen: list[list[str]] = []
def fake_run(command: list[str], *, timeout: float = 0.0) -> external.Result:
seen.append(list(command))
assert timeout > 0
return external.Result(ok=True, stdout="signal-api", stderr="")
monkeypatch.setattr(external, "run", fake_run)
with client_for(deps_at(tmp_path)) as client:
answer = client.post("/api/restart-container", json={})
assert answer.json() == {"ok": True, "stdout": "signal-api", "stderr": ""}
assert seen == [["docker", "restart", "signal-api"]]
def test_a_restart_that_failed_is_not_allowed_to_look_like_success(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(
external,
"run",
lambda *_a, **_k: external.Result(ok=False, stdout="", stderr="no such container"),
)
with client_for(deps_at(tmp_path)) as client:
body = client.post("/api/restart-container", json={}).json()
assert body["ok"] is False
assert body["stderr"] == "no such container"