diff --git a/shelfmark/core/system_routes.py b/shelfmark/core/system_routes.py new file mode 100644 index 00000000..cb212498 --- /dev/null +++ b/shelfmark/core/system_routes.py @@ -0,0 +1,330 @@ +"""System routes: the config and health endpoints, the debug tools, the frontend files and the SPA fallback.""" + +from __future__ import annotations + +import os +import sqlite3 +import subprocess +from functools import wraps +from pathlib import Path +from typing import TYPE_CHECKING, Any, NamedTuple, NoReturn + +from flask import jsonify, send_file, send_from_directory, session +from werkzeug.wrappers import Response + +from shelfmark.config.env import BUILD_VERSION, RELEASE_VERSION +from shelfmark.config.settings import _SUPPORTED_BOOK_LANGUAGE +from shelfmark.core import search_deadline +from shelfmark.core.config import config as app_config +from shelfmark.core.logger import setup_logger +from shelfmark.core.request_helpers import normalize_optional_text +from shelfmark.download import orchestrator as backend + +if TYPE_CHECKING: + from collections.abc import Callable + + from flask import Flask + +logger = setup_logger(__name__) + +_OPERATIONAL_ERRORS = (OSError, RuntimeError, TypeError, ValueError, sqlite3.Error) +_IMPORT_OPERATIONAL_ERRORS = (ImportError, *_OPERATIONAL_ERRORS) + +_BASE_TAG = '' + + +def _raise_runtime_error(message: str) -> NoReturn: + raise RuntimeError(message) + + +class DebugHooks(NamedTuple): + """What the DEBUG-only routes need from the app: stop the GUI, then run the debug script. + + ``run_script`` waits a second for the GUI to stop before it starts the script. + """ + + stop_gui: Callable[[], None] + run_script: Callable[[], subprocess.CompletedProcess[str]] + + +def register_system_routes( + app: Flask, + login_required: Callable[..., Callable[..., Response | tuple[Response, int]]], + *, + resolve_auth_mode: Callable[[], str], + resolve_db_user_id: Callable[[], int | None], + get_base_path: Callable[[], str], + get_frontend_dist: Callable[[], Path], + debug_hooks: DebugHooks | None, +) -> None: + """Register the system routes, the frontend files and the catch-all for client-side routes. + + ``login_required`` is the app's decorator. ``resolve_auth_mode``, ``resolve_db_user_id``, + ``get_base_path`` and ``get_frontend_dist`` are called per request so a runtime patch of any of them still takes + effect. ``/api/debug`` and ``/api/restart`` are registered only when ``debug_hooks`` is given. + """ + + def admin_required( + f: Callable[..., Response | tuple[Response, int]], + ) -> Callable[..., Response | tuple[Response, int]]: + """Require an authenticated admin session for a Flask route. + + Builds on `login_required`, so auth mode "none" (no users, the single + operator owns the instance) still has access. + """ + + @login_required + @wraps(f) + def decorated_function(*args: object, **kwargs: object) -> Response | tuple[Response, int]: + if resolve_auth_mode() != "none" and not session.get("is_admin", False): + return jsonify({"error": "Admin access required"}), 403 + return f(*args, **kwargs) + + return decorated_function + + def _base_href() -> str: + base_path = get_base_path() + if not base_path: + return "/" + return f"{base_path}/" + + def _serve_index_html() -> Response: + """Serve index.html with an adjusted base tag for subpath deployments.""" + frontend_dist = get_frontend_dist() + index_path = frontend_dist / "index.html" + try: + with index_path.open(encoding="utf-8") as handle: + html = handle.read() + except OSError: + return send_from_directory(frontend_dist, "index.html") + + if get_base_path() and _BASE_TAG in html: + html = html.replace(_BASE_TAG, f'', 1) + + return Response(html, mimetype="text/html") + + # Serve frontend static files + @app.route("/assets/") + def serve_frontend_assets(filename: str) -> Response: + """Serve static assets from the built frontend.""" + return send_from_directory(get_frontend_dist() / "assets", filename) + + @app.route("/") + def index() -> Response: + """Serve the React frontend application. + + Authentication is handled by the React app itself. + """ + return _serve_index_html() + + @app.route("/theme-init.js") + def theme_init_js() -> Response: + """Serve the blocking theme-init script.""" + return send_from_directory( + get_frontend_dist(), "theme-init.js", mimetype="application/javascript" + ) + + @app.route("/logo.png") + def logo() -> Response: + """Serve logo from built frontend assets.""" + return send_from_directory(get_frontend_dist(), "logo.png", mimetype="image/png") + + @app.route("/favicon.ico") + @app.route("/favico") + def favicon(_: Any = None) -> Response: + """Serve favicon from built frontend assets.""" + return send_from_directory( + get_frontend_dist(), "favicon.ico", mimetype="image/vnd.microsoft.icon" + ) + + @app.route("/api/config", methods=["GET"]) + @login_required + def api_config() -> Response | tuple[Response, int]: + """Get application configuration for frontend. + + Uses the dynamic config singleton to ensure settings changes + are reflected without requiring a container restart. + """ + try: + from shelfmark.config.env import _is_config_dir_writable + from shelfmark.core.onboarding import is_onboarding_complete as _get_onboarding_complete + from shelfmark.metadata_providers import ( + get_provider_default_sort, + get_provider_search_fields, + get_provider_sort_options, + ) + + db_user_id = resolve_db_user_id() + + search_mode = app_config.get("SEARCH_MODE", "universal", user_id=db_user_id) + default_release_source = app_config.get( + "DEFAULT_RELEASE_SOURCE", + "", + user_id=db_user_id, + ) + default_release_source_audiobook = app_config.get( + "DEFAULT_RELEASE_SOURCE_AUDIOBOOK", + "", + user_id=db_user_id, + ) + configured_metadata_provider = normalize_optional_text( + app_config.get( + "METADATA_PROVIDER", + "", + user_id=db_user_id, + ) + ) + _configured_metadata_provider_audiobook = normalize_optional_text( + app_config.get( + "METADATA_PROVIDER_AUDIOBOOK", + "", + user_id=db_user_id, + ) + ) + metadata_ui_provider = ( + configured_metadata_provider or _configured_metadata_provider_audiobook + ) + + config = { + "calibre_web_url": app_config.get("CALIBRE_WEB_URL", ""), + "audiobook_library_url": app_config.get("AUDIOBOOK_LIBRARY_URL", ""), + "search_page_title": app_config.get("SEARCH_PAGE_TITLE", "Shelfmark"), + "debug": app_config.get("DEBUG", False), + "build_version": BUILD_VERSION, + "release_version": RELEASE_VERSION, + "book_languages": _SUPPORTED_BOOK_LANGUAGE, + "default_language": app_config.get("BOOK_LANGUAGE", ["en"], user_id=db_user_id), + "default_content_type": app_config.get( + "DEFAULT_CONTENT_TYPE", "ebook", user_id=db_user_id + ), + "supported_formats": app_config.SUPPORTED_FORMATS, + "supported_audiobook_formats": app_config.SUPPORTED_AUDIOBOOK_FORMATS, + "search_mode": search_mode, + "metadata_sort_options": get_provider_sort_options(metadata_ui_provider), + "metadata_search_fields": get_provider_search_fields(metadata_ui_provider), + "default_release_source": default_release_source, + "default_release_source_audiobook": default_release_source_audiobook, + "show_release_source_links": app_config.get("SHOW_RELEASE_SOURCE_LINKS", True), + "show_combined_selector": app_config.get( + "SHOW_COMBINED_SELECTOR", True, user_id=db_user_id + ), + "force_combined_search": app_config.get( + "FORCE_COMBINED_SEARCH", False, user_id=db_user_id + ), + "books_output_mode": app_config.get("BOOKS_OUTPUT_MODE", "folder"), + "auto_open_downloads_sidebar": app_config.get("AUTO_OPEN_DOWNLOADS_SIDEBAR", True), + "hardcover_auto_remove_on_download": app_config.get( + "HARDCOVER_AUTO_REMOVE_ON_DOWNLOAD", True + ), + "download_to_browser_content_types": app_config.get( + "DOWNLOAD_TO_BROWSER_CONTENT_TYPES", + [], + user_id=db_user_id, + ), + # The client must not give up before this budget does. `/api/releases` + # answers a spent budget with a message naming the real cause (a protection + # challenge nobody could solve); a browser that aborted first replaces it + # with a generic network/proxy error and RELEASE_SEARCH_TIMEOUT becomes a + # setting the user can raise with no visible effect. See issue #1285. + "release_search_timeout": search_deadline.budget_seconds(), + "settings_enabled": _is_config_dir_writable(), + "onboarding_complete": _get_onboarding_complete(), + # Default sort orders + "default_sort": app_config.get( + "AA_DEFAULT_SORT", "" + ), # For direct mode (Anna's Archive) — empty means use local downloads sort + "metadata_default_sort": get_provider_default_sort( + metadata_ui_provider + ), # For universal mode + } + return jsonify(config) + except _IMPORT_OPERATIONAL_ERRORS as e: + logger.error_trace(f"Config error: {e}") + return jsonify({"error": "Could not load the configuration"}), 500 + + @app.route("/api/health", methods=["GET"]) + def api_health() -> Response | tuple[Response, int]: + """Health check endpoint for container orchestration. + + No authentication required. + + Returns: + flask.Response: JSON with status "ok" and optional degraded features. Answers 503 with + status "error" when the download coordinator is not running, since nothing is + downloaded without it. + + """ + response: dict[str, object] = {"status": "ok"} + + # Report degraded features + if not backend.WEBSOCKET_AVAILABLE: + response["degraded"] = { + "websocket": "WebSocket unavailable - real-time updates disabled" + } + + coordinator_up = backend.coordinator_running() + response["coordinator"] = "running" if coordinator_up else "stopped" + if not coordinator_up: + response["status"] = "error" + return jsonify(response), 503 + + return jsonify(response) + + if debug_hooks is not None: + + @app.route("/api/debug", methods=["GET"]) + @admin_required + def debug() -> Response | tuple[Response, int]: + """Run `/app/genDebug.sh`, generate a debug zip, and return it. + + The file is written to `/tmp/shelfmark-debug.zip` before being returned. + """ + try: + logger.info("Debug endpoint called, stopping GUI and generating debug info...") + debug_hooks.stop_gui() + result = debug_hooks.run_script() + if result.returncode != 0: + _raise_runtime_error(f"Debug script failed: {result.stderr}") + logger.info("Debug script executed: %s", result.stdout) + debug_file_path = result.stdout.strip().split("\n")[-1] + if not Path(debug_file_path).exists(): + logger.error("Debug zip file not found at: %s", debug_file_path) + return jsonify({"error": "Failed to generate debug information"}), 500 + + logger.info("Sending debug file: %s", debug_file_path) + return send_file( + debug_file_path, + mimetype="application/zip", + download_name=Path(debug_file_path).name, + as_attachment=True, + ) + except subprocess.CalledProcessError as e: + logger.error_trace( + f"Debug script error: {e}, stdout: {e.stdout}, stderr: {e.stderr}" + ) + return jsonify({"error": f"Debug script failed: {e.stderr}"}), 500 + except _OPERATIONAL_ERRORS as e: + logger.error_trace(f"Debug endpoint error: {e}") + return jsonify({"error": str(e)}), 500 + + @app.route("/api/restart", methods=["GET"]) + @admin_required + def restart() -> Response | tuple[Response, int]: + """Restart the application.""" + os._exit(0) + + # Catch-all route for React Router. Werkzeug tries static rules before dynamic ones, so it + # does not have to be registered last. + # This handles client-side routing by serving index.html for any unmatched routes + @app.route("/") + def catch_all(path: str) -> Response | tuple[Response, int]: + """Serve the React app for any route not matched by API endpoints. + + This allows React Router to handle client-side routing. + Authentication is handled by the React app itself. + """ + # If the request is for an API endpoint or static file, let it 404 + if path.startswith(("api/", "assets/")): + return jsonify({"error": "Resource not found"}), 404 + # Otherwise serve the React app + return _serve_index_html() diff --git a/shelfmark/main.py b/shelfmark/main.py index eef7fa3d..583f0539 100644 --- a/shelfmark/main.py +++ b/shelfmark/main.py @@ -14,7 +14,7 @@ from pathlib import Path from typing import TYPE_CHECKING, Any, Callable, NoReturn, cast from urllib.parse import quote -from flask import Flask, g, jsonify, request, send_file, send_from_directory, session +from flask import Flask, g, jsonify, request, send_file, session from flask_cors import CORS from flask_socketio import SocketIO, emit from werkzeug.middleware.proxy_fix import ProxyFix @@ -23,7 +23,6 @@ from werkzeug.wrappers import Response from shelfmark.api.websocket import ws_manager from shelfmark.config.env import ( - BUILD_VERSION, CONFIG_DIR, CWA_DB_PATH, DISABLE_LOCAL_AUTH, @@ -31,7 +30,6 @@ from shelfmark.config.env import ( FLASK_PORT, HIDE_LOCAL_AUTH, OIDC_AUTO_REDIRECT, - RELEASE_VERSION, SESSION_COOKIE_NAME, SESSION_COOKIE_SECURE_ENV, SOCKETIO_ALLOWED_ORIGINS, @@ -40,7 +38,6 @@ from shelfmark.config.env import ( ) from shelfmark.config.security import _migrate_security_settings from shelfmark.config.settings import ( - _SUPPORTED_BOOK_LANGUAGE, migrate_audiobook_format_settings, ) from shelfmark.core import api_key as api_key_module # module access lets tests monkeypatch the key @@ -97,6 +94,7 @@ from shelfmark.core.requests_service import ( from shelfmark.core.settings_routes import register_settings_routes from shelfmark.core.socketio_origins import build_cors_allowed_origins from shelfmark.core.stats_routes import STATS_PATH, register_stats_routes +from shelfmark.core.system_routes import DebugHooks, register_system_routes from shelfmark.core.user_db import UserDB from shelfmark.core.utils import AUDIOBOOK_FORMATS, normalize_base_path from shelfmark.core.viewer_redaction import ( @@ -1102,84 +1100,7 @@ def login_required( return decorated_function -def admin_required( - f: Callable[..., Response | tuple[Response, int]], -) -> Callable[..., Response | tuple[Response, int]]: - """Require an authenticated admin session for a Flask route. - - Builds on `login_required`, so auth mode "none" (no users, the single - operator owns the instance) still has access. - """ - - @login_required - @wraps(f) - def decorated_function(*args: object, **kwargs: object) -> Response | tuple[Response, int]: - if get_auth_mode() != "none" and not session.get("is_admin", False): - return jsonify({"error": "Admin access required"}), 403 - return f(*args, **kwargs) - - return decorated_function - - -_BASE_TAG = '' - - -def _base_href() -> str: - if not BASE_PATH: - return "/" - return f"{BASE_PATH}/" - - -def _serve_index_html() -> Response: - """Serve index.html with an adjusted base tag for subpath deployments.""" - index_path = FRONTEND_DIST / "index.html" - try: - with index_path.open(encoding="utf-8") as handle: - html = handle.read() - except OSError: - return send_from_directory(FRONTEND_DIST, "index.html") - - if BASE_PATH and _BASE_TAG in html: - html = html.replace(_BASE_TAG, f'', 1) - - return Response(html, mimetype="text/html") - - -# Serve frontend static files -@app.route("/assets/") -def serve_frontend_assets(filename: str) -> Response: - """Serve static assets from the built frontend.""" - return send_from_directory(FRONTEND_DIST / "assets", filename) - - -@app.route("/") -def index() -> Response: - """Serve the React frontend application. - - Authentication is handled by the React app itself. - """ - return _serve_index_html() - - -@app.route("/theme-init.js") -def theme_init_js() -> Response: - """Serve the blocking theme-init script.""" - return send_from_directory(FRONTEND_DIST, "theme-init.js", mimetype="application/javascript") - - -@app.route("/logo.png") -def logo() -> Response: - """Serve logo from built frontend assets.""" - return send_from_directory(FRONTEND_DIST, "logo.png", mimetype="image/png") - - -@app.route("/favicon.ico") -@app.route("/favico") -def favicon(_: Any = None) -> Response: - """Serve favicon from built frontend assets.""" - return send_from_directory(FRONTEND_DIST, "favicon.ico", mimetype="image/vnd.microsoft.icon") - - +_debug_hooks: DebugHooks | None = None if _is_debug_enabled(): import subprocess @@ -1194,47 +1115,21 @@ if _is_debug_enabled(): def _stop_gui() -> None: _cleanup_orphan_processes() - @app.route("/api/debug", methods=["GET"]) - @admin_required - def debug() -> Response | tuple[Response, int]: - """Run `/app/genDebug.sh`, generate a debug zip, and return it. + def _run_debug_script() -> subprocess.CompletedProcess[str]: + time.sleep(1) + return subprocess.run(["/app/genDebug.sh"], capture_output=True, text=True, check=True) - The file is written to `/tmp/shelfmark-debug.zip` before being returned. - """ - try: - logger.info("Debug endpoint called, stopping GUI and generating debug info...") - _stop_gui() - time.sleep(1) - result = subprocess.run( - ["/app/genDebug.sh"], capture_output=True, text=True, check=True - ) - if result.returncode != 0: - _raise_runtime_error(f"Debug script failed: {result.stderr}") - logger.info("Debug script executed: %s", result.stdout) - debug_file_path = result.stdout.strip().split("\n")[-1] - if not Path(debug_file_path).exists(): - logger.error("Debug zip file not found at: %s", debug_file_path) - return jsonify({"error": "Failed to generate debug information"}), 500 + _debug_hooks = DebugHooks(stop_gui=_stop_gui, run_script=_run_debug_script) - logger.info("Sending debug file: %s", debug_file_path) - return send_file( - debug_file_path, - mimetype="application/zip", - download_name=Path(debug_file_path).name, - as_attachment=True, - ) - except subprocess.CalledProcessError as e: - logger.error_trace(f"Debug script error: {e}, stdout: {e.stdout}, stderr: {e.stderr}") - return jsonify({"error": f"Debug script failed: {e.stderr}"}), 500 - except _OPERATIONAL_ERRORS as e: - logger.error_trace(f"Debug endpoint error: {e}") - return jsonify({"error": str(e)}), 500 - - @app.route("/api/restart", methods=["GET"]) - @admin_required - def restart() -> Response | tuple[Response, int]: - """Restart the application.""" - os._exit(0) +register_system_routes( + app, + login_required, + resolve_auth_mode=_resolve_auth_mode_for_routes, + resolve_db_user_id=lambda: get_session_db_user_id(session), + get_base_path=lambda: BASE_PATH, + get_frontend_dist=lambda: FRONTEND_DIST, + debug_hooks=_debug_hooks, +) def _parse_search_filters_from_request() -> SearchFilters: @@ -1388,112 +1283,6 @@ def api_download_release() -> Response | tuple[Response, int]: return jsonify({"error": str(e)}), 500 -@app.route("/api/config", methods=["GET"]) -@login_required -def api_config() -> Response | tuple[Response, int]: - """Get application configuration for frontend. - - Uses the dynamic config singleton to ensure settings changes - are reflected without requiring a container restart. - """ - try: - from shelfmark.config.env import _is_config_dir_writable - from shelfmark.core.onboarding import is_onboarding_complete as _get_onboarding_complete - from shelfmark.metadata_providers import ( - get_provider_default_sort, - get_provider_search_fields, - get_provider_sort_options, - ) - - db_user_id = get_session_db_user_id(session) - - search_mode = app_config.get("SEARCH_MODE", "universal", user_id=db_user_id) - default_release_source = app_config.get( - "DEFAULT_RELEASE_SOURCE", - "", - user_id=db_user_id, - ) - default_release_source_audiobook = app_config.get( - "DEFAULT_RELEASE_SOURCE_AUDIOBOOK", - "", - user_id=db_user_id, - ) - configured_metadata_provider = normalize_optional_text( - app_config.get( - "METADATA_PROVIDER", - "", - user_id=db_user_id, - ) - ) - _configured_metadata_provider_audiobook = normalize_optional_text( - app_config.get( - "METADATA_PROVIDER_AUDIOBOOK", - "", - user_id=db_user_id, - ) - ) - metadata_ui_provider = ( - configured_metadata_provider or _configured_metadata_provider_audiobook - ) - - config = { - "calibre_web_url": app_config.get("CALIBRE_WEB_URL", ""), - "audiobook_library_url": app_config.get("AUDIOBOOK_LIBRARY_URL", ""), - "search_page_title": app_config.get("SEARCH_PAGE_TITLE", "Shelfmark"), - "debug": app_config.get("DEBUG", False), - "build_version": BUILD_VERSION, - "release_version": RELEASE_VERSION, - "book_languages": _SUPPORTED_BOOK_LANGUAGE, - "default_language": app_config.get("BOOK_LANGUAGE", ["en"], user_id=db_user_id), - "default_content_type": app_config.get( - "DEFAULT_CONTENT_TYPE", "ebook", user_id=db_user_id - ), - "supported_formats": app_config.SUPPORTED_FORMATS, - "supported_audiobook_formats": app_config.SUPPORTED_AUDIOBOOK_FORMATS, - "search_mode": search_mode, - "metadata_sort_options": get_provider_sort_options(metadata_ui_provider), - "metadata_search_fields": get_provider_search_fields(metadata_ui_provider), - "default_release_source": default_release_source, - "default_release_source_audiobook": default_release_source_audiobook, - "show_release_source_links": app_config.get("SHOW_RELEASE_SOURCE_LINKS", True), - "show_combined_selector": app_config.get( - "SHOW_COMBINED_SELECTOR", True, user_id=db_user_id - ), - "force_combined_search": app_config.get( - "FORCE_COMBINED_SEARCH", False, user_id=db_user_id - ), - "books_output_mode": app_config.get("BOOKS_OUTPUT_MODE", "folder"), - "auto_open_downloads_sidebar": app_config.get("AUTO_OPEN_DOWNLOADS_SIDEBAR", True), - "hardcover_auto_remove_on_download": app_config.get( - "HARDCOVER_AUTO_REMOVE_ON_DOWNLOAD", True - ), - "download_to_browser_content_types": app_config.get( - "DOWNLOAD_TO_BROWSER_CONTENT_TYPES", - [], - user_id=db_user_id, - ), - # The client must not give up before this budget does. `/api/releases` - # answers a spent budget with a message naming the real cause (a protection - # challenge nobody could solve); a browser that aborted first replaces it - # with a generic network/proxy error and RELEASE_SEARCH_TIMEOUT becomes a - # setting the user can raise with no visible effect. See issue #1285. - "release_search_timeout": search_deadline.budget_seconds(), - "settings_enabled": _is_config_dir_writable(), - "onboarding_complete": _get_onboarding_complete(), - # Default sort orders - "default_sort": app_config.get( - "AA_DEFAULT_SORT", "" - ), # For direct mode (Anna's Archive) — empty means use local downloads sort - "metadata_default_sort": get_provider_default_sort( - metadata_ui_provider - ), # For universal mode - } - return jsonify(config) - except _IMPORT_OPERATIONAL_ERRORS as e: - logger.error_trace(f"Config error: {e}") - return jsonify({"error": "Could not load the configuration"}), 500 - - register_stats_routes( app, get_user_db=lambda: user_db, @@ -1501,33 +1290,6 @@ register_stats_routes( ) -@app.route("/api/health", methods=["GET"]) -def api_health() -> Response | tuple[Response, int]: - """Health check endpoint for container orchestration. - - No authentication required. - - Returns: - flask.Response: JSON with status "ok" and optional degraded features. Answers 503 with - status "error" when the download coordinator is not running, since nothing is - downloaded without it. - - """ - response: dict[str, object] = {"status": "ok"} - - # Report degraded features - if not backend.WEBSOCKET_AVAILABLE: - response["degraded"] = {"websocket": "WebSocket unavailable - real-time updates disabled"} - - coordinator_up = backend.coordinator_running() - response["coordinator"] = "running" if coordinator_up else "stopped" - if not coordinator_up: - response["status"] = "error" - return jsonify(response), 503 - - return jsonify(response) - - def _resolve_status_scope(*, require_authenticated: bool = True) -> tuple[bool, int | None, bool]: """Resolve queue-status visibility from session state. @@ -3080,22 +2842,6 @@ register_settings_routes(app, login_required) register_onboarding_routes(app, login_required) -# Catch-all route for React Router (must be last) -# This handles client-side routing by serving index.html for any unmatched routes -@app.route("/") -def catch_all(path: str) -> Response | tuple[Response, int]: - """Serve the React app for any route not matched by API endpoints. - - This allows React Router to handle client-side routing. - Authentication is handled by the React app itself. - """ - # If the request is for an API endpoint or static file, let it 404 - if path.startswith(("api/", "assets/")): - return jsonify({"error": "Resource not found"}), 404 - # Otherwise serve the React app - return _serve_index_html() - - def _get_request_sid() -> str | None: """Return the Socket.IO session id for the active request when available.""" sid = getattr(request, "sid", None) diff --git a/tests/core/test_system_routes.py b/tests/core/test_system_routes.py new file mode 100644 index 00000000..352a181c --- /dev/null +++ b/tests/core/test_system_routes.py @@ -0,0 +1,120 @@ +"""Tests for the frontend-serving routes and the gaps in /api/config coverage.""" + +from __future__ import annotations + +import importlib +from pathlib import Path +from unittest.mock import patch + +import pytest + +_INDEX = 'app' + + +@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() + + +@pytest.fixture +def dist(main_module, tmp_path: Path): + """A throwaway frontend build in place of the real `frontend-dist`.""" + (tmp_path / "assets").mkdir() + (tmp_path / "assets" / "app.js").write_text("console.log(1)") + (tmp_path / "index.html").write_text(_INDEX) + (tmp_path / "logo.png").write_bytes(b"\x89PNG") + (tmp_path / "favicon.ico").write_bytes(b"ico") + (tmp_path / "theme-init.js").write_text("// theme") + with ( + patch.object(main_module, "FRONTEND_DIST", tmp_path), + patch.object(main_module, "BASE_PATH", ""), + ): + yield tmp_path + + +def test_index_serves_the_app_with_the_default_base_tag(client, dist): + resp = client.get("/") + + assert resp.status_code == 200 + assert resp.mimetype == "text/html" + assert resp.get_data(as_text=True) == _INDEX + + +def test_index_rewrites_the_base_tag_for_a_subpath(main_module, client, dist): + with patch.object(main_module, "BASE_PATH", "/books"): + resp = client.get("/") + + assert '' in resp.get_data(as_text=True) + + +def test_index_without_a_frontend_build_is_a_404(client, dist): + (dist / "index.html").unlink() + + assert client.get("/").status_code == 404 + + +def test_catch_all_serves_the_app_for_client_side_routes(client, dist): + resp = client.get("/settings/general") + + assert resp.status_code == 200 + assert resp.get_data(as_text=True) == _INDEX + + +@pytest.mark.parametrize("path", ["/api/no-such-route", "/api/deeper/no-such-route"]) +def test_catch_all_keeps_unknown_api_paths_a_404(client, dist, path): + resp = client.get(path) + + assert resp.status_code == 404 + assert resp.get_json() == {"error": "Resource not found"} + + +def test_assets_are_served_from_the_build(client, dist): + resp = client.get("/assets/app.js") + + assert resp.status_code == 200 + assert resp.get_data(as_text=True) == "console.log(1)" + + +def test_a_missing_asset_is_a_404_not_the_app(client, dist): + assert client.get("/assets/missing.js").status_code == 404 + + +@pytest.mark.parametrize( + ("path", "mimetype"), + [ + ("/logo.png", "image/png"), + ("/favicon.ico", "image/vnd.microsoft.icon"), + ("/favicon-32.png", "image/vnd.microsoft.icon"), + ("/theme-init.js", "application/javascript"), + ], +) +def test_root_files_are_served_with_their_mimetype(client, dist, path, mimetype): + resp = client.get(path) + + assert resp.status_code == 200 + assert resp.mimetype == mimetype + + +def test_config_requires_a_session_when_auth_is_on(main_module, client): + with patch.object(main_module, "get_auth_mode", return_value="builtin"): + resp = client.get("/api/config") + + assert resp.status_code == 401 + + +def test_config_reports_an_operational_error(client): + with patch("shelfmark.core.onboarding.is_onboarding_complete", side_effect=RuntimeError("x")): + resp = client.get("/api/config") + + assert resp.status_code == 500 + assert resp.get_json() == {"error": "Could not load the configuration"}