signal-bot/tests/test_manage_envkeys.py
Krzysztof kuhy Rudnicki bdd20654bf feat: the .env layer the management page will write through
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
2026-08-31 14:31:29 +02:00

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"})