mirror of
https://github.com/calibrain/shelfmark.git
synced 2026-09-24 21:20:31 +01:00
- 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
433 lines
16 KiB
Python
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
|