Files
shelfmark/tests/core/test_viewer_redaction.py
T
CaliBrain bac0b93566 fix: keep https:// for qBittorrent and hide release sources from non-admins (#1422)
qBittorrent over HTTPS (#1417)

qbittorrent-api ignores the scheme in `host` and probes HTTP and HTTPS
itself. When the HTTPS probe fails, for instance on a certificate
Shelfmark doesn't trust, it falls back to plain HTTP against the TLS
port, and a reverse proxy answers "400 The plain HTTP request was sent
to HTTPS port". The download client and the Test Connection button now
pass FORCE_SCHEME_FROM_HOST for https:// URLs, so the configured scheme
is used as-is and a certificate problem is reported as one. http:// and
bare host:port URLs keep the probe: normalization adds http:// to a bare
host, and the probe follows a proxy's redirect to HTTPS.

Release sources leaked to non-admins (#1418)

A download queued from an approved request carries the release an admin
picked, and the requester could read it from their own activity feed:
the request's release_data (source_id, indexer, info URL, torrent
attributes), the download's full server path, and the download id
itself, which for Prowlarr is "<indexer id>:<guid>" and for a private
tracker is a URL into it. That id keys every status payload, the
/api/localdownload query and the cover proxy URL, so hiding
release_data alone would not have closed the leak.

For non-admin viewers:
- request rows keep only the release fields the activity cards display
- download_path is reduced to the file name, which the browser download
  reveals anyway
- request-linked downloads are addressed by an opaque id (a keyed HMAC
  of the task id) in the snapshot, history, /api/status, queue order,
  active downloads, dismissed keys, cover URLs and the websocket status
  and progress events. Routes that take a download id translate it
  back, searching only the caller's own downloads.

Downloads a user queued directly keep their real id: the user picked
that release from search results that already showed it, and the
release list matches its buttons to the queue by that id. Admin views
are unchanged. The activity sidebar now links a fulfilled request to
its download by request_id instead of release_data.source_id.

Closes #1417
Closes #1418
2026-10-02 20:38:58 -04:00

118 lines
3.9 KiB
Python

"""Tests for hiding release-source details from non-admin viewers (#1418)."""
from __future__ import annotations
from unittest.mock import MagicMock
import pytest
from shelfmark.api.websocket import WebSocketManager
from shelfmark.core import viewer_redaction
from shelfmark.core.viewer_redaction import (
match_public_download_id,
public_download_id,
redact_download_payload,
redact_release_data,
redact_status,
viewer_download_id,
)
_TRACKER_ID = "21:https://www.myanonamouse.net/t/123456"
@pytest.fixture(autouse=True)
def _restore_key():
original = viewer_redaction._public_id_key
yield
viewer_redaction._public_id_key = original
def test_public_id_is_stable_opaque_and_keyed():
viewer_redaction.configure_public_id_key(b"a" * 64)
first = public_download_id(_TRACKER_ID)
assert first == public_download_id(_TRACKER_ID)
assert first.startswith("dl_")
assert "myanonamouse" not in first
# Keyed: a plain hash of a guessed tracker URL must not reproduce it.
viewer_redaction.configure_public_id_key(b"b" * 64)
assert public_download_id(_TRACKER_ID) != first
def test_only_request_linked_downloads_get_an_opaque_id():
assert viewer_download_id(_TRACKER_ID, 7) == public_download_id(_TRACKER_ID)
assert viewer_download_id("direct-task", None) == "direct-task"
def test_an_opaque_id_resolves_only_among_the_given_tasks():
viewer_id = public_download_id(_TRACKER_ID)
assert match_public_download_id(viewer_id, ["other", _TRACKER_ID]) == _TRACKER_ID
assert match_public_download_id(viewer_id, ["other"]) is None
assert match_public_download_id(_TRACKER_ID, [_TRACKER_ID]) is None
def test_release_data_keeps_only_display_fields():
redacted = redact_release_data(
{
"source": "prowlarr",
"source_id": _TRACKER_ID,
"indexer": "MyAnonamouse",
"info_url": "https://www.myanonamouse.net/t/123456",
"download_url": "https://prowlarr.local/api/v1/indexer/21/download?apikey=k",
"protocol": "torrent",
"extra": {"info_hash": "abc"},
"title": "Book",
"format": "m4b",
"size": "300 MB",
}
)
assert redacted == {"source": "prowlarr", "title": "Book", "format": "m4b", "size": "300 MB"}
assert redact_release_data(None) is None
def test_download_payload_loses_its_path_and_tracker_id():
payload = {
"id": _TRACKER_ID,
"request_id": 7,
"preview": f"/api/covers/{_TRACKER_ID}?url=abc",
"download_path": "/audiobooks/Example Author/Example Title/Example Title.m4b",
}
redacted = redact_download_payload(payload)
viewer_id = public_download_id(_TRACKER_ID)
assert redacted["id"] == viewer_id
assert redacted["preview"] == f"/api/covers/{viewer_id}?url=abc"
assert redacted["download_path"] == "Example Title.m4b"
assert payload["id"] == _TRACKER_ID # the caller's copy is untouched
def test_status_is_rekeyed_only_for_request_linked_downloads():
status = {
"complete": {
_TRACKER_ID: {"id": _TRACKER_ID, "request_id": 7},
"direct-task": {"id": "direct-task", "request_id": None},
},
"queued": {},
}
redacted = redact_status(status)
assert set(redacted["complete"]) == {public_download_id(_TRACKER_ID), "direct-task"}
assert redacted["queued"] == {}
def test_progress_reaches_the_requester_under_the_opaque_id():
manager = WebSocketManager()
socketio = MagicMock()
manager.init_app(MagicMock(), socketio)
manager._user_rooms["user_5"] = 1
manager.broadcast_download_progress(_TRACKER_ID, 42.0, "downloading", user_id=5, request_id=7)
emitted = {call.kwargs["to"]: call.args[1] for call in socketio.emit.call_args_list}
assert emitted["admins"]["book_id"] == _TRACKER_ID
assert emitted["user_5"]["book_id"] == public_download_id(_TRACKER_ID)