Files
shelfmark/tests/core/test_viewer_redaction.py
T
splitsec2andCaliBrain 237ddd53a2 fix(redaction): a non-admin's status message loses the links it quotes (#1541)
#1422 hid `source_id` and the download path from non-admins but left the
status message alone. A failed add stores the client's exception text
there, for example `401 Client Error: Unauthorized for url: <full url>`,
and for a requests error that URL carries the path and query: an indexer
key, a tracker passkey, a peer name. It reached the owner through
`/api/status`, `/api/activity` and the `status_update` socket event.

`redact_download_payload` now replaces every link in `status_message`
with `(link removed)`. That covers the live queue, the socket push and
the history snapshot, since all three go through it. Messages without a
link are unchanged and admins still see the full text.

Tests cover the payload, a whole status snapshot, and messages that must
stay as they are. They fail on main and pass here.

Found in a Claude pass over 1.5.0.

---------

Co-authored-by: CaliBrain <calibrain@l4n.xyz>
2026-10-22 20:26:47 -04:00

189 lines
6.6 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_message_loses_the_links_a_client_error_carries():
"""A failed add embeds the client's exception text, which names the full URL."""
message = (
"Failed to add to qbittorrent: 401 Client Error: Unauthorized for url: "
"http://prowlarr:9696/api/v1/indexer/21/download?apikey=s3cret&link=abc&file=Example"
)
redacted = redact_download_payload({"id": "task", "status_message": message})
assert redacted["status_message"] == (
"Failed to add to qbittorrent: 401 Client Error: Unauthorized for url: (link removed)"
)
assert "s3cret" not in redacted["status_message"]
assert "prowlarr:9696" not in redacted["status_message"]
assert message.endswith("file=Example") # the caller's copy is untouched
def test_status_message_loses_the_path_a_connection_error_carries():
"""requests prints a failed connection as ``with url: <path and query>``, no scheme."""
message = (
"Failed to add to qbittorrent: HTTPConnectionPool(host='prowlarr', port=9696): "
"Max retries exceeded with url: /api/v1/indexer/21/download?apikey=s3cret&link=abc "
"(Caused by NewConnectionError(\"HTTPConnection(host='prowlarr', port=9696): "
'Failed to establish a new connection: [Errno 111] Connection refused"))'
)
redacted = redact_download_payload({"id": "task", "status_message": message})["status_message"]
assert "s3cret" not in redacted
assert "/api/v1/indexer" not in redacted
assert (
"Max retries exceeded with url: (link removed) (Caused by NewConnectionError(" in redacted
)
@pytest.mark.parametrize("scheme", ["HTTP", "Https", "HTTPS"])
def test_status_message_link_scheme_is_matched_case_insensitively(scheme):
message = f"502 Server Error: Bad Gateway for url: {scheme}://prowlarr:9696/api?apikey=s3cret"
redacted = redact_download_payload({"id": "task", "status_message": message})["status_message"]
assert redacted == "502 Server Error: Bad Gateway for url: (link removed)"
@pytest.mark.parametrize(
"message",
["Downloading 42% - 1.2 MB/s", "No seeders: 0 found, 1 needed, for 5 min", None, ""],
)
def test_status_message_without_a_link_is_left_alone(message):
assert (
redact_download_payload({"id": "task", "status_message": message})["status_message"]
== message
)
def test_status_message_is_redacted_in_a_whole_status_snapshot():
status = {
"error": {
"task": {
"id": "task",
"request_id": None,
"status_message": "Search failed for url: https://indexer.example/api?passkey=abc",
}
}
}
redacted = redact_status(status)
assert redacted["error"]["task"]["status_message"] == "Search failed for url: (link removed)"
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)