Fixes: Env variable config usage, retry availability, Entrypoint permissions (#817)

- Clean up a few uses of config options that may miss the env variable
if this is set
- Add enhanced retry availability utilising the DB to persist download
errors / retries across restarts, request failures, and pass Prowlarr
detail through the download task to maintain retry data.
- Strip back entrypoint permissions for less intensive chown operations.

Fixes #796
This commit is contained in:
Alex
2026-03-29 16:39:40 +01:00
committed by GitHub
parent 678c54cba2
commit 9bfcf828ea
38 changed files with 1366 additions and 293 deletions
+31 -20
View File
@@ -186,34 +186,43 @@ test_write() {
}
make_writable() {
folder=$1
did_full_chown=0
local folder="$1"
local mode="${2:-tree}"
local did_full_chown=0
local is_writable
set +e
test_write $folder
test_write "$folder"
is_writable=$?
set -e
if [ $is_writable -eq 0 ]; then
echo "Folder $folder is writable, no need to change ownership"
else
echo "Folder $folder is not writable, changing ownership"
change_ownership $folder
chmod -R g+r,g+w $folder || echo "Failed to change group permissions for ${folder}, continuing..."
if [ "$mode" = "root" ]; then
echo "Folder $folder is not writable, fixing top-level ownership and permissions"
mkdir -p "$folder"
chown "${RUN_UID}:${RUN_GID}" "$folder" || echo "Failed to change ownership for ${folder}, continuing..."
chmod u+rwx "$folder" || echo "Failed to change owner permissions for ${folder}, continuing..."
else
echo "Folder $folder is not writable, changing ownership"
change_ownership "$folder"
chmod -R g+r,g+w "$folder" || echo "Failed to change group permissions for ${folder}, continuing..."
fi
did_full_chown=1
fi
# Fix any misowned subdirectories/files (e.g., from previous runs as root)
if [ "$did_full_chown" -eq 0 ] && [ -d "$folder" ]; then
if [ "$mode" = "tree" ] && [ "$did_full_chown" -eq 0 ] && [ -d "$folder" ]; then
echo "Checking for misowned files/directories in $folder"
# Stay on the same filesystem to avoid traversing mounted subpaths
# (for example read-only bind mounts under /app in dev setups).
find "$folder" -xdev -mindepth 1 \( ! -user "$RUN_UID" -o ! -group "$RUN_GID" \) \
-exec chown "$RUN_UID:$RUN_GID" {} + 2>/dev/null || true
fi
test_write $folder || echo "Failed to test write to ${folder}, continuing..."
test_write "$folder" || echo "Failed to test write to ${folder}, continuing..."
}
fix_misowned() {
folder=$1
mkdir -p $folder
local folder="$1"
mkdir -p "$folder"
echo "Checking for misowned files/directories in $folder"
# Stay on the same filesystem to avoid traversing mounted subpaths
# (for example read-only bind mounts under /app in dev setups).
@@ -223,8 +232,8 @@ fix_misowned() {
# Ensure proper ownership of application directories
change_ownership() {
folder=$1
mkdir -p $folder
local folder="$1"
mkdir -p "$folder"
echo "Changing ownership of $folder to $USERNAME:$RUN_GID"
chown -R "${RUN_UID}:${RUN_GID}" "${folder}" || echo "Failed to change ownership for ${folder}, continuing..."
}
@@ -273,7 +282,6 @@ ensure_symlinked_dir() {
fi
}
fix_misowned /app
fix_misowned /var/log/shelfmark
fix_misowned /tmp/shelfmark
@@ -299,19 +307,22 @@ if [ "${USING_EXTERNAL_BYPASSER}" != "true" ]; then
fi
fi
# Test write to all folders
make_writable ${CONFIG_DIR:-/config}
make_writable ${INGEST_DIR:-/books}
# Config can contain existing state we must keep accessing, so it keeps the
# thorough repair path. Output destination roots only need top-level writability.
make_writable "${CONFIG_DIR:-/config}" tree
# Entrypoint only has env vars available at this stage, so use the legacy
# INGEST_DIR env var as the fallback source for the default destination root.
make_writable "${INGEST_DIR:-/books}" root
# Fix permissions on directories configured in settings
echo "Checking for additional configured directories..."
# Check any additional configured destination roots from saved settings
echo "Checking for additional configured destination roots..."
if [ -f /app/scripts/fix_permissions.py ]; then
configured_dirs=$(python3 /app/scripts/fix_permissions.py 2>/dev/null || echo "")
if [ -n "$configured_dirs" ]; then
echo "$configured_dirs" | while read -r dir; do
if [ -n "$dir" ] && [ -d "$dir" ]; then
echo "Checking configured directory: $dir"
make_writable "$dir"
echo "Checking configured destination root: $dir"
make_writable "$dir" root
fi
done
fi
+9 -11
View File
@@ -1,10 +1,8 @@
#!/usr/bin/env python3
"""Fix permissions on all configured directories.
"""List configured destination roots that may need permission repair.
This script is called by the entrypoint to ensure all user-configured
directories have correct ownership. It reads directory paths from:
- CONFIG_DIR environment variable
- Config files in CONFIG_DIR/plugins/
This script is called by the entrypoint to find configured output destination
roots from config files under CONFIG_DIR/plugins/.
Outputs directory paths that need permission fixing (one per line).
The entrypoint handles the actual chown operations.
@@ -17,7 +15,7 @@ from pathlib import Path
def get_directories_from_config() -> set[str]:
"""Extract all directory paths from config files."""
"""Extract configured destination-style paths from config files."""
directories = set()
config_dir = Path(os.getenv("CONFIG_DIR", "/config"))
@@ -26,12 +24,12 @@ def get_directories_from_config() -> set[str]:
if not plugins_dir.exists():
return directories
# Keys that contain directory paths
# Keys that can point at output destination roots or legacy equivalents
directory_keys = {
# Main destinations
# Current destination settings
"DESTINATION",
"DESTINATION_AUDIOBOOK",
# Content type routing directories
# Content-type routing destinations
"AA_CONTENT_TYPE_DIR_FICTION",
"AA_CONTENT_TYPE_DIR_NON_FICTION",
"AA_CONTENT_TYPE_DIR_UNKNOWN",
@@ -40,7 +38,7 @@ def get_directories_from_config() -> set[str]:
"AA_CONTENT_TYPE_DIR_STANDARDS",
"AA_CONTENT_TYPE_DIR_MUSICAL_SCORE",
"AA_CONTENT_TYPE_DIR_OTHER",
# Legacy keys (in case of old configs)
# Legacy path settings still recognized in older configs
"INGEST_DIR",
"INGEST_DIR_AUDIOBOOK",
"INGEST_DIR_BOOK_FICTION",
@@ -73,7 +71,7 @@ def get_directories_from_config() -> set[str]:
def main():
"""Output all configured directories that exist."""
"""Output configured destination roots that currently exist."""
directories = get_directories_from_config()
# Filter to directories that actually exist
+4 -2
View File
@@ -6,6 +6,7 @@ import re
from typing import Any
from urllib.parse import urlsplit
from shelfmark.core.config import config as app_config
from shelfmark.core.notifications import NotificationEvent, send_test_notification
from shelfmark.core.settings_registry import (
ActionButton,
@@ -245,8 +246,9 @@ def _on_save_notifications(values: dict[str, Any]) -> dict[str, Any]:
def _test_admin_notification_action(current_values: dict[str, Any]) -> dict[str, Any]:
persisted = load_config_file("notifications")
effective: dict[str, Any] = dict(persisted)
effective: dict[str, Any] = {
"ADMIN_NOTIFICATION_ROUTES": app_config.get("ADMIN_NOTIFICATION_ROUTES", []),
}
if isinstance(current_values, dict):
effective.update(current_values)
+4 -1
View File
@@ -7,6 +7,7 @@ from shelfmark.config.security_handlers import (
on_save_security,
test_oidc_connection,
)
from shelfmark.core.config import config as app_config
from shelfmark.core.logger import setup_logger
from shelfmark.core.settings_registry import (
register_settings,
@@ -58,7 +59,9 @@ def _on_save_security(values: Dict[str, Any]) -> Dict[str, Any]:
def _test_oidc_connection(current_values: Dict[str, Any] = None) -> Dict[str, Any]:
return test_oidc_connection(
load_security_config=lambda: load_config_file("security"),
load_security_config=lambda: {
"OIDC_DISCOVERY_URL": app_config.get("OIDC_DISCOVERY_URL", ""),
},
current_values=current_values or {},
logger=logger,
)
+6 -1
View File
@@ -336,6 +336,7 @@ def _effective_download_row_for_activity(
effective_row = dict(row)
effective_row["final_status"] = QueueStatus.ERROR.value
effective_row["retry_final_status"] = final_status
status_message = effective_row.get("status_message")
if not isinstance(status_message, str) or not status_message.strip():
@@ -364,9 +365,9 @@ def _build_download_status_from_db(
continue
final_status = row.get("final_status")
queue_entry = queue_index.pop(task_id, None)
if final_status == ACTIVE_DOWNLOAD_STATUS:
queue_entry = queue_index.pop(task_id, None)
if queue_entry is not None:
bucket_key, queue_payload = queue_entry
status[bucket_key][task_id] = queue_payload
@@ -379,6 +380,10 @@ def _build_download_status_from_db(
status[QueueStatus.ERROR][task_id] = download_payload
elif final_status in VALID_TERMINAL_STATUSES:
download_payload = DownloadHistoryService.to_download_payload(row)
if queue_entry is not None:
_, queue_payload = queue_entry
if isinstance(queue_payload, dict) and "retry_available" in queue_payload:
download_payload["retry_available"] = bool(queue_payload.get("retry_available"))
# For complete/cancelled the saved status_message is a stale
# progress string (e.g. "Fetching download sources") — clear it
# so the frontend only shows its own status label. Error rows
+15 -15
View File
@@ -17,6 +17,7 @@ from shelfmark.config.booklore_settings import (
get_booklore_path_options,
)
from shelfmark.config.env import CWA_DB_PATH
from shelfmark.core.config import config as app_config
from shelfmark.core.admin_settings_routes import (
register_admin_settings_routes,
validate_user_settings,
@@ -32,7 +33,6 @@ from shelfmark.core.auth_modes import (
)
from shelfmark.core.cwa_user_sync import sync_cwa_users_from_rows
from shelfmark.core.logger import setup_logger
from shelfmark.core.settings_registry import load_config_file
from shelfmark.core.user_db import UserDB
logger = setup_logger(__name__)
@@ -47,10 +47,12 @@ def _get_user_edit_capabilities(
user.get("auth_source"),
user.get("oidc_subject"),
)
if security_config is None and auth_source == AUTH_SOURCE_OIDC:
security_config = load_config_file("security")
oidc_use_admin_group = bool((security_config or {}).get("OIDC_USE_ADMIN_GROUP", True))
oidc_use_admin_group = bool(
(security_config or {}).get(
"OIDC_USE_ADMIN_GROUP",
app_config.get("OIDC_USE_ADMIN_GROUP", True),
)
)
role_managed_by_oidc_group = auth_source == AUTH_SOURCE_OIDC and oidc_use_admin_group
can_edit_role = auth_source == AUTH_SOURCE_BUILTIN or (
auth_source == AUTH_SOURCE_OIDC and not role_managed_by_oidc_group
@@ -72,8 +74,11 @@ def _sanitize_user(user: dict) -> dict:
return sanitized
def _oidc_role_management_message(security_config: dict[str, Any]) -> str:
admin_group = security_config.get("OIDC_ADMIN_GROUP", "")
def _oidc_role_management_message(security_config: dict[str, Any] | None = None) -> str:
admin_group = (security_config or {}).get(
"OIDC_ADMIN_GROUP",
app_config.get("OIDC_ADMIN_GROUP", ""),
)
if admin_group:
return (
"Admin roles for OIDC users are managed by the "
@@ -152,9 +157,8 @@ def register_admin_routes(app: Flask, user_db: UserDB) -> None:
"""List all users."""
users = user_db.list_users()
auth_mode = g.auth_mode
security_config = load_config_file("security")
return jsonify([
_serialize_user(u, auth_mode, security_config=security_config)
_serialize_user(u, auth_mode)
for u in users
])
@@ -217,7 +221,6 @@ def register_admin_routes(app: Flask, user_db: UserDB) -> None:
_serialize_user(
user,
g.auth_mode,
security_config=load_config_file("security"),
)
), 201
@@ -232,7 +235,6 @@ def register_admin_routes(app: Flask, user_db: UserDB) -> None:
result = _serialize_user(
user,
g.auth_mode,
security_config=load_config_file("security"),
)
result["settings"] = user_db.get_user_settings(user_id)
return jsonify(result)
@@ -246,12 +248,11 @@ def register_admin_routes(app: Flask, user_db: UserDB) -> None:
return jsonify({"error": "User not found"}), 404
data = request.get_json() or {}
security_config = load_config_file("security")
auth_source = normalize_auth_source(
user.get("auth_source"),
user.get("oidc_subject"),
)
capabilities = _get_user_edit_capabilities(user, security_config=security_config)
capabilities = _get_user_edit_capabilities(user)
# Handle optional password update
password = data.get("password", "")
@@ -285,7 +286,7 @@ def register_admin_routes(app: Flask, user_db: UserDB) -> None:
if auth_source == AUTH_SOURCE_OIDC:
return jsonify({
"error": "Cannot change role for OIDC user when group-based authorization is enabled",
"message": _oidc_role_management_message(security_config),
"message": _oidc_role_management_message(),
}), 400
return jsonify({
@@ -347,7 +348,6 @@ def register_admin_routes(app: Flask, user_db: UserDB) -> None:
result = _serialize_user(
updated,
g.auth_mode,
security_config=security_config,
)
result["settings"] = user_db.get_user_settings(user_id)
logger.info(f"Admin updated user {user_id}")
+5 -6
View File
@@ -4,6 +4,7 @@ from typing import Any, Callable
from flask import Flask, jsonify, request
from shelfmark.core.config import config as app_config
from shelfmark.config.notifications_settings import (
build_notification_test_result,
is_valid_notification_url,
@@ -143,16 +144,14 @@ def register_admin_settings_routes(
@app.route("/api/admin/download-defaults", methods=["GET"])
@require_admin
def admin_download_defaults():
config = load_config_file("downloads")
defaults = {
key: ("" if (value := config.get(key, field.default)) is None else value)
key: ("" if (value := app_config.get(key, field.default)) is None else value)
for key, field in _get_ordered_user_overridable_fields("downloads")
}
security_config = load_config_file("security")
defaults["OIDC_ADMIN_GROUP"] = security_config.get("OIDC_ADMIN_GROUP", "")
defaults["OIDC_USE_ADMIN_GROUP"] = security_config.get("OIDC_USE_ADMIN_GROUP", True)
defaults["OIDC_AUTO_PROVISION"] = security_config.get("OIDC_AUTO_PROVISION", True)
defaults["OIDC_ADMIN_GROUP"] = app_config.get("OIDC_ADMIN_GROUP", "")
defaults["OIDC_USE_ADMIN_GROUP"] = app_config.get("OIDC_USE_ADMIN_GROUP", True)
defaults["OIDC_AUTO_PROVISION"] = app_config.get("OIDC_AUTO_PROVISION", True)
return jsonify(defaults)
@app.route("/api/admin/booklore-options", methods=["GET"])
+8 -25
View File
@@ -75,30 +75,6 @@ def determine_auth_mode(
return "none"
def _load_security_config() -> dict[str, Any]:
"""Load security settings with environment-backed values applied."""
from shelfmark.core.settings_registry import (
get_setting_value,
get_settings_field_map,
load_config_file,
)
try:
import shelfmark.config.security # noqa: F401
except Exception:
return load_config_file("security")
config = load_config_file("security")
field_map = get_settings_field_map(tab_name="security")
if not field_map:
return config
resolved = dict(config)
for key, (field, tab_name) in field_map.items():
resolved[key] = get_setting_value(field, tab_name)
return resolved
def load_active_auth_mode(
cwa_db_path: Any | None,
*,
@@ -106,7 +82,14 @@ def load_active_auth_mode(
) -> str:
"""Resolve active auth mode using current security config and runtime prerequisites."""
try:
security_config = _load_security_config()
from shelfmark.core.config import config as app_config
security_config = {
"AUTH_METHOD": app_config.get("AUTH_METHOD", "none"),
"PROXY_AUTH_USER_HEADER": app_config.get("PROXY_AUTH_USER_HEADER", ""),
"OIDC_DISCOVERY_URL": app_config.get("OIDC_DISCOVERY_URL", ""),
"OIDC_CLIENT_ID": app_config.get("OIDC_CLIENT_ID", ""),
}
return determine_auth_mode(
security_config,
cwa_db_path,
+3 -12
View File
@@ -106,18 +106,9 @@ class Config:
self._field_map.clear()
self._cache.clear()
for tab in registry.get_all_settings_tabs():
for field in tab.fields:
# Skip action buttons and headings - they don't have values
if isinstance(field, (registry.ActionButton, registry.HeadingField)):
continue
key = field.key
self._field_map[key] = (field, tab.name)
# Load current value
value = registry.get_setting_value(field, tab.name)
self._cache[key] = value
for key, (field, tab_name) in registry.get_settings_field_map().items():
self._field_map[key] = (field, tab_name)
self._cache[key] = registry.get_setting_value(field, tab_name)
self._loaded = True
+92 -6
View File
@@ -2,6 +2,7 @@
from __future__ import annotations
import json
import os
import sqlite3
import threading
@@ -74,9 +75,17 @@ class DownloadHistoryService:
conn.execute("PRAGMA foreign_keys = ON")
return conn
@staticmethod
def _row_to_dict(row: sqlite3.Row | None) -> dict[str, Any] | None:
return dict(row) if row is not None else None
@classmethod
def _normalize_row_dict(cls, row: dict[str, Any] | None) -> dict[str, Any] | None:
if row is None:
return None
normalized = dict(row)
normalized["retry_payload"] = cls._deserialize_retry_payload(normalized.get("retry_payload"))
return normalized
@classmethod
def _row_to_dict(cls, row: sqlite3.Row | None) -> dict[str, Any] | None:
return cls._normalize_row_dict(dict(row) if row is not None else None)
@staticmethod
def _to_item_key(task_id: str) -> str:
@@ -89,6 +98,69 @@ class DownloadHistoryService:
return None
return normalized if os.path.exists(normalized) else None
@staticmethod
def _serialize_retry_payload(payload: Any) -> str | None:
if payload is None:
return None
try:
return json.dumps(payload, ensure_ascii=False, separators=(",", ":"))
except (TypeError, ValueError) as exc:
raise ValueError("retry_payload must be JSON-serializable") from exc
@staticmethod
def _deserialize_retry_payload(value: Any) -> dict[str, Any] | None:
if isinstance(value, dict):
return dict(value)
normalized = normalize_optional_text(value)
if normalized is None:
return None
try:
parsed = json.loads(normalized)
except json.JSONDecodeError:
return None
return parsed if isinstance(parsed, dict) else None
@staticmethod
def _has_staged_retry_source(retry_payload: dict[str, Any]) -> bool:
staged_path = retry_payload.get("staged_path")
normalized_staged_path = normalize_optional_text(staged_path)
if normalized_staged_path is None:
return False
return os.path.exists(normalized_staged_path)
@staticmethod
def _can_retry_without_staged_source(retry_payload: dict[str, Any]) -> bool:
return bool(retry_payload.get("can_retry_without_staged_source", True))
@staticmethod
def is_retry_available(row: dict[str, Any]) -> bool:
final_status = str(
row.get("retry_final_status") or row.get("final_status") or ""
).strip().lower()
retry_payload = DownloadHistoryService._deserialize_retry_payload(row.get("retry_payload"))
if retry_payload is None:
return False
has_staged_retry_source = DownloadHistoryService._has_staged_retry_source(retry_payload)
can_retry_without_staged_source = (
DownloadHistoryService._can_retry_without_staged_source(retry_payload)
)
request_id = normalize_optional_positive_int(row.get("request_id"), "request_id")
if request_id is None:
if final_status in {ACTIVE_DOWNLOAD_STATUS, "cancelled"}:
return can_retry_without_staged_source
if final_status == "error":
return has_staged_retry_source or can_retry_without_staged_source
return False
if final_status in {ACTIVE_DOWNLOAD_STATUS, "cancelled"}:
return can_retry_without_staged_source
if final_status != "error":
return False
return has_staged_retry_source
@staticmethod
def to_download_payload(row: dict[str, Any]) -> dict[str, Any]:
return {
@@ -107,6 +179,7 @@ class DownloadHistoryService:
"user_id": row.get("user_id"),
"username": row.get("username"),
"request_id": row.get("request_id"),
"retry_available": DownloadHistoryService.is_retry_available(row),
}
@staticmethod
@@ -163,6 +236,7 @@ class DownloadHistoryService:
preview: str | None,
content_type: str | None,
origin: str,
retry_payload: dict[str, Any] | None = None,
) -> None:
"""Record a download at queue time with final_status='active'.
@@ -180,6 +254,7 @@ class DownloadHistoryService:
if normalized_title is None:
raise ValueError("title must be a non-empty string")
normalized_origin = _normalize_origin(origin)
normalized_retry_payload = self._serialize_retry_payload(retry_payload)
recorded_at = now_utc_iso()
with self._lock:
@@ -192,14 +267,15 @@ class DownloadHistoryService:
source, source_display_name,
title, author, format, size, preview, content_type,
origin, final_status,
status_message, download_path,
status_message, download_path, retry_payload,
queued_at, terminal_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'active', NULL, NULL, ?, ?)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'active', NULL, NULL, ?, ?, ?)
ON CONFLICT(task_id) DO UPDATE SET
final_status = 'active',
status_message = NULL,
download_path = NULL,
retry_payload = excluded.retry_payload,
terminal_at = ?
""",
(
@@ -216,6 +292,7 @@ class DownloadHistoryService:
normalize_optional_text(preview),
normalize_optional_text(content_type),
normalized_origin,
normalized_retry_payload,
recorded_at,
recorded_at,
recorded_at,
@@ -232,12 +309,14 @@ class DownloadHistoryService:
final_status: str,
status_message: str | None = None,
download_path: str | None = None,
retry_payload: dict[str, Any] | None = None,
) -> None:
"""Update an existing download row to its terminal state."""
normalized_task_id = _normalize_task_id(task_id)
normalized_final_status = _normalize_final_status(final_status)
normalized_status_message = normalize_optional_text(status_message)
normalized_download_path = normalize_optional_text(download_path)
normalized_retry_payload = self._serialize_retry_payload(retry_payload)
effective_terminal_at = now_utc_iso()
with self._lock:
@@ -249,6 +328,7 @@ class DownloadHistoryService:
SET final_status = ?,
status_message = ?,
download_path = ?,
retry_payload = COALESCE(?, retry_payload),
terminal_at = ?
WHERE task_id = ? AND final_status = 'active'
""",
@@ -256,6 +336,7 @@ class DownloadHistoryService:
normalized_final_status,
normalized_status_message,
normalized_download_path,
normalized_retry_payload,
effective_terminal_at,
normalized_task_id,
),
@@ -301,6 +382,11 @@ class DownloadHistoryService:
conn = self._connect()
try:
rows = conn.execute(query, params).fetchall()
return [dict(row) for row in rows]
result: list[dict[str, Any]] = []
for row in rows:
normalized = self._normalize_row_dict(dict(row))
if normalized is not None:
result.append(normalized)
return result
finally:
conn.close()
+7
View File
@@ -84,6 +84,13 @@ class DownloadTask:
preview: Optional[str] = None
content_type: Optional[str] = None # "book (fiction)", "audiobook", "magazine", etc.
source_url: Optional[str] = None # Original release URL used by source-specific handlers
retry_download_url: Optional[str] = None # Resolved download URL for restart-safe retries
retry_download_protocol: Optional[str] = None # Protocol for retry_download_url (e.g. torrent, usenet)
retry_release_name: Optional[str] = None # Display name to send back to external download clients
retry_expected_hash: Optional[str] = None # Optional torrent hash used to match client downloads
retry_ratio_limit: Optional[float] = None # Optional post-download seeding ratio
retry_seeding_time_limit_minutes: Optional[int] = None # Optional post-download seeding time limit
can_retry_without_staged_source: bool = True # Whether the source can restart without a preserved staged file
# Series info (for library naming templates)
series_name: Optional[str] = None
+15 -10
View File
@@ -11,13 +11,13 @@ from authlib.jose.errors import InvalidClaimError
from authlib.integrations.flask_client import OAuth
from flask import Flask, jsonify, redirect, request, session
from shelfmark.core.config import config as app_config
from shelfmark.core.logger import setup_logger
from shelfmark.core.oidc_auth import (
extract_user_info,
parse_group_claims,
provision_oidc_user,
)
from shelfmark.core.settings_registry import load_config_file
from shelfmark.core.user_db import UserDB
from shelfmark.download.network import get_ssl_verify
@@ -118,14 +118,13 @@ def _post_login_redirect_target(return_to: str | None) -> str:
def _get_oidc_client() -> tuple[Any, dict[str, Any]]:
"""Register and return an OIDC client from the current security config."""
config = load_config_file("security")
discovery_url = config.get("OIDC_DISCOVERY_URL", "")
client_id = config.get("OIDC_CLIENT_ID", "")
discovery_url = str(app_config.get("OIDC_DISCOVERY_URL", "") or "")
client_id = str(app_config.get("OIDC_CLIENT_ID", "") or "")
if not discovery_url or not client_id:
raise ValueError("OIDC not configured")
configured_scopes = config.get("OIDC_SCOPES", ["openid", "email", "profile"])
configured_scopes = app_config.get("OIDC_SCOPES", ["openid", "email", "profile"])
if isinstance(configured_scopes, list):
scope_values = [str(scope).strip() for scope in configured_scopes if str(scope).strip()]
elif isinstance(configured_scopes, str):
@@ -136,9 +135,9 @@ def _get_oidc_client() -> tuple[Any, dict[str, Any]]:
scopes = list(dict.fromkeys(["openid"] + scope_values))
admin_group = config.get("OIDC_ADMIN_GROUP", "")
group_claim = config.get("OIDC_GROUP_CLAIM", "groups")
use_admin_group = config.get("OIDC_USE_ADMIN_GROUP", True)
admin_group = app_config.get("OIDC_ADMIN_GROUP", "")
group_claim = app_config.get("OIDC_GROUP_CLAIM", "groups")
use_admin_group = app_config.get("OIDC_USE_ADMIN_GROUP", True)
if admin_group and use_admin_group and group_claim and group_claim not in scopes:
scopes.append(group_claim)
@@ -151,7 +150,7 @@ def _get_oidc_client() -> tuple[Any, dict[str, Any]]:
oauth.register(
name="shelfmark_idp",
client_id=client_id,
client_secret=config.get("OIDC_CLIENT_SECRET", ""),
client_secret=app_config.get("OIDC_CLIENT_SECRET", ""),
server_metadata_url=discovery_url,
client_kwargs={
"scope": " ".join(scopes),
@@ -165,7 +164,13 @@ def _get_oidc_client() -> tuple[Any, dict[str, Any]]:
if client is None:
raise RuntimeError("OIDC client initialization failed")
return client, config
return client, {
"OIDC_DISCOVERY_URL": discovery_url,
"OIDC_GROUP_CLAIM": group_claim,
"OIDC_ADMIN_GROUP": admin_group,
"OIDC_AUTO_PROVISION": app_config.get("OIDC_AUTO_PROVISION", True),
"OIDC_USE_ADMIN_GROUP": use_admin_group,
}
def register_oidc_routes(app: Flask, user_db: UserDB) -> None:
+7 -2
View File
@@ -5,8 +5,8 @@ from __future__ import annotations
from datetime import datetime, timezone
from typing import Any
from shelfmark.core.config import config as app_config
from shelfmark.core.logger import setup_logger
from shelfmark.core.settings_registry import load_config_file
_logger = setup_logger(__name__)
@@ -38,7 +38,12 @@ def emit_ws_event(
def load_users_request_policy_settings() -> dict[str, Any]:
"""Load global request-policy settings from the users config file."""
return load_config_file("users")
from shelfmark.core.request_policy import REQUEST_POLICY_KEYS
return {
key: app_config.get(key)
for key in REQUEST_POLICY_KEYS
}
def coerce_bool(value: Any, default: bool = False) -> bool:
+23 -2
View File
@@ -198,6 +198,7 @@ def sync_delivery_states_from_queue_status(
unique_request_ids_by_source.pop(source_id, None)
request_delivery_states: dict[int, str] = {}
request_delivery_payloads: dict[int, dict[str, Any]] = {}
for status_key in QueueStatus:
status_bucket = queue_status.get(status_key)
if not isinstance(status_bucket, dict):
@@ -211,22 +212,42 @@ def sync_delivery_states_from_queue_status(
if request_id is None:
continue
request_delivery_states[request_id] = status_key
if isinstance(task_payload, dict):
request_delivery_payloads[request_id] = dict(task_payload)
if not request_delivery_states:
return []
updated: list[dict[str, Any]] = []
for row in fulfilled_rows:
delivery_state = request_delivery_states.get(int(row["id"]))
request_id = int(row["id"])
delivery_state = request_delivery_states.get(request_id)
if delivery_state is None:
continue
task_payload = request_delivery_payloads.get(request_id) or {}
retry_available = task_payload.get("retry_available")
if delivery_state == QueueStatus.ERROR and retry_available is False:
raw_status_message = task_payload.get("status_message")
failure_reason = (
raw_status_message.strip()
if isinstance(raw_status_message, str) and raw_status_message.strip()
else "Download failed"
)
reopened = user_db.reopen_failed_request(
request_id,
failure_reason=failure_reason,
)
if reopened is not None:
updated.append(reopened)
continue
if row.get("delivery_state", DELIVERY_STATE_NONE) == delivery_state:
continue
updated.append(
user_db.update_request(
row["id"],
request_id,
delivery_state=delivery_state,
delivery_updated_at=_now_timestamp(),
)
+9
View File
@@ -85,6 +85,7 @@ CREATE TABLE IF NOT EXISTS download_history (
final_status TEXT NOT NULL,
status_message TEXT,
download_path TEXT,
retry_payload TEXT,
queued_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
terminal_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
@@ -190,6 +191,7 @@ class UserDB:
self._migrate_auth_source_column(conn)
self._migrate_request_delivery_columns(conn)
self._migrate_download_history_queued_at(conn)
self._migrate_download_history_retry_payload(conn)
conn.commit()
# WAL mode must be changed outside an open transaction.
conn.execute("PRAGMA journal_mode=WAL")
@@ -254,6 +256,13 @@ class UserDB:
"UPDATE download_history SET queued_at = CURRENT_TIMESTAMP WHERE queued_at IS NULL"
)
def _migrate_download_history_retry_payload(self, conn: sqlite3.Connection) -> None:
"""Ensure download_history.retry_payload exists for restart-safe retries."""
columns = conn.execute("PRAGMA table_info(download_history)").fetchall()
column_names = {str(col["name"]) for col in columns}
if "retry_payload" not in column_names:
conn.execute("ALTER TABLE download_history ADD COLUMN retry_payload TEXT")
def create_user(
self,
username: str,
+262 -5
View File
@@ -18,6 +18,7 @@ from shelfmark.core.config import config
from shelfmark.core.logger import setup_logger
from shelfmark.core.models import DownloadTask, QueueStatus, SearchMode
from shelfmark.core.queue import book_queue
from shelfmark.core.request_helpers import normalize_optional_text, normalize_positive_int
from shelfmark.core.utils import transform_cover_url, is_audiobook as check_audiobook
from shelfmark.config import env as env_config
from shelfmark.download.fs import run_blocking_io
@@ -79,6 +80,8 @@ def _resolve_email_destination(
return None, "Configured email recipient is invalid"
return None, None
def _parse_release_search_mode(value: Any) -> SearchMode:
if isinstance(value, SearchMode):
return value
@@ -91,6 +94,65 @@ def _parse_release_search_mode(value: Any) -> SearchMode:
raise ValueError(f"Invalid search_mode: {value}") from exc
raise ValueError(f"Invalid search_mode: {value}")
def _optional_number(value: Any) -> Optional[float]:
if isinstance(value, (int, float)) and not isinstance(value, bool):
return float(value)
try:
return float(value)
except (TypeError, ValueError):
return None
def _optional_positive_int(value: Any) -> Optional[int]:
if isinstance(value, bool):
return None
try:
parsed = int(value)
except (TypeError, ValueError):
return None
return parsed if parsed > 0 else None
def _seed_time_seconds_to_minutes(value: Any) -> Optional[int]:
seed_time_seconds = _optional_positive_int(value)
if seed_time_seconds is None:
return None
return (seed_time_seconds + 59) // 60
def _build_retry_resolution_fields(
release_data: dict[str, Any],
) -> Dict[str, Any]:
"""Persist generic resolved-download data needed for restart-safe retries."""
extra = release_data.get("extra")
if not isinstance(extra, dict):
extra = {}
protocol = normalize_optional_text(release_data.get("protocol"))
ratio_limit = _optional_number(release_data.get("ratio_limit"))
if ratio_limit is None:
ratio_limit = _optional_number(extra.get("minimum_ratio"))
seeding_time_limit_minutes = _optional_positive_int(
release_data.get("seeding_time_limit_minutes")
)
if seeding_time_limit_minutes is None:
seeding_time_limit_minutes = _seed_time_seconds_to_minutes(
extra.get("minimum_seed_time")
)
return {
"retry_download_url": normalize_optional_text(release_data.get("download_url")),
"retry_download_protocol": protocol.lower() if protocol is not None else None,
"retry_release_name": normalize_optional_text(release_data.get("title")),
"retry_expected_hash": normalize_optional_text(
release_data.get("expected_hash") or extra.get("info_hash")
),
"retry_ratio_limit": ratio_limit,
"retry_seeding_time_limit_minutes": seeding_time_limit_minutes,
"can_retry_without_staged_source": True,
}
def queue_release(
release_data: dict,
@@ -136,6 +198,7 @@ def queue_release(
output_mode = "folder" if is_audiobook else books_output_mode
output_args: Dict[str, Any] = {}
retry_resolution_fields = _build_retry_resolution_fields(release_data)
if output_mode == "email" and not is_audiobook:
email_to, email_error = _resolve_email_destination(user_id=user_id)
@@ -166,6 +229,7 @@ def queue_release(
user_id=user_id,
username=username,
request_id=request_id,
**retry_resolution_fields,
)
if not book_queue.add(task):
@@ -204,7 +268,7 @@ def queue_status(user_id: Optional[int] = None) -> Dict[str, Dict[str, Any]]:
# Convert Enum keys to strings and DownloadTask objects to dicts for JSON serialization
return {
status_type.value: {
task_id: _task_to_dict(task)
task_id: _task_to_dict(task, current_status=status_type)
for task_id, task in tasks.items()
}
for status_type, tasks in status.items()
@@ -230,10 +294,202 @@ def get_book_data(task_id: str) -> Tuple[Optional[bytes], Optional[DownloadTask]
task.download_path = None
return None, task
def _task_to_dict(task: DownloadTask) -> Dict[str, Any]:
def _has_staged_retry_source(task: DownloadTask) -> bool:
"""Whether a failed task still has a staged file available for retry."""
staged_path = task.staged_path.strip() if isinstance(task.staged_path, str) else ""
if not staged_path:
return False
try:
return run_blocking_io(Path(staged_path).exists)
except OSError:
return False
def _has_fresh_retry_context(task: DownloadTask) -> bool:
"""Whether the task can restart without relying on a staged file."""
return bool(getattr(task, "can_retry_without_staged_source", True))
def can_retry_download_task(
task: Optional[DownloadTask],
status: Optional[QueueStatus],
) -> bool:
"""Whether the task can be manually retried from the Activity UI."""
if task is None or status not in (QueueStatus.ERROR, QueueStatus.CANCELLED):
return False
if task.request_id is None:
return _has_staged_retry_source(task) or _has_fresh_retry_context(task)
if status == QueueStatus.CANCELLED:
return _has_fresh_retry_context(task)
return _has_staged_retry_source(task)
def serialize_task_for_retry(task: DownloadTask) -> Dict[str, Any]:
"""Serialize the task state needed for restart-safe retries."""
raw_search_mode = getattr(task, "search_mode", None)
search_mode: Optional[str] = None
if isinstance(raw_search_mode, SearchMode):
search_mode = raw_search_mode.value
elif isinstance(raw_search_mode, str):
normalized_search_mode = raw_search_mode.strip().lower()
search_mode = normalized_search_mode or None
raw_output_args = getattr(task, "output_args", None)
return {
"task_id": getattr(task, "task_id", None),
"source": getattr(task, "source", None),
"title": getattr(task, "title", None),
"author": getattr(task, "author", None),
"year": getattr(task, "year", None),
"format": getattr(task, "format", None),
"size": getattr(task, "size", None),
"preview": getattr(task, "preview", None),
"content_type": getattr(task, "content_type", None),
"source_url": getattr(task, "source_url", None),
"series_name": getattr(task, "series_name", None),
"series_position": getattr(task, "series_position", None),
"subtitle": getattr(task, "subtitle", None),
"search_mode": search_mode,
"output_mode": getattr(task, "output_mode", None),
"output_args": dict(raw_output_args) if isinstance(raw_output_args, dict) else {},
"user_id": getattr(task, "user_id", None),
"username": getattr(task, "username", None),
"request_id": getattr(task, "request_id", None),
"staged_path": getattr(task, "staged_path", None),
"retry_download_url": getattr(task, "retry_download_url", None),
"retry_download_protocol": getattr(task, "retry_download_protocol", None),
"retry_release_name": getattr(task, "retry_release_name", None),
"retry_expected_hash": getattr(task, "retry_expected_hash", None),
"retry_ratio_limit": getattr(task, "retry_ratio_limit", None),
"retry_seeding_time_limit_minutes": getattr(task, "retry_seeding_time_limit_minutes", None),
"can_retry_without_staged_source": bool(
getattr(task, "can_retry_without_staged_source", True)
),
}
def _restore_task_from_retry_payload(payload: Any) -> Optional[DownloadTask]:
if not isinstance(payload, dict):
return None
task_id = normalize_optional_text(payload.get("task_id"))
source = normalize_optional_text(payload.get("source"))
title = normalize_optional_text(payload.get("title"))
if task_id is None or source is None or title is None:
return None
search_mode = None
raw_search_mode = payload.get("search_mode")
if raw_search_mode is not None:
try:
search_mode = _parse_release_search_mode(raw_search_mode)
except ValueError:
search_mode = None
output_args = payload.get("output_args")
return DownloadTask(
task_id=task_id,
source=source,
title=title,
author=normalize_optional_text(payload.get("author")),
year=normalize_optional_text(payload.get("year")),
format=normalize_optional_text(payload.get("format")),
size=normalize_optional_text(payload.get("size")),
preview=normalize_optional_text(payload.get("preview")),
content_type=normalize_optional_text(payload.get("content_type")),
source_url=normalize_optional_text(payload.get("source_url")),
series_name=normalize_optional_text(payload.get("series_name")),
series_position=_optional_number(payload.get("series_position")),
subtitle=normalize_optional_text(payload.get("subtitle")),
search_mode=search_mode,
output_mode=normalize_optional_text(payload.get("output_mode")),
output_args=dict(output_args) if isinstance(output_args, dict) else {},
user_id=normalize_positive_int(payload.get("user_id")),
username=normalize_optional_text(payload.get("username")),
request_id=normalize_positive_int(payload.get("request_id")),
staged_path=normalize_optional_text(payload.get("staged_path")),
retry_download_url=normalize_optional_text(payload.get("retry_download_url")),
retry_download_protocol=normalize_optional_text(payload.get("retry_download_protocol")),
retry_release_name=normalize_optional_text(payload.get("retry_release_name")),
retry_expected_hash=normalize_optional_text(payload.get("retry_expected_hash")),
retry_ratio_limit=_optional_number(payload.get("retry_ratio_limit")),
retry_seeding_time_limit_minutes=_optional_positive_int(
payload.get("retry_seeding_time_limit_minutes")
),
can_retry_without_staged_source=bool(
payload.get("can_retry_without_staged_source", True)
),
)
def retry_persisted_download(
payload: Any,
*,
final_status: Any,
priority: int = -10,
) -> Tuple[bool, Optional[str]]:
"""Retry a persisted download row after the in-memory task has been lost."""
task = _restore_task_from_retry_payload(payload)
if task is None:
return False, "Download cannot be retried"
normalized_status = normalize_optional_text(final_status)
if normalized_status is None:
return False, "Download cannot be retried"
normalized_status = normalized_status.lower()
if normalized_status not in {"active", "error", "cancelled"}:
return False, "Download cannot be retried"
has_staged_retry_source = _has_staged_retry_source(task)
has_fresh_retry_context = _has_fresh_retry_context(task)
if normalized_status in {"active", "cancelled"} and not has_fresh_retry_context:
return False, "Download cannot be retried"
if (
task.request_id is not None
and normalized_status == "error"
and not has_staged_retry_source
):
if task.request_id is not None:
return False, "Request-linked downloads must be retried from requests"
if (
task.request_id is None
and normalized_status == "error"
and not has_staged_retry_source
and not has_fresh_retry_context
):
return False, "Download cannot be retried"
task.priority = priority
task.status_message = None
_clear_task_error_state(task)
if not book_queue.add(task):
return False, "Failed to requeue download"
book_queue.update_status_message(task.task_id, "Retrying now")
if ws_manager:
ws_manager.broadcast_status_update(queue_status())
return True, None
def _task_to_dict(
task: DownloadTask,
current_status: Optional[QueueStatus] = None,
) -> Dict[str, Any]:
"""Convert DownloadTask to dict for frontend, transforming cover URLs."""
# Transform external preview URLs to local proxy URLs
preview = transform_cover_url(task.preview, task.task_id)
retry_status = current_status or book_queue.get_task_status(task.task_id)
return {
'id': task.task_id,
@@ -254,6 +510,7 @@ def _task_to_dict(task: DownloadTask) -> Dict[str, Any]:
'user_id': task.user_id,
'username': task.username,
'request_id': task.request_id,
'retry_available': can_retry_download_task(task, retry_status),
}
@@ -505,8 +762,8 @@ def cancel_download(book_id: str) -> bool:
def retry_download(book_id: str) -> Tuple[bool, Optional[str]]:
"""Retry a failed or cancelled download.
Request-linked downloads can only be retried when cancelled (errors
reopen the request for admin re-approval instead).
Request-linked downloads can only be manually retried when cancelled or
when a staged post-processing retry is available.
"""
task = book_queue.get_task(book_id)
if task is None:
@@ -516,7 +773,7 @@ def retry_download(book_id: str) -> Tuple[bool, Optional[str]]:
if status not in (QueueStatus.ERROR, QueueStatus.CANCELLED):
return False, "Download is not in an error or cancelled state"
if task.request_id and status != QueueStatus.CANCELLED:
if not can_retry_download_task(task, status):
return False, "Request-linked downloads must be retried from requests"
task.last_error_message = None
+67 -31
View File
@@ -549,8 +549,6 @@ def proxy_auth_middleware():
if request.path == '/api/health':
return None
from shelfmark.core.settings_registry import load_config_file
def get_proxy_header(header_name: str) -> str | None:
"""Resolve proxy auth values from headers with WSGI env fallbacks."""
value = request.headers.get(header_name)
@@ -569,8 +567,7 @@ def proxy_auth_middleware():
return None
try:
security_config = load_config_file("security")
user_header = security_config.get("PROXY_AUTH_USER_HEADER", "X-Auth-User")
user_header = app_config.get("PROXY_AUTH_USER_HEADER", "X-Auth-User")
# Extract username from proxy header
username = get_proxy_header(user_header)
@@ -586,8 +583,8 @@ def proxy_auth_middleware():
# If an admin group is configured, derive from groups header.
# Otherwise preserve existing DB role for known users and default
# first-time users to admin (to avoid lockouts).
admin_group_header = security_config.get("PROXY_AUTH_ADMIN_GROUP_HEADER", "X-Auth-Groups")
admin_group_name = str(security_config.get("PROXY_AUTH_ADMIN_GROUP_NAME", "") or "").strip()
admin_group_header = app_config.get("PROXY_AUTH_ADMIN_GROUP_HEADER", "X-Auth-Groups")
admin_group_name = str(app_config.get("PROXY_AUTH_ADMIN_GROUP_NAME", "") or "").strip()
is_admin = True
if admin_group_name:
@@ -685,12 +682,9 @@ def login_required(f):
# Check admin access for settings/onboarding endpoints.
if is_settings_or_onboarding_path(request.path):
from shelfmark.core.settings_registry import load_config_file
try:
users_config = load_config_file("users")
if (
requires_admin_for_settings_access(request.path, users_config)
requires_admin_for_settings_access(request.path, {})
and not session.get('is_admin', False)
):
return jsonify({"error": "Admin access required"}), 403
@@ -1185,6 +1179,7 @@ def _record_download_queued(task_id: str, task: Any) -> None:
preview=normalize_optional_text(getattr(task, "preview", None)),
content_type=normalize_optional_text(getattr(task, "content_type", None)),
origin=origin,
retry_payload=backend.serialize_task_for_retry(task),
)
except Exception as exc:
logger.warning("Failed to record download at queue time for task %s: %s", task_id, exc)
@@ -1231,6 +1226,7 @@ def _record_download_terminal_snapshot(task_id: str, status: QueueStatus, task:
final_status=final_status,
status_message=normalize_optional_text(getattr(task, "status_message", None)),
download_path=normalize_optional_text(getattr(task, "download_path", None)),
retry_payload=backend.serialize_task_for_retry(task),
)
finalized_download = True
except Exception as exc:
@@ -1252,6 +1248,8 @@ def _record_download_terminal_snapshot(task_id: str, status: QueueStatus, task:
request_id = normalize_positive_int(getattr(task, "request_id", None))
if request_id is None:
return
if backend.can_retry_download_task(task, status):
return
raw_error_message = getattr(task, "status_message", None)
fallback_reason = (
@@ -1298,6 +1296,23 @@ def _task_owned_by_actor(task: Any, *, actor_user_id: int | None, actor_username
return False
def _download_row_owned_by_actor(
row: dict[str, Any],
*,
actor_user_id: int | None,
actor_username: str | None,
) -> bool:
owner_user_id = normalize_positive_int(row.get("user_id"))
if actor_user_id is not None and owner_user_id is not None:
return owner_user_id == actor_user_id
row_username = normalize_optional_text(row.get("username"))
if row_username is not None and isinstance(actor_username, str):
return row_username == actor_username.strip()
return False
backend.book_queue.set_queue_hook(_record_download_queued)
backend.book_queue.set_terminal_status_hook(_record_download_terminal_snapshot)
@@ -1527,28 +1542,56 @@ def api_retry_download(book_id: str) -> Union[Response, Tuple[Response, int]]:
"""Retry a failed download."""
try:
task = backend.book_queue.get_task(book_id)
if task is None:
history_row = None
if task is None and download_history_service is not None:
history_row = download_history_service.get_by_task_id(book_id)
if task is None and history_row is None:
return jsonify({"error": "Download not found"}), 404
is_admin, db_user_id, can_access_status = _resolve_status_scope()
actor_username = session.get("user_id")
normalized_actor_username = actor_username if isinstance(actor_username, str) else None
if not is_admin:
if not can_access_status or db_user_id is None:
return jsonify({"error": "User identity unavailable", "code": "user_identity_unavailable"}), 403
actor_username = session.get("user_id")
normalized_actor_username = actor_username if isinstance(actor_username, str) else None
if not _task_owned_by_actor(
task,
if task is not None:
if not _task_owned_by_actor(
task,
actor_user_id=db_user_id,
actor_username=normalized_actor_username,
):
return jsonify({"error": "Forbidden", "code": "download_not_owned"}), 403
elif history_row is None or not _download_row_owned_by_actor(
history_row,
actor_user_id=db_user_id,
actor_username=normalized_actor_username,
):
return jsonify({"error": "Forbidden", "code": "download_not_owned"}), 403
task_status = backend.book_queue.get_task_status(book_id)
if getattr(task, "request_id", None) is not None and task_status != QueueStatus.CANCELLED:
return jsonify({"error": "Forbidden", "code": "requested_download_retry_forbidden"}), 403
if task is not None:
task_status = backend.book_queue.get_task_status(book_id)
if (
getattr(task, "request_id", None) is not None
and not backend.can_retry_download_task(task, task_status)
):
return jsonify({"error": "Forbidden", "code": "requested_download_retry_forbidden"}), 403
success, error = backend.retry_download(book_id)
else:
assert history_row is not None
request_id = normalize_positive_int(history_row.get("request_id"))
retry_payload = history_row.get("retry_payload")
final_status = history_row.get("final_status")
if (
request_id is not None
and not download_history_service.is_retry_available(history_row)
):
return jsonify({"error": "Forbidden", "code": "requested_download_retry_forbidden"}), 403
success, error = backend.retry_persisted_download(
retry_payload,
final_status=final_status,
)
success, error = backend.retry_download(book_id)
if success:
return jsonify({"status": "queued", "book_id": book_id})
@@ -1857,8 +1900,6 @@ def api_logout() -> Union[Response, Tuple[Response, int]]:
Returns:
flask.Response: JSON with success status and optional logout_url.
"""
from shelfmark.core.settings_registry import load_config_file
try:
auth_mode = get_auth_mode()
ip_address = get_client_ip()
@@ -1868,8 +1909,7 @@ def api_logout() -> Union[Response, Tuple[Response, int]]:
# For proxy auth, include logout URL if configured
if auth_mode == "proxy":
security_config = load_config_file("security")
logout_url = security_config.get("PROXY_AUTH_LOGOUT_URL", "")
logout_url = app_config.get("PROXY_AUTH_LOGOUT_URL", "")
if logout_url:
return jsonify({"success": True, "logout_url": logout_url})
@@ -1887,11 +1927,7 @@ def api_auth_check() -> Union[Response, Tuple[Response, int]]:
flask.Response: JSON with authentication status, whether auth is required,
which auth mode is active, and whether user has admin privileges.
"""
from shelfmark.core.settings_registry import load_config_file
try:
security_config = load_config_file("security")
users_config = load_config_file("users")
auth_mode = get_auth_mode()
# If no authentication is configured, access is allowed (full admin)
@@ -1906,7 +1942,7 @@ def api_auth_check() -> Union[Response, Tuple[Response, int]]:
# Check if user has a valid session
is_authenticated = 'user_id' in session
is_admin = get_auth_check_admin_status(auth_mode, users_config, session)
is_admin = get_auth_check_admin_status(auth_mode, {}, session)
display_name = None
if is_authenticated and session.get('db_user_id') and user_db is not None:
@@ -1927,14 +1963,14 @@ def api_auth_check() -> Union[Response, Tuple[Response, int]]:
}
# Add logout URL for proxy auth if configured
if auth_mode == "proxy" and security_config.get("PROXY_AUTH_USER_HEADER"):
logout_url = security_config.get("PROXY_AUTH_LOGOUT_URL", "")
if auth_mode == "proxy" and app_config.get("PROXY_AUTH_USER_HEADER", ""):
logout_url = app_config.get("PROXY_AUTH_LOGOUT_URL", "")
if logout_url:
response_data["logout_url"] = logout_url
# Add custom OIDC button label and SSO enforcement flags if configured
if auth_mode == "oidc":
oidc_button_label = security_config.get("OIDC_BUTTON_LABEL", "")
oidc_button_label = app_config.get("OIDC_BUTTON_LABEL", "")
if oidc_button_label:
response_data["oidc_button_label"] = oidc_button_label
if HIDE_LOCAL_AUTH:
+43 -3
View File
@@ -5,6 +5,7 @@ from typing import Callable, Optional
from shelfmark.core.config import config # noqa: F401 (compat patch target in tests)
from shelfmark.core.logger import setup_logger
from shelfmark.core.models import DownloadTask
from shelfmark.core.request_helpers import normalize_optional_text
from shelfmark.download.clients import DownloadClient, get_client, list_configured_clients
from shelfmark.download.clients.base_handler import (
COMPLETED_PATH_MAX_ATTEMPTS as _DEFAULT_COMPLETED_PATH_MAX_ATTEMPTS,
@@ -65,6 +66,41 @@ class ProwlarrHandler(ExternalClientHandler):
def _completed_path_max_attempts(self) -> int:
return COMPLETED_PATH_MAX_ATTEMPTS
@classmethod
def _restore_download_request_from_task(cls, task: DownloadTask) -> Optional[DownloadRequest]:
"""Rebuild a DownloadRequest when the in-memory Prowlarr cache is gone."""
retry_download_url = normalize_optional_text(getattr(task, "retry_download_url", None))
retry_download_protocol = normalize_optional_text(
getattr(task, "retry_download_protocol", None)
)
if retry_download_url is None or retry_download_protocol is None:
return None
protocol = retry_download_protocol.lower()
if protocol not in {"torrent", "usenet"}:
return None
ratio_limit = getattr(task, "retry_ratio_limit", None)
if not isinstance(ratio_limit, (int, float)) or isinstance(ratio_limit, bool):
ratio_limit = None
seeding_time_limit = getattr(task, "retry_seeding_time_limit_minutes", None)
if not isinstance(seeding_time_limit, int) or isinstance(seeding_time_limit, bool):
seeding_time_limit = None
return DownloadRequest(
url=retry_download_url,
protocol=protocol,
release_name=(
normalize_optional_text(getattr(task, "retry_release_name", None))
or task.title
or "Unknown"
),
expected_hash=normalize_optional_text(getattr(task, "retry_expected_hash", None)),
seeding_time_limit=seeding_time_limit,
ratio_limit=float(ratio_limit) if ratio_limit is not None else None,
)
def _resolve_download(
self,
task: DownloadTask,
@@ -74,9 +110,13 @@ class ProwlarrHandler(ExternalClientHandler):
# Look up the cached release
prowlarr_result = get_release(task.task_id)
if not prowlarr_result:
logger.warning(f"Release cache miss: {task.task_id}")
status_callback("error", "Release not found in cache (may have expired)")
return None
restored_request = self._restore_download_request_from_task(task)
if restored_request is None:
logger.warning(f"Release cache miss: {task.task_id}")
status_callback("error", "Release not found in cache (may have expired)")
return None
logger.info("Restored Prowlarr download request for retry: %s", task.task_id)
return restored_request
# Extract download URL
download_url = get_preferred_download_url(prowlarr_result)
+10 -3
View File
@@ -1336,9 +1336,18 @@ export const ReleaseModal = ({
const getButtonState = useCallback(
(release: Release): ButtonStateInfo => {
const releaseId = release.source_id;
const mode = getReleaseActionMode(release);
// Check error first
if (currentStatus.error && currentStatus.error[releaseId]) {
return { text: 'Failed', state: 'error' };
if (mode === 'request_release') {
return { text: 'Request', state: 'download' };
}
if (mode === 'blocked' || mode === 'request_book') {
return { text: 'Unavailable', state: 'blocked' };
}
return currentStatus.error[releaseId].retry_available === true
? { text: 'Retry', state: 'download' }
: { text: 'Failed', state: 'error' };
}
// Check completed
if (currentStatus.complete && currentStatus.complete[releaseId]) {
@@ -1362,8 +1371,6 @@ export const ReleaseModal = ({
if (currentStatus.queued && currentStatus.queued[releaseId]) {
return { text: 'Queued', state: 'queued' };
}
const mode = getReleaseActionMode(release);
if (mode === 'request_release') {
return { text: 'Request', state: 'download' };
}
@@ -152,6 +152,7 @@ const buildRequestNoteLine = (item: ActivityItem): string | undefined => {
const buildActions = (item: ActivityItem, isAdmin: boolean): ActivityCardAction[] => {
if (item.kind === 'download' && item.downloadBookId) {
const canRetry = item.downloadRetryAvailable === true;
if (item.visualStatus === 'queued') {
return [{ kind: 'download-remove', bookId: item.downloadBookId }];
}
@@ -162,7 +163,7 @@ const buildActions = (item: ActivityItem, isAdmin: boolean): ActivityCardAction[
) {
return [{ kind: 'download-stop', bookId: item.downloadBookId }];
}
if (item.visualStatus === 'error' && !item.requestId) {
if (item.visualStatus === 'error' && canRetry) {
return [
{
kind: 'download-retry',
@@ -175,7 +176,7 @@ const buildActions = (item: ActivityItem, isAdmin: boolean): ActivityCardAction[
},
];
}
if (item.visualStatus === 'cancelled') {
if (item.visualStatus === 'cancelled' && canRetry) {
return [
{
kind: 'download-retry',
@@ -94,6 +94,7 @@ export const downloadToActivityItem = (book: Book, statusKey: DownloadStatusKey)
]);
const progress = getDownloadProgress(visualStatus, book.progress);
const statusDetail = toOptionalText(book.status_message);
const downloadRetryAvailable = book.retry_available === true;
return {
id: book.id,
@@ -110,6 +111,7 @@ export const downloadToActivityItem = (book: Book, statusKey: DownloadStatusKey)
timestamp: toEpochMillis(book.added_time),
username: toOptionalText(book.username),
downloadBookId: book.id,
downloadRetryAvailable,
downloadPath: toOptionalText(book.download_path),
sizeRaw: toOptionalText(book.size),
requestId,
@@ -37,6 +37,7 @@ export interface ActivityItem {
username?: string;
downloadBookId?: string;
downloadRetryAvailable?: boolean;
downloadPath?: string;
requestId?: number;
requestLevel?: 'book' | 'release';
@@ -36,7 +36,9 @@ export function useDownloadTracking(currentStatus: StatusData): UseDownloadTrack
// Get button state for a book in direct mode
const getButtonState = useCallback((bookId: string): ButtonStateInfo => {
if (currentStatus.error && currentStatus.error[bookId]) {
return { text: 'Failed', state: 'error' };
return currentStatus.error[bookId].retry_available === true
? { text: 'Retry', state: 'download' }
: { text: 'Failed', state: 'error' };
}
if (currentStatus.complete && currentStatus.complete[bookId]) {
return { text: 'Downloaded', state: 'complete' };
@@ -105,7 +107,9 @@ export function useDownloadTracking(currentStatus: StatusData): UseDownloadTrack
foundActiveState = true;
} else if (currentStatus.error && currentStatus.error[releaseId]) {
if (bestState.state === 'download') {
bestState = { text: 'Failed', state: 'error' };
bestState = currentStatus.error[releaseId].retry_available === true
? { text: 'Retry', state: 'download' }
: { text: 'Failed', state: 'error' };
}
}
}
@@ -213,4 +213,33 @@ describe('activityCardModel', () => {
42
);
});
it('shows retry for request-linked downloads when the backend marks them retryable', () => {
const model = buildActivityCardModel(
makeItem({
visualStatus: 'error',
statusLabel: 'Failed',
requestId: 42,
downloadRetryAvailable: true,
}),
false
);
assert.equal(model.actions.length, 2);
assert.equal(model.actions[0]?.kind, 'download-retry');
assert.equal(model.actions[1]?.kind, 'download-dismiss');
});
it('does not show retry for error downloads without a live retry path', () => {
const model = buildActivityCardModel(
makeItem({
visualStatus: 'error',
statusLabel: 'Failed',
}),
false
);
assert.equal(model.actions.length, 1);
assert.equal(model.actions[0]?.kind, 'download-dismiss');
});
});
+1
View File
@@ -55,6 +55,7 @@ export interface Book {
authors?: string[];
titles_by_language?: Record<string, string>;
username?: string;
retry_available?: boolean;
}
// Status response types
+18
View File
@@ -252,6 +252,24 @@ class TestSettingsSystem:
assert string_to_bool("0") is False
assert string_to_bool("anything_else") is False
def test_request_policy_loader_reads_env_backed_values(self, monkeypatch):
"""Request-policy helpers should read effective values via the config singleton."""
from shelfmark.core.config import config
from shelfmark.core.request_helpers import load_users_request_policy_settings
monkeypatch.setenv("REQUESTS_ENABLED", "true")
monkeypatch.setenv("REQUEST_POLICY_DEFAULT_EBOOK", "blocked")
config.refresh(force=True)
try:
settings = load_users_request_policy_settings()
assert settings["REQUESTS_ENABLED"] is True
assert settings["REQUEST_POLICY_DEFAULT_EBOOK"] == "blocked"
finally:
monkeypatch.delenv("REQUESTS_ENABLED", raising=False)
monkeypatch.delenv("REQUEST_POLICY_DEFAULT_EBOOK", raising=False)
config.refresh(force=True)
# =============================================================================
# Archive Handling Configuration Tests
+194
View File
@@ -578,6 +578,57 @@ class TestActivityRoutes:
assert history_response.json[0]["final_status"] == "error"
assert history_response.json[0]["snapshot"]["download"]["status_message"] == "Interrupted"
def test_dismiss_many_preserves_retry_for_stale_active_requested_download_history(self, main_module, client):
user = _create_user(main_module, prefix="reader")
_set_session(client, user_id=user["username"], db_user_id=user["id"], is_admin=False)
task_id = "dismiss-many-stale-requested-active"
retry_payload = {
"task_id": task_id,
"source": "prowlarr",
"title": "Interrupted Requested Download",
"user_id": user["id"],
"username": user["username"],
"request_id": 321,
"search_mode": "universal",
"retry_download_url": "magnet:?xt=urn:btih:dismissmany123",
"retry_download_protocol": "torrent",
"retry_release_name": "Interrupted Requested Download",
"can_retry_without_staged_source": True,
}
main_module.download_history_service.record_download(
task_id=task_id,
user_id=user["id"],
username=user["username"],
request_id=321,
source="prowlarr",
source_display_name="Prowlarr",
title="Interrupted Requested Download",
author="Stale Author",
format="epub",
size="1 MB",
preview=None,
content_type="ebook",
origin="requested",
retry_payload=retry_payload,
)
with patch.object(main_module, "get_auth_mode", return_value="builtin"):
with patch.object(main_module.backend, "queue_status", return_value=_sample_status_payload()):
dismiss_many_response = client.post(
"/api/activity/dismiss-many",
json={"items": [{"item_type": "download", "item_key": f"download:{task_id}"}]},
)
history_response = client.get("/api/activity/history?limit=10&offset=0")
assert dismiss_many_response.status_code == 200
assert dismiss_many_response.json["status"] == "dismissed"
assert history_response.status_code == 200
assert len(history_response.json) == 1
assert history_response.json[0]["item_key"] == f"download:{task_id}"
assert history_response.json[0]["snapshot"]["download"]["status_message"] == "Interrupted"
assert history_response.json[0]["snapshot"]["download"]["retry_available"] is True
def test_dismiss_many_returns_404_without_partial_dismiss_when_any_item_is_missing(self, main_module, client):
user = _create_user(main_module, prefix="reader")
_set_session(client, user_id=user["username"], db_user_id=user["id"], is_admin=False)
@@ -860,6 +911,149 @@ class TestActivityRoutes:
assert "stale-active-task" in response.json["status"]["error"]
assert response.json["status"]["error"]["stale-active-task"]["status_message"] == "Interrupted"
def test_snapshot_preserves_retry_for_stale_active_requested_download(self, main_module, client):
user = _create_user(main_module, prefix="reader")
_set_session(client, user_id=user["username"], db_user_id=user["id"], is_admin=False)
task_id = "stale-active-requested-task"
retry_payload = {
"task_id": task_id,
"source": "prowlarr",
"title": "Stale Active Requested Task",
"user_id": user["id"],
"username": user["username"],
"request_id": 123,
"search_mode": "universal",
"retry_download_url": "magnet:?xt=urn:btih:staleactive123",
"retry_download_protocol": "torrent",
"retry_release_name": "Stale Active Requested Task",
"can_retry_without_staged_source": True,
}
main_module.download_history_service.record_download(
task_id=task_id,
user_id=user["id"],
username=user["username"],
request_id=123,
source="prowlarr",
source_display_name="Prowlarr",
title="Stale Active Requested Task",
author="Stale Author",
format="epub",
size="1 MB",
preview=None,
content_type="ebook",
origin="requested",
retry_payload=retry_payload,
)
with patch.object(main_module, "get_auth_mode", return_value="builtin"):
with patch.object(main_module.backend, "queue_status", return_value=_sample_status_payload()):
response = client.get("/api/activity/snapshot")
assert response.status_code == 200
assert response.json["status"]["error"][task_id]["status_message"] == "Interrupted"
assert response.json["status"]["error"][task_id]["retry_available"] is True
def test_snapshot_includes_retry_available_for_live_terminal_downloads(self, main_module, client):
user = _create_user(main_module, prefix="reader")
_set_session(client, user_id=user["username"], db_user_id=user["id"], is_admin=False)
_record_terminal_download(
main_module,
task_id="retryable-terminal-task",
user_id=user["id"],
username=user["username"],
title="Retryable Terminal Task",
origin="requested",
request_id=123,
final_status="error",
status_message="Destination not writable",
)
queue_status_payload = _sample_status_payload()
queue_status_payload["error"]["retryable-terminal-task"] = {
"retry_available": True,
}
with patch.object(main_module, "get_auth_mode", return_value="builtin"):
with patch.object(main_module.backend, "queue_status", return_value=queue_status_payload):
response = client.get("/api/activity/snapshot")
assert response.status_code == 200
assert response.json["status"]["error"]["retryable-terminal-task"]["retry_available"] is True
def test_snapshot_reopens_request_when_error_retry_is_no_longer_available(self, main_module, client):
user = _create_user(main_module, prefix="reader")
_set_session(client, user_id=user["username"], db_user_id=user["id"], is_admin=False)
request_row = main_module.user_db.create_request(
user_id=user["id"],
content_type="ebook",
request_level="release",
policy_mode="request_release",
book_data={
"title": "Retry Gone Request",
"author": "Retry Author",
"provider": "openlibrary",
"provider_id": "retry-gone-1",
},
release_data={
"source": "prowlarr",
"source_id": "retry-gone-task",
"title": "Retry Gone.epub",
},
status="fulfilled",
delivery_state="queued",
)
retry_payload = {
"task_id": "retry-gone-task",
"source": "prowlarr",
"title": "Retry Gone Request",
"user_id": user["id"],
"username": user["username"],
"request_id": request_row["id"],
"search_mode": "universal",
"retry_download_url": "magnet:?xt=urn:btih:abc123",
"retry_download_protocol": "torrent",
"retry_release_name": "Retry Gone Request",
"can_retry_without_staged_source": True,
}
main_module.download_history_service.record_download(
task_id="retry-gone-task",
user_id=user["id"],
username=user["username"],
request_id=request_row["id"],
source="prowlarr",
source_display_name="Prowlarr",
title="Retry Gone Request",
author="Retry Author",
format="epub",
size="1 MB",
preview=None,
content_type="ebook",
origin="requested",
retry_payload=retry_payload,
)
main_module.download_history_service.finalize_download(
task_id="retry-gone-task",
final_status="error",
status_message="Output routing failed",
retry_payload=retry_payload,
)
with patch.object(main_module, "get_auth_mode", return_value="builtin"):
with patch.object(main_module.backend, "queue_status", return_value=_sample_status_payload()):
response = client.get("/api/activity/snapshot")
assert response.status_code == 200
refreshed_request = main_module.user_db.get_request(request_row["id"])
assert refreshed_request["status"] == "pending"
assert refreshed_request["last_failure_reason"] == "Output routing failed"
assert any(
row["id"] == request_row["id"] and row["status"] == "pending"
for row in response.json["requests"]
)
def test_snapshot_active_download_with_queue_entry_shows_in_correct_bucket(self, main_module, client):
user = _create_user(main_module, prefix="reader")
_set_session(client, user_id=user["username"], db_user_id=user["id"], is_admin=False)
@@ -225,6 +225,64 @@ class TestTerminalSnapshotCapture:
finally:
main_module.backend.book_queue.cancel_download(task_id)
def test_error_transition_keeps_request_fulfilled_when_postprocess_retry_is_available(
self,
main_module,
tmp_path,
):
user = _create_user(main_module, prefix="snap-retryable-request")
task_id = f"retryable-request-{uuid.uuid4().hex[:8]}"
staged_file = tmp_path / "retryable-request.epub"
staged_file.write_text("staged")
request_row = main_module.user_db.create_request(
user_id=user["id"],
content_type="ebook",
request_level="release",
policy_mode="request_release",
book_data={
"title": "Retryable Request",
"author": "Retry Author",
"provider": "openlibrary",
"provider_id": "retryable-request-1",
},
release_data={
"source": "prowlarr",
"source_id": task_id,
"title": "Retryable Request.epub",
},
status="fulfilled",
delivery_state="queued",
)
task = DownloadTask(
task_id=task_id,
source="prowlarr",
title="Retryable Request",
user_id=user["id"],
username=user["username"],
request_id=request_row["id"],
staged_path=str(staged_file),
)
assert main_module.backend.book_queue.add(task) is True
try:
main_module.backend.book_queue.update_status_message(task_id, "Destination not writable")
with patch.object(main_module, "reopen_failed_request") as mock_reopen:
main_module.backend.book_queue.update_status(task_id, QueueStatus.ERROR)
mock_reopen.assert_not_called()
persisted_request = next(
row for row in main_module.user_db.list_requests(user_id=user["id"])
if row["id"] == request_row["id"]
)
assert persisted_request["status"] == "fulfilled"
assert persisted_request["release_data"] is not None
history_row = _read_download_history_row(main_module, task_id)
assert history_row is not None
assert history_row["final_status"] == "error"
finally:
main_module.backend.book_queue.cancel_download(task_id)
def test_queue_hook_records_active_row_at_queue_time(self, main_module):
user = _create_user(main_module, prefix="snap-queue")
task_id = f"queue-{uuid.uuid4().hex[:8]}"
+14 -7
View File
@@ -152,8 +152,10 @@ class TestAdminUsersListEndpoint:
)
with patch(
"shelfmark.core.admin_routes.load_config_file",
return_value={"OIDC_USE_ADMIN_GROUP": False},
"shelfmark.core.admin_routes.app_config.get",
side_effect=lambda key, default=None, user_id=None: {
"OIDC_USE_ADMIN_GROUP": False,
}.get(key, default),
):
resp = admin_client.get("/api/admin/users")
@@ -981,9 +983,11 @@ class TestAdminDownloadDefaults:
"""Create a temporary downloads config file."""
import json
from pathlib import Path
from shelfmark.core.config import config as app_config
config_dir = str(tmp_path)
monkeypatch.setenv("CONFIG_DIR", config_dir)
monkeypatch.delenv("INGEST_DIR", raising=False)
monkeypatch.setattr("shelfmark.config.env.CONFIG_DIR", Path(config_dir))
plugins_dir = tmp_path / "plugins"
plugins_dir.mkdir()
@@ -996,6 +1000,9 @@ class TestAdminDownloadDefaults:
"EMAIL_RECIPIENT": "reader@example.com",
}
(plugins_dir / "downloads.json").write_text(json.dumps(config))
app_config.refresh(force=True)
yield
app_config.refresh(force=True)
def test_returns_download_defaults(self, admin_client):
resp = admin_client.get("/api/admin/download-defaults")
@@ -1097,7 +1104,7 @@ class TestAdminDeliveryPreferences:
(plugins_dir / "downloads.json").write_text(json.dumps(downloads_config))
from shelfmark.core.config import config as app_config
app_config.refresh()
app_config.refresh(force=True)
def test_returns_curated_fields_and_effective_values(self, admin_client, user_db):
user = user_db.create_user(username="alice")
@@ -1181,7 +1188,7 @@ class TestAdminSearchPreferences:
(plugins_dir / "search_mode.json").write_text(json.dumps(search_mode_config))
from shelfmark.core.config import config as app_config
app_config.refresh()
app_config.refresh(force=True)
def test_returns_curated_fields_and_effective_values(self, admin_client, user_db):
user = user_db.create_user(username="alice")
@@ -1268,7 +1275,7 @@ class TestAdminNotificationPreferences:
(plugins_dir / "notifications.json").write_text(json.dumps(notifications_config))
from shelfmark.core.config import config as app_config
app_config.refresh()
app_config.refresh(force=True)
def test_returns_curated_fields_and_effective_values(self, admin_client, user_db):
user = user_db.create_user(username="alice")
@@ -1341,7 +1348,7 @@ class TestAdminNotificationPreferencesTestAction:
(plugins_dir / "notifications.json").write_text(json.dumps(notifications_config))
from shelfmark.core.config import config as app_config
app_config.refresh()
app_config.refresh(force=True)
def test_requires_admin(self, regular_client, user_db):
user = user_db.create_user(username="alice")
@@ -1500,7 +1507,7 @@ class TestAdminEffectiveSettings:
# Ensure config singleton sees the current test env/config dir.
from shelfmark.core.config import config as app_config
app_config.refresh()
app_config.refresh(force=True)
def test_returns_effective_values_with_sources(self, admin_client, user_db):
user = user_db.create_user(username="alice")
+142
View File
@@ -494,6 +494,54 @@ class TestRetryDownloadEndpointGuardrails:
assert resp.get_json() == {"status": "queued", "book_id": "direct-task-retry-1"}
mock_retry.assert_called_once_with("direct-task-retry-1")
def test_owner_can_retry_persisted_direct_download_when_live_task_is_missing(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,
)
retry_payload = {
"task_id": "persisted-direct-retry-1",
"source": "direct_download",
"title": "Persisted Direct Task",
"user_id": user["id"],
"username": user["username"],
"search_mode": "direct",
}
main_module.download_history_service.record_download(
task_id="persisted-direct-retry-1",
user_id=user["id"],
username=user["username"],
request_id=None,
source="direct_download",
source_display_name="Direct Download",
title="Persisted Direct Task",
author="Direct Author",
format="epub",
size="1 MB",
preview=None,
content_type="ebook",
origin="direct",
retry_payload=retry_payload,
)
with patch.object(main_module, "get_auth_mode", return_value="builtin"):
with patch.object(main_module.backend.book_queue, "get_task", return_value=None):
with patch.object(
main_module.backend,
"retry_persisted_download",
return_value=(True, None),
) as mock_retry:
resp = client.post("/api/download/persisted-direct-retry-1/retry")
assert resp.status_code == 200
assert resp.get_json() == {"status": "queued", "book_id": "persisted-direct-retry-1"}
assert mock_retry.call_args.args[0] == retry_payload
assert mock_retry.call_args.kwargs["final_status"] == "active"
def test_non_owner_cannot_retry_download(self, main_module, client):
owner = _create_user(main_module, prefix="owner")
actor = _create_user(main_module, prefix="actor")
@@ -591,6 +639,100 @@ class TestRetryDownloadEndpointGuardrails:
assert resp.get_json()["code"] == "requested_download_retry_forbidden"
mock_retry.assert_not_called()
def test_retry_allows_request_linked_postprocess_error_with_staged_file(self, main_module, client, tmp_path):
user = _create_user(main_module, prefix="requester")
_set_authenticated_session(
client,
user_id=user["username"],
db_user_id=user["id"],
is_admin=False,
)
staged_file = tmp_path / "requested-postprocess.epub"
staged_file.write_text("staged")
task = DownloadTask(
task_id="requested-retry-postprocess-1",
source="prowlarr",
title="Requested Book",
user_id=user["id"],
username=user["username"],
request_id=123,
staged_path=str(staged_file),
)
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.book_queue,
"get_task_status",
return_value=main_module.QueueStatus.ERROR,
):
with patch.object(main_module.backend, "retry_download", return_value=(True, None)) as mock_retry:
resp = client.post("/api/download/requested-retry-postprocess-1/retry")
assert resp.status_code == 200
assert resp.get_json() == {"status": "queued", "book_id": "requested-retry-postprocess-1"}
mock_retry.assert_called_once_with("requested-retry-postprocess-1")
def test_retry_allows_persisted_request_postprocess_error_with_staged_file(
self, main_module, client, tmp_path
):
user = _create_user(main_module, prefix="requester")
_set_authenticated_session(
client,
user_id=user["username"],
db_user_id=user["id"],
is_admin=False,
)
staged_file = tmp_path / "persisted-request-postprocess.epub"
staged_file.write_text("staged")
retry_payload = {
"task_id": "persisted-request-retry-1",
"source": "prowlarr",
"title": "Persisted Requested Book",
"user_id": user["id"],
"username": user["username"],
"request_id": 123,
"search_mode": "universal",
"staged_path": str(staged_file),
}
main_module.download_history_service.record_download(
task_id="persisted-request-retry-1",
user_id=user["id"],
username=user["username"],
request_id=123,
source="prowlarr",
source_display_name="Prowlarr",
title="Persisted Requested Book",
author="Request Author",
format="epub",
size="1 MB",
preview=None,
content_type="ebook",
origin="requested",
retry_payload=retry_payload,
)
main_module.download_history_service.finalize_download(
task_id="persisted-request-retry-1",
final_status="error",
status_message="Output routing failed",
retry_payload=retry_payload,
)
with patch.object(main_module, "get_auth_mode", return_value="builtin"):
with patch.object(main_module.backend.book_queue, "get_task", return_value=None):
with patch.object(
main_module.backend,
"retry_persisted_download",
return_value=(True, None),
) as mock_retry:
resp = client.post("/api/download/persisted-request-retry-1/retry")
assert resp.status_code == 200
assert resp.get_json() == {"status": "queued", "book_id": "persisted-request-retry-1"}
assert mock_retry.call_args.args[0] == retry_payload
assert mock_retry.call_args.kwargs["final_status"] == "error"
def test_retry_returns_409_for_non_retryable_state(self, main_module, client):
user = _create_user(main_module, prefix="reader")
_set_authenticated_session(
+23 -1
View File
@@ -64,8 +64,11 @@ class TestDetermineAuthMode:
assert determine_auth_mode(config, cwa_db_path=None, has_local_admin=False) == "none"
def test_load_active_auth_mode_reads_env_backed_cwa_setting(self, monkeypatch, tmp_path):
from shelfmark.core.config import config as app_config
monkeypatch.setenv("CONFIG_DIR", str(tmp_path))
monkeypatch.setenv("AUTH_METHOD", "cwa")
app_config.refresh(force=True)
cwa_db_path = tmp_path / "app.db"
conn = sqlite3.connect(cwa_db_path)
@@ -73,7 +76,26 @@ class TestDetermineAuthMode:
conn.commit()
conn.close()
assert load_active_auth_mode(cwa_db_path) == "cwa"
try:
assert load_active_auth_mode(cwa_db_path) == "cwa"
finally:
monkeypatch.delenv("AUTH_METHOD", raising=False)
app_config.refresh(force=True)
def test_load_active_auth_mode_reads_env_backed_proxy_setting(self, monkeypatch, tmp_path):
from shelfmark.core.config import config as app_config
monkeypatch.setenv("CONFIG_DIR", str(tmp_path))
monkeypatch.setenv("AUTH_METHOD", "proxy")
monkeypatch.setenv("PROXY_AUTH_USER_HEADER", "X-Forwarded-User")
app_config.refresh(force=True)
try:
assert load_active_auth_mode(cwa_db_path=None) == "proxy"
finally:
monkeypatch.delenv("AUTH_METHOD", raising=False)
monkeypatch.delenv("PROXY_AUTH_USER_HEADER", raising=False)
app_config.refresh(force=True)
class TestSettingsRestrictionPolicy:
+11 -4
View File
@@ -21,6 +21,13 @@ def _get_oidc_error(resp) -> str | None:
return errors[0] if errors else None
def _config_getter(values: dict[str, object]):
def _get(key: str, default: object = None, user_id: object = None):
return values.get(key, default)
return _get
@pytest.fixture
def db_path():
with tempfile.TemporaryDirectory() as tmpdir:
@@ -64,7 +71,7 @@ def client(app):
class TestOIDCClientRegistration:
@patch("shelfmark.core.oidc_routes.load_config_file", return_value=MOCK_OIDC_CONFIG)
@patch("shelfmark.core.oidc_routes.app_config.get", side_effect=_config_getter(MOCK_OIDC_CONFIG))
@patch("shelfmark.core.oidc_routes.oauth.create_client")
@patch("shelfmark.core.oidc_routes.oauth.register")
def test_registers_client_with_pkce_and_expected_scopes(
@@ -78,7 +85,7 @@ class TestOIDCClientRegistration:
client_obj, config = _get_oidc_client()
assert client_obj is fake_client
assert config["OIDC_CLIENT_ID"] == "shelfmark"
assert config["OIDC_DISCOVERY_URL"] == MOCK_OIDC_CONFIG["OIDC_DISCOVERY_URL"]
kwargs = mock_register.call_args.kwargs
assert kwargs["name"] == "shelfmark_idp"
assert kwargs["server_metadata_url"] == MOCK_OIDC_CONFIG["OIDC_DISCOVERY_URL"]
@@ -90,7 +97,7 @@ class TestOIDCClientRegistration:
assert "profile" in scope_str
assert "groups" in scope_str
@patch("shelfmark.core.oidc_routes.load_config_file")
@patch("shelfmark.core.oidc_routes.app_config.get")
@patch("shelfmark.core.oidc_routes.oauth.create_client")
@patch("shelfmark.core.oidc_routes.oauth.register")
def test_does_not_append_group_claim_when_admin_group_auth_disabled(
@@ -104,7 +111,7 @@ class TestOIDCClientRegistration:
"OIDC_USE_ADMIN_GROUP": False,
"OIDC_GROUP_CLAIM": "groups",
}
mock_config.return_value = config
mock_config.side_effect = _config_getter(config)
mock_create_client.return_value = Mock()
_get_oidc_client()
+37
View File
@@ -706,6 +706,43 @@ def test_sync_delivery_states_from_queue_status_uses_request_id_for_duplicate_so
assert user_db.get_request(newer_request["id"])["delivery_state"] == "downloading"
def test_sync_delivery_states_reopens_fulfilled_request_when_error_is_not_retryable(user_db):
user = user_db.create_user(username="alice")
fulfilled_request = user_db.create_request(
user_id=user["id"],
source_hint="prowlarr",
content_type="ebook",
request_level="release",
policy_mode="request_release",
book_data=_book_data(),
release_data={"source": "prowlarr", "source_id": "retry-gone-rel", "title": "Retry Gone"},
status="fulfilled",
delivery_state="queued",
)
updated = sync_delivery_states_from_queue_status(
user_db,
queue_status={
"error": {
"retry-gone-rel": {
"id": "retry-gone-rel",
"request_id": fulfilled_request["id"],
"retry_available": False,
"status_message": "Staged retry source no longer exists",
},
},
},
user_id=user["id"],
)
assert [row["id"] for row in updated] == [fulfilled_request["id"]]
refreshed = user_db.get_request(fulfilled_request["id"])
assert refreshed["status"] == "pending"
assert refreshed["delivery_state"] == "none"
assert refreshed["release_data"] is None
assert refreshed["last_failure_reason"] == "Staged retry source no longer exists"
# ---------------------------------------------------------------------------
# book_data validation
# ---------------------------------------------------------------------------
+30
View File
@@ -62,6 +62,36 @@ def test_retry_download_rejects_request_linked_tasks(monkeypatch):
mock_queue.enqueue_existing.assert_not_called()
def test_can_retry_download_task_allows_request_postprocess_retry_when_staged_file_exists(tmp_path):
import shelfmark.download.orchestrator as orchestrator
staged_file = tmp_path / "requested-staged.epub"
staged_file.write_text("staged")
task = DownloadTask(
task_id="task-request-staged-1",
source="prowlarr",
title="Requested Retryable",
request_id=123,
staged_path=str(staged_file),
)
assert orchestrator.can_retry_download_task(task, QueueStatus.ERROR) is True
def test_can_retry_download_task_blocks_request_error_retry_without_staged_file():
import shelfmark.download.orchestrator as orchestrator
task = DownloadTask(
task_id="task-request-staged-2",
source="prowlarr",
title="Requested Not Retryable",
request_id=123,
staged_path="/tmp/does-not-exist.epub",
)
assert orchestrator.can_retry_download_task(task, QueueStatus.ERROR) is False
def test_finalize_download_failure_sets_terminal_error(monkeypatch):
import shelfmark.download.orchestrator as orchestrator
@@ -110,3 +110,45 @@ def test_queue_release_email_mode_without_recipient_is_queued(monkeypatch):
task = captured["task"]
assert task.output_mode == "email"
assert task.output_args == {}
def test_queue_release_persists_generic_retry_resolution_fields(monkeypatch):
import shelfmark.download.orchestrator as orchestrator
captured: dict[str, object] = {}
def fake_add(task):
captured["task"] = task
return True
monkeypatch.setattr(orchestrator.book_queue, "add", fake_add)
monkeypatch.setattr(orchestrator, "ws_manager", None)
success, error = orchestrator.queue_release(
{
"source": "prowlarr",
"source_id": "prowlarr-release-1",
"title": "Queued Prowlarr Release",
"download_url": "magnet:?xt=urn:btih:abc123",
"protocol": "torrent",
"indexer": "MyIndexer",
"extra": {
"minimum_ratio": 1.25,
"minimum_seed_time": 5400,
"info_hash": "ABC123",
},
},
user_id=42,
username="alice",
)
assert success is True
assert error is None
task = captured["task"]
assert task.retry_download_url == "magnet:?xt=urn:btih:abc123"
assert task.retry_download_protocol == "torrent"
assert task.retry_release_name == "Queued Prowlarr Release"
assert task.retry_expected_hash == "ABC123"
assert task.retry_ratio_limit == 1.25
assert task.retry_seeding_time_limit_minutes == 90
assert task.can_retry_without_staged_source is True
+40 -40
View File
@@ -24,6 +24,13 @@ def _as_response(result: Any):
return result
def _config_getter(values: dict[str, Any]):
def _get(key: str, default: Any = None, user_id: Any = None):
return values.get(key, default)
return _get
@pytest.fixture(scope="module")
def main_module():
"""Import `shelfmark.main` with background thread startup disabled."""
@@ -37,49 +44,43 @@ def main_module():
class TestGetAuthMode:
def test_get_auth_mode_none(self, main_module):
with patch("shelfmark.core.settings_registry.load_config_file", return_value={"AUTH_METHOD": "none"}):
with patch.object(main_module.app_config, "get", side_effect=_config_getter({"AUTH_METHOD": "none"})):
assert main_module.get_auth_mode() == "none"
def test_get_auth_mode_builtin(self, main_module):
with patch(
"shelfmark.core.settings_registry.load_config_file",
return_value={"AUTH_METHOD": "builtin"},
):
with patch.object(main_module.app_config, "get", side_effect=_config_getter({"AUTH_METHOD": "builtin"})):
with patch("shelfmark.core.auth_modes.has_local_password_admin", return_value=True):
assert main_module.get_auth_mode() == "builtin"
def test_get_auth_mode_builtin_without_local_admin_falls_back_to_none(self, main_module):
with patch(
"shelfmark.core.settings_registry.load_config_file",
return_value={"AUTH_METHOD": "builtin"},
):
with patch.object(main_module.app_config, "get", side_effect=_config_getter({"AUTH_METHOD": "builtin"})):
with patch("shelfmark.core.auth_modes.has_local_password_admin", return_value=False):
assert main_module.get_auth_mode() == "none"
def test_get_auth_mode_proxy(self, main_module):
with patch(
"shelfmark.core.settings_registry.load_config_file",
return_value={"AUTH_METHOD": "proxy", "PROXY_AUTH_USER_HEADER": "X-Auth-User"},
with patch.object(
main_module.app_config,
"get",
side_effect=_config_getter({"AUTH_METHOD": "proxy", "PROXY_AUTH_USER_HEADER": "X-Auth-User"}),
):
assert main_module.get_auth_mode() == "proxy"
def test_get_auth_mode_cwa(self, main_module):
with patch("shelfmark.core.settings_registry.load_config_file", return_value={"AUTH_METHOD": "cwa"}):
with patch.object(main_module.app_config, "get", side_effect=_config_getter({"AUTH_METHOD": "cwa"})):
with patch.object(main_module, "CWA_DB_PATH", object()):
assert main_module.get_auth_mode() == "cwa"
def test_get_auth_mode_default_on_error(self, main_module):
with patch("shelfmark.core.settings_registry.load_config_file", side_effect=Exception("boom")):
with patch.object(main_module.app_config, "get", side_effect=Exception("boom")):
assert main_module.get_auth_mode() == "none"
class TestAuthCheckEndpoint:
def test_auth_check_no_auth(self, main_module):
with patch.object(main_module, "get_auth_mode", return_value="none"):
with patch("shelfmark.core.settings_registry.load_config_file", return_value={}):
with main_module.app.test_request_context("/api/auth/check"):
resp = _as_response(main_module.api_auth_check())
data = resp.get_json()
with main_module.app.test_request_context("/api/auth/check"):
resp = _as_response(main_module.api_auth_check())
data = resp.get_json()
assert resp.status_code == 200
assert data == {
@@ -91,10 +92,9 @@ class TestAuthCheckEndpoint:
def test_auth_check_builtin_not_authenticated(self, main_module):
with patch.object(main_module, "get_auth_mode", return_value="builtin"):
with patch("shelfmark.core.settings_registry.load_config_file", return_value={}):
with main_module.app.test_request_context("/api/auth/check"):
resp = _as_response(main_module.api_auth_check())
data = resp.get_json()
with main_module.app.test_request_context("/api/auth/check"):
resp = _as_response(main_module.api_auth_check())
data = resp.get_json()
assert resp.status_code == 200
assert data["authenticated"] is False
@@ -105,12 +105,11 @@ class TestAuthCheckEndpoint:
def test_auth_check_builtin_authenticated(self, main_module):
with patch.object(main_module, "get_auth_mode", return_value="builtin"):
with patch("shelfmark.core.settings_registry.load_config_file", return_value={}):
with main_module.app.test_request_context("/api/auth/check"):
main_module.session["user_id"] = "admin"
main_module.session["is_admin"] = True
resp = _as_response(main_module.api_auth_check())
data = resp.get_json()
with main_module.app.test_request_context("/api/auth/check"):
main_module.session["user_id"] = "admin"
main_module.session["is_admin"] = True
resp = _as_response(main_module.api_auth_check())
data = resp.get_json()
assert resp.status_code == 200
assert data["authenticated"] is True
@@ -121,12 +120,13 @@ class TestAuthCheckEndpoint:
def test_auth_check_proxy_includes_logout_url(self, main_module):
with patch.object(main_module, "get_auth_mode", return_value="proxy"):
with patch(
"shelfmark.core.settings_registry.load_config_file",
return_value={
with patch.object(
main_module.app_config,
"get",
side_effect=_config_getter({
"PROXY_AUTH_USER_HEADER": "X-Auth-User",
"PROXY_AUTH_LOGOUT_URL": "https://auth.example.com/logout",
},
}),
):
with main_module.app.test_request_context("/api/auth/check"):
main_module.session["user_id"] = "proxyuser"
@@ -287,9 +287,10 @@ class TestLoginEndpoint:
class TestLogoutEndpoint:
def test_logout_proxy_returns_logout_url(self, main_module):
with patch.object(main_module, "get_auth_mode", return_value="proxy"):
with patch(
"shelfmark.core.settings_registry.load_config_file",
return_value={"PROXY_AUTH_LOGOUT_URL": "https://auth.example.com/logout"},
with patch.object(
main_module.app_config,
"get",
side_effect=_config_getter({"PROXY_AUTH_LOGOUT_URL": "https://auth.example.com/logout"}),
):
with main_module.app.test_request_context("/api/auth/logout", method="POST"):
main_module.session["user_id"] = "proxyuser"
@@ -302,11 +303,10 @@ class TestLogoutEndpoint:
def test_logout_basic(self, main_module):
with patch.object(main_module, "get_auth_mode", return_value="builtin"):
with patch("shelfmark.core.settings_registry.load_config_file", return_value={}):
with main_module.app.test_request_context("/api/auth/logout", method="POST"):
main_module.session["user_id"] = "admin"
resp = _as_response(main_module.api_logout())
data = resp.get_json()
with main_module.app.test_request_context("/api/auth/logout", method="POST"):
main_module.session["user_id"] = "admin"
resp = _as_response(main_module.api_logout())
data = resp.get_json()
assert resp.status_code == 200
assert data["success"] is True
+69 -82
View File
@@ -18,6 +18,13 @@ def _as_response(result: Any):
return result
def _config_getter(values: dict[str, Any]):
def _get(key: str, default: Any = None, user_id: Any = None):
return values.get(key, default)
return _get
@pytest.fixture(scope="module")
def main_module():
with patch("shelfmark.download.orchestrator.start"):
@@ -43,9 +50,10 @@ class TestProxyAuthMiddleware:
def test_allows_auth_check_without_header(self, main_module):
with patch.object(main_module, "get_auth_mode", return_value="proxy"):
with patch(
"shelfmark.core.settings_registry.load_config_file",
return_value={"PROXY_AUTH_USER_HEADER": "X-Auth-User"},
with patch.object(
main_module.app_config,
"get",
side_effect=_config_getter({"PROXY_AUTH_USER_HEADER": "X-Auth-User"}),
):
with main_module.app.test_request_context("/api/auth/check"):
result = main_module.proxy_auth_middleware()
@@ -54,11 +62,10 @@ class TestProxyAuthMiddleware:
def test_sets_session_from_header(self, main_module):
with patch.object(main_module, "get_auth_mode", return_value="proxy"):
with patch(
"shelfmark.core.settings_registry.load_config_file",
return_value={
"PROXY_AUTH_USER_HEADER": "X-Auth-User",
},
with patch.object(
main_module.app_config,
"get",
side_effect=_config_getter({"PROXY_AUTH_USER_HEADER": "X-Auth-User"}),
):
with main_module.app.test_request_context(
"/api/releases",
@@ -84,9 +91,10 @@ class TestProxyAuthMiddleware:
)
with patch.object(main_module, "get_auth_mode", return_value="proxy"):
with patch(
"shelfmark.core.settings_registry.load_config_file",
return_value={"PROXY_AUTH_USER_HEADER": "X-Auth-User"},
with patch.object(
main_module.app_config,
"get",
side_effect=_config_getter({"PROXY_AUTH_USER_HEADER": "X-Auth-User"}),
):
with main_module.app.test_request_context(
"/api/releases",
@@ -104,11 +112,10 @@ class TestProxyAuthMiddleware:
def test_reprovisions_when_proxy_identity_changes(self, main_module):
with patch.object(main_module, "get_auth_mode", return_value="proxy"):
with patch(
"shelfmark.core.settings_registry.load_config_file",
return_value={
"PROXY_AUTH_USER_HEADER": "X-Auth-User",
},
with patch.object(
main_module.app_config,
"get",
side_effect=_config_getter({"PROXY_AUTH_USER_HEADER": "X-Auth-User"}),
):
with main_module.app.test_request_context(
"/api/releases",
@@ -130,11 +137,10 @@ class TestProxyAuthMiddleware:
assert main_module.user_db.get_user(user_id=stale_user_id) is None
with patch.object(main_module, "get_auth_mode", return_value="proxy"):
with patch(
"shelfmark.core.settings_registry.load_config_file",
return_value={
"PROXY_AUTH_USER_HEADER": "X-Auth-User",
},
with patch.object(
main_module.app_config,
"get",
side_effect=_config_getter({"PROXY_AUTH_USER_HEADER": "X-Auth-User"}),
):
with main_module.app.test_request_context(
"/api/releases",
@@ -164,11 +170,10 @@ class TestProxyAuthMiddleware:
)
with patch.object(main_module, "get_auth_mode", return_value="proxy"):
with patch(
"shelfmark.core.settings_registry.load_config_file",
return_value={
"PROXY_AUTH_USER_HEADER": "X-Auth-User",
},
with patch.object(
main_module.app_config,
"get",
side_effect=_config_getter({"PROXY_AUTH_USER_HEADER": "X-Auth-User"}),
):
with main_module.app.test_request_context(
"/api/releases",
@@ -191,9 +196,10 @@ class TestProxyAuthMiddleware:
def test_returns_401_when_header_missing_on_protected_path(self, main_module):
with patch.object(main_module, "get_auth_mode", return_value="proxy"):
with patch(
"shelfmark.core.settings_registry.load_config_file",
return_value={"PROXY_AUTH_USER_HEADER": "X-Auth-User"},
with patch.object(
main_module.app_config,
"get",
side_effect=_config_getter({"PROXY_AUTH_USER_HEADER": "X-Auth-User"}),
):
with main_module.app.test_request_context("/api/releases"):
resp = _as_response(main_module.proxy_auth_middleware())
@@ -204,13 +210,14 @@ class TestProxyAuthMiddleware:
def test_admin_group_membership(self, main_module):
with patch.object(main_module, "get_auth_mode", return_value="proxy"):
with patch(
"shelfmark.core.settings_registry.load_config_file",
return_value={
with patch.object(
main_module.app_config,
"get",
side_effect=_config_getter({
"PROXY_AUTH_USER_HEADER": "X-Auth-User",
"PROXY_AUTH_ADMIN_GROUP_HEADER": "X-Auth-Groups",
"PROXY_AUTH_ADMIN_GROUP_NAME": "admins",
},
}),
):
with main_module.app.test_request_context(
"/api/releases",
@@ -259,78 +266,58 @@ class TestLoginRequiredDecorator:
def test_settings_access_requires_admin_even_when_legacy_toggle_off(self, main_module, view):
with patch.object(main_module, "get_auth_mode", return_value="builtin"):
with patch(
"shelfmark.core.settings_registry.load_config_file",
return_value={"RESTRICT_SETTINGS_TO_ADMIN": False},
):
with main_module.app.test_request_context("/api/settings/general"):
main_module.session["user_id"] = "user"
main_module.session["is_admin"] = False
decorated = main_module.login_required(view)
resp = _as_response(decorated())
data = resp.get_json()
with main_module.app.test_request_context("/api/settings/general"):
main_module.session["user_id"] = "user"
main_module.session["is_admin"] = False
decorated = main_module.login_required(view)
resp = _as_response(decorated())
data = resp.get_json()
assert resp.status_code == 403
assert "Admin access required" in (data.get("error") or "")
def test_security_tab_always_blocks_non_admin_even_when_toggle_off(self, main_module, view):
with patch.object(main_module, "get_auth_mode", return_value="builtin"):
with patch(
"shelfmark.core.settings_registry.load_config_file",
return_value={"RESTRICT_SETTINGS_TO_ADMIN": False},
):
with main_module.app.test_request_context("/api/settings/security"):
main_module.session["user_id"] = "user"
main_module.session["is_admin"] = False
decorated = main_module.login_required(view)
resp = _as_response(decorated())
data = resp.get_json()
with main_module.app.test_request_context("/api/settings/security"):
main_module.session["user_id"] = "user"
main_module.session["is_admin"] = False
decorated = main_module.login_required(view)
resp = _as_response(decorated())
data = resp.get_json()
assert resp.status_code == 403
assert "Admin access required" in (data.get("error") or "")
def test_users_tab_always_blocks_non_admin_even_when_toggle_off(self, main_module, view):
with patch.object(main_module, "get_auth_mode", return_value="builtin"):
with patch(
"shelfmark.core.settings_registry.load_config_file",
return_value={"RESTRICT_SETTINGS_TO_ADMIN": False},
):
with main_module.app.test_request_context("/api/settings/users"):
main_module.session["user_id"] = "user"
main_module.session["is_admin"] = False
decorated = main_module.login_required(view)
resp = _as_response(decorated())
data = resp.get_json()
with main_module.app.test_request_context("/api/settings/users"):
main_module.session["user_id"] = "user"
main_module.session["is_admin"] = False
decorated = main_module.login_required(view)
resp = _as_response(decorated())
data = resp.get_json()
assert resp.status_code == 403
assert "Admin access required" in (data.get("error") or "")
def test_proxy_admin_restriction_blocks_non_admin(self, main_module, view):
with patch.object(main_module, "get_auth_mode", return_value="proxy"):
with patch(
"shelfmark.core.settings_registry.load_config_file",
return_value={"RESTRICT_SETTINGS_TO_ADMIN": True},
):
with main_module.app.test_request_context("/api/settings/general"):
main_module.session["user_id"] = "user"
main_module.session["is_admin"] = False
decorated = main_module.login_required(view)
resp = _as_response(decorated())
data = resp.get_json()
with main_module.app.test_request_context("/api/settings/general"):
main_module.session["user_id"] = "user"
main_module.session["is_admin"] = False
decorated = main_module.login_required(view)
resp = _as_response(decorated())
data = resp.get_json()
assert resp.status_code == 403
assert "Admin access required" in (data.get("error") or "")
def test_cwa_admin_restriction_blocks_non_admin(self, main_module, view):
with patch.object(main_module, "get_auth_mode", return_value="cwa"):
with patch(
"shelfmark.core.settings_registry.load_config_file",
return_value={"RESTRICT_SETTINGS_TO_ADMIN": True},
):
with main_module.app.test_request_context("/api/settings/general"):
main_module.session["user_id"] = "user"
main_module.session["is_admin"] = False
decorated = main_module.login_required(view)
resp = _as_response(decorated())
with main_module.app.test_request_context("/api/settings/general"):
main_module.session["user_id"] = "user"
main_module.session["is_admin"] = False
decorated = main_module.login_required(view)
resp = _as_response(decorated())
assert resp.status_code == 403
+26
View File
@@ -108,6 +108,32 @@ class TestProwlarrHandlerDownloadErrors:
assert recorder.last_message is not None
assert "cache" in recorder.last_message.lower()
def test_resolve_download_uses_task_retry_fields_when_cache_is_missing(self):
"""Generic retry fields should let restarts recover without the in-memory cache."""
with patch(
"shelfmark.release_sources.prowlarr.handler.get_release",
return_value=None,
):
handler = ProwlarrHandler()
task = DownloadTask(
task_id="retry-context-release",
source="prowlarr",
title="Recovered Release",
retry_download_url="magnet:?xt=urn:btih:abc123",
retry_download_protocol="torrent",
retry_release_name="Recovered Release",
retry_seeding_time_limit_minutes=60,
retry_ratio_limit=1.5,
)
request = handler._resolve_download(task, lambda *_: None)
assert request is not None
assert request.url == "magnet:?xt=urn:btih:abc123"
assert request.protocol == "torrent"
assert request.seeding_time_limit == 60
assert request.ratio_limit == 1.5
def test_download_fails_without_download_url(self):
"""Test that download fails when release has no download URL."""
with patch(