signal-bot/tests/test_manage_envfile.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

99 lines
3.4 KiB
Python

# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
"""Rewriting .env without losing what it says about itself."""
from __future__ import annotations
from typing import TYPE_CHECKING
from signal_bot.manage import envfile
if TYPE_CHECKING:
from pathlib import Path
import pytest
SAMPLE = """\
# The account, in +E.164 form.
PHONE_NUMBER=+15550000000
# Empty means every group this account is in.
GROUP_IDS=
COOLDOWN_SECONDS=10
"""
def test_comments_blank_lines_and_junk_are_not_assignments() -> None:
text = "# a comment\n\nNOT_AN_ASSIGNMENT\n=novalue\nKEY=value\n"
assert envfile.parse(text) == {"KEY": "value"}
def test_a_value_keeps_its_content_but_loses_surrounding_space() -> None:
assert envfile.parse(" A = one=two \n") == {"A": "one=two"}
def test_a_repeated_key_takes_the_last_one_as_dotenv_does() -> None:
assert envfile.parse("A=1\nA=2\n") == {"A": "2"}
def test_editing_a_present_key_replaces_it_where_it_stands() -> None:
rewritten = envfile.render(SAMPLE, {"COOLDOWN_SECONDS": "30"})
assert "COOLDOWN_SECONDS=30" in rewritten
# The whole reason this is not a regenerated file: the prose survives.
assert "# Empty means every group this account is in." in rewritten
assert rewritten.index("PHONE_NUMBER") < rewritten.index("GROUP_IDS")
assert "Added by the management page" not in rewritten
def test_a_key_the_file_never_had_is_appended_not_inserted() -> None:
rewritten = envfile.render(SAMPLE, {"LOG_LEVEL": "DEBUG"})
# At the end, under a marker: the comments in the body explain their own
# key, and dropping a new one among them would attach it to prose about
# something else entirely.
assert rewritten.endswith("# Added by the management page.\nLOG_LEVEL=DEBUG\n")
def test_the_file_is_returned_unchanged_when_nothing_was_edited() -> None:
assert envfile.render(SAMPLE, {}) == SAMPLE
def test_the_candidate_is_the_file_with_the_edits_on_top() -> None:
merged = envfile.candidate(SAMPLE, {"GROUP_IDS": "group.abc"})
assert merged["GROUP_IDS"] == "group.abc"
assert merged["PHONE_NUMBER"] == "+15550000000"
def test_the_process_environment_shows_through_where_the_file_is_silent(
monkeypatch: pytest.MonkeyPatch,
) -> None:
# load_dotenv does not overwrite what systemd already exported, so a check
# that ignored os.environ would be validating a file the running unit is
# not actually configured by.
monkeypatch.setenv("SIGNAL_BOT_ONLY_IN_THE_ENVIRONMENT", "seen")
assert envfile.candidate(SAMPLE, {})["SIGNAL_BOT_ONLY_IN_THE_ENVIRONMENT"] == "seen"
def test_an_edit_wins_over_the_process_environment(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setenv("GROUP_IDS", "group.from.systemd")
merged = envfile.candidate(SAMPLE, {"GROUP_IDS": "group.from.form"})
assert merged["GROUP_IDS"] == "group.from.form"
def test_writing_replaces_the_file_and_keeps_it_unreadable_to_others(
tmp_path: Path,
) -> None:
path = tmp_path / ".env"
path.write_text(SAMPLE, encoding="utf-8")
path.chmod(0o600)
envfile.write(path, {"COOLDOWN_SECONDS": "45"})
assert "COOLDOWN_SECONDS=45" in path.read_text(encoding="utf-8")
assert path.stat().st_mode & 0o777 == envfile.FILE_MODE
# Temp-then-replace, and nothing left behind: this file is edited from a
# browser tab that can be closed mid-save, and a half-written .env is not
# a bad value, it is a bot that will not start.
assert list(tmp_path.iterdir()) == [path]