diff --git a/CHANGELOG.md b/CHANGELOG.md index af882e9..56b2e7e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -82,6 +82,18 @@ Two behaviour changes come with it: than leaving the proxy to fail to start for an unstated reason. - `Start Mitm Proxy` takes `proxy_auth`, requiring clients to authenticate before the proxy serves them. +- Failure simulation, for the paths an application exercises least. `Simulate Timeout` + holds a request and then drops it without contacting the server, which is what a client + sees when a service accepts a connection and says nothing - a different test from a + service that answers with an error. `Simulate Truncated Response` cuts an answer short + while it still claims its full length, which is what a connection dropped mid-answer + looks like. A dropped connection is `Block Requests` with `mode=RESET` rather than a + keyword of its own. + + Bandwidth throttling is deliberately not supported: mitmproxy hands a response body to + a synchronous callback with no way to wait between chunks, so the only implementable + version would delay the whole body and deliver it in one piece, which is + `Add Response Delay` under a name that would promise more than it does. - `Get Proxy Rules` returns the loaded rules in the order they are applied. - Blocking rules have an alias, like every other rule, so they are removed the same way. - Rules survive a restart of the proxy: the registry outlives it, and only the addon diff --git a/MitmLibrary/__init__.py b/MitmLibrary/__init__.py index 82d70fd..43bdc56 100644 --- a/MitmLibrary/__init__.py +++ b/MitmLibrary/__init__.py @@ -19,6 +19,7 @@ from robot.api.deco import keyword, library, not_keyword from robot.utils import DotDict, timestr_to_secs +from MitmLibrary.failures import TimeoutAction, TruncateAction from MitmLibrary.interceptor import Interceptor from MitmLibrary.listener import LibraryListener from MitmLibrary.matching import ANY_METHOD, MatchMode, UrlMatcher @@ -153,6 +154,25 @@ class MitmLibrary: | # Send everything on through the network's own proxy | Start Mitm Proxy mode=upstream:http://corporate-proxy:3128 + = Failure simulation = + Most rules make a request succeed differently. These make it fail the way a network + fails, which is usually the least exercised path in an application: + + - `Simulate Timeout` holds a request and then drops it, so the client waits for an + answer that never comes. + - `Simulate Truncated Response` cuts an answer short while it still claims to be the + full length. + - A dropped connection is `Block Requests` with `mode=RESET`, which is where that + behaviour lives rather than in a keyword of its own. + + How a client reports any of these depends on the HTTP library it uses, so assert that + a request failed rather than on the particular error it raised. + + Bandwidth throttling is not supported. mitmproxy hands a response body to a + synchronous callback, with no way to wait between chunks, so the only implementable + version would delay the whole body and deliver it in one piece - which is + `Add Response Delay`, honestly named. + = Mitm Certificates = To test with SSL verification or use a browser without ignoring certificates, you need to set up certificates related to mitm. Follow the guide on the @@ -681,6 +701,88 @@ def redirect_requests_to_host( alias, url, method, match, times, RedirectAction(host, port, scheme) ) + @keyword + def simulate_timeout( + self, + alias: str, + url: str, + hold: str = "60s", + method: str = ANY_METHOD, + match: MatchMode = MatchMode.SUBSTRING, + times: int = 0, + ) -> None: + """Holds matching requests and then drops them, without contacting the server. + + This is what a client sees when a service accepts its connection and then says + nothing, and it is a different test from a service that answers with an error: an + application that handles a 504 correctly may still wait forever when nothing + arrives at all. + + Hold the request for longer than the client's own timeout, so the client is the + one that gives up. Other traffic is unaffected while a request is held. + + - `alias`: The handle for this rule. Reusing an alias replaces the rule that + already uses it. + - `url`: The pattern the request url is compared against. See `match`. + - `hold`: How long to hold the request, in Robot Framework time format. It is + then dropped, so the client does not wait forever if its own timeout is longer. + - `method`: Only match this HTTP method. `ANY` matches every method. + - `match`: How `url` is interpreted. See the `Matching` section. + - `times`: How often the rule may be applied. `0` means unlimited. + + Example: + | Simulate Timeout hang /api/orders hold=30s + """ + self._require_proxy() + self._add_rule( + alias, url, method, match, times, TimeoutAction(timestr_to_secs(hold), hold) + ) + + @keyword + def simulate_truncated_response( + self, + alias: str, + url: str, + keep_bytes: Optional[int] = None, + keep_fraction: float = 0.5, + method: str = ANY_METHOD, + match: MatchMode = MatchMode.SUBSTRING, + times: int = 0, + ) -> None: + """Cuts matching responses short while they still claim to be the full length. + + The response keeps saying how long its body was meant to be, so a client reads + what arrived, waits for the rest and eventually gives up. That is what a + connection dropped mid-answer looks like, and it is the case that finds parsers + which assume a body is either complete or absent. + + How the client reports it differs per HTTP library, so assert that the request + failed rather than on a particular error. + + - `alias`: The handle for this rule. Reusing an alias replaces the rule that + already uses it. + - `url`: The pattern the request url is compared against. See `match`. + - `keep_bytes`: How many bytes of the body to keep. Overrides `keep_fraction`. + - `keep_fraction`: How much of the body to keep, as a fraction. Half by default. + - `method`: Only match this HTTP method. `ANY` matches every method. + - `match`: How `url` is interpreted. See the `Matching` section. + - `times`: How often the rule may be applied. `0` means unlimited. + + A response with no body, and one already shorter than what would be kept, are + left alone and say so in the log. + + A compressed body is cut in its compressed form, so what arrives is not decodable + rather than being a valid shorter document. That is the more realistic failure, + and the more interesting one to test against. + + Example: + | Simulate Truncated Response cut /api/orders keep_bytes=10 + """ + self._require_proxy() + self._add_rule( + alias, url, method, match, times, TruncateAction(keep_bytes, keep_fraction) + ) + @keyword def remove_rule(self, alias: str) -> None: """Removes the rule with the given alias. diff --git a/MitmLibrary/failures.py b/MitmLibrary/failures.py new file mode 100644 index 0000000..f350c13 --- /dev/null +++ b/MitmLibrary/failures.py @@ -0,0 +1,100 @@ +""" +This file defines the rules that break traffic on purpose. + +Blocking a request answers it, cleanly and immediately. These do the opposite: they make a +request fail the way a real network fails, so a suite can see what the application under +test does when a service hangs or an answer arrives half-finished. Those paths are usually +the least exercised and the most likely to be wrong. + +They live apart from the other rules because they are the most version-sensitive part of +the library: each one depends on a mitmproxy behaviour that is documented but not promised, +and if one has to be reverted it should be possible to do that without touching the model +everything else is built on. +""" + +import asyncio +from dataclasses import dataclass +from typing import Any, Dict, Optional + +from mitmproxy import http +from robot.api import logger + +from MitmLibrary.rules import Action, kill_flow, Phase, Priority + + +@dataclass(frozen=True) +class TimeoutAction(Action): + """Holds a request and then drops it, so the client runs into its own timeout. + + The request is never sent, so the service is not involved at all: this is what a + client sees when a service accepts a connection and then says nothing. Holding rather + than answering is the point - an application that handles a 504 correctly may still + hang forever when nothing arrives. + """ + + hold_seconds: float = 60.0 + hold: str = "" + + phase = Phase.REQUEST + priority = Priority.TERMINAL + + def apply(self, flow: http.HTTPFlow) -> bool: # pragma: no cover - async path is used + raise NotImplementedError("A timeout can only be applied from the async hook.") + + async def apply_async(self, flow: http.HTTPFlow) -> bool: + await asyncio.sleep(self.hold_seconds) + kill_flow(flow) + return True + + def describe(self) -> Dict[str, Any]: + return {"type": "timeout", "hold": self.hold, "hold_seconds": self.hold_seconds} + + +@dataclass(frozen=True) +class TruncateAction(Action): + """Cuts a response short while it still claims to be the full length. + + The `content-length` header keeps saying how long the body was meant to be, so a + client reads it, waits for the rest, and eventually gives up. That mismatch is the + fault being injected, which is why the body is replaced through `raw_content`: + `set_content` would helpfully correct the header and there would be nothing wrong + with the response at all. + """ + + keep_bytes: Optional[int] = None + keep_fraction: float = 0.5 + + phase = Phase.RESPONSE + priority = Priority.MUTATE + + def apply(self, flow: http.HTTPFlow) -> bool: + if flow.response is None: + return False + body = flow.response.raw_content + if body is None: + # A streamed response has no body to cut here, and a 204 or 304 has none at + # all. Saying so beats a rule that silently did nothing. + logger.info("There was no body to truncate, so the response is unchanged.") + return False + keep = self._keep(len(body)) + if keep >= len(body): + logger.info( + f"The body is {len(body)} bytes, which is not longer than the " + f"{keep} bytes to keep, so the response is unchanged." + ) + return False + flow.response.raw_content = body[:keep] + return False + + def _keep(self, length: int) -> int: + """How many bytes to keep, from either a count or a fraction of the body.""" + if self.keep_bytes is not None: + return max(0, self.keep_bytes) + return max(0, int(length * self.keep_fraction)) + + def describe(self) -> Dict[str, Any]: + return { + "type": "truncate", + "keep_bytes": self.keep_bytes, + "keep_fraction": self.keep_fraction, + } diff --git a/MitmLibrary/interceptor.py b/MitmLibrary/interceptor.py index 79ad210..30f4a99 100644 --- a/MitmLibrary/interceptor.py +++ b/MitmLibrary/interceptor.py @@ -28,15 +28,20 @@ def set_console_logging(self, value: bool) -> None: """Enables or disables reporting each manipulation on the console.""" self.log_to_console = value - def request(self, flow: http.HTTPFlow) -> None: - """Applies the rules that act before the request is sent.""" + async def request(self, flow: http.HTTPFlow) -> None: + """Applies the rules that act before the request is sent. + + Asynchronous because a rule may wait here: a simulated timeout holds the request + rather than answering it. mitmproxy awaits an addon hook that returns a + coroutine, and waiting in one flow does not hold up the others. + """ for rule in self.registry.snapshot(Phase.REQUEST): if not self._applies(rule, flow): continue if not self.registry.consume(rule): continue self._log(rule, flow) - if rule.action.apply(flow): + if await rule.action.apply_async(flow): return async def response(self, flow: http.HTTPFlow) -> None: diff --git a/MitmLibrary/rules.py b/MitmLibrary/rules.py index dcfaf90..369ead2 100644 --- a/MitmLibrary/rules.py +++ b/MitmLibrary/rules.py @@ -107,7 +107,7 @@ class BlockAction(Action): def apply(self, flow: http.HTTPFlow) -> bool: if self.mode is BlockMode.RESET: - _kill(flow) + kill_flow(flow) return True flow.response = http.Response.make( self.status_code, safe_str(self.body) if self.body is not None else b"" @@ -351,7 +351,7 @@ def _is_default_port(scheme: str, port: int) -> bool: return (scheme == "http" and port == 80) or (scheme == "https" and port == 443) -def _kill(flow: http.HTTPFlow) -> None: +def kill_flow(flow: http.HTTPFlow) -> None: """Drops the connection, if mitmproxy still lets us. `kill()` raises when the flow is no longer killable, which happens when something else diff --git a/README.md b/README.md index 7395994..ddccfa9 100644 --- a/README.md +++ b/README.md @@ -78,6 +78,24 @@ All matching rules are applied. A rule that blocks a request ends it and nothing runs; otherwise `Set Response` runs before rules that change part of a response, which run before delays, so combinations behave predictably rather than overwriting each other. +### Simulating failures + +Most rules make a request succeed differently. These make it fail the way a network does: + +```robotframework +Simulate Timeout hang /api/orders hold=30s +Simulate Truncated Response cut /api/orders keep_bytes=10 +Block Requests drop /api/orders mode=RESET +``` + +How a client reports any of these depends on the HTTP library it uses, so assert that the +request failed rather than on the particular error. + +Bandwidth throttling is not supported: mitmproxy hands a response body to a synchronous +callback with no way to wait between chunks, so the only implementable version would delay +the whole body and deliver it in one piece — which is what `Add Response Delay` already +does, honestly named. + ### Recording The proxy can also remember what went through it, so a suite can assert on what the diff --git a/atest/testcases/Test.robot b/atest/testcases/Test.robot index df50b84..5c0175b 100644 --- a/atest/testcases/Test.robot +++ b/atest/testcases/Test.robot @@ -231,6 +231,35 @@ Waiting For A Request That Never Comes Fails Recording Keywords Explain Themselves When Recording Is Off Run Keyword And Expect Error *Start Recording* Get Recorded Requests +A Held Request Runs Into The Client's Own Timeout + [Documentation] How a client reports this differs per HTTP library, so this asserts + ... that the request failed rather than on a particular error. The hold is far + ... longer than the client's timeout, so the client is the one that gives up and + ... the test does not depend on the two being close together. + Simulate Timeout hang /test_post hold=30s + ${start} Get Time epoch + Run Keyword And Expect Error * + ... POST On Session alias=proxy url=test_post/1 timeout=2 + ${end} Get Time epoch + # It failed because it waited, not because it was refused outright. + Should Be True ${end} - ${start} >= 2 The request failed without waiting + +Other Traffic Keeps Flowing While A Request Is Held + [Documentation] Holding must not stall the proxy for everything else. + Simulate Timeout hang /test_get hold=30s + Check POST Response smaller than 2 ${200} + +A Truncated Response Fails The Client + [Documentation] The response still claims its full length, so the client waits for + ... a rest that never comes. Which error that produces is the client's business. + Simulate Truncated Response cut /test_post keep_bytes=5 + Run Keyword And Expect Error * + ... POST On Session alias=proxy url=test_post/1 timeout=5 + +A Response Shorter Than The Cut Is Left Alone + Simulate Truncated Response cut /test_post keep_bytes=1000 + Check POST Response smaller than 2 ${200} + Delayed Response With Post Check POST Response smaller than 2 ${200} Add Response Delay alias=delay url=test_post delay=5s diff --git a/tests/test_failures.py b/tests/test_failures.py new file mode 100644 index 0000000..667e482 --- /dev/null +++ b/tests/test_failures.py @@ -0,0 +1,244 @@ +"""Tests for the rules that break traffic on purpose. + +These are the most version-sensitive rules in the library, because each depends on a +mitmproxy behaviour that is documented but not promised. The assertions are written to +notice if one of those behaviours changes, rather than only that the keyword ran. +""" + +import asyncio +import unittest +from unittest.mock import patch + +from mitmproxy import exceptions, http +from mitmproxy.test import tflow, tutils +from robot.api import logger + +from MitmLibrary.failures import TimeoutAction, TruncateAction +from MitmLibrary.interceptor import Interceptor +from MitmLibrary.matching import MatchMode, UrlMatcher +from MitmLibrary.rules import Rule, RuleRegistry + + +def make_flow(url="http://example.com/api/users", body=b"a full response body"): + """A real flow whose content-length agrees with its body, as a server's would.""" + flow = tflow.tflow(req=tutils.treq(), resp=tutils.tresp(content=body)) + flow.request.url = url + flow.response.headers["content-length"] = str(len(body)) + return flow + + +class FailureTestCase(unittest.TestCase): + def setUp(self): + self.registry = RuleRegistry() + self.interceptor = Interceptor(self.registry, log_to_console=False) + + def add(self, alias, action, url="/api", method="ANY", times=0): + self.registry.add( + Rule(alias, UrlMatcher(url, MatchMode.SUBSTRING, method), action, times) + ) + + def send(self, flow): + asyncio.run(self.interceptor.request(flow)) + + def respond(self, flow): + asyncio.run(self.interceptor.response(flow)) + + +class TestTimeout(unittest.TestCase): + """The wait is asserted with a patched sleep; waiting for real proves nothing extra.""" + + def setUp(self): + self.registry = RuleRegistry() + self.interceptor = Interceptor(self.registry, log_to_console=False) + self.registry.add( + Rule("hang", UrlMatcher("/api"), TimeoutAction(30.0, "30s")) + ) + + def test_the_request_is_held_for_the_configured_time(self): + flow = make_flow() + waited = [] + + async def record(seconds): + waited.append(seconds) + + with patch("MitmLibrary.failures.asyncio.sleep", record): + asyncio.run(self.interceptor.request(flow)) + self.assertEqual(waited, [30.0]) + + def test_the_request_is_dropped_afterwards(self): + """Without this the client waits forever when its own timeout is longer.""" + flow = make_flow() + flow.kill = lambda: setattr(flow, "killed", True) + + async def record(_seconds): + self.assertFalse(getattr(flow, "killed", False)) # held, not yet dropped + + with patch("MitmLibrary.failures.asyncio.sleep", record): + asyncio.run(self.interceptor.request(flow)) + self.assertTrue(flow.killed) + + def test_the_request_never_reaches_the_server(self): + """A timeout is not a slow answer: the request is not sent at all.""" + flow = make_flow() + + async def record(_seconds): + return None + + with patch("MitmLibrary.failures.asyncio.sleep", record): + asyncio.run(self.interceptor.request(flow)) + self.assertIsNotNone(flow.error) + + def test_a_timeout_ends_the_flow(self): + """Nothing after it should run, since there is no longer a request to change.""" + later = Rule("later", UrlMatcher("/api"), TimeoutAction(1.0, "1s")) + self.registry.add(later) + + async def record(_seconds): + return None + + with patch("MitmLibrary.failures.asyncio.sleep", record): + asyncio.run(self.interceptor.request(make_flow())) + self.assertEqual(later.used, 0) + + def test_holding_one_request_does_not_hold_up_another(self): + """The whole point of holding rather than blocking: other traffic keeps flowing.""" + self.registry.clear() + self.registry.add(Rule("hang", UrlMatcher("/slow"), TimeoutAction(0.4, "0.4s"))) + slow = make_flow(url="http://example.com/slow") + fast = make_flow() + + async def scenario(): + held = asyncio.create_task(self.interceptor.request(slow)) + await asyncio.sleep(0) + start = asyncio.get_running_loop().time() + await self.interceptor.request(fast) + elapsed = asyncio.get_running_loop().time() - start + await held + return elapsed + + self.assertLess(asyncio.run(scenario()), 0.2) + + def test_a_flow_that_cannot_be_killed_does_not_raise(self): + flow = make_flow() + flow.kill = lambda: (_ for _ in ()).throw( + exceptions.ControlException("Flow is not killable.") + ) + flow.live = False + + async def record(_seconds): + return None + + with patch("MitmLibrary.failures.asyncio.sleep", record): + asyncio.run(self.interceptor.request(flow)) # must not raise + + def test_the_rule_reports_what_it_was_asked_for(self): + described = self.registry.describe()[0] + self.assertEqual(described.type, "timeout") + self.assertEqual(described.hold, "30s") + self.assertEqual(described.hold_seconds, 30.0) + self.assertEqual(described.phase, "request") + + +class TestTruncate(FailureTestCase): + def test_the_body_is_cut_short(self): + self.add("cut", TruncateAction(keep_bytes=6)) + flow = make_flow(body=b"a full response body") + self.respond(flow) + self.assertEqual(flow.response.raw_content, b"a full") + + def test_the_declared_length_is_left_alone(self): + """The mismatch is the fault being injected. + + set_content would correct content-length and leave a perfectly valid, merely + shorter response, which is not a failure at all. This assertion is what would + notice a mitmproxy version that started normalising the header on write. + """ + self.add("cut", TruncateAction(keep_bytes=6)) + flow = make_flow(body=b"a full response body") + self.respond(flow) + self.assertEqual(flow.response.headers["content-length"], "20") + self.assertEqual(len(flow.response.raw_content), 6) + + def test_half_the_body_is_kept_by_default(self): + self.add("cut", TruncateAction()) + flow = make_flow(body=b"0123456789") + self.respond(flow) + self.assertEqual(flow.response.raw_content, b"01234") + + def test_a_fraction_can_be_given(self): + self.add("cut", TruncateAction(keep_fraction=0.25)) + flow = make_flow(body=b"0123456789") + self.respond(flow) + self.assertEqual(flow.response.raw_content, b"01") + + def test_a_byte_count_wins_over_a_fraction(self): + self.add("cut", TruncateAction(keep_bytes=3, keep_fraction=0.9)) + flow = make_flow(body=b"0123456789") + self.respond(flow) + self.assertEqual(flow.response.raw_content, b"012") + + def test_nothing_at_all_can_be_kept(self): + self.add("cut", TruncateAction(keep_bytes=0)) + flow = make_flow(body=b"0123456789") + self.respond(flow) + self.assertEqual(flow.response.raw_content, b"") + self.assertEqual(flow.response.headers["content-length"], "10") + + def test_a_negative_count_keeps_nothing_rather_than_failing(self): + self.add("cut", TruncateAction(keep_bytes=-5)) + flow = make_flow(body=b"0123456789") + self.respond(flow) + self.assertEqual(flow.response.raw_content, b"") + + def test_a_body_that_is_already_short_enough_is_left_alone(self): + self.add("cut", TruncateAction(keep_bytes=100)) + flow = make_flow(body=b"short") + with patch.object(logger, "info") as mock_info: + self.respond(flow) + self.assertEqual(flow.response.raw_content, b"short") + logged = " ".join(str(call.args[0]) for call in mock_info.call_args_list) + self.assertIn("unchanged", logged) + + def test_a_response_without_a_body_says_so(self): + """A 204 or a streamed response has nothing here to cut.""" + self.add("cut", TruncateAction(keep_bytes=1)) + flow = make_flow() + flow.response = http.Response.make(204) + flow.response.raw_content = None + with patch.object(logger, "info") as mock_info: + self.respond(flow) + logged = " ".join(str(call.args[0]) for call in mock_info.call_args_list) + self.assertIn("no body to truncate", logged) + + def test_a_flow_without_a_response_is_a_no_op(self): + self.add("cut", TruncateAction(keep_bytes=1)) + flow = make_flow() + flow.response = None + self.respond(flow) # must not raise + + def test_a_compressed_body_is_cut_in_its_compressed_form(self): + """What arrives is not a valid shorter document but a broken one. + + That is the more realistic failure and the more interesting one to test an + application with. mitmproxy does not raise on a body it cannot decompress - it + hands back nothing - so the assertion is that the original text is gone, not that + decoding fails. + """ + self.add("cut", TruncateAction(keep_fraction=0.5)) + flow = make_flow(body=b"a full response body repeated over and over and over") + flow.response.encode("gzip") + compressed = flow.response.raw_content + self.respond(flow) + self.assertEqual(len(flow.response.raw_content), len(compressed) // 2) + self.assertNotIn(b"a full response body", flow.response.get_content()) + + def test_the_rule_reports_what_it_was_asked_for(self): + self.add("cut", TruncateAction(keep_bytes=6)) + described = self.registry.describe()[0] + self.assertEqual(described.type, "truncate") + self.assertEqual(described.keep_bytes, 6) + self.assertEqual(described.phase, "response") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_interceptor.py b/tests/test_interceptor.py index f466270..4b0bbb6 100644 --- a/tests/test_interceptor.py +++ b/tests/test_interceptor.py @@ -59,26 +59,30 @@ def respond(self, flow): """Runs the async response hook, as mitmproxy would.""" asyncio.run(self.interceptor.response(flow)) + def send(self, flow): + """Runs the async request hook, as mitmproxy would.""" + asyncio.run(self.interceptor.request(flow)) + class TestBlocking(InterceptorTestCase): def test_respond_mode_answers_without_reaching_the_server(self): self.add("block", BlockAction(BlockMode.RESPOND, 403)) flow = make_flow() - self.interceptor.request(flow) + self.send(flow) self.assertEqual(flow.response.status_code, 403) flow.kill.assert_not_called() def test_respond_mode_can_carry_a_body(self): self.add("block", BlockAction(BlockMode.RESPOND, 503, "maintenance")) flow = make_flow() - self.interceptor.request(flow) + self.send(flow) self.assertEqual(flow.response.content, b"maintenance") self.assertEqual(flow.response.status_code, 503) def test_reset_mode_drops_the_connection(self): self.add("block", BlockAction(BlockMode.RESET)) flow = make_flow() - self.interceptor.request(flow) + self.send(flow) flow.kill.assert_called_once() def test_reset_mode_does_not_raise_on_a_flow_that_cannot_be_killed(self): @@ -87,13 +91,13 @@ def test_reset_mode_does_not_raise_on_a_flow_that_cannot_be_killed(self): flow = make_flow() flow.killable = False flow.kill.side_effect = exceptions.ControlException("Flow is not killable.") - self.interceptor.request(flow) # must not raise + self.send(flow) # must not raise flow.kill.assert_not_called() def test_a_request_that_does_not_match_is_left_alone(self): self.add("block", BlockAction(), url="/orders") flow = make_flow() - self.interceptor.request(flow) + self.send(flow) self.assertEqual(flow.response.status_code, 200) def test_blocking_ends_the_flow(self): @@ -101,7 +105,7 @@ def test_blocking_ends_the_flow(self): self.add("first", BlockAction(BlockMode.RESPOND, 403)) second = self.add("second", BlockAction(BlockMode.RESPOND, 503)) flow = make_flow() - self.interceptor.request(flow) + self.send(flow) self.assertEqual(flow.response.status_code, 403) self.assertEqual(second.used, 0) @@ -250,9 +254,9 @@ def test_an_exhausted_rule_is_skipped_in_the_response_hook(self): def test_an_exhausted_rule_is_skipped_in_the_request_hook(self): rule = self.add("block", BlockAction(BlockMode.RESPOND, 403), times=1) - self.interceptor.request(make_flow()) + self.send(make_flow()) flow = make_flow() - self.interceptor.request(flow) + self.send(flow) self.assertEqual(flow.response.status_code, 200) self.assertEqual(rule.used, 1) diff --git a/tests/test_request_rules.py b/tests/test_request_rules.py index c79c158..f62a069 100644 --- a/tests/test_request_rules.py +++ b/tests/test_request_rules.py @@ -50,36 +50,40 @@ def add(self, alias, action, url="/api", method="ANY", times=0): def respond(self, flow): asyncio.run(self.interceptor.response(flow)) + def send(self, flow): + """Runs the async request hook, as mitmproxy would.""" + asyncio.run(self.interceptor.request(flow)) + class TestRequestHeaders(RequestRuleTestCase): def test_a_header_can_be_added(self): self.add("auth", RequestHeadersAction({"Authorization": "Bearer token"})) flow = make_flow() - self.interceptor.request(flow) + self.send(flow) self.assertEqual(flow.request.headers["Authorization"], "Bearer token") def test_an_existing_header_is_replaced(self): self.add("auth", RequestHeadersAction({"Authorization": "Bearer new"})) flow = make_flow(headers={"Authorization": "Bearer old"}) - self.interceptor.request(flow) + self.send(flow) self.assertEqual(flow.request.headers["Authorization"], "Bearer new") def test_other_headers_are_left_alone(self): """Merging is the point: setting one header must not drop the rest.""" self.add("auth", RequestHeadersAction({"Authorization": "Bearer token"})) flow = make_flow(headers={"X-Kept": "yes"}) - self.interceptor.request(flow) + self.send(flow) self.assertEqual(flow.request.headers["X-Kept"], "yes") def test_a_header_can_be_removed(self): self.add("cookies", RequestHeadersAction(None, ["Cookie"])) flow = make_flow(headers={"Cookie": "session=1"}) - self.interceptor.request(flow) + self.send(flow) self.assertNotIn("Cookie", flow.request.headers) def test_removing_a_header_that_is_not_there_is_a_no_op(self): self.add("cookies", RequestHeadersAction(None, ["Cookie"])) - self.interceptor.request(make_flow()) # must not raise + self.send(make_flow()) # must not raise def test_removing_and_setting_the_same_header_leaves_one_value(self): """Headers can repeat; naming one in both is how a suite collapses them.""" @@ -87,7 +91,7 @@ def test_removing_and_setting_the_same_header_leaves_one_value(self): flow = make_flow() flow.request.headers.add("Accept", "text/plain") flow.request.headers.add("Accept", "text/html") - self.interceptor.request(flow) + self.send(flow) self.assertEqual( flow.request.headers.get_all("Accept"), ["application/json"] ) @@ -97,14 +101,14 @@ class TestRequestBody(RequestRuleTestCase): def test_the_body_is_replaced(self): self.add("payload", RequestBodyAction('{"id": 1}')) flow = make_flow(method="POST", body=b"original request") - self.interceptor.request(flow) + self.send(flow) self.assertEqual(flow.request.content, b'{"id": 1}') def test_the_content_length_is_updated(self): """A declared length that no longer matches makes the request unreadable.""" self.add("payload", RequestBodyAction("short")) flow = make_flow(method="POST", body=b"a much longer original body") - self.interceptor.request(flow) + self.send(flow) self.assertEqual(flow.request.headers["content-length"], "5") @@ -112,7 +116,7 @@ class TestRewrite(RequestRuleTestCase): def test_the_whole_url_is_replaced(self): self.add("v2", RewriteAction("https://other.example.com:8443/api/v2/users?x=1")) flow = make_flow() - self.interceptor.request(flow) + self.send(flow) self.assertEqual( flow.request.pretty_url, "https://other.example.com:8443/api/v2/users?x=1" ) @@ -120,7 +124,7 @@ def test_the_whole_url_is_replaced(self): def test_the_parts_of_the_request_stay_consistent(self): self.add("v2", RewriteAction("https://other.example.com:8443/api/v2/users")) flow = make_flow() - self.interceptor.request(flow) + self.send(flow) self.assertEqual(flow.request.scheme, "https") self.assertEqual(flow.request.host, "other.example.com") self.assertEqual(flow.request.port, 8443) @@ -133,7 +137,7 @@ def test_the_host_header_follows_the_url(self): """ self.add("v2", RewriteAction("https://other.example.com:8443/api/v2/users")) flow = make_flow(headers={"Host": "example.com"}) - self.interceptor.request(flow) + self.send(flow) self.assertEqual(flow.request.host_header, "other.example.com:8443") @@ -141,7 +145,7 @@ class TestRedirect(RequestRuleTestCase): def test_the_host_is_replaced_and_the_path_is_kept(self): self.add("stub", RedirectAction("127.0.0.1", 8000)) flow = make_flow(url="http://api.example.com/api/users?page=2") - self.interceptor.request(flow) + self.send(flow) self.assertEqual(flow.request.host, "127.0.0.1") self.assertEqual(flow.request.port, 8000) self.assertEqual(flow.request.path, "/api/users?page=2") @@ -149,13 +153,13 @@ def test_the_host_is_replaced_and_the_path_is_kept(self): def test_the_original_port_is_kept_when_none_is_given(self): self.add("stub", RedirectAction("127.0.0.1")) flow = make_flow(url="http://api.example.com:9000/api/users") - self.interceptor.request(flow) + self.send(flow) self.assertEqual(flow.request.port, 9000) def test_the_scheme_can_be_changed(self): self.add("stub", RedirectAction("127.0.0.1", 8000, "https")) flow = make_flow(url="http://api.example.com/api/users") - self.interceptor.request(flow) + self.send(flow) self.assertEqual(flow.request.scheme, "https") def test_the_host_header_is_updated(self): @@ -163,7 +167,7 @@ def test_the_host_header_is_updated(self): self.add("stub", RedirectAction("127.0.0.1", 8000)) flow = make_flow(url="http://api.example.com/api/users", headers={"Host": "api.example.com"}) - self.interceptor.request(flow) + self.send(flow) self.assertEqual(flow.request.host_header, "127.0.0.1:8000") def test_a_default_port_is_left_out_of_the_host_header(self): @@ -171,14 +175,14 @@ def test_a_default_port_is_left_out_of_the_host_header(self): self.add("stub", RedirectAction("other.example.com", 80)) flow = make_flow(url="http://api.example.com/api/users", headers={"Host": "api.example.com"}) - self.interceptor.request(flow) + self.send(flow) self.assertEqual(flow.request.host_header, "other.example.com") def test_a_default_https_port_is_left_out_too(self): self.add("stub", RedirectAction("other.example.com", 443, "https")) flow = make_flow(url="http://api.example.com/api/users", headers={"Host": "api.example.com"}) - self.interceptor.request(flow) + self.send(flow) self.assertEqual(flow.request.host_header, "other.example.com") def test_a_request_without_a_host_header_does_not_gain_one(self): @@ -186,7 +190,7 @@ def test_a_request_without_a_host_header_does_not_gain_one(self): self.add("stub", RedirectAction("127.0.0.1", 8000)) flow = make_flow() self.assertIsNone(flow.request.host_header) # tflow builds one without it - self.interceptor.request(flow) + self.send(flow) self.assertIsNone(flow.request.host_header)