diff --git a/projects/egress-gate/tests/gates/test_base.py b/projects/egress-gate/tests/gates/test_base.py index 85f0086b..dad0543c 100644 --- a/projects/egress-gate/tests/gates/test_base.py +++ b/projects/egress-gate/tests/gates/test_base.py @@ -16,8 +16,16 @@ GateResources, RegexConfig, RegexGate, + Utf8BodyGate, +) +from egress_gate.request import ( + ExistingHeaderAction, + HttpRequest, + HttpTarget, + RequestContext, + RequestMutations, + WriteHeaderMutation, ) -from egress_gate.request import HttpRequest, HttpTarget, RequestContext from egress_gate.result import Finding, GateControl, GateEvaluation from egress_gate.timeout import Timeout @@ -107,6 +115,63 @@ def _evaluate( return GateEvaluation.proceed().model_copy(update={"control": GateControl.DENY}) +class _UndeclaredBodyMutationGate(Gate[_RequestConfig, None]): + capabilities = frozenset() + finding_types = () + + def _evaluate( + self, + request: HttpRequest, + *, + timeout: Timeout, + ) -> GateEvaluation: + del request, timeout + return GateEvaluation.proceed( + request_mutations=RequestMutations(replacement_body=b"changed") + ) + + +class _UndeclaredHeaderMutationGate(Gate[_RequestConfig, None]): + capabilities = frozenset() + finding_types = () + + def _evaluate( + self, + request: HttpRequest, + *, + timeout: Timeout, + ) -> GateEvaluation: + del request, timeout + return GateEvaluation.proceed( + request_mutations=RequestMutations( + header_mutations=( + WriteHeaderMutation( + kind="write", + name="x-openshell-middleware-test", + value="changed", + on_existing=ExistingHeaderAction.OVERWRITE, + ), + ) + ) + ) + + +class _InvalidUtf8ReplacementGate(Utf8BodyGate[_RequestConfig, None]): + capabilities = frozenset({GateCapability.READ_BODY, GateCapability.REPLACE_BODY}) + finding_types = () + + def _evaluate_text( + self, + text: str, + *, + timeout: Timeout, + ) -> GateEvaluation: + del text, timeout + return GateEvaluation.proceed( + request_mutations=RequestMutations(replacement_body=b"\xff") + ) + + def _request(*, body: bytes = b"payload", host: str = "example.com") -> HttpRequest: return HttpRequest( context=RequestContext(request_id="request-1", sandbox_id="sandbox-1"), @@ -145,6 +210,16 @@ def test_gate_public_wrapper_enforces_declared_output_capabilities() -> None: _RequestConfig(name="test", kind="test-request"), None ).evaluate(_request(), timeout=Timeout.from_seconds(1)) + with pytest.raises(GateContractError, match="undeclared body replacement"): + _UndeclaredBodyMutationGate( + _RequestConfig(name="test", kind="test-request"), None + ).evaluate(_request(), timeout=Timeout.from_seconds(1)) + + with pytest.raises(GateContractError, match="undeclared header mutations"): + _UndeclaredHeaderMutationGate( + _RequestConfig(name="test", kind="test-request"), None + ).evaluate(_request(), timeout=Timeout.from_seconds(1)) + with pytest.raises(GateContractError, match="undeclared finding"): _CapabilityBypassGate( _RequestConfig(name="test", kind="test-request"), @@ -152,6 +227,15 @@ def test_gate_public_wrapper_enforces_declared_output_capabilities() -> None: ).evaluate(_request(), timeout=Timeout.from_seconds(1)) +def test_utf8_body_gate_rejects_a_non_utf8_replacement() -> None: + gate = _InvalidUtf8ReplacementGate( + _RequestConfig(name="test", kind="test-request"), None + ) + + with pytest.raises(GateContractError, match="non-UTF-8 replacement"): + gate.evaluate(_request(), timeout=Timeout.from_seconds(1)) + + def test_gate_public_wrapper_classifies_invalid_models_as_contract_errors() -> None: with pytest.raises(GateContractError, match="gate output is invalid"): _InvalidEvaluationGate( diff --git a/projects/egress-gate/tests/gates/test_regex.py b/projects/egress-gate/tests/gates/test_regex.py index 37f40a89..e002bb91 100644 --- a/projects/egress-gate/tests/gates/test_regex.py +++ b/projects/egress-gate/tests/gates/test_regex.py @@ -417,6 +417,71 @@ def test_replacement_selects_ranked_non_overlapping_winners() -> None: assert len(evaluation.findings) == 2 +@pytest.mark.parametrize( + ("rules", "text", "expected"), + [ + ( + [ + {"name": "short", "pattern": "ab", "confidence": "high"}, + {"name": "long", "pattern": "abc", "confidence": "high"}, + ], + "abc", + b"", + ), + ( + [ + {"name": "earlier", "pattern": "ab", "confidence": "high"}, + {"name": "later", "pattern": "ba", "confidence": "high"}, + ], + "aba", + b"a", + ), + ], +) +def test_equal_confidence_overlap_prefers_length_then_start( + rules: list[dict[str, object]], + text: str, + expected: bytes, +) -> None: + evaluation = _run( + _config(rules, action_kind="replace", template="<{entity}>"), + text, + ) + + assert evaluation.request_mutations.replacement_body == expected + assert evaluation.findings[0].count == 2 + + +def test_identical_overlap_uses_entity_name_as_a_stable_tie_breaker() -> None: + config = RegexConfig.model_validate( + { + "name": "regex", + "kind": "regex", + "scan": { + "kind": "body", + "action": {"kind": "replace", "template": "<{entity}>"}, + }, + "pattern_catalog": { + "entities": [ + { + "name": "zeta", + "rules": [{"pattern": "abc", "confidence": "high"}], + }, + { + "name": "alpha", + "rules": [{"pattern": "abc", "confidence": "high"}], + }, + ] + }, + } + ) + + evaluation = _run(config, "abc") + + assert evaluation.request_mutations.replacement_body == b"" + assert [finding.label for finding in evaluation.findings] == ["alpha", "zeta"] + + @pytest.mark.parametrize( "template", [ diff --git a/projects/egress-gate/tests/service/test_grpc_integration.py b/projects/egress-gate/tests/service/test_grpc_integration.py index bad72f9a..133de57d 100644 --- a/projects/egress-gate/tests/service/test_grpc_integration.py +++ b/projects/egress-gate/tests/service/test_grpc_integration.py @@ -71,6 +71,38 @@ def _evaluation( ) +def _progressive_redaction_config() -> Message: + values = { + "gates": [ + { + "name": name, + "kind": "regex", + "scan": { + "kind": "body", + "action": {"kind": "replace", "template": "[{entity}]"}, + }, + "pattern_catalog": { + "entities": [ + { + "name": entity, + "rules": [{"pattern": pattern, "confidence": "high"}], + } + ] + }, + } + for name, entity, pattern in ( + ("redact-email", "email", r"alice@example\.com"), + ("redact-api-key", "api_key", r"sk-[0-9]+"), + ("redact-phone", "phone", r"555-[0-9]{4}"), + ) + ], + "default_decision": "allow", + } + request = pb2.ValidateConfigRequest() + json_format.ParseDict(values, request.config) + return request.config + + @asynccontextmanager async def _running_stub( middleware: EgressGateMiddleware, @@ -118,6 +150,25 @@ async def test_generated_stub_round_trip_covers_manifest_and_gate_actions() -> N assert denied.reason_code == "egress_gate_regex_denied" +@pytest.mark.asyncio +async def test_generated_stub_returns_three_gate_progressive_redaction() -> None: + middleware = EgressGateMiddleware(create_builtin_registry()) + request = _evaluation(b"email=alice@example.com api_key=sk-123456 phone=555-0100") + request.config.CopyFrom(_progressive_redaction_config()) + + async with _running_stub(middleware) as (stub, _): + response = await stub.EvaluateHttpRequest(request) + + assert response.decision == pb2.DECISION_ALLOW + assert response.has_body is True + assert response.body == b"email=[email] api_key=[api_key] phone=[phone]" + assert [finding.label for finding in response.findings] == [ + "email", + "api_key", + "phone", + ] + + @pytest.mark.asyncio async def test_generated_stub_maps_invalid_phase_to_invalid_argument() -> None: middleware = EgressGateMiddleware(create_builtin_registry()) diff --git a/projects/egress-gate/tests/test_request.py b/projects/egress-gate/tests/test_request.py index e4ccd575..8013ce31 100644 --- a/projects/egress-gate/tests/test_request.py +++ b/projects/egress-gate/tests/test_request.py @@ -134,11 +134,30 @@ def test_request_mutations_preserve_ordered_discriminated_header_mutations() -> def test_request_mutations_reject_invalid_bounds() -> None: mutation = RemoveHeaderMutation(kind="remove", name="x-test") + assert ( + len( + RequestMutations( + header_mutations=tuple(mutation for _ in range(MAX_HEADER_MUTATIONS)) + ).header_mutations + ) + == MAX_HEADER_MUTATIONS + ) with pytest.raises(ValidationError): RequestMutations( header_mutations=tuple(mutation for _ in range(MAX_HEADER_MUTATIONS + 1)) ) + exact_data = RequestMutations( + header_mutations=( + WriteHeaderMutation( + kind="write", + name="x", + value="x" * (MAX_HEADER_MUTATION_DATA_BYTES - 1), + on_existing=ExistingHeaderAction.OVERWRITE, + ), + ) + ) + assert exact_data.header_mutations with pytest.raises(ValidationError): RequestMutations( header_mutations=( @@ -151,6 +170,16 @@ def test_request_mutations_reject_invalid_bounds() -> None: ) ) + assert ( + len( + RequestMutations(replacement_body=b"x" * MAX_BODY_BYTES).replacement_body + or b"" + ) + == MAX_BODY_BYTES + ) + with pytest.raises(ValidationError): + RequestMutations(replacement_body=b"x" * (MAX_BODY_BYTES + 1)) + def test_request_models_reject_non_tuple_sequences_and_extra_fields() -> None: values: dict[str, object] = { diff --git a/projects/egress-gate/tests/test_request_processor.py b/projects/egress-gate/tests/test_request_processor.py index 05dcd094..fe068535 100644 --- a/projects/egress-gate/tests/test_request_processor.py +++ b/projects/egress-gate/tests/test_request_processor.py @@ -13,8 +13,11 @@ DEFAULT_DENY_REASON_CODE, LIMIT_REASON_CODE, MAX_FINDING_COUNT, + MAX_HEADER_MUTATION_DATA_BYTES, MAX_HEADER_MUTATIONS, MAX_PROTO_FINDING_GROUPS, + MAX_PROTO_HEADERS, + MAX_PROTO_HEADERS_BYTES, ) from egress_gate.errors import ( EgressGateError, @@ -56,7 +59,12 @@ class _ControlConfig(GateConfig): control: Literal["proceed", "allow", "deny"] = "proceed" replacement: str | None = None expected_body: str | None = None + expected_header_name: str | None = None + expected_header_value: str | None = None header_value: str | None = None + header_name: str = "x-openshell-middleware-test" + header_action: Literal["append", "overwrite", "skip"] = "overwrite" + remove_header: str | None = None header_count: int = 0 finding_label: str | None = None finding_count: int = 1 @@ -92,6 +100,19 @@ def _evaluate( and request.body.decode("utf-8") != self.config.expected_body ): raise AssertionError("later gate did not see the current request") + if self.config.expected_header_name is not None: + values = tuple( + header.value + for header in request.headers + if header.name.lower() == self.config.expected_header_name.lower() + ) + expected = ( + () + if self.config.expected_header_value is None + else (self.config.expected_header_value,) + ) + if values != expected: + raise AssertionError("later gate did not see current request headers") findings: tuple[Finding, ...] = () if self.config.boundary_finding: finding = Finding( @@ -118,7 +139,7 @@ def _evaluate( ) if self.config.control == "allow": return GateEvaluation.allow(findings=findings) - mutations: tuple[WriteHeaderMutation, ...] = tuple( + mutations: tuple[WriteHeaderMutation | RemoveHeaderMutation, ...] = tuple( WriteHeaderMutation( kind="write", name=f"x-openshell-middleware-test-{index}", @@ -131,11 +152,15 @@ def _evaluate( mutations = ( WriteHeaderMutation( kind="write", - name="x-openshell-middleware-test", + name=self.config.header_name, value=self.config.header_value, - on_existing=ExistingHeaderAction.OVERWRITE, + on_existing=ExistingHeaderAction(self.config.header_action), ), ) + if self.config.remove_header is not None: + mutations += ( + RemoveHeaderMutation(kind="remove", name=self.config.remove_header), + ) return GateEvaluation.proceed( request_mutations=RequestMutations( replacement_body=( @@ -171,11 +196,14 @@ def _regex_config( action_kind: str = "detect", *, scan: dict[str, object] | None = None, + entity: str = "token", + pattern: str = "secret", + template: str = "[{entity}]", ) -> dict[str, object]: scan_values = {"kind": "body"} if scan is None else dict(scan) action: dict[str, object] = {"kind": action_kind} if action_kind == "replace": - action["template"] = "[{entity}]" + action["template"] = template scan_values["action"] = action return { "kind": "regex", @@ -183,8 +211,8 @@ def _regex_config( "pattern_catalog": { "entities": [ { - "name": "token", - "rules": [{"pattern": "secret", "confidence": "high"}], + "name": entity, + "rules": [{"pattern": pattern, "confidence": "high"}], } ] }, @@ -274,6 +302,337 @@ def test_processor_applies_mutations_to_the_current_request_and_preserves_intent assert result.policy_fingerprint == "policy-fingerprint" +def test_three_gates_aggregate_interacting_body_and_header_mutations() -> None: + original = _request( + body=b"original secret", + headers=( + HttpHeader(name="X-OpenShell-Middleware-State", value="old-one"), + HttpHeader(name="x-openshell-middleware-state", value="old-two"), + HttpHeader(name="x-remove", value="discard"), + HttpHeader(name="x-keep", value="preserve"), + ), + ) + processor = _processor( + ( + ( + "first", + { + "kind": "test-control", + "replacement": "first redaction", + "header_name": "x-openshell-middleware-state", + "header_value": "stage-one", + "header_action": "overwrite", + }, + ), + ( + "second", + { + "kind": "test-control", + "expected_body": "first redaction", + "expected_header_name": "x-openshell-middleware-state", + "expected_header_value": "stage-one", + "replacement": "", + "header_name": "x-openshell-middleware-chain", + "header_value": "stage-two", + "header_action": "append", + "remove_header": "x-openshell-middleware-state", + }, + ), + ( + "third", + { + "kind": "test-control", + "expected_body": "", + "expected_header_name": "x-openshell-middleware-state", + "replacement": "final body", + "header_name": "x-openshell-middleware-state", + "header_value": "stage-three", + "header_action": "append", + "remove_header": "x-remove", + }, + ), + ) + ) + + result = processor.process(original, timeout=Timeout.from_seconds(1)) + final_request = apply_request_mutations(original, result.request_mutations) + first_mutations = RequestMutations( + replacement_body=b"first redaction", + header_mutations=( + WriteHeaderMutation( + kind="write", + name="x-openshell-middleware-state", + value="stage-one", + on_existing=ExistingHeaderAction.OVERWRITE, + ), + ), + ) + second_mutations = RequestMutations( + replacement_body=b"", + header_mutations=( + WriteHeaderMutation( + kind="write", + name="x-openshell-middleware-chain", + value="stage-two", + on_existing=ExistingHeaderAction.APPEND, + ), + RemoveHeaderMutation( + kind="remove", + name="x-openshell-middleware-state", + ), + ), + ) + third_mutations = RequestMutations( + replacement_body=b"final body", + header_mutations=( + WriteHeaderMutation( + kind="write", + name="x-openshell-middleware-state", + value="stage-three", + on_existing=ExistingHeaderAction.APPEND, + ), + RemoveHeaderMutation(kind="remove", name="x-remove"), + ), + ) + sequential_request = original + for mutations in (first_mutations, second_mutations, third_mutations): + sequential_request = apply_request_mutations(sequential_request, mutations) + + assert result.decision is EgressDecision.ALLOW + assert result.request_mutations == RequestMutations( + replacement_body=b"final body", + header_mutations=( + first_mutations.header_mutations + + second_mutations.header_mutations + + third_mutations.header_mutations + ), + ) + assert final_request == sequential_request + assert final_request.body == b"final body" + assert final_request.headers == ( + HttpHeader(name="x-keep", value="preserve"), + HttpHeader(name="x-openshell-middleware-chain", value="stage-two"), + HttpHeader(name="x-openshell-middleware-state", value="stage-three"), + ) + + +def test_three_regex_gates_progressively_redact_the_current_body() -> None: + original = _request( + body=( + b"Customer record\n" + b"email: alice@example.com\n" + b"api key: sk-123456\n" + b"phone: 555-0100\n" + ) + ) + processor = _processor( + ( + ( + "redact-email", + _regex_config( + "replace", + entity="email", + pattern=r"alice@example\.com", + ), + ), + ( + "redact-api-key", + _regex_config( + "replace", + entity="api_key", + pattern=r"sk-[0-9]+", + ), + ), + ( + "redact-phone", + _regex_config( + "replace", + entity="phone", + pattern=r"555-[0-9]{4}", + ), + ), + ), + include_regex=True, + ) + + result = processor.process(original, timeout=Timeout.from_seconds(1)) + final_request = apply_request_mutations(original, result.request_mutations) + + expected_body = ( + b"Customer record\nemail: [email]\napi key: [api_key]\nphone: [phone]\n" + ) + assert result.decision is EgressDecision.ALLOW + assert result.request_mutations.replacement_body == expected_body + assert final_request.body == expected_body + assert [(item.source_gate, item.finding.type) for item in result.findings] == [ + ("redact-email", "regex_match"), + ("redact-api-key", "regex_match"), + ("redact-phone", "regex_match"), + ] + + +def test_later_regex_gates_use_the_body_after_overlapping_text_is_redacted() -> None: + original = _request(body=b"credential: alice@example.com") + processor = _processor( + ( + ( + "redact-email", + _regex_config( + "replace", + entity="email", + pattern=r"alice@example\.com", + ), + ), + ( + "redact-original-domain", + _regex_config( + "replace", + entity="domain", + pattern=r"example\.com", + ), + ), + ( + "classify-redaction", + _regex_config( + "replace", + entity="redacted_email", + pattern=r"\[email\]", + template="<{entity}>", + ), + ), + ), + include_regex=True, + ) + + result = processor.process(original, timeout=Timeout.from_seconds(1)) + final_request = apply_request_mutations(original, result.request_mutations) + + assert final_request.body == b"credential: " + assert [(item.source_gate, item.finding.label) for item in result.findings] == [ + ("redact-email", "email"), + ("classify-redaction", "redacted_email"), + ] + + +def test_final_empty_body_replacement_is_preserved_across_gates() -> None: + original = _request(body=b"original") + processor = _processor( + ( + ("first", {"kind": "test-control", "replacement": "intermediate"}), + ( + "second", + { + "kind": "test-control", + "expected_body": "intermediate", + "replacement": "", + }, + ), + ( + "observe", + { + "kind": "test-control", + "expected_body": "", + }, + ), + ) + ) + + result = processor.process(original, timeout=Timeout.from_seconds(1)) + final_request = apply_request_mutations(original, result.request_mutations) + + assert result.decision is EgressDecision.ALLOW + assert result.request_mutations.replacement_body == b"" + assert final_request.body == b"" + + +def test_header_skip_and_append_remain_ordered_across_gates() -> None: + original = _request( + headers=(HttpHeader(name="X-OpenShell-Middleware-State", value="original"),) + ) + processor = _processor( + ( + ( + "overwrite", + { + "kind": "test-control", + "header_name": "x-openshell-middleware-state", + "header_value": "first", + "header_action": "overwrite", + }, + ), + ( + "skip", + { + "kind": "test-control", + "expected_header_name": "x-openshell-middleware-state", + "expected_header_value": "first", + "header_name": "x-openshell-middleware-state", + "header_value": "ignored", + "header_action": "skip", + }, + ), + ( + "append", + { + "kind": "test-control", + "expected_header_name": "x-openshell-middleware-state", + "expected_header_value": "first", + "header_name": "x-openshell-middleware-state", + "header_value": "second", + "header_action": "append", + }, + ), + ) + ) + + result = processor.process(original, timeout=Timeout.from_seconds(1)) + final_request = apply_request_mutations(original, result.request_mutations) + + assert final_request.headers == ( + HttpHeader(name="x-openshell-middleware-state", value="first"), + HttpHeader(name="x-openshell-middleware-state", value="second"), + ) + + +def test_terminal_allow_returns_mutations_from_prior_gates() -> None: + original = _request(body=b"original") + processor = _processor( + ( + ("body", {"kind": "test-control", "replacement": "updated"}), + ( + "header", + { + "kind": "test-control", + "expected_body": "updated", + "header_name": "x-openshell-middleware-reviewed", + "header_value": "true", + }, + ), + ( + "allow", + { + "kind": "test-control", + "control": "allow", + "expected_body": "updated", + "expected_header_name": "x-openshell-middleware-reviewed", + "expected_header_value": "true", + }, + ), + ) + ) + + result = processor.process(original, timeout=Timeout.from_seconds(1)) + final_request = apply_request_mutations(original, result.request_mutations) + + assert result.decision is EgressDecision.ALLOW + assert isinstance(result.decision_source, GateDecisionSource) + assert result.decision_source.gate_name == "allow" + assert final_request.body == b"updated" + assert final_request.headers == ( + HttpHeader(name="x-openshell-middleware-reviewed", value="true"), + ) + + def test_regex_gate_sees_header_mutations_from_an_earlier_gate() -> None: processor = _processor( ( @@ -479,6 +838,112 @@ def test_composed_header_mutation_overflow_is_an_atomic_runtime_limit() -> None: assert result.traces == () +def test_composed_header_mutation_data_overflow_is_an_atomic_runtime_limit() -> None: + half_limit = MAX_HEADER_MUTATION_DATA_BYTES // 2 + processor = _processor( + ( + ( + "first", + { + "kind": "test-control", + "header_name": "x-openshell-middleware-first", + "header_value": "a" * half_limit, + }, + ), + ( + "second", + { + "kind": "test-control", + "header_name": "x-openshell-middleware-second", + "header_value": "b" * half_limit, + }, + ), + ) + ) + + result = processor.process(_request(), timeout=Timeout.from_seconds(1)) + + assert result.decision is EgressDecision.DENY + assert result.decision_source.kind is DecisionSourceKind.RUNTIME_LIMIT + assert result.reason_code == LIMIT_REASON_CODE + assert result.request_mutations.is_empty + assert result.findings == () + assert result.traces == () + + +@pytest.mark.parametrize( + "current_request", + [ + _request( + headers=tuple( + HttpHeader(name=f"x-{index}", value="value") + for index in range(MAX_PROTO_HEADERS) + ) + ), + _request( + headers=( + HttpHeader( + name="x-existing", + value="x" * (MAX_PROTO_HEADERS_BYTES - len("x-existing")), + ), + ) + ), + ], + ids=("header-count", "header-bytes"), +) +def test_mutation_that_overflows_the_current_request_is_an_atomic_runtime_limit( + current_request: HttpRequest, +) -> None: + processor = _processor( + ( + ( + "append", + { + "kind": "test-control", + "header_name": "x-openshell-middleware-added", + "header_value": "value", + "header_action": "append", + }, + ), + ) + ) + + result = processor.process(current_request, timeout=Timeout.from_seconds(1)) + + assert result.decision is EgressDecision.DENY + assert result.decision_source.kind is DecisionSourceKind.RUNTIME_LIMIT + assert result.reason_code == LIMIT_REASON_CODE + assert result.request_mutations.is_empty + assert result.findings == () + assert result.traces == () + + +@pytest.mark.parametrize( + "gate_config", + [ + { + "kind": "test-control", + "header_name": "authorization", + "header_value": "secret", + }, + { + "kind": "test-control", + "remove_header": "Host", + }, + ], + ids=("write-outside-namespace", "remove-protected-header"), +) +def test_processor_translates_disallowed_header_mutations_to_a_stable_error( + gate_config: dict[str, object], +) -> None: + processor = _processor((("invalid", gate_config),)) + + with pytest.raises(EgressGateError) as error: + processor.process(_request(), timeout=Timeout.from_seconds(1)) + + assert error.value.code is ErrorCode.GATE_OUTPUT_INVALID + + def test_trace_finding_count_overflow_is_an_atomic_runtime_limit() -> None: processor = _processor( ( @@ -571,6 +1036,23 @@ def test_header_mutations_are_ordered_and_protected() -> None: HttpHeader(name="x-openshell-middleware-added", value="one"), ) + skip_absent = apply_request_mutations( + _request(), + RequestMutations( + header_mutations=( + WriteHeaderMutation( + kind="write", + name="x-openshell-middleware-added", + value="created", + on_existing=ExistingHeaderAction.SKIP, + ), + ) + ), + ) + assert skip_absent.headers == ( + HttpHeader(name="x-openshell-middleware-added", value="created"), + ) + with pytest.raises(GateContractError): apply_request_mutations( original,