Setting the file. One moment. Test Read Mailcatcher · Reading Mailcatcher API · bitwarden/ai-plugins · Skills Docs77
def test_message_without_an_id_is_skipped_not_indexed
— line 77
This file
- Number
- 42.6
- Position
- 6 of 8
- Type
- Python
- Size
- 24 KB
- Lines
- 560
scripts/tests/test_read_mailcatcher.py
Python·560 lines·24 KB
import
unittest
15from unittest import mock
16
17HERE = os.path.dirname(os.path.abspath(__file__))
18SCRIPTS = os.path.dirname(HERE)
19sys.path.insert(0, SCRIPTS)
20
21import read_mailcatcher
22
23BASE = "http://localhost:1080"
24
25
26class FakeHTTP:
27 """Route table standing in for _http_get. Unrouted URLs are unreachable."""
28
29 def __init__(self, routes):
30 self.routes = routes
31 self.calls = []
32
33 def __call__(self, url):
34 self.calls.append(url)
35 if url not in self.routes:
36 raise read_mailcatcher.Unreachable(f"no route for {url}")
37 value = self.routes[url]
38 if isinstance(value, Exception):
39 raise value
40 return value
41
42
43def messages_json(*entries):
44 return json.dumps(list(entries))
45
46
47def message(msg_id, recipient, subject):
48 return {"id": msg_id, "recipients": [f"<{recipient}>"], "subject": subject}
49
50
51class SelectMessageTest(unittest.TestCase):
52 def test_recipient_match_is_case_insensitive_substring(self):
53 msgs = [message(1, "User@Bitwarden.test", "Verify your email")]
54 self.assertEqual(read_mailcatcher.select_message(msgs, "user@bitwarden.test", ""), 1)
55
56 def test_subject_pattern_filters(self):
57 msgs = [message(1, "a@b.test", "Welcome aboard")]
58 self.assertIsNone(read_mailcatcher.select_message(msgs, "a@b.test", "verify"))
59 self.assertEqual(read_mailcatcher.select_message(msgs, "a@b.test", "welcome"), 1)
60
61 def test_empty_pattern_matches_any_subject(self):
62 msgs = [message(1, "a@b.test", "Anything at all")]
63 self.assertEqual(read_mailcatcher.select_message(msgs, "a@b.test", ""), 1)
64
65 def test_newest_id_wins(self):
66 msgs = [
67 message(1, "a@b.test", "Verify your email"),
68 message(7, "a@b.test", "Verify your email"),
69 message(4, "a@b.test", "Verify your email"),
70 ]
71 self.assertEqual(read_mailcatcher.select_message(msgs, "a@b.test", "verify"), 7)
72
73 def test_no_match_returns_none(self):
74 msgs = [message(1, "someone@else.test", "Verify your email")]
75 self.assertIsNone(read_mailcatcher.select_message(msgs, "a@b.test", "verify"))
76
77 def test_message_without_an_id_is_skipped_not_indexed(self):
78 """A missing `id` used to raise KeyError, escaping the exit-code contract."""
79 broken = message(1, "a@b.test", "Verify your email")
80 del broken["id"]
81 msgs = [broken, message(3, "a@b.test", "Verify your email")]
82 self.assertEqual(read_mailcatcher.select_message(msgs, "a@b.test", "verify"), 3)
83
84 def test_only_candidate_lacking_an_id_returns_none(self):
85 broken = message(1, "a@b.test", "Verify your email")
86 broken["id"] = None
87 self.assertIsNone(
88 read_mailcatcher.select_message([broken], "a@b.test", "verify")
89 )
90
91 def test_non_integer_id_is_skipped_not_compared(self):
92 """Mixed int and str ids used to raise TypeError inside max()."""
93 string_id = message(9, "a@b.test", "Verify your email")
94 string_id["id"] = "9"
95 msgs = [string_id, message(1, "a@b.test", "Verify your email")]
96 self.assertEqual(read_mailcatcher.select_message(msgs, "a@b.test", "verify"), 1)
97
98 def test_non_object_entry_is_skipped(self):
99 msgs = ["not a message", message(2, "a@b.test", "Verify your email")]
100 self.assertEqual(read_mailcatcher.select_message(msgs, "a@b.test", "verify"), 2)
101
102
103class MatchingUrlsTest(unittest.TestCase):
104 def setUp(self):
105 self.link_filter = read_mailcatcher.DEFAULT_LINK_FILTER
106
107 def test_keeps_only_urls_matching_the_filter(self):
108 body = "Ignore https://localhost:8080/#/home then use https://localhost:8080/#/verify?t=1"
109 self.assertEqual(
110 read_mailcatcher.matching_urls(body, self.link_filter),
111 ["https://localhost:8080/#/verify?t=1"],
112 )
113
114 def test_returns_all_matches_in_document_order(self):
115 # The caller (attempt) picks the first local one out of these, so the
116 # full ordered list must come back, not just the first match.
117 body = "first https://localhost:8080/#/login then https://localhost:8080/#/verify?t=1"
118 self.assertEqual(
119 read_mailcatcher.matching_urls(body, self.link_filter),
120 [
121 "https://localhost:8080/#/login",
122 "https://localhost:8080/#/verify?t=1",
123 ],
124 )
125
126 def test_returns_empty_when_nothing_matches(self):
127 self.assertEqual(
128 read_mailcatcher.matching_urls("only https://localhost:8080/#/home here", self.link_filter),
129 [],
130 )
131
132 def test_returns_empty_for_empty_body(self):
133 self.assertEqual(read_mailcatcher.matching_urls("", self.link_filter), [])
134
135 def test_multiline_body_does_not_glue_lines_together(self):
136 # grep is line-based, so a newline can never land inside a match. The
137 # regex must exclude all whitespace or these two lines would join.
138 body = "https://localhost:8080/#/verify?t=1\nhttps://localhost:8080/#/login"
139 self.assertEqual(
140 read_mailcatcher.matching_urls(body, self.link_filter),
141 [
142 "https://localhost:8080/#/verify?t=1",
143 "https://localhost:8080/#/login",
144 ],
145 )
146
147 def test_custom_filter_is_honored(self):
148 body = "https://localhost:8080/#/sso-landing"
149 self.assertEqual(read_mailcatcher.matching_urls(body, "sso"), [body])
150
151 def test_invalid_regex_returns_empty_instead_of_raising(self):
152 # An unbalanced group is a malformed ERE. grep -iE would error out
153 # and yield no match rather than a match; matching_urls must fall
154 # through to [] (and thus the caller's NO_MATCH branch) the same
155 # way, instead of letting re.error propagate as a traceback.
156 body = "https://localhost:8080/#/verify?t=1"
157 self.assertEqual(read_mailcatcher.matching_urls(body, "("), [])
158
159 def test_backslash_ends_url(self):
160 body = "https://evil.com/verify\\@localhost:8080/x"
161 self.assertEqual(
162 read_mailcatcher.matching_urls(body, self.link_filter),
163 ["https://evil.com/verify"],
164 )
165
166 def test_single_quoted_href_excludes_quote(self):
167 body = "<a href='https://localhost:8080/#/verify?t=1'>Verify</a>"
168 self.assertEqual(
169 read_mailcatcher.matching_urls(body, self.link_filter),
170 ["https://localhost:8080/#/verify?t=1"],
171 )
172
173 def test_angle_bracket_ends_url(self):
174 body = "https://localhost:8080/#/verify?t=1<br>"
175 self.assertEqual(
176 read_mailcatcher.matching_urls(body, self.link_filter),
177 ["https://localhost:8080/#/verify?t=1"],
178 )
179
180
181class IsLocalTest(unittest.TestCase):
182 def setUp(self):
183 self.allowed = read_mailcatcher.allowed_hosts({})
184
185 def test_allowlisted_hosts_accepted(self):
186 self.assertTrue(read_mailcatcher.is_local("https://localhost:8080/x", self.allowed))
187 self.assertTrue(read_mailcatcher.is_local("http://bitwarden.test/x", self.allowed))
188
189 def test_external_host_rejected(self):
190 self.assertFalse(read_mailcatcher.is_local("https://evil.com/x", self.allowed))
191
192 def test_userinfo_trick_rejected(self):
193 self.assertFalse(read_mailcatcher.is_local("https://localhost@evil.com/x", self.allowed))
194
195 def test_userinfo_on_allowed_host_rejected(self):
196 self.assertFalse(read_mailcatcher.is_local("https://evil.com@localhost/x", self.allowed))
197
198 def test_backslash_userinfo_rejected(self):
199 # urlparse reads this as localhost; a browser navigates to evil.com.
200 self.assertFalse(
201 read_mailcatcher.is_local("https://evil.com\\@localhost:8080/verify", self.allowed)
202 )
203
204 def test_backslash_userinfo_on_bitwarden_test_rejected(self):
205 self.assertFalse(
206 read_mailcatcher.is_local("https://evil.com\\@bitwarden.test/login", self.allowed)
207 )
208
209 def test_env_extension_honored(self):
210 allowed = read_mailcatcher.allowed_hosts({"MAILCATCHER_ALLOWED_HOSTS": "dev.local"})
211 self.assertTrue(read_mailcatcher.is_local("http://dev.local/x", allowed))
212
213 def test_env_extension_accepts_multiple_hosts(self):
214 allowed = read_mailcatcher.allowed_hosts(
215 {"MAILCATCHER_ALLOWED_HOSTS": "one.local, two.local"}
216 )
217 self.assertTrue(read_mailcatcher.is_local("http://one.local/x", allowed))
218 self.assertTrue(read_mailcatcher.is_local("http://two.local/x", allowed))
219
220
221class MainTest(unittest.TestCase):
222 def _run(self, routes, argv=None, env=None):
223 argv = argv or ["--recipient", "user@bitwarden.test", "--pattern", "verify"]
224 fake = FakeHTTP(routes)
225 out, err = io.StringIO(), io.StringIO()
226 with mock.patch.object(read_mailcatcher, "_http_get", fake), mock.patch.object(
227 read_mailcatcher.time, "sleep"
228 ) as sleeper, contextlib.redirect_stdout(out), contextlib.redirect_stderr(err):
229 code = read_mailcatcher.main(argv, env or {})
230 return code, out.getvalue(), err.getvalue(), fake, sleeper
231
232 def test_success_prints_url(self):
233 code, out, _err, _fake, _sleep = self._run(
234 {
235 f"{BASE}/messages": messages_json(message(1, "user@bitwarden.test", "Verify your email")),
236 f"{BASE}/messages/1.plain": "Click https://localhost:8080/#/verify?t=abc to continue",
237 }
238 )
239 self.assertEqual(code, 0)
240 self.assertEqual(out.strip(), "https://localhost:8080/#/verify?t=abc")
241
242 def test_unreachable_exits_3_without_retrying(self):
243 code, _out, err, fake, sleeper = self._run({})
244 self.assertEqual(code, 3)
245 self.assertIn("unreachable", err.lower())
246 sleeper.assert_not_called()
247 self.assertEqual(len(fake.calls), 1)
248
249 def test_invalid_json_exits_3(self):
250 code, _out, _err, _fake, _sleep = self._run({f"{BASE}/messages": "not json at all"})
251 self.assertEqual(code, 3)
252
253 def test_no_message_retries_once_then_exits_1(self):
254 code, _out, err, fake, sleeper = self._run({f"{BASE}/messages": messages_json()})
255 self.assertEqual(code, 1)
256 sleeper.assert_called_once_with(read_mailcatcher.RETRY_DELAY)
257 self.assertEqual(fake.calls.count(f"{BASE}/messages"), 2)
258 self.assertIn("NO_MATCH", err)
259 self.assertIn("with subject containing 'verify'", err)
260
261 def test_no_message_without_pattern_omits_subject_clause(self):
262 code, _out, err, _fake, _sleep = self._run(
263 {f"{BASE}/messages": messages_json()},
264 argv=["--recipient", "user@bitwarden.test"],
265 )
266 self.assertEqual(code, 1)
267 self.assertIn("no email for recipient 'user@bitwarden.test'", err)
268 self.assertNotIn("subject containing", err)
269
270 def test_matched_message_with_no_matching_url_exits_1(self):
271 code, _out, err, _fake, sleeper = self._run(
272 {
273 f"{BASE}/messages": messages_json(message(1, "user@bitwarden.test", "Verify your email")),
274 f"{BASE}/messages/1.plain": "no links in this body",
275 }
276 )
277 self.assertEqual(code, 1)
278 self.assertIn("contained no URL", err)
279 sleeper.assert_not_called()
280
281 def test_invalid_link_filter_regex_emits_no_match_not_a_traceback(self):
282 code, _out, err, _fake, sleeper = self._run(
283 {
284 f"{BASE}/messages": messages_json(message(1, "user@bitwarden.test", "Verify your email")),
285 f"{BASE}/messages/1.plain": "Click https://localhost:8080/#/verify?t=abc",
286 },
287 argv=[
288 "--recipient",
289 "user@bitwarden.test",
290 "--pattern",
291 "verify",
292 "--link-filter",
293 "(",
294 ],
295 )
296 self.assertEqual(code, 1)
297 self.assertIn("NO_MATCH", err)
298 self.assertIn("contained no URL", err)
299 sleeper.assert_not_called()
300
301 def test_non_local_url_exits_1(self):
302 code, _out, err, _fake, _sleep = self._run(
303 {
304 f"{BASE}/messages": messages_json(message(1, "user@bitwarden.test", "Verify your email")),
305 f"{BASE}/messages/1.plain": "Click https://evil.com/verify?t=abc",
306 }
307 )
308 self.assertEqual(code, 1)
309 self.assertIn("local dev host", err)
310 self.assertIn("evil.com", err)
311
312 def test_backslash_bypass_url_exits_1(self):
313 # The backslash ends the extracted URL at "https://evil.com", which no
314 # longer matches the link filter, so nothing reaches stdout.
315 code, out, err, _fake, _sleep = self._run(
316 {
317 f"{BASE}/messages": messages_json(message(1, "user@bitwarden.test", "Verify your email")),
318 f"{BASE}/messages/1.plain": "Please verify: https://evil.com\\@localhost:8080/#/verify?token=abc123",
319 }
320 )
321 self.assertEqual(code, 1)
322 self.assertEqual(out, "")
323 self.assertIn("contained no URL", err)
324
325 def test_multiple_non_local_urls_report_all_rejected_hosts(self):
326 # Several external links all match the filter; the diagnostic must name
327 # every rejected host and the count, not just the first candidate.
328 body = "Login https://evil.com/login and verify https://bad.example/verify"
329 code, _out, err, _fake, _sleep = self._run(
330 {
331 f"{BASE}/messages": messages_json(message(1, "user@bitwarden.test", "Verify your email")),
332 f"{BASE}/messages/1.plain": body,
333 }
334 )
335 self.assertEqual(code, 1)
336 self.assertIn("2 filter-matched", err)
337 self.assertIn("evil.com", err)
338 self.assertIn("bad.example", err)
339
340 def test_unreachable_during_body_fetch_exits_3(self):
341 # Mailcatcher answers the message list, then goes down before the body
342 # fetch: both the .plain and .html fetches are unreachable. That is an
343 # outage (exit 3), not a link-filter miss (exit 1), and there is no retry.
344 code, _out, err, _fake, sleeper = self._run(
345 {
346 f"{BASE}/messages": messages_json(message(1, "user@bitwarden.test", "Verify your email")),
347 }
348 )
349 self.assertEqual(code, 3)
350 self.assertIn("unreachable", err.lower())
351 sleeper.assert_not_called()
352
353 def test_external_footer_link_does_not_mask_a_later_local_link(self):
354 # A marketing/help footer link matches the default filter ("login")
355 # but is not a local host. It must not shadow the real local action
356 # link that follows it: the script walks every filter match and picks
357 # the first that is also local.
358 body = (
359 "Need help? https://bitwarden.com/help/login-with-device\n"
360 "Verify: https://localhost:8080/#/finish-signup?token=ABC"
361 )
362 code, out, _err, _fake, _sleep = self._run(
363 {
364 f"{BASE}/messages": messages_json(message(1, "user@bitwarden.test", "Verify your email")),
365 f"{BASE}/messages/1.plain": body,
366 }
367 )
368 self.assertEqual(code, 0)
369 self.assertEqual(out.strip(), "https://localhost:8080/#/finish-signup?token=ABC")
370
371 def test_empty_plain_body_falls_back_to_html_with_warning(self):
372 code, out, err, _fake, _sleep = self._run(
373 {
374 f"{BASE}/messages": messages_json(message(3, "user@bitwarden.test", "Verify your email")),
375 f"{BASE}/messages/3.plain": "",
376 f"{BASE}/messages/3.html": '<a href="https://localhost:8080/#/verify?t=z">Verify</a>',
377 }
378 )
379 self.assertEqual(code, 0)
380 self.assertEqual(out.strip(), "https://localhost:8080/#/verify?t=z")
381 self.assertIn("WARNING", err)
382
383 def test_html_fallback_unescapes_entities_in_the_link(self):
384 # The HTML body carries an href with an `&` entity between query
385 # params. Extracting from raw HTML would emit the literal `&` and
386 # corrupt every parameter after the first, so the HTML branch must
387 # unescape before the URL is returned.
388 href = "https://localhost:8080/#/finish-signup?token=ABC&email=qa%40example.com"
389 code, out, _err, _fake, _sleep = self._run(
390 {
391 f"{BASE}/messages": messages_json(message(4, "user@bitwarden.test", "Verify your email")),
392 f"{BASE}/messages/4.plain": "",
393 f"{BASE}/messages/4.html": f'<a href="{href}">Verify</a>',
394 }
395 )
396 self.assertEqual(code, 0)
397 self.assertEqual(
398 out.strip(),
399 "https://localhost:8080/#/finish-signup?token=ABC&email=qa%40example.com",
400 )
401
402 def test_mailcatcher_url_env_is_ignored(self):
403 # The base URL is fixed at localhost:1080 and takes no override. A
404 # hostile MAILCATCHER_URL env var must have no effect: the script still
405 # queries localhost:1080 and never contacts the attacker host. This is
406 # the regression guard for the removed base-URL override.
407 code, out, _err, fake, _sleep = self._run(
408 {
409 f"{BASE}/messages": messages_json(message(1, "user@bitwarden.test", "Verify your email")),
410 f"{BASE}/messages/1.plain": "Click https://localhost:8080/#/verify?t=safe to continue",
411 },
412 env={"MAILCATCHER_URL": "https://attacker.example/leak"},
413 )
414 self.assertEqual(code, 0)
415 self.assertEqual(out.strip(), "https://localhost:8080/#/verify?t=safe")
416 self.assertTrue(all(url.startswith(BASE) for url in fake.calls))
417 self.assertFalse(any("attacker.example" in url for url in fake.calls))
418
419 def test_missing_recipient_exits_2(self):
420 with mock.patch("sys.stderr", new_callable=io.StringIO):
421 self.assertEqual(read_mailcatcher.main([], {}), 2)
422
423 def test_empty_recipient_exits_2(self):
424 # required=True only enforces presence, not a non-empty value. An
425 # empty --recipient is a substring of every recipient string, so
426 # without this guard select_message would return the newest message
427 # in the entire inbox regardless of addressee.
428 err = io.StringIO()
429 with mock.patch("sys.stderr", err):
430 code = read_mailcatcher.main(["--recipient", ""], {})
431 self.assertEqual(code, 2)
432 self.assertIn("ERROR: --recipient is required", err.getvalue())
433
434
435class _QuietHandler(http.server.BaseHTTPRequestHandler):
436 """BaseHTTPRequestHandler that stays silent on stderr during tests."""
437
438 def log_message(self, *args, **kwargs): # noqa: D401 - silence per-request logging
439 pass
440
441
442class NoRedirectIntegrationTest(unittest.TestCase):
443 """Exercises the real _http_get path against local HTTP servers.
444
445 Every MainTest and unit test above patches the _http_get seam, so nothing
446 else in this file exercises the real implementation. A redirect server
447 (on 127.0.0.1, an allowlisted host) answers with a 302 pointing at a
448 target server on a different port. If _http_get ever followed that
449 redirect, the target server would be hit. It must never be hit: this is
450 what catches a future regression that removes the no-redirect handler.
451
452 An error server rounds out the curl -f parity check: a halted 3xx must
453 return its own body as a success, while a status >= 400 must still raise
454 Unreachable.
455 """
456
457 def setUp(self):
458 target_hit = threading.Event()
459 self.target_hit = target_hit
460 redirect_body = b"redirect body, never the target's"
461 self.redirect_body = redirect_body
462
463 class TargetHandler(_QuietHandler):
464 def do_GET(self):
465 target_hit.set()
466 body = b"should never be reached"
467 self.send_response(200)
468 self.send_header("Content-Length", str(len(body)))
469 self.end_headers()
470 self.wfile.write(body)
471
472 self.target_server = http.server.HTTPServer(("127.0.0.1", 0), TargetHandler)
473 target_port = self.target_server.server_address[1]
474
475 class RedirectHandler(_QuietHandler):
476 def do_GET(self):
477 self.send_response(302)
478 self.send_header(
479 "Location", f"http://127.0.0.1:{target_port}/elsewhere"
480 )
481 self.send_header("Content-Length", str(len(redirect_body)))
482 self.end_headers()
483 self.wfile.write(redirect_body)
484
485 self.redirect_server = http.server.HTTPServer(("127.0.0.1", 0), RedirectHandler)
486 self.redirect_port = self.redirect_server.server_address[1]
487
488 class ErrorHandler(_QuietHandler):
489 def do_GET(self):
490 body = b"server error"
491 self.send_response(500)
492 self.send_header("Content-Length", str(len(body)))
493 self.end_headers()
494 self.wfile.write(body)
495
496 self.error_server = http.server.HTTPServer(("127.0.0.1", 0), ErrorHandler)
497 self.error_port = self.error_server.server_address[1]
498
499 self.threads = [
500 threading.Thread(target=server.serve_forever, daemon=True)
501 for server in (self.target_server, self.redirect_server, self.error_server)
502 ]
503 for thread in self.threads:
504 thread.start()
505
506 self.addCleanup(self._shutdown)
507
508 def _shutdown(self):
509 self.target_server.shutdown()
510 self.redirect_server.shutdown()
511 self.error_server.shutdown()
512 self.target_server.server_close()
513 self.redirect_server.server_close()
514 self.error_server.server_close()
515 for thread in self.threads:
516 thread.join(timeout=5)
517
518 def test_redirect_is_not_followed(self):
519 """A halted 3xx is a success: it returns the redirect response's own
520 body, and the Location target is never requested."""
521 url = f"http://127.0.0.1:{self.redirect_port}/start"
522 body = read_mailcatcher._http_get(url)
523 self.assertEqual(body, self.redirect_body.decode("utf-8"))
524 self.assertFalse(self.target_hit.is_set())
525
526 def test_status_500_raises_unreachable(self):
527 url = f"http://127.0.0.1:{self.error_port}/fail"
528 with self.assertRaises(read_mailcatcher.Unreachable):
529 read_mailcatcher._http_get(url)
530
531 def test_fetch_messages_treats_halted_redirect_body_as_the_response(self):
532 """The concrete parity break the review identified: bash's curl -fsS
533 with no -L treats a 3xx as success and reads its body, so a redirect
534 whose body happens to be valid JSON must flow through fetch_messages
535 normally instead of becoming exit 3."""
536 body = b"[]"
537
538 class MessagesHandler(_QuietHandler):
539 def do_GET(self):
540 self.send_response(302)
541 self.send_header("Location", "http://127.0.0.1:1/unused")
542 self.send_header("Content-Type", "application/json")
543 self.send_header("Content-Length", str(len(body)))
544 self.end_headers()
545 self.wfile.write(body)
546
547 server = http.server.HTTPServer(("127.0.0.1", 0), MessagesHandler)
548 thread = threading.Thread(target=server.serve_forever, daemon=True)
549 thread.start()
550 try:
551 base = f"http://127.0.0.1:{server.server_address[1]}"
552 self.assertEqual(read_mailcatcher.fetch_messages(base), [])
553 finally:
554 server.shutdown()
555 server.server_close()
556 thread.join(timeout=5)
557
558
559if __name__ == "__main__":
560 unittest.main()