signal-bot/tests/test_membership.py
Krzysztof kuhy Rudnicki bb72f0ae93 fix: commit the five modules the last commit only referenced
The wrap-up script stages dirty *tracked* files, so every newly created
file in the multi-group change -- the group-id module, the membership
verifier, their tests, the isolation scenario and prime_profile.sh --
was listed as untracked and then left behind. The tree compiled locally
because the files exist on disk; CI checked out a repo where config.py
imported signal_bot.groups and nothing provided it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013yaBT45KhHqxTKVCTA87iy
2026-08-29 15:05:18 +02:00

156 lines
6.0 KiB
Python

# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
"""The startup membership gate: invited is not joined, and each group counts."""
from __future__ import annotations
from collections.abc import Callable
from dataclasses import replace
from typing import TYPE_CHECKING
from urllib.parse import quote
import httpx
import pytest
from signal_bot.transport_rest import RestTransport, TransportError
if TYPE_CHECKING:
from signal_bot.config import Config
# The callable httpx.MockTransport expects -- not a bot command handler.
MockHandler = Callable[[httpx.Request], httpx.Response]
def _transport(config: Config, handler: MockHandler) -> RestTransport:
"""A transport whose HTTP calls are answered by handler."""
return RestTransport(config, httpx.AsyncClient(transport=httpx.MockTransport(handler)))
def _groups(*entries: object) -> MockHandler:
"""Answer /v1/groups with an arbitrary payload."""
return lambda r: httpx.Response(200, json=list(entries))
def _member(group_id: str, *, member: bool = True, name: str = "testbot") -> dict[str, object]:
"""One /v1/groups row for a configured group."""
return {"id": group_id, "internal_id": group_id, "name": name, "member": member}
def _all_joined(config: Config) -> MockHandler:
"""Answer /v1/groups with every configured group, fully joined."""
return _groups(*(_member(group_id) for group_id in config.group_ids))
async def test_membership_accepts_a_group_matched_by_its_internal_id(
config: Config,
) -> None:
"""Envelopes carry internal_id, so that spelling must satisfy the gate."""
handler = _groups(
{"id": "group.other", "internal_id": config.group_ids[0], "member": True},
_member(config.group_ids[1]),
)
assert await _transport(config, handler).verify_membership() is None
async def test_membership_checks_every_configured_group(config: Config) -> None:
"""One joined group must not vouch for another: that is a silent deafness."""
handler = _groups(_member(config.group_ids[0]))
with pytest.raises(TransportError, match="in no group matching"):
await _transport(config, handler).verify_membership()
async def test_membership_names_the_group_it_is_only_invited_to(
config: Config,
) -> None:
"""The join self-heal fails too, so the gate must still refuse loudly."""
joined, invited = config.group_ids
def handler(request: httpx.Request) -> httpx.Response:
if request.method == "POST":
return httpx.Response(400, text="Cannot find service ID for self")
return httpx.Response(
200, json=[_member(joined), _member(invited, member=False, name="prod")]
)
with pytest.raises(TransportError, match="invited to 'prod'") as raised:
await _transport(config, handler).verify_membership()
# The join attempt's own answer travels into the error, so the reason is
# in the journal rather than one debugging session away.
assert "Cannot find service ID" in str(raised.value)
assert invited in str(raised.value)
async def test_membership_joins_a_pending_invitation(config: Config) -> None:
"""The self-heal: accept the invite at startup instead of demanding a re-add."""
joined = config.group_ids[0]
# A real internal id, slashes and pluses included: unencoded, they would
# not match the join route at all, and the self-heal would never fire.
invited = "Y29uZmlnLWdyb3VwLWlkLTMy"
config = replace(config, group_ids=(joined, invited))
posts: list[str] = []
listings = iter(
[
[_member(joined), _member(invited, member=False)],
[_member(joined), _member(invited)],
]
)
def handler(request: httpx.Request) -> httpx.Response:
if request.method == "POST":
posts.append(request.url.raw_path.decode())
return httpx.Response(201, text="")
return httpx.Response(200, json=next(listings))
assert await _transport(config, handler).verify_membership() is None
# The id is percent-encoded: the internal spelling carries '/' and '+',
# which would otherwise not match the route at all.
expected = f"/v1/groups/{config.phone_number}/{quote(invited, safe='')}/join"
assert posts == [expected]
async def test_membership_survives_a_join_endpoint_that_is_not_there(
config: Config,
) -> None:
"""A transport error on join must become the loud refusal, not a crash."""
joined, invited = config.group_ids
def handler(request: httpx.Request) -> httpx.Response:
if request.method == "POST":
msg = "connection refused"
raise httpx.ConnectError(msg)
return httpx.Response(200, json=[_member(joined), _member(invited, member=False)])
with pytest.raises(TransportError, match="join request failed"):
await _transport(config, handler).verify_membership()
async def test_membership_refuses_when_the_group_vanishes_after_joining(
config: Config,
) -> None:
"""The re-check is the authority, so a group gone from the listing fails."""
joined, invited = config.group_ids
listings = iter([[_member(joined), _member(invited, member=False)], [_member(joined)]])
def handler(request: httpx.Request) -> httpx.Response:
if request.method == "POST":
return httpx.Response(201, text="")
return httpx.Response(200, json=next(listings))
with pytest.raises(TransportError, match="has not joined"):
await _transport(config, handler).verify_membership()
async def test_membership_rejects_an_account_in_no_matching_group(
config: Config,
) -> None:
handler = _groups({"id": "group.someone-elses", "member": True})
with pytest.raises(TransportError, match="in no group matching"):
await _transport(config, handler).verify_membership()
async def test_membership_ignores_entries_that_are_not_objects(
config: Config,
) -> None:
"""A malformed row must not mask the real reason the bot is deaf."""
handler = _groups("unexpected", *(_member(g) for g in config.group_ids))
assert await _transport(config, handler).verify_membership() is None