Skip to content

Commit 64963f9

Browse files
Richard Taylorclaude
authored andcommitted
DownloadManager: cover redact_url exception-path scrubbing with tests
PR #136 added URL scrubbing to the `except Exception as e:` handler in `_download_url_async`, but the verification of that branch was deferred to a hardware smoke-test. Move it under unit-test coverage: - Install a fake `aiohttp` into `sys.modules` so the function-local `import aiohttp` resolves to a stub whose `ClientSession.get()` raises a `RuntimeError(message)` where `message` contains the full request URL (mimicking aiohttp's `ClientConnectorError` behaviour without needing the network). - Capture `print(...)` via builtins monkey-patch, then assert: - `redact_url=True` strips the secret-bearing URL substring from the printed exception line and substitutes the `scheme://host/...REDACTED...` form - `redact_url=False` preserves the full URL in the printed line (regression guard for the default behaviour) - Use a plain class instance for the fake module — MicroPython's unittest port doesn't support `type(sys)(...)` to mint a new module object, but `sys.modules[name]` accepts any object. `bash tests/unittest.sh tests/test_download_manager.py` → 32 tests OK (was 30). Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
1 parent e608c65 commit 64963f9

1 file changed

Lines changed: 147 additions & 0 deletions

File tree

tests/test_download_manager.py

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -617,3 +617,150 @@ async def go():
617617

618618
self.assertEqual(mock.call_history[-1]['redact_url'], True)
619619
self.assertEqual(mock.redact_url_received, True)
620+
621+
622+
class TestRedactedExceptionPath(unittest.TestCase):
623+
"""End-to-end verification of the URL-bearing exception scrubbing in
624+
`_download_url_async`'s `except` handler.
625+
626+
Real-world trigger: aiohttp's `ClientConnectorError` (and friends) often
627+
embed the full request URL in `str(e)`. Without scrubbing, the
628+
`print(f"DownloadManager: Exception during download: {err_str}")` line
629+
leaks the secret-bearing URL to the serial / REPL log on every failed
630+
download attempt — exactly the case `redact_url=True` is meant to cover.
631+
632+
Strategy: install a fake `aiohttp` module into `sys.modules` so the
633+
function-local `import aiohttp` inside `_download_url_async` resolves to
634+
our fake. The fake's `ClientSession.get(...)` raises a `RuntimeError`
635+
whose message contains the full URL — mimicking aiohttp's behaviour
636+
closely enough to exercise the `if redact_url and url in err_str`
637+
branch deterministically (no network required).
638+
"""
639+
640+
def _capture_download_with_failing_aiohttp(self, *, url, redact_url,
641+
exc_message):
642+
"""Run `_download_url_async(url, redact_url=...)` against a fake
643+
aiohttp that raises `RuntimeError(exc_message)` from `session.get()`.
644+
Returns (captured_print_lines, raised_exception_or_None).
645+
"""
646+
import asyncio
647+
import sys
648+
import builtins
649+
650+
# Minimal fake aiohttp surface — only what _download_url_async touches
651+
# before it hits the failure point.
652+
class _FakeClientSession:
653+
def get(self, request_url, **kwargs):
654+
# Raise synchronously from .get() — the `async with
655+
# session.get(...) as response:` line will surface this as
656+
# an exception inside the outer try/except.
657+
raise RuntimeError(exc_message)
658+
659+
async def close(self):
660+
pass
661+
662+
# MicroPython doesn't support `type(sys)(...)` to instantiate a
663+
# fresh module object. The import machinery only checks
664+
# `sys.modules` for the name and binds whatever object it finds —
665+
# so a plain instance with the right attributes works just as
666+
# well for `import aiohttp; aiohttp.ClientSession()`.
667+
class _FakeAiohttp:
668+
pass
669+
670+
fake_aiohttp = _FakeAiohttp()
671+
fake_aiohttp.ClientSession = _FakeClientSession
672+
673+
# Capture print output without disturbing other tests' stdout.
674+
captured = []
675+
orig_print = builtins.print
676+
677+
def _fake_print(*args, **kwargs):
678+
captured.append(" ".join(str(a) for a in args))
679+
680+
old_aiohttp = sys.modules.get("aiohttp")
681+
sys.modules["aiohttp"] = fake_aiohttp
682+
builtins.print = _fake_print
683+
try:
684+
async def _go():
685+
await DownloadManager._download_url_async(
686+
url, redact_url=redact_url)
687+
688+
raised = None
689+
try:
690+
asyncio.run(_go())
691+
except Exception as e:
692+
raised = e
693+
finally:
694+
builtins.print = orig_print
695+
if old_aiohttp is None:
696+
# Don't leave a fake module behind that would mask real
697+
# aiohttp imports in subsequent tests in the same run.
698+
try:
699+
del sys.modules["aiohttp"]
700+
except KeyError:
701+
pass
702+
else:
703+
sys.modules["aiohttp"] = old_aiohttp
704+
705+
return captured, raised
706+
707+
def test_redact_url_true_scrubs_url_from_exception_print_line(self):
708+
# The motivating case: the URL embeds a secret (zpub / API key) in
709+
# the path, the aiohttp error embeds that URL in its message, and
710+
# the framework's except-handler print would leak it.
711+
url = ("https://btc1.trezor.io/api/v2/xpub/"
712+
"zpub6qSECRETxpubABCDEFG?details=txs&tokens=derived")
713+
exc_message = "Cannot connect to host: {} — DNS failure".format(url)
714+
715+
captured, raised = self._capture_download_with_failing_aiohttp(
716+
url=url, redact_url=True, exc_message=exc_message)
717+
718+
# The function must still re-raise (the framework's contract is
719+
# that scrubbing only affects logs, not control flow).
720+
self.assertIsNotNone(raised, "exception should still propagate")
721+
722+
# Find the exception-print line specifically.
723+
exc_lines = [l for l in captured
724+
if "Exception during download:" in l]
725+
self.assertTrue(exc_lines,
726+
"expected an 'Exception during download:' "
727+
"print line; got: {}".format(captured))
728+
729+
for line in exc_lines:
730+
# The secret-bearing path component must NOT appear in the
731+
# printed exception line.
732+
self.assertFalse(
733+
"zpub6qSECRETxpubABCDEFG" in line,
734+
"secret-bearing URL substring leaked into exception "
735+
"print: {}".format(line))
736+
self.assertFalse(
737+
url in line,
738+
"full URL leaked into exception print: {}".format(line))
739+
# The redacted form must appear (scheme + host preserved,
740+
# path replaced with /...REDACTED...).
741+
self.assertIn("https://btc1.trezor.io/...REDACTED...", line)
742+
743+
def test_redact_url_false_preserves_url_in_exception_print_line(self):
744+
# Regression guard: default behaviour MUST keep the full URL in
745+
# the exception print line so debugging public-URL downloads
746+
# (app icons, OS updates, weather) isn't degraded.
747+
url = "https://example.com/path/with/some/components"
748+
exc_message = "Cannot connect to host: {}".format(url)
749+
750+
captured, raised = self._capture_download_with_failing_aiohttp(
751+
url=url, redact_url=False, exc_message=exc_message)
752+
753+
self.assertIsNotNone(raised)
754+
755+
exc_lines = [l for l in captured
756+
if "Exception during download:" in l]
757+
self.assertTrue(exc_lines)
758+
self.assertTrue(
759+
any(url in l for l in exc_lines),
760+
"default behaviour should not scrub URL; "
761+
"got lines: {}".format(exc_lines))
762+
# And it must NOT redact when not asked to.
763+
self.assertFalse(
764+
any("...REDACTED..." in l for l in exc_lines),
765+
"default behaviour should not insert REDACTED placeholder; "
766+
"got lines: {}".format(exc_lines))

0 commit comments

Comments
 (0)