signal-bot/tests/test_selftest.py
Krzysztof kuhy Rudnicki 558dd297a4 release: 0.0.7 «Telesfor» — !papież, and the bot can test itself
Adds !papież / !papiez / !pope: a random pope as a photo, captioned with his
Latin name, reign and pl.wikipedia link. popes.json already carried a Commons
portrait filename for all 267 and nothing had ever consumed it.

The Latin name comes from the Latin column of Poczet papieży, not from the
la.wikipedia sitelink. The sitelink resolved for all 267 and was still wrong:
la.wikipedia numbers several popes differently, so #154 came out as
"Stefan IX — Stephanus X" in a single caption, which reads as an off-by-one.
The table's own column says "Stephanus Nonus" and cannot disagree with the
name beside it. LATIN_OVERRIDES ships empty; it is a tripwire, not a backlog.

Portraits are cached on disk under CacheDirectory=signal-bot, named from the
ordinal so no upstream string ever reaches a path, and bounded at 267 entries
so nothing needs evicting. Verified that CacheDirectory wins against
ProtectHome=read-only before relying on it.

Also adds POST /_selftest and scripts/selftest.sh. Signal never delivers an
account its own sends, so no command has ever been exercised end-to-end
without asking a human to type it. This injects a real frame into the live
dispatcher; it is loopback-only and refuses any group not in SELFTEST_GROUPS,
which is empty by default.

Verified against the real endpoints, not just the fake: 267/267 portraits
resolve on Commons, regeneration is byte-identical on every previously
shipped field, and !papież was driven 7 times through the live dispatcher in
testbot, returning distinct popes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015d48t4qsQLBJnDwDeo2Nom
2026-08-30 17:06:25 +02:00

187 lines
7.2 KiB
Python

# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
"""Typing a command at the bot on its own behalf."""
from __future__ import annotations
import json
from types import SimpleNamespace
from typing import TYPE_CHECKING
import httpx
import pytest
from fastapi import Response, status
from signal_bot import app as app_module
from signal_bot.app import SelfTestRequest
from signal_bot.app import selftest as selftest_route
from signal_bot.bot import Bot, build_bot
from signal_bot.liveness import Liveness
from signal_bot.selftest import (
SelfTestError,
allowed_groups,
build_frame,
resolve_group,
run,
)
from tests.conftest import WATCHED_GROUP, mock_client
if TYPE_CHECKING:
from signal_bot.config import Config
from signal_bot.discovery import GroupDirectory
from signal_bot.store import Store
from signal_bot.transport_fake import FakeTransport
GROUP_ID = "group.internal-id"
OTHER = "+48000000000"
def _bot(config: Config, store: Store, transport: FakeTransport, directory: GroupDirectory) -> Bot:
"""The production object graph, so !papież resolves the way it really does."""
client = mock_client(_commons)
return build_bot(config, client, store, transport, directory, Liveness(config, client))
def _commons(request: httpx.Request) -> httpx.Response:
if "commons" in str(request.url):
return httpx.Response(
200,
json={"query": {"pages": [{"imageinfo": [{"thumburl": "https://cdn/portrait.jpg"}]}]}},
)
return httpx.Response(200, content=b"portrait-bytes")
def _client(entries: list[dict[str, object]]) -> httpx.AsyncClient:
def handler(_: httpx.Request) -> httpx.Response:
return httpx.Response(200, json=entries)
return httpx.AsyncClient(transport=httpx.MockTransport(handler))
def test_no_groups_are_allowed_by_default() -> None:
"""Fail closed: a loopback POST must not reach the production group."""
assert allowed_groups({}) == frozenset()
def test_the_allowlist_is_parsed_and_trimmed() -> None:
assert allowed_groups({"SELFTEST_GROUPS": " testbot , catbot ,"}) == {"testbot", "catbot"}
def test_the_frame_is_the_shape_signal_cli_emits() -> None:
body = json.loads(build_frame(GROUP_ID, OTHER, "!papież"))["envelope"]
assert body["sourceUuid"] == OTHER
assert body["dataMessage"]["message"] == "!papież"
assert body["dataMessage"]["groupInfo"] == {"groupId": GROUP_ID, "type": "DELIVER"}
assert body["timestamp"] == body["dataMessage"]["timestamp"]
async def test_resolve_group_picks_a_member_who_is_not_the_bot(config: Config) -> None:
"""The reply quotes the author, so it has to be somebody Signal knows."""
entries = [
{"name": "testbot", "internal_id": GROUP_ID, "members": [config.phone_number, OTHER]}
]
assert await resolve_group(_client(entries), config, "testbot") == (GROUP_ID, OTHER)
async def test_resolve_group_rejects_an_unknown_name(config: Config) -> None:
with pytest.raises(SelfTestError, match="no group named"):
await resolve_group(_client([]), config, "nope")
async def test_resolve_group_rejects_a_group_with_nobody_else_in_it(config: Config) -> None:
entries = [{"name": "testbot", "internal_id": GROUP_ID, "members": [config.phone_number]}]
with pytest.raises(SelfTestError, match="no usable id or no other member"):
await resolve_group(_client(entries), config, "testbot")
async def test_run_refuses_a_group_outside_the_allowlist(
config: Config, store: Store, transport: FakeTransport, directory: GroupDirectory
) -> None:
bot = _bot(config, store, transport, directory)
with pytest.raises(SelfTestError, match="refusing to post"):
await run(bot, _client([]), "realgroup", "!papiez", env={"SELFTEST_GROUPS": "testbot"})
async def test_run_feeds_the_live_dispatcher(
config: Config, store: Store, transport: FakeTransport, directory: GroupDirectory
) -> None:
"""The point of the module: the real wiring, not a reconstruction.
build_bot is what production calls, so a command that resolves here is a
command the group can actually type -- which is exactly what could not
be checked before, Signal never delivering an account its own sends.
"""
bot = _bot(config, store, transport, directory)
entries = [
{"name": "testbot", "internal_id": WATCHED_GROUP, "members": [config.phone_number, OTHER]}
]
delivered = await run(
bot, _client(entries), "testbot", "!papiez", env={"SELFTEST_GROUPS": "testbot"}
)
assert delivered == WATCHED_GROUP
assert transport.sent[0].image_base64 is not None
assert "pl.wikipedia.org" in transport.sent[0].caption
async def test_the_endpoint_reports_connecting_before_the_bot_is_up() -> None:
"""A self-test during startup is not a failure, it is "not yet"."""
app_module.app.state.starter = SimpleNamespace(runtime=None, reason="container down")
response = Response()
body = await selftest_route(SelfTestRequest(group="testbot", text="!papiez"), response)
assert response.status_code == status.HTTP_503_SERVICE_UNAVAILABLE
assert body == {"status": "connecting", "reason": "container down"}
async def test_the_endpoint_refuses_a_group_outside_the_allowlist(
config: Config, store: Store, transport: FakeTransport, directory: GroupDirectory
) -> None:
bot = _bot(config, store, transport, directory)
app_module.app.state.starter = SimpleNamespace(runtime=SimpleNamespace(bot=bot), reason="")
app_module.app.state.client = _client([])
response = Response()
body = await selftest_route(SelfTestRequest(group="realgroup", text="!papiez"), response)
assert response.status_code == status.HTTP_403_FORBIDDEN
assert body["status"] == "refused"
assert transport.sent == []
async def test_the_endpoint_delivers_and_says_where(
config: Config,
store: Store,
transport: FakeTransport,
directory: GroupDirectory,
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setenv("SELFTEST_GROUPS", "testbot")
bot = _bot(config, store, transport, directory)
app_module.app.state.starter = SimpleNamespace(runtime=SimpleNamespace(bot=bot), reason="")
app_module.app.state.client = _client(
[{"name": "testbot", "internal_id": WATCHED_GROUP, "members": [config.phone_number, OTHER]}]
)
response = Response()
body = await selftest_route(SelfTestRequest(group="testbot", text="!papiez"), response)
assert body == {"status": "delivered", "group": WATCHED_GROUP, "text": "!papiez"}
assert transport.sent[0].image_base64 is not None
async def test_resolve_group_rejects_an_entry_with_no_id(config: Config) -> None:
entries = [{"name": "testbot", "members": [config.phone_number, OTHER]}]
with pytest.raises(SelfTestError, match="no usable id or no other member"):
await resolve_group(_client(entries), config, "testbot")
async def test_resolve_group_skips_past_groups_with_other_names(config: Config) -> None:
"""The bot is in several groups; only the named one may be posted into."""
entries = [
{"name": "produkcja", "internal_id": "group.real", "members": [config.phone_number, OTHER]},
{"name": "testbot", "internal_id": GROUP_ID, "members": [config.phone_number, OTHER]},
]
assert await resolve_group(_client(entries), config, "testbot") == (GROUP_ID, OTHER)