mirror of
https://github.com/kuhyx/signal-bot.git
synced 2026-11-10 18:07:29 +01:00
envfile.py edits the file in place, line by line: only the KEY= line of a key that actually changed is replaced, so the paragraph explaining why GROUP_IDS is empty survives an edit made in a browser. A regenerated file would be correct and would still lose most of what that file is for. Temp-then-replace with the mode reapplied before the swap, because .env holds the account number and is edited from a tab that can be closed mid-save. envkeys.py is the policy, as data. Nothing here re-implements validation: load_config(candidate, use_dotenv=False) is what startup runs, so an edit this bot would refuse at boot is refused at the form instead, with the journal's own message. Read-only is wider than "the phone number". API_BASE_URL repoints every outgoing message and attachment fetch at the next restart, which is worse than changing the number; DATABASE_PATH orphans the history; PHOTO_PACK_DIR cannot be validated at all, because _photo_pack_dirs() reads os.environ directly rather than the mapping it is handed -- deliberately, since that is what keeps the suite away from a real album. GROUP_IDS and SELFTEST_GROUPS are editable but refused while no group listing has been read. parse_group_ids only checks syntax; startup also requires the group to be one the bot actually joined, so writing one blind with the container down turns a scoping mistake into a unit that will not start. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01416TegJ3sa8xL5qGGgrygP
87 lines
3.3 KiB
Python
87 lines
3.3 KiB
Python
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
|
|
"""Which .env keys a browser may write, and what makes an edit valid."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import pytest
|
|
|
|
from signal_bot.groups import ConfigError
|
|
from signal_bot.manage import envkeys
|
|
|
|
SAMPLE = """\
|
|
PHONE_NUMBER=+15550000000
|
|
GROUP_IDS=
|
|
COOLDOWN_SECONDS=10
|
|
"""
|
|
|
|
|
|
def test_every_known_key_is_listed_with_whether_it_may_be_edited() -> None:
|
|
listed = envkeys.settings(SAMPLE)
|
|
by_key = {entry["key"]: entry for entry in listed}
|
|
assert by_key["COOLDOWN_SECONDS"] == {
|
|
"key": "COOLDOWN_SECONDS",
|
|
"value": "10",
|
|
"editable": True,
|
|
}
|
|
# Absent from the file is not absent from the page: empty is how .env
|
|
# spells "use the default", and the page has to be able to show that.
|
|
assert by_key["SILENCE_SECONDS"]["value"] == ""
|
|
assert len(listed) == len(envkeys.EDITABLE) + len(envkeys.READ_ONLY)
|
|
|
|
|
|
def test_the_account_and_the_container_it_talks_to_are_not_editable() -> None:
|
|
# PHONE_NUMBER because the container holds the matching registration.
|
|
# API_BASE_URL because repointing it sends every outgoing message and
|
|
# every attachment fetch somewhere else on the next restart, which is a
|
|
# strictly worse hole than changing the number.
|
|
assert "PHONE_NUMBER" in envkeys.READ_ONLY
|
|
assert "API_BASE_URL" in envkeys.READ_ONLY
|
|
assert not set(envkeys.EDITABLE) & set(envkeys.READ_ONLY)
|
|
|
|
|
|
def test_a_read_only_key_is_named_as_the_reason_for_refusing() -> None:
|
|
assert envkeys.refuse({"PHONE_NUMBER": "+15550000001"}, groups_known=True) == "PHONE_NUMBER"
|
|
|
|
|
|
def test_an_ordinary_edit_is_allowed() -> None:
|
|
assert envkeys.refuse({"COOLDOWN_SECONDS": "20"}, groups_known=True) is None
|
|
|
|
|
|
def test_group_keys_are_refused_while_the_container_is_unreachable() -> None:
|
|
# parse_group_ids only checks syntax. Startup additionally requires that
|
|
# every listed group is one the bot actually joined, judged against a
|
|
# listing that was really read -- so writing one blind turns a scoping
|
|
# mistake into a unit that will not start.
|
|
assert envkeys.refuse({"GROUP_IDS": "group.x"}, groups_known=False) == "GROUP_IDS"
|
|
assert envkeys.refuse({"SELFTEST_GROUPS": "x"}, groups_known=False) == "SELFTEST_GROUPS"
|
|
|
|
|
|
def test_group_keys_are_allowed_once_a_listing_has_been_read() -> None:
|
|
assert envkeys.refuse({"GROUP_IDS": "group.x"}, groups_known=True) is None
|
|
|
|
|
|
def test_a_valid_edit_is_one_the_bot_could_start_on() -> None:
|
|
envkeys.validate(SAMPLE, {"COOLDOWN_SECONDS": "20"})
|
|
|
|
|
|
def test_an_edit_the_bot_would_refuse_at_boot_is_refused_at_the_form() -> None:
|
|
# The message is load_config's own, so the page shows what the journal
|
|
# would have shown eight hours later.
|
|
with pytest.raises(ConfigError, match="REPORT_HOUR"):
|
|
envkeys.validate(SAMPLE, {"REPORT_HOUR": "99"})
|
|
|
|
|
|
def test_a_non_numeric_cooldown_is_refused() -> None:
|
|
with pytest.raises(ConfigError, match="COOLDOWN_SECONDS"):
|
|
envkeys.validate(SAMPLE, {"COOLDOWN_SECONDS": "soon"})
|
|
|
|
|
|
def test_an_unknown_log_level_is_refused() -> None:
|
|
with pytest.raises(ConfigError, match="LOG_LEVEL"):
|
|
envkeys.validate(SAMPLE, {"LOG_LEVEL": "LOUD"})
|
|
|
|
|
|
def test_a_trusted_inviter_that_is_neither_a_number_nor_a_uuid_is_refused() -> None:
|
|
with pytest.raises(ConfigError, match="TRUSTED_INVITERS"):
|
|
envkeys.validate(SAMPLE, {"TRUSTED_INVITERS": "whoever"})
|