# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence. """Status, config, .env and the two actions.""" from __future__ import annotations from typing import TYPE_CHECKING import httpx from signal_bot import external from tests.manage_fixture import ( ENV_TEXT, FIXTURE_GROUP, answers_junk, bot_is_down, client_for, deps_at, ) if TYPE_CHECKING: from pathlib import Path import pytest def test_a_healthy_bot_is_reported_with_what_it_says_about_itself( tmp_path: Path, ) -> None: with client_for(deps_at(tmp_path)) as client: body = client.get("/api/status").json() assert body["reachable"] is True assert body["bot"]["status"] == "ok" def test_the_bot_being_down_is_a_state_and_never_an_error(tmp_path: Path) -> None: # The single most important thing this page can tell you, so it must not # be the thing that stops the page rendering. with client_for(deps_at(tmp_path, bot_is_down)) as client: answer = client.get("/api/status") assert answer.status_code == 200 assert answer.json() == {"reachable": False, "bot": {}} def test_something_listening_that_is_not_the_bot_is_also_just_down( tmp_path: Path, ) -> None: with client_for(deps_at(tmp_path, answers_junk)) as client: assert client.get("/api/status").json()["reachable"] is False def test_groups_are_listed_so_an_id_is_picked_and_never_typed( tmp_path: Path, ) -> None: with client_for(deps_at(tmp_path)) as client: groups = client.get("/api/groups").json()["groups"] # The entry with no id is dropped: it cannot be picked, so offering it # would only produce a group setting that fails at the next startup. assert groups == [{"id": FIXTURE_GROUP, "name": "alfa"}] def test_an_unreachable_container_lists_no_groups_rather_than_failing( tmp_path: Path, ) -> None: with client_for(deps_at(tmp_path, bot_is_down)) as client: assert client.get("/api/groups").json()["groups"] == [] def test_a_group_listing_that_is_not_json_lists_no_groups(tmp_path: Path) -> None: with client_for(deps_at(tmp_path, answers_junk)) as client: assert client.get("/api/groups").json()["groups"] == [] def test_the_configuration_is_shown_with_the_number_cut_down( tmp_path: Path, ) -> None: with client_for(deps_at(tmp_path)) as client: body = client.get("/api/config").json() # Enough to recognise, not enough to be the number. This repository # leaked one into a public place once and the fix was to delete it. assert body["phone_number"] == "…0000" assert "+1555" not in body["phone_number"] assert body["bind"] == "127.0.0.1:8137" assert body["report_at"] == "21:37" def test_the_env_view_says_whether_group_settings_can_be_edited_now( tmp_path: Path, ) -> None: with client_for(deps_at(tmp_path)) as client: body = client.get("/api/env").json() assert body["groups_known"] is True assert "GROUP_IDS" in body["needs_groups"] assert {"key": "COOLDOWN_SECONDS", "value": "10", "editable": True} in body["keys"] def test_group_settings_go_read_only_when_the_container_is_unreachable( tmp_path: Path, ) -> None: with client_for(deps_at(tmp_path, bot_is_down)) as client: assert client.get("/api/env").json()["groups_known"] is False def test_an_ordinary_edit_is_written_and_asks_for_a_restart(tmp_path: Path) -> None: deps = deps_at(tmp_path) with client_for(deps) as client: answer = client.put("/api/env", json={"values": {"COOLDOWN_SECONDS": "30"}}) assert answer.json() == {"status": "written", "restart_required": True} written = deps.env_path.read_text(encoding="utf-8") assert "COOLDOWN_SECONDS=30" in written assert "# Empty means every group this account is in." in written def test_a_read_only_key_is_refused_by_name(tmp_path: Path) -> None: deps = deps_at(tmp_path) with client_for(deps) as client: # The value never matters: a read-only key is refused before # anything is parsed, so it cannot be smuggled past by making the # rest of the file invalid. answer = client.put("/api/env", json={"values": {"PHONE_NUMBER": "anything"}}) assert answer.status_code == 403 assert "PHONE_NUMBER" in answer.json()["detail"] assert deps.env_path.read_text(encoding="utf-8") == ENV_TEXT def test_a_group_edit_is_refused_while_no_listing_has_been_read( tmp_path: Path, ) -> None: deps = deps_at(tmp_path, bot_is_down) with client_for(deps) as client: answer = client.put("/api/env", json={"values": {"GROUP_IDS": "group.x"}}) assert answer.status_code == 403 assert deps.env_path.read_text(encoding="utf-8") == ENV_TEXT def test_an_edit_the_bot_would_refuse_at_boot_leaves_the_file_alone( tmp_path: Path, ) -> None: deps = deps_at(tmp_path) with client_for(deps) as client: answer = client.put("/api/env", json={"values": {"REPORT_HOUR": "99"}}) assert answer.status_code == 400 assert "REPORT_HOUR" in answer.json()["detail"] # Byte-identical: validation happens before anything is written. assert deps.env_path.read_text(encoding="utf-8") == ENV_TEXT def test_a_selftest_is_proxied_to_the_bot_rather_than_reimplemented( tmp_path: Path, ) -> None: with client_for(deps_at(tmp_path)) as client: answer = client.post("/api/selftest", json={"group": "alfa", "text": "!kot"}) assert answer.json()["status"] == "delivered" def test_the_bots_own_refusal_is_passed_through_unchanged(tmp_path: Path) -> None: def refuses(request: httpx.Request) -> httpx.Response: if request.url.path.endswith("/_selftest"): return httpx.Response(403, json={"status": "refused", "reason": "not listed"}) return httpx.Response(200, json=[]) # SELFTEST_GROUPS stays fail-closed in exactly one place, and rewording # its refusal here would make two sentences for one rule. with client_for(deps_at(tmp_path, refuses)) as client: answer = client.post("/api/selftest", json={"group": "other", "text": "!kot"}) assert answer.status_code == 403 assert answer.json()["detail"]["reason"] == "not listed" def test_a_bot_that_does_not_answer_is_a_bad_gateway(tmp_path: Path) -> None: with client_for(deps_at(tmp_path, bot_is_down)) as client: answer = client.post("/api/selftest", json={"group": "alfa", "text": "!kot"}) assert answer.status_code == 502 def test_restarting_the_container_reports_what_actually_happened( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: seen: list[list[str]] = [] def fake_run(command: list[str], *, timeout: float = 0.0) -> external.Result: seen.append(list(command)) assert timeout > 0 return external.Result(ok=True, stdout="signal-api", stderr="") monkeypatch.setattr(external, "run", fake_run) with client_for(deps_at(tmp_path)) as client: answer = client.post("/api/restart-container", json={}) assert answer.json() == {"ok": True, "stdout": "signal-api", "stderr": ""} assert seen == [["docker", "restart", "signal-api"]] def test_a_restart_that_failed_is_not_allowed_to_look_like_success( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: monkeypatch.setattr( external, "run", lambda *_a, **_k: external.Result(ok=False, stdout="", stderr="no such container"), ) with client_for(deps_at(tmp_path)) as client: body = client.post("/api/restart-container", json={}).json() assert body["ok"] is False assert body["stderr"] == "no such container"