mirror of
https://github.com/kuhyx/signal-bot.git
synced 2026-11-10 20:17:02 +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
99 lines
3.4 KiB
Python
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]
|