signal-bot/tests/test_config.py
Krzysztof kuhy Rudnicki c35e382cd6 feat: make the bot actually reply on Signal, and gate every way it went silent
First live reply, as its own registered account. `!cat` and `!dog` from the
phone return images -- verified in the production shape (the bot *invited to
an already-existing group*) and under a systemd user unit.

Four failures, each of which left the bot looking perfectly healthy:

- Invited is not joined. Signal only adds an account outright if the adder
  holds its profile key; with no profile the add becomes a pending invite,
  and signal-cli cannot accept one addressed to a phone number ("Cannot find
  service ID for self"). Group listed, websocket up, /healthz OK, zero
  messages. register_bot.sh now sets the profile *before* the add, and
  startup refuses on `member: false`.
- MODE=json-rpc-native is a GraalVM build with no AWT: text sends work,
  every attachment 400s with "Can't load library: awt". Both mode guards now
  demand exactly json-rpc, and start_container replaces a wrong-mode
  container instead of reusing it.
- The API spells one group two ways -- /v1/groups returns `group.<base64>`,
  envelopes carry what that decodes to -- so the scoping gate rejected the
  bot's own group. e2e.sh now models both spellings; reverting the fix fails
  4 scenarios, which is why the offline suite missed it before.
- BIND_PORT in .env was dead: __main__ read os.getenv before anything loaded
  a .env. The bind address comes from Config now.

A taken port used to be uvicorn logging the collision and exiting 0, which
every supervisor reads as success. portcheck.py refuses to start, exits
non-zero, and names the holder by reading /proc directly -- no subprocess, no
PATH assumptions, testable against a fake proc tree. Default port is 8137:
not 8000, and below the ephemeral range so the kernel cannot take it.

The bot now runs as signal-bot.service (Restart=always, linger checked rather
than assumed, StartLimitBurst so a config error fails visibly instead of
looping). Verified by SIGKILL and by re-running a command under the unit.

100% branch coverage, 230 tests, all gates green.

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

174 lines
5.8 KiB
Python

# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
"""Configuration loading, validation and URL derivation."""
from __future__ import annotations
import pytest
from signal_bot.config import Config, ConfigError, group_id_aliases, load_config
BASE_ENV = {
"PHONE_NUMBER": "+15550000000",
"GROUP_ID": "watched==",
"GROUP_ID_SEND": "reply==",
}
def test_loads_minimal_environment() -> None:
config = load_config(dict(BASE_ENV))
assert config.phone_number == "+15550000000"
assert config.api_base_url == "http://localhost:9922"
assert config.report_hour == 21
assert config.report_minute == 37
@pytest.mark.parametrize("missing", ["PHONE_NUMBER", "GROUP_ID", "GROUP_ID_SEND"])
def test_refuses_to_start_without_required_value(missing: str) -> None:
env = dict(BASE_ENV)
del env[missing]
with pytest.raises(ConfigError, match=missing):
load_config(env)
@pytest.mark.parametrize("missing", ["PHONE_NUMBER", "GROUP_ID", "GROUP_ID_SEND"])
def test_refuses_to_start_on_blank_value(missing: str) -> None:
"""A linked device sees every chat, so an unscoped bot must not start."""
env = dict(BASE_ENV) | {missing: " "}
with pytest.raises(ConfigError, match=missing):
load_config(env)
def test_overrides_are_honoured() -> None:
env = dict(BASE_ENV) | {
"API_BASE_URL": "https://signal.example:8443",
"DATABASE_PATH": "/var/lib/bot/bot.sqlite3",
"REPORT_HOUR": "6",
"REPORT_MINUTE": "5",
"COOLDOWN_SECONDS": "0",
}
config = load_config(env)
assert config.api_base_url == "https://signal.example:8443"
assert config.database_path == "/var/lib/bot/bot.sqlite3"
assert config.report_hour == 6
assert config.cooldown_seconds == 0
def test_blank_optionals_fall_back_to_defaults() -> None:
env = dict(BASE_ENV) | {"API_BASE_URL": " ", "DATABASE_PATH": "", "REPORT_HOUR": ""}
config = load_config(env)
assert config.api_base_url == "http://localhost:9922"
assert config.database_path == "signal_bot.sqlite3"
assert config.report_hour == 21
def test_rejects_non_integer_option() -> None:
with pytest.raises(ConfigError, match="must be an integer"):
load_config(dict(BASE_ENV) | {"REPORT_HOUR": "quarter past"})
def test_rejects_negative_option() -> None:
with pytest.raises(ConfigError, match="must not be negative"):
load_config(dict(BASE_ENV) | {"COOLDOWN_SECONDS": "-1"})
@pytest.mark.parametrize(("hour", "minute"), [("24", "0"), ("0", "60")])
def test_rejects_out_of_range_clock(hour: str, minute: str) -> None:
env = dict(BASE_ENV) | {"REPORT_HOUR": hour, "REPORT_MINUTE": minute}
with pytest.raises(ConfigError, match="out of range"):
load_config(env)
def test_reads_os_environ_when_no_mapping_given(monkeypatch: pytest.MonkeyPatch) -> None:
for key, value in BASE_ENV.items():
monkeypatch.setenv(key, value)
config = load_config(use_dotenv=False)
assert config.group_id == "watched=="
def test_derived_urls() -> None:
config = load_config(dict(BASE_ENV))
assert config.receive_url == (
"ws://localhost:9922/v1/receive/+15550000000?send_read_receipts=false"
)
assert config.send_url == "http://localhost:9922/v2/send"
assert config.attachments_url == "http://localhost:9922/v1/attachments"
assert config.about_url == "http://localhost:9922/v1/about"
def test_https_base_url_yields_wss() -> None:
config = Config(
phone_number="+1",
group_id="g",
group_id_send="r",
api_base_url="https://signal.example",
)
assert config.receive_url.startswith("wss://signal.example/")
def test_group_id_aliases_adds_the_internal_form() -> None:
"""The API spells one group two ways; the bot must answer to both."""
# Real value observed live: the base64 decodes to the internal_id the
# same endpoint reports, and to what envelopes actually carry.
configured = "group.Ni9nRXdSa3FzLzI1V1F4MkpPMkVlUlc4RUIwSXJBdXVPKzhEMGh1K09wST0="
assert group_id_aliases(configured) == {
configured,
"6/gEwRkqs/25WQx2JO2EeRW8EB0IrAuuO+8D0hu+OpI=",
}
def test_group_id_aliases_leaves_a_bare_internal_id_alone() -> None:
assert group_id_aliases("6/gEwRkqs=") == {"6/gEwRkqs="}
def test_group_id_aliases_tolerates_undecodable_ids() -> None:
"""A hand-edited GROUP_ID must not crash startup before the real check."""
assert group_id_aliases("group.not!valid!base64") == {"group.not!valid!base64"}
def test_watched_group_ids_exposes_both_spellings() -> None:
config = load_config(
{
"PHONE_NUMBER": "+15550000000",
"GROUP_ID": "group.Ni9nRXdSa3FzLzI1V1F4MkpPMkVlUlc4RUIwSXJBdXVPKzhEMGh1K09wST0=",
"GROUP_ID_SEND": "reply==",
}
)
assert "6/gEwRkqs/25WQx2JO2EeRW8EB0IrAuuO+8D0hu+OpI=" in config.watched_group_ids
def test_groups_url_is_scoped_to_the_account() -> None:
config = load_config(
{
"PHONE_NUMBER": "+15550000000",
"GROUP_ID": "g==",
"GROUP_ID_SEND": "g==",
"API_BASE_URL": "http://localhost:9922",
}
)
assert config.groups_url == "http://localhost:9922/v1/groups/+15550000000"
def test_an_unknown_log_level_is_rejected() -> None:
with pytest.raises(ConfigError, match="LOG_LEVEL"):
load_config(
{
"PHONE_NUMBER": "+15550000000",
"GROUP_ID": "g==",
"GROUP_ID_SEND": "g==",
"LOG_LEVEL": "chatty",
}
)
@pytest.mark.parametrize("port", ["0", "65536"])
def test_a_port_outside_the_valid_range_is_rejected(port: str) -> None:
with pytest.raises(ConfigError, match="BIND_PORT"):
load_config(
{
"PHONE_NUMBER": "+15550000000",
"GROUP_ID": "g==",
"GROUP_ID_SEND": "g==",
"BIND_PORT": port,
}
)