mirror of
https://github.com/kuhyx/signal-bot.git
synced 2026-11-11 18:23:49 +01:00
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
156 lines
6.0 KiB
Python
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
|