#!/usr/bin/env python3 """Unit tests for read_mailcatcher: message selection, URL extraction, and the exit-code contract. No network: every test patches the _http_get seam. Run with: python3 -m unittest discover -s scripts/tests (from the skill dir) """ import contextlib import http.server import io import json import os import sys import threading import unittest from unittest import mock HERE = os.path.dirname(os.path.abspath(__file__)) SCRIPTS = os.path.dirname(HERE) sys.path.insert(0, SCRIPTS) import read_mailcatcher BASE = "http://localhost:1080" class FakeHTTP: """Route table standing in for _http_get. Unrouted URLs are unreachable.""" def __init__(self, routes): self.routes = routes self.calls = [] def __call__(self, url): self.calls.append(url) if url not in self.routes: raise read_mailcatcher.Unreachable(f"no route for {url}") value = self.routes[url] if isinstance(value, Exception): raise value return value def messages_json(*entries): return json.dumps(list(entries)) def message(msg_id, recipient, subject): return {"id": msg_id, "recipients": [f"<{recipient}>"], "subject": subject} class SelectMessageTest(unittest.TestCase): def test_recipient_match_is_case_insensitive_substring(self): msgs = [message(1, "User@Bitwarden.test", "Verify your email")] self.assertEqual(read_mailcatcher.select_message(msgs, "user@bitwarden.test", ""), 1) def test_subject_pattern_filters(self): msgs = [message(1, "a@b.test", "Welcome aboard")] self.assertIsNone(read_mailcatcher.select_message(msgs, "a@b.test", "verify")) self.assertEqual(read_mailcatcher.select_message(msgs, "a@b.test", "welcome"), 1) def test_empty_pattern_matches_any_subject(self): msgs = [message(1, "a@b.test", "Anything at all")] self.assertEqual(read_mailcatcher.select_message(msgs, "a@b.test", ""), 1) def test_newest_id_wins(self): msgs = [ message(1, "a@b.test", "Verify your email"), message(7, "a@b.test", "Verify your email"), message(4, "a@b.test", "Verify your email"), ] self.assertEqual(read_mailcatcher.select_message(msgs, "a@b.test", "verify"), 7) def test_no_match_returns_none(self): msgs = [message(1, "someone@else.test", "Verify your email")] self.assertIsNone(read_mailcatcher.select_message(msgs, "a@b.test", "verify")) def test_message_without_an_id_is_skipped_not_indexed(self): """A missing `id` used to raise KeyError, escaping the exit-code contract.""" broken = message(1, "a@b.test", "Verify your email") del broken["id"] msgs = [broken, message(3, "a@b.test", "Verify your email")] self.assertEqual(read_mailcatcher.select_message(msgs, "a@b.test", "verify"), 3) def test_only_candidate_lacking_an_id_returns_none(self): broken = message(1, "a@b.test", "Verify your email") broken["id"] = None self.assertIsNone( read_mailcatcher.select_message([broken], "a@b.test", "verify") ) def test_non_integer_id_is_skipped_not_compared(self): """Mixed int and str ids used to raise TypeError inside max().""" string_id = message(9, "a@b.test", "Verify your email") string_id["id"] = "9" msgs = [string_id, message(1, "a@b.test", "Verify your email")] self.assertEqual(read_mailcatcher.select_message(msgs, "a@b.test", "verify"), 1) def test_non_object_entry_is_skipped(self): msgs = ["not a message", message(2, "a@b.test", "Verify your email")] self.assertEqual(read_mailcatcher.select_message(msgs, "a@b.test", "verify"), 2) class MatchingUrlsTest(unittest.TestCase): def setUp(self): self.link_filter = read_mailcatcher.DEFAULT_LINK_FILTER def test_keeps_only_urls_matching_the_filter(self): body = "Ignore https://localhost:8080/#/home then use https://localhost:8080/#/verify?t=1" self.assertEqual( read_mailcatcher.matching_urls(body, self.link_filter), ["https://localhost:8080/#/verify?t=1"], ) def test_returns_all_matches_in_document_order(self): # The caller (attempt) picks the first local one out of these, so the # full ordered list must come back, not just the first match. body = "first https://localhost:8080/#/login then https://localhost:8080/#/verify?t=1" self.assertEqual( read_mailcatcher.matching_urls(body, self.link_filter), [ "https://localhost:8080/#/login", "https://localhost:8080/#/verify?t=1", ], ) def test_returns_empty_when_nothing_matches(self): self.assertEqual( read_mailcatcher.matching_urls("only https://localhost:8080/#/home here", self.link_filter), [], ) def test_returns_empty_for_empty_body(self): self.assertEqual(read_mailcatcher.matching_urls("", self.link_filter), []) def test_multiline_body_does_not_glue_lines_together(self): # grep is line-based, so a newline can never land inside a match. The # regex must exclude all whitespace or these two lines would join. body = "https://localhost:8080/#/verify?t=1\nhttps://localhost:8080/#/login" self.assertEqual( read_mailcatcher.matching_urls(body, self.link_filter), [ "https://localhost:8080/#/verify?t=1", "https://localhost:8080/#/login", ], ) def test_custom_filter_is_honored(self): body = "https://localhost:8080/#/sso-landing" self.assertEqual(read_mailcatcher.matching_urls(body, "sso"), [body]) def test_invalid_regex_returns_empty_instead_of_raising(self): # An unbalanced group is a malformed ERE. grep -iE would error out # and yield no match rather than a match; matching_urls must fall # through to [] (and thus the caller's NO_MATCH branch) the same # way, instead of letting re.error propagate as a traceback. body = "https://localhost:8080/#/verify?t=1" self.assertEqual(read_mailcatcher.matching_urls(body, "("), []) def test_backslash_ends_url(self): body = "https://evil.com/verify\\@localhost:8080/x" self.assertEqual( read_mailcatcher.matching_urls(body, self.link_filter), ["https://evil.com/verify"], ) def test_single_quoted_href_excludes_quote(self): body = "Verify" self.assertEqual( read_mailcatcher.matching_urls(body, self.link_filter), ["https://localhost:8080/#/verify?t=1"], ) def test_angle_bracket_ends_url(self): body = "https://localhost:8080/#/verify?t=1
" self.assertEqual( read_mailcatcher.matching_urls(body, self.link_filter), ["https://localhost:8080/#/verify?t=1"], ) class IsLocalTest(unittest.TestCase): def setUp(self): self.allowed = read_mailcatcher.allowed_hosts({}) def test_allowlisted_hosts_accepted(self): self.assertTrue(read_mailcatcher.is_local("https://localhost:8080/x", self.allowed)) self.assertTrue(read_mailcatcher.is_local("http://bitwarden.test/x", self.allowed)) def test_external_host_rejected(self): self.assertFalse(read_mailcatcher.is_local("https://evil.com/x", self.allowed)) def test_userinfo_trick_rejected(self): self.assertFalse(read_mailcatcher.is_local("https://localhost@evil.com/x", self.allowed)) def test_userinfo_on_allowed_host_rejected(self): self.assertFalse(read_mailcatcher.is_local("https://evil.com@localhost/x", self.allowed)) def test_backslash_userinfo_rejected(self): # urlparse reads this as localhost; a browser navigates to evil.com. self.assertFalse( read_mailcatcher.is_local("https://evil.com\\@localhost:8080/verify", self.allowed) ) def test_backslash_userinfo_on_bitwarden_test_rejected(self): self.assertFalse( read_mailcatcher.is_local("https://evil.com\\@bitwarden.test/login", self.allowed) ) def test_env_extension_honored(self): allowed = read_mailcatcher.allowed_hosts({"MAILCATCHER_ALLOWED_HOSTS": "dev.local"}) self.assertTrue(read_mailcatcher.is_local("http://dev.local/x", allowed)) def test_env_extension_accepts_multiple_hosts(self): allowed = read_mailcatcher.allowed_hosts( {"MAILCATCHER_ALLOWED_HOSTS": "one.local, two.local"} ) self.assertTrue(read_mailcatcher.is_local("http://one.local/x", allowed)) self.assertTrue(read_mailcatcher.is_local("http://two.local/x", allowed)) class MainTest(unittest.TestCase): def _run(self, routes, argv=None, env=None): argv = argv or ["--recipient", "user@bitwarden.test", "--pattern", "verify"] fake = FakeHTTP(routes) out, err = io.StringIO(), io.StringIO() with mock.patch.object(read_mailcatcher, "_http_get", fake), mock.patch.object( read_mailcatcher.time, "sleep" ) as sleeper, contextlib.redirect_stdout(out), contextlib.redirect_stderr(err): code = read_mailcatcher.main(argv, env or {}) return code, out.getvalue(), err.getvalue(), fake, sleeper def test_success_prints_url(self): code, out, _err, _fake, _sleep = self._run( { f"{BASE}/messages": messages_json(message(1, "user@bitwarden.test", "Verify your email")), f"{BASE}/messages/1.plain": "Click https://localhost:8080/#/verify?t=abc to continue", } ) self.assertEqual(code, 0) self.assertEqual(out.strip(), "https://localhost:8080/#/verify?t=abc") def test_unreachable_exits_3_without_retrying(self): code, _out, err, fake, sleeper = self._run({}) self.assertEqual(code, 3) self.assertIn("unreachable", err.lower()) sleeper.assert_not_called() self.assertEqual(len(fake.calls), 1) def test_invalid_json_exits_3(self): code, _out, _err, _fake, _sleep = self._run({f"{BASE}/messages": "not json at all"}) self.assertEqual(code, 3) def test_no_message_retries_once_then_exits_1(self): code, _out, err, fake, sleeper = self._run({f"{BASE}/messages": messages_json()}) self.assertEqual(code, 1) sleeper.assert_called_once_with(read_mailcatcher.RETRY_DELAY) self.assertEqual(fake.calls.count(f"{BASE}/messages"), 2) self.assertIn("NO_MATCH", err) self.assertIn("with subject containing 'verify'", err) def test_no_message_without_pattern_omits_subject_clause(self): code, _out, err, _fake, _sleep = self._run( {f"{BASE}/messages": messages_json()}, argv=["--recipient", "user@bitwarden.test"], ) self.assertEqual(code, 1) self.assertIn("no email for recipient 'user@bitwarden.test'", err) self.assertNotIn("subject containing", err) def test_matched_message_with_no_matching_url_exits_1(self): code, _out, err, _fake, sleeper = self._run( { f"{BASE}/messages": messages_json(message(1, "user@bitwarden.test", "Verify your email")), f"{BASE}/messages/1.plain": "no links in this body", } ) self.assertEqual(code, 1) self.assertIn("contained no URL", err) sleeper.assert_not_called() def test_invalid_link_filter_regex_emits_no_match_not_a_traceback(self): code, _out, err, _fake, sleeper = self._run( { f"{BASE}/messages": messages_json(message(1, "user@bitwarden.test", "Verify your email")), f"{BASE}/messages/1.plain": "Click https://localhost:8080/#/verify?t=abc", }, argv=[ "--recipient", "user@bitwarden.test", "--pattern", "verify", "--link-filter", "(", ], ) self.assertEqual(code, 1) self.assertIn("NO_MATCH", err) self.assertIn("contained no URL", err) sleeper.assert_not_called() def test_non_local_url_exits_1(self): code, _out, err, _fake, _sleep = self._run( { f"{BASE}/messages": messages_json(message(1, "user@bitwarden.test", "Verify your email")), f"{BASE}/messages/1.plain": "Click https://evil.com/verify?t=abc", } ) self.assertEqual(code, 1) self.assertIn("local dev host", err) self.assertIn("evil.com", err) def test_backslash_bypass_url_exits_1(self): # The backslash ends the extracted URL at "https://evil.com", which no # longer matches the link filter, so nothing reaches stdout. code, out, err, _fake, _sleep = self._run( { f"{BASE}/messages": messages_json(message(1, "user@bitwarden.test", "Verify your email")), f"{BASE}/messages/1.plain": "Please verify: https://evil.com\\@localhost:8080/#/verify?token=abc123", } ) self.assertEqual(code, 1) self.assertEqual(out, "") self.assertIn("contained no URL", err) def test_multiple_non_local_urls_report_all_rejected_hosts(self): # Several external links all match the filter; the diagnostic must name # every rejected host and the count, not just the first candidate. body = "Login https://evil.com/login and verify https://bad.example/verify" code, _out, err, _fake, _sleep = self._run( { f"{BASE}/messages": messages_json(message(1, "user@bitwarden.test", "Verify your email")), f"{BASE}/messages/1.plain": body, } ) self.assertEqual(code, 1) self.assertIn("2 filter-matched", err) self.assertIn("evil.com", err) self.assertIn("bad.example", err) def test_unreachable_during_body_fetch_exits_3(self): # Mailcatcher answers the message list, then goes down before the body # fetch: both the .plain and .html fetches are unreachable. That is an # outage (exit 3), not a link-filter miss (exit 1), and there is no retry. code, _out, err, _fake, sleeper = self._run( { f"{BASE}/messages": messages_json(message(1, "user@bitwarden.test", "Verify your email")), } ) self.assertEqual(code, 3) self.assertIn("unreachable", err.lower()) sleeper.assert_not_called() def test_external_footer_link_does_not_mask_a_later_local_link(self): # A marketing/help footer link matches the default filter ("login") # but is not a local host. It must not shadow the real local action # link that follows it: the script walks every filter match and picks # the first that is also local. body = ( "Need help? https://bitwarden.com/help/login-with-device\n" "Verify: https://localhost:8080/#/finish-signup?token=ABC" ) code, out, _err, _fake, _sleep = self._run( { f"{BASE}/messages": messages_json(message(1, "user@bitwarden.test", "Verify your email")), f"{BASE}/messages/1.plain": body, } ) self.assertEqual(code, 0) self.assertEqual(out.strip(), "https://localhost:8080/#/finish-signup?token=ABC") def test_empty_plain_body_falls_back_to_html_with_warning(self): code, out, err, _fake, _sleep = self._run( { f"{BASE}/messages": messages_json(message(3, "user@bitwarden.test", "Verify your email")), f"{BASE}/messages/3.plain": "", f"{BASE}/messages/3.html": 'Verify', } ) self.assertEqual(code, 0) self.assertEqual(out.strip(), "https://localhost:8080/#/verify?t=z") self.assertIn("WARNING", err) def test_html_fallback_unescapes_entities_in_the_link(self): # The HTML body carries an href with an `&` entity between query # params. Extracting from raw HTML would emit the literal `&` and # corrupt every parameter after the first, so the HTML branch must # unescape before the URL is returned. href = "https://localhost:8080/#/finish-signup?token=ABC&email=qa%40example.com" code, out, _err, _fake, _sleep = self._run( { f"{BASE}/messages": messages_json(message(4, "user@bitwarden.test", "Verify your email")), f"{BASE}/messages/4.plain": "", f"{BASE}/messages/4.html": f'Verify', } ) self.assertEqual(code, 0) self.assertEqual( out.strip(), "https://localhost:8080/#/finish-signup?token=ABC&email=qa%40example.com", ) def test_mailcatcher_url_env_is_ignored(self): # The base URL is fixed at localhost:1080 and takes no override. A # hostile MAILCATCHER_URL env var must have no effect: the script still # queries localhost:1080 and never contacts the attacker host. This is # the regression guard for the removed base-URL override. code, out, _err, fake, _sleep = self._run( { f"{BASE}/messages": messages_json(message(1, "user@bitwarden.test", "Verify your email")), f"{BASE}/messages/1.plain": "Click https://localhost:8080/#/verify?t=safe to continue", }, env={"MAILCATCHER_URL": "https://attacker.example/leak"}, ) self.assertEqual(code, 0) self.assertEqual(out.strip(), "https://localhost:8080/#/verify?t=safe") self.assertTrue(all(url.startswith(BASE) for url in fake.calls)) self.assertFalse(any("attacker.example" in url for url in fake.calls)) def test_missing_recipient_exits_2(self): with mock.patch("sys.stderr", new_callable=io.StringIO): self.assertEqual(read_mailcatcher.main([], {}), 2) def test_empty_recipient_exits_2(self): # required=True only enforces presence, not a non-empty value. An # empty --recipient is a substring of every recipient string, so # without this guard select_message would return the newest message # in the entire inbox regardless of addressee. err = io.StringIO() with mock.patch("sys.stderr", err): code = read_mailcatcher.main(["--recipient", ""], {}) self.assertEqual(code, 2) self.assertIn("ERROR: --recipient is required", err.getvalue()) class _QuietHandler(http.server.BaseHTTPRequestHandler): """BaseHTTPRequestHandler that stays silent on stderr during tests.""" def log_message(self, *args, **kwargs): # noqa: D401 - silence per-request logging pass class NoRedirectIntegrationTest(unittest.TestCase): """Exercises the real _http_get path against local HTTP servers. Every MainTest and unit test above patches the _http_get seam, so nothing else in this file exercises the real implementation. A redirect server (on 127.0.0.1, an allowlisted host) answers with a 302 pointing at a target server on a different port. If _http_get ever followed that redirect, the target server would be hit. It must never be hit: this is what catches a future regression that removes the no-redirect handler. An error server rounds out the curl -f parity check: a halted 3xx must return its own body as a success, while a status >= 400 must still raise Unreachable. """ def setUp(self): target_hit = threading.Event() self.target_hit = target_hit redirect_body = b"redirect body, never the target's" self.redirect_body = redirect_body class TargetHandler(_QuietHandler): def do_GET(self): target_hit.set() body = b"should never be reached" self.send_response(200) self.send_header("Content-Length", str(len(body))) self.end_headers() self.wfile.write(body) self.target_server = http.server.HTTPServer(("127.0.0.1", 0), TargetHandler) target_port = self.target_server.server_address[1] class RedirectHandler(_QuietHandler): def do_GET(self): self.send_response(302) self.send_header( "Location", f"http://127.0.0.1:{target_port}/elsewhere" ) self.send_header("Content-Length", str(len(redirect_body))) self.end_headers() self.wfile.write(redirect_body) self.redirect_server = http.server.HTTPServer(("127.0.0.1", 0), RedirectHandler) self.redirect_port = self.redirect_server.server_address[1] class ErrorHandler(_QuietHandler): def do_GET(self): body = b"server error" self.send_response(500) self.send_header("Content-Length", str(len(body))) self.end_headers() self.wfile.write(body) self.error_server = http.server.HTTPServer(("127.0.0.1", 0), ErrorHandler) self.error_port = self.error_server.server_address[1] self.threads = [ threading.Thread(target=server.serve_forever, daemon=True) for server in (self.target_server, self.redirect_server, self.error_server) ] for thread in self.threads: thread.start() self.addCleanup(self._shutdown) def _shutdown(self): self.target_server.shutdown() self.redirect_server.shutdown() self.error_server.shutdown() self.target_server.server_close() self.redirect_server.server_close() self.error_server.server_close() for thread in self.threads: thread.join(timeout=5) def test_redirect_is_not_followed(self): """A halted 3xx is a success: it returns the redirect response's own body, and the Location target is never requested.""" url = f"http://127.0.0.1:{self.redirect_port}/start" body = read_mailcatcher._http_get(url) self.assertEqual(body, self.redirect_body.decode("utf-8")) self.assertFalse(self.target_hit.is_set()) def test_status_500_raises_unreachable(self): url = f"http://127.0.0.1:{self.error_port}/fail" with self.assertRaises(read_mailcatcher.Unreachable): read_mailcatcher._http_get(url) def test_fetch_messages_treats_halted_redirect_body_as_the_response(self): """The concrete parity break the review identified: bash's curl -fsS with no -L treats a 3xx as success and reads its body, so a redirect whose body happens to be valid JSON must flow through fetch_messages normally instead of becoming exit 3.""" body = b"[]" class MessagesHandler(_QuietHandler): def do_GET(self): self.send_response(302) self.send_header("Location", "http://127.0.0.1:1/unused") self.send_header("Content-Type", "application/json") self.send_header("Content-Length", str(len(body))) self.end_headers() self.wfile.write(body) server = http.server.HTTPServer(("127.0.0.1", 0), MessagesHandler) thread = threading.Thread(target=server.serve_forever, daemon=True) thread.start() try: base = f"http://127.0.0.1:{server.server_address[1]}" self.assertEqual(read_mailcatcher.fetch_messages(base), []) finally: server.shutdown() server.server_close() thread.join(timeout=5) if __name__ == "__main__": unittest.main()