mirror of
https://github.com/calibrain/shelfmark.git
synced 2026-09-24 20:20:20 +01:00
140 lines
4.8 KiB
Python
140 lines
4.8 KiB
Python
"""GET /api/releases answers within a budget instead of outliving the caller.
|
|
|
|
Issue #1276: the endpoint is synchronous and had no deadline, while the bypass path it
|
|
reaches was allowed ~840s per URL. A protection challenge nobody could solve therefore
|
|
ran until the reverse proxy in front of Shelfmark gave up, and the user was shown
|
|
"Server unavailable (504). If using a reverse proxy, check its configuration." - which
|
|
names the wrong thing entirely.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import importlib
|
|
from unittest.mock import patch
|
|
|
|
import pytest
|
|
|
|
from shelfmark.core import search_deadline
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def main_module():
|
|
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 _authenticate(client) -> None:
|
|
with client.session_transaction() as sess:
|
|
sess["user_id"] = "alice"
|
|
sess["is_admin"] = False
|
|
sess["db_user_id"] = 7
|
|
|
|
|
|
def _request(client, main_module, sources, search_impl):
|
|
"""Drive /api/releases with a stubbed source list and search implementation."""
|
|
|
|
class _Source:
|
|
def search(self, book, plan, *, expand_search=False, content_type="ebook"):
|
|
return search_impl(book, plan)
|
|
|
|
def get_column_config(self):
|
|
from shelfmark.release_sources import _default_column_config
|
|
|
|
return _default_column_config()
|
|
|
|
with (
|
|
patch.object(main_module, "get_auth_mode", return_value="none"),
|
|
patch("shelfmark.release_sources.list_available_sources", return_value=sources),
|
|
patch("shelfmark.release_sources.get_source", return_value=_Source()),
|
|
patch("shelfmark.release_sources.source_results_are_releases", return_value=False),
|
|
):
|
|
return client.get(
|
|
"/api/releases",
|
|
query_string={"provider": "manual", "book_id": "abc", "title": "Dune"},
|
|
)
|
|
|
|
|
|
def test_a_search_runs_under_a_budget(client, main_module):
|
|
"""The handler must put a deadline in force for whatever the sources do."""
|
|
_authenticate(client)
|
|
observed: list[float | None] = []
|
|
|
|
def _search(_book, _plan):
|
|
deadline = search_deadline.current()
|
|
observed.append(deadline.budget_seconds if deadline else None)
|
|
return []
|
|
|
|
resp = _request(client, main_module, [{"name": "direct_download", "enabled": True}], _search)
|
|
|
|
assert resp.status_code == 200
|
|
assert observed and observed[0] is not None, "no budget was in force during the search"
|
|
|
|
|
|
def test_the_budget_is_shared_across_sources(client, main_module):
|
|
"""A stuck first source must not spend the whole request on its own."""
|
|
_authenticate(client)
|
|
searched: list[str] = []
|
|
|
|
def _search(book, _plan):
|
|
searched.append(book.title)
|
|
# Whatever the first source did, it ran the clock out.
|
|
deadline = search_deadline.current()
|
|
if deadline is not None:
|
|
deadline.event.set()
|
|
return []
|
|
|
|
sources = [
|
|
{"name": "direct_download", "enabled": True},
|
|
{"name": "prowlarr", "enabled": True},
|
|
]
|
|
resp = _request(client, main_module, sources, _search)
|
|
|
|
assert len(searched) == 1, "the second source should not have been started"
|
|
assert "ran out of time" in resp.get_json()["error"]
|
|
|
|
|
|
def test_the_failure_carries_a_message_the_frontend_will_show(client, main_module):
|
|
"""The whole point of the budget.
|
|
|
|
Shelfmark answers 503 when a search comes back empty with errors, and the frontend
|
|
only substitutes its "Server unavailable ... check your reverse proxy" text when the
|
|
body carries no message of its own. So the budget has to produce a body that names
|
|
the protection challenge - and has to trip before the proxy's own timeout, where
|
|
there would be no body at all.
|
|
"""
|
|
_authenticate(client)
|
|
|
|
def _search(_book, _plan):
|
|
deadline = search_deadline.current()
|
|
if deadline is not None:
|
|
deadline.event.set()
|
|
return []
|
|
|
|
sources = [
|
|
{"name": "direct_download", "enabled": True},
|
|
{"name": "prowlarr", "enabled": True},
|
|
]
|
|
resp = _request(client, main_module, sources, _search)
|
|
|
|
message = resp.get_json()["error"]
|
|
assert "protection challenge" in message
|
|
assert "reverse proxy" not in message
|
|
# The source prefix is stripped by the handler; the sentence must survive intact.
|
|
assert message.startswith("The release search ran out of time")
|
|
|
|
|
|
def test_no_budget_leaks_out_of_the_request(client, main_module):
|
|
"""A queued download later on must not inherit a search's deadline."""
|
|
_authenticate(client)
|
|
_request(client, main_module, [{"name": "direct_download", "enabled": True}], lambda *_: [])
|
|
|
|
assert search_deadline.current() is None
|