mirror of
https://github.com/kuhyx/signal-bot.git
synced 2026-11-10 19:27:20 +01:00
A second process rather than more routes on the bot's own app. This one can rewrite .env, and the bot's port already serves /_selftest, which posts for real into a real group; keeping them apart means the surface that can reconfigure the bot only listens while somebody has the page open. The tagger's seven routes move here unchanged, out of tool/ and into the coverage gate -- which is the reason an unconditional ImportError once sat in tagger.py through two releases. The media route is still indexed by integer, and that is not a style choice: a filename from a client is a path, and the directory holds private photographs. FastAPI refuses a name with 422 before the handler runs, which the tests assert. guard.py is the part that was not asked for. Binding to loopback stops the network, not the browser already running on this machine, and two attacks reach a loopback port from an ordinary web page: a cross-origin form, which can only send urlencoded, multipart or plain text, so requiring application/json refuses it with no token to keep; and DNS rebinding, whose request still carries the attacker's hostname in Host. Forty lines instead of authentication, which is the right trade for something started by hand and stopped again a few minutes later. assets.py branches on whether the page has been built, because StaticFiles raises at construction on a missing directory -- mounting it unconditionally would take the app down on a fresh clone, and in CI, where pytest never runs pnpm build. Both branches are tested; "not built" answers with the command. The container restart goes through signal_bot.external, wrapped in asyncio.to_thread: it is synchronous with a 60s default, and blocking the loop would freeze the page showing the restart. 100% branch coverage across all eleven modules, no omits, no pragmas. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01416TegJ3sa8xL5qGGgrygP
62 lines
2.3 KiB
Python
62 lines
2.3 KiB
Python
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
|
|
"""Serving the built page, and saying so plainly when it is not built."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import TYPE_CHECKING
|
|
|
|
from signal_bot.manage import assets
|
|
from tests.manage_fixture import client_for, deps_at
|
|
|
|
if TYPE_CHECKING:
|
|
from pathlib import Path
|
|
|
|
|
|
def test_the_page_is_served_once_it_has_been_built(tmp_path: Path) -> None:
|
|
with client_for(deps_at(tmp_path, built=True)) as client:
|
|
answer = client.get("/")
|
|
assert answer.status_code == 200
|
|
assert "built" in answer.text
|
|
|
|
|
|
def test_hashed_assets_are_served_from_their_own_mount(tmp_path: Path) -> None:
|
|
with client_for(deps_at(tmp_path, built=True)) as client:
|
|
answer = client.get("/assets/app.js")
|
|
assert answer.status_code == 200
|
|
assert "// built" in answer.text
|
|
|
|
|
|
def test_a_client_routed_path_falls_through_to_the_page(tmp_path: Path) -> None:
|
|
# A reload on a sub-view asks this server for a path only the browser
|
|
# knows about, so the catch-all has to answer with the page itself.
|
|
with client_for(deps_at(tmp_path, built=True)) as client:
|
|
assert "built" in client.get("/config").text
|
|
|
|
|
|
def test_an_unbuilt_page_names_the_command_instead_of_crashing(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
# StaticFiles raises at construction on a missing directory, so mounting
|
|
# it unconditionally would take the whole app down on a fresh clone --
|
|
# and in CI, where pytest runs without ever calling pnpm build.
|
|
with client_for(deps_at(tmp_path)) as client:
|
|
answer = client.get("/")
|
|
assert answer.status_code == 503
|
|
assert assets.BUILD_COMMAND in answer.text
|
|
|
|
|
|
def test_a_half_built_directory_counts_as_not_built(tmp_path: Path) -> None:
|
|
# index.html without assets/ would raise from the mount exactly the way
|
|
# a missing dist/ does.
|
|
dist = tmp_path / "dist"
|
|
dist.mkdir()
|
|
(dist / "index.html").write_text("<title>half</title>", encoding="utf-8")
|
|
assert assets.is_built(dist) is False
|
|
|
|
|
|
def test_the_api_is_not_shadowed_by_the_catch_all(tmp_path: Path) -> None:
|
|
# /{path:path} would swallow every route registered after it, which is
|
|
# why the assets go on last.
|
|
with client_for(deps_at(tmp_path, built=True)) as client:
|
|
assert client.get("/api/status").json()["reachable"] is True
|