Files
shelfmark/tests/core/test_redaction.py
T
splitsec2 cfba44b754 refactor: one module for taking credentials and links out of text (#1578)
Draft until #1571 merges: this is stacked on it, so the first two
commits here are that PR's and only the last one is new. I will rebase
once #1571 is in.

Shelfmark strips credentials and links out of text in five places,
written in different PRs with different patterns: the log formatter,
`_safe_url` and `_redact_urls_in_text` in `torrent_utils.py`,
`redact_status_message` in `viewer_redaction.py`, the `sed` rules in
`genDebug.sh`, and the password handling in the settings registry.
Between them there were three regexes that find a URL in text, two
separate userinfo patterns and no shared list of what counts as a
secret. #1571 made the gap visible, because every pattern added to the
logger had to be remembered somewhere else too.

This moves the text and URL part into `shelfmark/core/redaction.py`,
which imports nothing from the app so the logger can depend on it:

- `redact_secrets` (mask in place, for logs) moved from
`core/logger.py`, patterns unchanged. The logger imports it.
- `safe_url` and `redact_urls_in_text` (keep scheme, host and path, drop
the query) moved from `torrent_utils.py` and are public now, so
`alldebrid.py` no longer imports a private name from another module.
- `strip_links` (remove links entirely) is what `redact_status_message`
now calls. It and `redact_urls_in_text` share one link pattern, the one
the viewer redaction already used.

Two small behaviour changes come from sharing that pattern. A closing
bracket or quote right after a URL is no longer swallowed into it when
scrubbing an exception message. And the scheme-less path that requests
prints after `url: ` now keeps only what is before its query in
`redact_urls_in_text`, where before it passed through as it was (the log
formatter masked the key, but the path and query stayed in the message).

Also here: the two `sed` rules in `genDebug.sh` that mask userinfo now
stop at `?` and `#` like the logger pattern does, with a test that fails
on the old script.

Not in this PR: the debug bundle zips the live log directory as it is,
so log lines written before #1571 are not redacted in it. Fixing that
means copying the logs to a staging folder and running them through this
module, which I would do as a separate change.

Tests: a new file covers `safe_url`, `redact_urls_in_text` and
`strip_links` directly, and two checks that the viewer and the logger
really use these. Full suite passes.
2026-10-26 14:48:36 -06:00

75 lines
2.8 KiB
Python

"""The shared helpers that take credentials and links out of text."""
import pytest
from shelfmark.core import logger as logger_module
from shelfmark.core import redaction
from shelfmark.core.viewer_redaction import redact_status_message
class TestSafeUrl:
def test_keeps_scheme_host_and_path_and_drops_the_query_and_fragment(self):
url = "https://prowlarr.example:9696/1/download?apikey=s3cr3t&link=abc#frag"
assert redaction.safe_url(url) == "https://prowlarr.example:9696/1/download"
def test_is_cut_to_the_limit(self):
assert len(redaction.safe_url("https://t.example/" + "a" * 500, limit=40)) == 40
@pytest.mark.parametrize("value", ["magnet:?xt=urn:btih:abc", "not a url", ""])
def test_a_value_that_is_not_a_url_is_not_echoed(self, value):
assert redaction.safe_url(value) == "<unparsed url: str>"
class TestRedactUrlsInText:
def test_every_url_loses_its_query(self):
text = "GET https://a.example/x?apikey=k1 then https://b.example/y?token=k2 failed"
assert redaction.redact_urls_in_text(text) == (
"GET https://a.example/x then https://b.example/y failed"
)
def test_a_closing_bracket_or_quote_is_not_part_of_the_link(self):
text = "failed (https://a.example/x?apikey=k1) and 'https://b.example/y?key=k2'"
assert redaction.redact_urls_in_text(text) == (
"failed (https://a.example/x) and 'https://b.example/y'"
)
def test_the_path_a_failed_request_prints_keeps_everything_before_its_query(self):
text = (
"Max retries exceeded with url: /api/v1/indexer/1/download?apikey=s3cr3t&link=abc (x)"
)
assert redaction.redact_urls_in_text(text) == (
"Max retries exceeded with url: /api/v1/indexer/1/download (x)"
)
def test_text_without_a_link_is_untouched(self):
assert redaction.redact_urls_in_text("Connection refused") == "Connection refused"
class TestStripLinks:
def test_replaces_a_full_url(self):
assert redaction.strip_links("401 for url: https://i.example/dl?apikey=k") == (
"401 for url: (link removed)"
)
def test_replaces_the_scheme_less_path_after_url(self):
assert redaction.strip_links("Max retries exceeded with url: /a/b?apikey=k (Caused)") == (
"Max retries exceeded with url: (link removed) (Caused)"
)
def test_the_scheme_is_case_insensitive(self):
assert redaction.strip_links("HTTPS://I.example/dl?k=1 failed") == "(link removed) failed"
def test_the_viewer_message_redaction_is_the_shared_one():
message = "Unauthorized for url: https://i.example/dl?apikey=k"
assert redact_status_message(message) == redaction.strip_links(message)
def test_the_log_formatter_masks_with_the_shared_function():
assert logger_module.redact_secrets is redaction.redact_secrets