Files
shelfmark/tests/core/test_debug_routes_admin.py
splitsec2 386fdb313c Merge commit from fork
GET /api/debug returns a bundle with application logs, process listings
and environment names, and GET /api/restart exits the process. Both are
registered only when DEBUG is on, but any logged-in user could call them.

Add an admin_required decorator built on login_required and use it for
both routes. Non-admin sessions get 403 "Admin access required";
authentication mode "none" keeps working as before.
2026-10-20 23:34:32 -04:00

124 lines
4.2 KiB
Python

"""Access control tests for the DEBUG-only /api/debug and /api/restart routes."""
from __future__ import annotations
import importlib
from collections.abc import Iterator
from types import ModuleType
from unittest.mock import patch
import pytest
from shelfmark.core.config import config as app_config
_DEBUG_OVERRIDES: dict[str, object] = {"DEBUG": True, "USING_EXTERNAL_BYPASSER": True}
_ORIGINAL_CONFIG_GET = app_config.get
def _config_get_with_debug(key: str, default: object = None, user_id: int | None = None) -> object:
if key in _DEBUG_OVERRIDES:
return _DEBUG_OVERRIDES[key]
return _ORIGINAL_CONFIG_GET(key, default, user_id=user_id)
@pytest.fixture(scope="module")
def main_module() -> Iterator[ModuleType]:
"""Import `shelfmark.main` with DEBUG on so the debug routes get registered."""
with (
patch("shelfmark.download.orchestrator.start"),
patch.object(app_config, "get", side_effect=_config_get_with_debug),
):
import shelfmark.main as main
importlib.reload(main)
yield main
# Put the module back the way other test modules expect it (no debug routes).
with patch("shelfmark.download.orchestrator.start"):
importlib.reload(main)
@pytest.fixture
def client(main_module):
return main_module.app.test_client()
def _set_session(client, *, user_id: str, is_admin: bool) -> None:
with client.session_transaction() as sess:
sess["user_id"] = user_id
sess["is_admin"] = is_admin
@pytest.fixture
def no_side_effects(main_module):
"""Never run the real debug script or exit the process from a test."""
with (
patch.object(main_module.subprocess, "run") as run_mock,
patch.object(main_module.os, "_exit") as exit_mock,
):
yield run_mock, exit_mock
def test_debug_routes_are_registered_when_debug_is_on(main_module):
rules = {rule.rule for rule in main_module.app.url_map.iter_rules()}
assert "/api/debug" in rules
assert "/api/restart" in rules
@pytest.mark.parametrize("path", ["/api/debug", "/api/restart"])
def test_debug_routes_reject_anonymous_users(client, main_module, no_side_effects, path):
run_mock, exit_mock = no_side_effects
with patch.object(main_module, "get_auth_mode", return_value="builtin"):
response = client.get(path)
assert response.status_code == 401
run_mock.assert_not_called()
exit_mock.assert_not_called()
@pytest.mark.parametrize("path", ["/api/debug", "/api/restart"])
def test_debug_routes_reject_non_admin_users(client, main_module, no_side_effects, path):
run_mock, exit_mock = no_side_effects
_set_session(client, user_id="regular-user", is_admin=False)
with patch.object(main_module, "get_auth_mode", return_value="builtin"):
response = client.get(path)
assert response.status_code == 403
assert response.get_json() == {"error": "Admin access required"}
run_mock.assert_not_called()
exit_mock.assert_not_called()
def test_restart_allows_admin_users(client, main_module, no_side_effects):
_, exit_mock = no_side_effects
_set_session(client, user_id="admin-user", is_admin=True)
with patch.object(main_module, "get_auth_mode", return_value="builtin"):
client.get("/api/restart")
exit_mock.assert_called_once_with(0)
def test_restart_allows_access_when_auth_is_disabled(client, main_module, no_side_effects):
_, exit_mock = no_side_effects
with patch.object(main_module, "get_auth_mode", return_value="none"):
client.get("/api/restart")
exit_mock.assert_called_once_with(0)
def test_debug_allows_admin_users(client, main_module, no_side_effects, tmp_path):
run_mock, _ = no_side_effects
bundle = tmp_path / "shelfmark-debug.zip"
bundle.write_bytes(b"PK\x05\x06" + b"\x00" * 18)
run_mock.return_value.returncode = 0
run_mock.return_value.stdout = f"{bundle}\n"
_set_session(client, user_id="admin-user", is_admin=True)
with (
patch.object(main_module, "get_auth_mode", return_value="builtin"),
patch.object(main_module.time, "sleep"),
):
response = client.get("/api/debug")
assert response.status_code == 200
assert response.mimetype == "application/zip"
run_mock.assert_called_once()