Files
shelfmark/tests/core/test_download_api_guardrails.py
T
Alex b7bee132a1 Requests: Various fixes and improvements (#617)
- Refactored activity backend for full user-level management, using the
db file
- Revamped the activity sidebar UX and categorisation
- Added download history and user filtering
- Added User Preferences modal, giving limited configuration for
non-admins - replaces the "restrict settings" config option.
- Many many bug fixes
- Many many new tests
2026-02-14 18:24:28 +00:00

433 lines
16 KiB
Python

"""Baseline guardrail tests for download API endpoints.
These tests lock current behavior for `/api/download`, `/api/releases/download`,
and `/api/status` so policy work in later phases cannot accidentally change
existing contracts.
"""
from __future__ import annotations
import importlib
import uuid
from unittest.mock import patch
import pytest
from shelfmark.core.models import DownloadTask
@pytest.fixture(scope="module")
def main_module():
"""Import `shelfmark.main` with background startup disabled."""
with patch("shelfmark.download.orchestrator.start"):
import shelfmark.main as main
importlib.reload(main)
return main
@pytest.fixture
def client(main_module):
return main_module.app.test_client()
def _set_authenticated_session(
client,
*,
user_id: str = "alice",
db_user_id: int | None = 7,
is_admin: bool = False,
) -> None:
with client.session_transaction() as sess:
sess["user_id"] = user_id
sess["is_admin"] = is_admin
if db_user_id is not None:
sess["db_user_id"] = db_user_id
def _create_user(main_module, *, prefix: str, role: str = "user") -> dict:
username = f"{prefix}-{uuid.uuid4().hex[:8]}"
return main_module.user_db.create_user(username=username, role=role)
class TestDownloadEndpointGuardrails:
def test_missing_book_id_returns_400_and_does_not_queue(self, main_module, client):
with patch.object(main_module, "get_auth_mode", return_value="none"):
with patch.object(main_module.backend, "queue_book") as mock_queue_book:
resp = client.get("/api/download")
assert resp.status_code == 400
assert resp.get_json() == {"error": "No book ID provided"}
mock_queue_book.assert_not_called()
def test_success_returns_queued_payload_and_forwards_user_context(self, main_module, client):
captured: dict[str, object] = {}
def fake_queue_book(book_id, priority, user_id=None, username=None):
captured.update(
{
"book_id": book_id,
"priority": priority,
"user_id": user_id,
"username": username,
}
)
return True, None
_set_authenticated_session(
client,
user_id="alice",
db_user_id=42,
is_admin=False,
)
with patch.object(main_module, "get_auth_mode", return_value="builtin"):
with patch.object(main_module.backend, "queue_book", side_effect=fake_queue_book):
resp = client.get("/api/download?id=book-123&priority=5")
assert resp.status_code == 200
assert resp.get_json() == {"status": "queued", "priority": 5}
assert captured == {
"book_id": "book-123",
"priority": 5,
"user_id": 42,
"username": "alice",
}
def test_malformed_priority_returns_500_current_behavior(self, main_module, client):
with patch.object(main_module, "get_auth_mode", return_value="none"):
with patch.object(main_module.backend, "queue_book") as mock_queue_book:
resp = client.get("/api/download?id=book-123&priority=high")
body = resp.get_json()
assert resp.status_code == 500
assert "invalid literal for int()" in body["error"]
mock_queue_book.assert_not_called()
def test_auth_enabled_without_session_returns_401(self, main_module, client):
with patch.object(main_module, "get_auth_mode", return_value="builtin"):
resp = client.get("/api/download?id=book-123")
assert resp.status_code == 401
assert resp.get_json() == {"error": "Unauthorized"}
class TestReleaseDownloadEndpointGuardrails:
def test_empty_json_payload_returns_400(self, main_module, client):
with patch.object(main_module, "get_auth_mode", return_value="none"):
with patch.object(main_module.backend, "queue_release") as mock_queue_release:
resp = client.post("/api/releases/download", json={})
assert resp.status_code == 400
assert resp.get_json() == {"error": "No data provided"}
mock_queue_release.assert_not_called()
def test_missing_source_id_returns_400(self, main_module, client):
payload = {
"source": "direct_download",
"title": "Example",
}
with patch.object(main_module, "get_auth_mode", return_value="none"):
with patch.object(main_module.backend, "queue_release") as mock_queue_release:
resp = client.post("/api/releases/download", json=payload)
assert resp.status_code == 400
assert resp.get_json() == {"error": "source_id is required"}
mock_queue_release.assert_not_called()
def test_success_returns_queued_payload_and_forwards_user_context(self, main_module, client):
captured: dict[str, object] = {}
def fake_queue_release(release_data, priority, user_id=None, username=None):
captured.update(
{
"release_data": release_data,
"priority": priority,
"user_id": user_id,
"username": username,
}
)
return True, None
_set_authenticated_session(
client,
user_id="bob",
db_user_id=19,
is_admin=False,
)
payload = {
"source": "direct_download",
"source_id": "release-xyz",
"title": "Release Title",
"priority": 3,
}
with patch.object(main_module, "get_auth_mode", return_value="builtin"):
with patch.object(main_module.backend, "queue_release", side_effect=fake_queue_release):
resp = client.post("/api/releases/download", json=payload)
assert resp.status_code == 200
assert resp.get_json() == {"status": "queued", "priority": 3}
assert captured["release_data"] == {**payload, "content_type": "ebook"}
assert captured["priority"] == 3
assert captured["user_id"] == 19
assert captured["username"] == "bob"
def test_missing_content_type_infers_audiobook_from_format(self, main_module, client):
captured: dict[str, object] = {}
def fake_queue_release(release_data, priority, user_id=None, username=None):
captured.update(
{
"release_data": release_data,
"priority": priority,
"user_id": user_id,
"username": username,
}
)
return True, None
payload = {
"source": "prowlarr",
"source_id": "release-audio",
"title": "Audio Title [m4b]",
"format": "m4b",
"priority": 1,
}
with patch.object(main_module, "get_auth_mode", return_value="none"):
with patch.object(main_module.backend, "queue_release", side_effect=fake_queue_release):
resp = client.post("/api/releases/download", json=payload)
assert resp.status_code == 200
assert resp.get_json() == {"status": "queued", "priority": 1}
assert captured["release_data"] == {**payload, "content_type": "audiobook"}
assert captured["priority"] == 1
def test_non_json_payload_returns_500_current_behavior(self, main_module, client):
with patch.object(main_module, "get_auth_mode", return_value="none"):
with patch.object(main_module.backend, "queue_release") as mock_queue_release:
resp = client.post(
"/api/releases/download",
data="not-json",
content_type="text/plain",
)
body = resp.get_json()
assert resp.status_code == 500
assert "Unsupported Media Type" in body["error"]
mock_queue_release.assert_not_called()
class TestCancelDownloadEndpointGuardrails:
def test_owner_can_cancel_direct_download(self, main_module, client):
user = _create_user(main_module, prefix="reader")
_set_authenticated_session(
client,
user_id=user["username"],
db_user_id=user["id"],
is_admin=False,
)
task = DownloadTask(
task_id="direct-task-1",
source="direct_download",
title="Direct Task",
user_id=user["id"],
username=user["username"],
)
with patch.object(main_module, "get_auth_mode", return_value="builtin"):
with patch.object(main_module.backend.book_queue, "get_task", return_value=task):
with patch.object(main_module.backend, "cancel_download", return_value=True) as mock_cancel:
resp = client.delete("/api/download/direct-task-1/cancel")
assert resp.status_code == 200
assert resp.get_json() == {"status": "cancelled", "book_id": "direct-task-1"}
mock_cancel.assert_called_once_with("direct-task-1")
def test_non_owner_cannot_cancel_download(self, main_module, client):
owner = _create_user(main_module, prefix="owner")
actor = _create_user(main_module, prefix="actor")
_set_authenticated_session(
client,
user_id=actor["username"],
db_user_id=actor["id"],
is_admin=False,
)
task = DownloadTask(
task_id="owned-task-1",
source="direct_download",
title="Owned Task",
user_id=owner["id"],
username=owner["username"],
)
with patch.object(main_module, "get_auth_mode", return_value="builtin"):
with patch.object(main_module.backend.book_queue, "get_task", return_value=task):
with patch.object(main_module.backend, "cancel_download", return_value=True) as mock_cancel:
resp = client.delete("/api/download/owned-task-1/cancel")
assert resp.status_code == 403
assert resp.get_json()["code"] == "download_not_owned"
mock_cancel.assert_not_called()
def test_owner_cannot_cancel_graduated_request_download(self, main_module, client):
user = _create_user(main_module, prefix="requester")
_set_authenticated_session(
client,
user_id=user["username"],
db_user_id=user["id"],
is_admin=False,
)
main_module.user_db.create_request(
user_id=user["id"],
content_type="ebook",
request_level="release",
policy_mode="request_release",
book_data={
"title": "Requested Book",
"author": "Request Author",
"provider": "openlibrary",
"provider_id": "req-guard-1",
},
release_data={
"source": "prowlarr",
"source_id": "requested-task-1",
"title": "Requested Book.epub",
},
status="fulfilled",
delivery_state="queued",
)
task = DownloadTask(
task_id="requested-task-1",
source="prowlarr",
title="Requested Book",
user_id=user["id"],
username=user["username"],
)
with patch.object(main_module, "get_auth_mode", return_value="builtin"):
with patch.object(main_module.backend.book_queue, "get_task", return_value=task):
with patch.object(main_module.backend, "cancel_download", return_value=True) as mock_cancel:
resp = client.delete("/api/download/requested-task-1/cancel")
assert resp.status_code == 403
assert resp.get_json()["code"] == "requested_download_cancel_forbidden"
mock_cancel.assert_not_called()
def test_admin_can_cancel_graduated_request_download(self, main_module, client):
admin = _create_user(main_module, prefix="admin", role="admin")
requester = _create_user(main_module, prefix="requester")
_set_authenticated_session(
client,
user_id=admin["username"],
db_user_id=admin["id"],
is_admin=True,
)
main_module.user_db.create_request(
user_id=requester["id"],
content_type="ebook",
request_level="release",
policy_mode="request_release",
book_data={
"title": "Admin Requested Book",
"author": "Admin Request Author",
"provider": "openlibrary",
"provider_id": "req-guard-2",
},
release_data={
"source": "prowlarr",
"source_id": "requested-task-2",
"title": "Admin Requested Book.epub",
},
status="fulfilled",
delivery_state="queued",
)
task = DownloadTask(
task_id="requested-task-2",
source="prowlarr",
title="Admin Requested Book",
user_id=requester["id"],
username=requester["username"],
)
with patch.object(main_module, "get_auth_mode", return_value="builtin"):
with patch.object(main_module.backend.book_queue, "get_task", return_value=task):
with patch.object(main_module.backend, "cancel_download", return_value=True) as mock_cancel:
resp = client.delete("/api/download/requested-task-2/cancel")
assert resp.status_code == 200
assert resp.get_json() == {"status": "cancelled", "book_id": "requested-task-2"}
mock_cancel.assert_called_once_with("requested-task-2")
class TestStatusEndpointGuardrails:
def test_no_auth_allows_without_session_and_returns_status(self, main_module, client):
observed: dict[str, object] = {}
expected_status = {
"queued": {"book-1": {"title": "One"}},
"downloading": {},
"completed": {},
"failed": {},
"cancelled": {},
}
def fake_queue_status(user_id=None):
observed["user_id"] = user_id
return expected_status
with patch.object(main_module, "get_auth_mode", return_value="none"):
with patch.object(main_module.backend, "queue_status", side_effect=fake_queue_status):
resp = client.get("/api/status")
assert resp.status_code == 200
assert resp.get_json() == expected_status
assert observed["user_id"] is None
def test_auth_enabled_without_session_returns_401(self, main_module, client):
with patch.object(main_module, "get_auth_mode", return_value="builtin"):
resp = client.get("/api/status")
assert resp.status_code == 401
assert resp.get_json() == {"error": "Unauthorized"}
def test_non_admin_status_is_scoped_to_db_user(self, main_module, client):
observed: dict[str, object] = {}
def fake_queue_status(user_id=None):
observed["user_id"] = user_id
return {"queued": {}, "downloading": {}, "completed": {}, "failed": {}, "cancelled": {}}
_set_authenticated_session(
client,
user_id="reader",
db_user_id=55,
is_admin=False,
)
with patch.object(main_module, "get_auth_mode", return_value="builtin"):
with patch.object(main_module.backend, "queue_status", side_effect=fake_queue_status):
resp = client.get("/api/status")
assert resp.status_code == 200
assert observed["user_id"] == 55
def test_admin_status_is_unscoped(self, main_module, client):
observed: dict[str, object] = {}
def fake_queue_status(user_id=None):
observed["user_id"] = user_id
return {"queued": {}, "downloading": {}, "completed": {}, "failed": {}, "cancelled": {}}
_set_authenticated_session(
client,
user_id="admin",
db_user_id=1,
is_admin=True,
)
with patch.object(main_module, "get_auth_mode", return_value="builtin"):
with patch.object(main_module.backend, "queue_status", side_effect=fake_queue_status):
resp = client.get("/api/status")
assert resp.status_code == 200
assert observed["user_id"] is None