Add verified browser login and human approval HTTP client

Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a07ff8-19d0-7820-b4d0-1353833cb7fc
This commit is contained in:
tegwick 2026-09-10 22:05:17 +02:00
parent 4e103f62a0
commit 0e48355b9f
16 changed files with 1365 additions and 39 deletions

View file

@ -0,0 +1,101 @@
"""Opt-in actual engine contract check, using signed synthetic identity fixtures.
INFD_APPROVAL_ENGINE_SOURCE=/path/to/approval-engine python -m pytest -q
No native credentials, human login, policy verdict or audit admission is proved.
"""
from datetime import datetime, timedelta, timezone
import io
import json
import os
from pathlib import Path
import sys
import jwt
import pytest
from informed_decision.approval_client import ApprovalEngineError
from informed_decision.approval_http import ApprovalHTTPClient
from test_browser_auth import ISSUER, IssuerFixture, finish, signing_key
from informed_decision.oidc import KeyCapeLogin
@pytest.fixture
def component(signing_key, tmp_path):
source = os.environ.get("INFD_APPROVAL_ENGINE_SOURCE")
if not source:
pytest.skip("set INFD_APPROVAL_ENGINE_SOURCE for the real-engine contract check")
assert (Path(source) / "approval_engine" / "api.py").is_file()
sys.path.insert(0, source)
from approval_engine.api import App
from approval_engine.auth import JWTAuthenticator
from approval_engine.store import Engine
issuer = IssuerFixture(signing_key)
login = KeyCapeLogin(ISSUER, transport=issuer)
sid = finish((login, issuer))
session = login.session(sid)
class Keys:
def get_signing_key_from_jwt(self, token):
# Use the exact same synthetic JWKS that the login client verified.
return jwt.PyJWK.from_dict(issuer.request("GET", ISSUER + "/jwks")[1]["keys"][0])
engine = Engine(tmp_path / "approval.db")
auth = JWTAuthenticator(issuer=ISSUER, audience="approval-engine", jwks_url=ISSUER + "/jwks", jwks_client=Keys())
app = App(engine, auth)
class Transport:
def __init__(self): self.calls = []
def request(self, method, url, *, headers=None, body=None):
self.calls.append((method, url))
payload = body or b""
environ = {"PATH_INFO": url.removeprefix("https://approval.test"), "REQUEST_METHOD": method,
"CONTENT_LENGTH": str(len(payload)), "wsgi.input": io.BytesIO(payload),
"HTTP_AUTHORIZATION": (headers or {}).get("Authorization", "")}
result = {}
def start(status, headers): result["status"] = int(status.split()[0])
raw = b"".join(app(environ, start))
return result["status"], json.loads(raw)
transport = Transport()
now = datetime.now(timezone.utc)
engine.create({"actor": "synthetic-requester", "principal": "factory-fixture",
"action": "deliver", "purpose": "local-component-proof", "target": {"resource": "fixture"}},
{"not_before": (now - timedelta(seconds=1)).isoformat(),
"expires_at": (now + timedelta(minutes=10)).isoformat()},
human_control=True, approval_id="fixture")
client = ApprovalHTTPClient("https://approval.test", session, transport=transport)
yield client, engine, transport, session, signing_key
def test_real_engine_verifies_human_access_jwt_and_duplicate_correlation(component):
client, engine, transport, session, key = component
result = client.add_entry("fixture")
assert result.subject == session.subject and result.status == "approved"
entry = engine.get("fixture").as_dict()["entries"][0]
assert result.approved_at == entry["approved_at"]
assert entry["principal_type"] == "human" and entry["evidence_ref"].startswith("jwt-sha256:")
duplicate = client.add_entry("fixture")
assert duplicate.duplicate and duplicate.correlation == result.correlation
assert len(engine.get("fixture").entries) == 1
assert not any(url.endswith("/consume") for _, url in transport.calls)
def test_real_engine_service_token_cannot_bind_declared_control(component):
client, engine, transport, session, key = component
claims = jwt.decode(session.access_token, options={"verify_signature": False})
claims["principal_type"] = "service"
service = jwt.encode(claims, key, algorithm="RS256", headers={"kid": "key-1"})
status, body = transport.request("POST", "https://approval.test/v1/approvals/fixture/entries",
headers={"Authorization": "Bearer " + service}, body=b"{}")
assert (status, body["error"]) == (403, "forbidden")
assert engine.get("fixture").entries == []
def test_real_engine_revocation_stays_a_conflict(component):
client, engine, transport, session, key = component
engine.revoke("fixture")
with pytest.raises(ApprovalEngineError) as error: client.add_entry("fixture")
assert error.value.status == 409 and error.value.reason == "conflict"
assert engine.get("fixture").entries == []

120
tests/test_approval_http.py Normal file
View file

@ -0,0 +1,120 @@
import copy
from dataclasses import replace
import time
import pytest
from informed_decision.approval_client import ApprovalEngineError
from informed_decision.approval_http import ApprovalHTTPClient
from informed_decision.http_transport import TransportError
from informed_decision.oidc import HumanSession
from informed_decision.provenance import Claim, Route
ORIGIN = "https://approval.test"
APPROVAL = {"id": "fixture", "binding": {"digest": "sha256:" + "a" * 64, "human_control": True},
"status": "approved", "updated_at": "2099-01-01T00:00:00Z", "entries": [
{"subject_id": "human-fixture", "principal_type": "human", "approved_at": "2026-09-10T00:00:00Z"}]}
def session():
return HumanSession("human-fixture", Claim("tenant:platform", Route.REGISTRATION),
Claim("human", Route.AUTHENTICATION), {}, time.time() + 900, "synthetic-access-token")
class Responses:
def __init__(self, *responses):
self.responses = list(responses)
self.calls = []
def request(self, method, url, **kwargs):
self.calls.append((method, url, kwargs))
result = self.responses.pop(0)
if isinstance(result, Exception): raise result
return copy.deepcopy(result)
def test_entry_uses_access_token_empty_body_and_actual_entry_correlation():
transport = Responses((200, APPROVAL), (200, APPROVAL))
client = ApprovalHTTPClient(ORIGIN, session(), transport=transport)
result = client.add_entry("fixture")
assert result.correlation == ("fixture", "human-fixture", "2026-09-10T00:00:00Z")
assert not result.duplicate
assert [x[:2] for x in transport.calls] == [("GET", ORIGIN + "/v1/approvals/fixture"),
("POST", ORIGIN + "/v1/approvals/fixture/entries")]
assert transport.calls[1][2]["body"] == b"{}"
assert transport.calls[1][2]["headers"]["Authorization"] == "Bearer synthetic-access-token"
assert not hasattr(client, "consume")
def test_duplicate_recovers_original_entry_never_invents_time_or_reposts():
transport = Responses((200, APPROVAL), (409, {"error": "duplicate_approver"}), (200, APPROVAL))
result = ApprovalHTTPClient(ORIGIN, session(), transport=transport).add_entry("fixture")
assert result.duplicate and result.approved_at == APPROVAL["entries"][0]["approved_at"]
assert [x[0] for x in transport.calls] == ["GET", "POST", "GET"]
@pytest.mark.parametrize("value", [False, None, "true", 1])
def test_undeclared_or_malformed_human_control_refused_before_mutation(value):
data = copy.deepcopy(APPROVAL)
data["binding"]["human_control"] = value
transport = Responses((200, data))
with pytest.raises(ApprovalEngineError, match="invalid_approval_response"):
ApprovalHTTPClient(ORIGIN, session(), transport=transport).add_entry("fixture")
assert len(transport.calls) == 1 and transport.calls[0][0] == "GET"
@pytest.mark.parametrize("value", [[], [{"subject_id": "other"}], [1], None,
[{"subject_id": "human-fixture", "principal_type": "service", "approved_at": "2026-09-10T00:00:00Z"}],
[{"subject_id": "human-fixture", "principal_type": "human", "approved_at": "2026-09-10"}],
[{"subject_id": "human-fixture", "principal_type": "human", "approved_at": None}]])
def test_missing_corresponding_entry_cannot_be_reported_as_success(value):
data = copy.deepcopy(APPROVAL)
data["entries"] = value
transport = Responses((200, APPROVAL), (200, data))
with pytest.raises(ApprovalEngineError, match="entry_correlation_missing"):
ApprovalHTTPClient(ORIGIN, session(), transport=transport).add_entry("fixture")
@pytest.mark.parametrize("response,reason", [
((403, {"error": "forbidden", "message": "secret-sentinel"}), "forbidden"),
((409, {"error": "conflict"}), "conflict"),
((503, {"error": "store_unavailable"}), "store_unavailable"),
((500, {"error": ["secret-sentinel"]}), "upstream_refused"),
(TransportError("secret-sentinel"), "upstream_unavailable"),
])
def test_refusal_and_uncertain_post_are_not_retried(response, reason):
transport = Responses((200, APPROVAL), response)
with pytest.raises(ApprovalEngineError) as error:
ApprovalHTTPClient(ORIGIN, session(), transport=transport).add_entry("fixture")
assert error.value.reason == reason and "secret-sentinel" not in str(error.value)
assert len(transport.calls) == 2
@pytest.mark.parametrize("identifier", ["../consume", "a/entries", "a?consume", "a%2Fentries", "", "a" * 129])
def test_identifier_cannot_inject_a_route(identifier):
transport = Responses()
with pytest.raises(ValueError):
ApprovalHTTPClient(ORIGIN, session(), transport=transport).get_approval(identifier)
assert not transport.calls
def test_session_expiry_refused_before_request():
transport = Responses()
with pytest.raises(ApprovalEngineError, match="session_expired"):
ApprovalHTTPClient(ORIGIN, replace(session(), expires_at=1), transport=transport).get_approval("fixture")
assert not transport.calls
def test_read_current_state_each_time():
revoked = {**APPROVAL, "status": "revoked"}
transport = Responses((200, APPROVAL), (200, revoked))
client = ApprovalHTTPClient(ORIGIN, session(), transport=transport)
assert client.get_approval("fixture")["status"] == "approved"
assert client.get_approval("fixture")["status"] == "revoked"
def test_cluster_http_is_explicit_and_cannot_select_a_public_http_endpoint():
with pytest.raises(ValueError): ApprovalHTTPClient("http://approval-engine.approval-engine.svc", session())
client = ApprovalHTTPClient("http://approval-engine.approval-engine.svc", session(), allow_internal_http=True)
assert client.origin.startswith("http:")
with pytest.raises(ValueError): ApprovalHTTPClient("http://attacker.test", session(), allow_internal_http=True)

302
tests/test_browser_auth.py Normal file
View file

@ -0,0 +1,302 @@
"""Signed synthetic issuer fixtures: these are not proof of a native human login."""
import base64
import copy
import hashlib
import io
import json
import time
from urllib.parse import parse_qs, urlencode, urlsplit
from cryptography.hazmat.primitives.asymmetric import rsa
import jwt
import pytest
from informed_decision.http_transport import JSONTransport, TransportError
from informed_decision.oidc import CALLBACK, CLIENT_ID, KeyCapeLogin, LoginError, ORIGIN
from informed_decision.provenance import Route
from informed_decision.web import App, FLOW_COOKIE, SESSION_COOKIE
ISSUER = "https://keycape.test"
@pytest.fixture(scope="module")
def signing_key():
return rsa.generate_private_key(public_exponent=65537, key_size=2048)
class IssuerFixture:
def __init__(self, key):
self.key = key
self.params = None
self.calls = []
self.change = lambda tokens, claims: None
self.now = int(time.time())
def request(self, method, url, *, headers=None, body=None):
self.calls.append((method, url, headers, body))
if url == ISSUER + "/jwks":
jwk = json.loads(jwt.algorithms.RSAAlgorithm.to_jwk(self.key.public_key()))
return 200, {"keys": [{**jwk, "kid": "key-1", "alg": "RS256", "use": "sig"}]}
assert method == "POST" and url == ISSUER + "/token"
params = {k: v[0] for k, v in parse_qs(body.decode()).items()}
assert params["client_id"] == CLIENT_ID and params["redirect_uri"] == CALLBACK
assert params["grant_type"] == "authorization_code" and "client_secret" not in params
challenge = base64.urlsafe_b64encode(hashlib.sha256(params["code_verifier"].encode()).digest()).rstrip(b"=").decode()
assert challenge == self.params["code_challenge"]
claims = {"iss": ISSUER, "sub": "human-fixture", "iat": self.now, "exp": self.now + 900,
"tenant": "tenant:platform", "tenant_source": "registration", "principal_type": "human",
"roles": [], "assurance": {"at": self.now - 120, "level": "aal2", "mfa": True,
"methods": ["pwd", "otp"], "source": "key-cape"}}
payloads = {"id_token": {**copy.deepcopy(claims), "aud": CLIENT_ID, "nonce": self.params["nonce"]},
"access_token": {**copy.deepcopy(claims), "aud": "approval-engine",
"scope": "openid approval:read approval:approve"}}
tokens = {"token_type": "Bearer"}
self.change(tokens, payloads)
for name, value in payloads.items():
tokens.setdefault(name, jwt.encode(value, self.key, algorithm="RS256", headers={"kid": "key-1"}))
return 200, tokens
@pytest.fixture
def login(signing_key):
transport = IssuerFixture(signing_key)
login = KeyCapeLogin(ISSUER, transport=transport)
return login, transport
def begin(pair):
login, issuer = pair
url, browser = login.start()
issuer.params = {k: v[0] for k, v in parse_qs(urlsplit(url).query).items()}
return issuer.params["state"], browser
def finish(pair):
state, browser = begin(pair)
return pair[0].finish(state, browser, "synthetic-code")
def call(app, path="/", method="GET", cookie="", query="", body=b"", origin=None, content_type="application/x-www-form-urlencoded"):
env = {"PATH_INFO": path, "REQUEST_METHOD": method, "HTTP_COOKIE": cookie,
"QUERY_STRING": query, "wsgi.input": io.BytesIO(body), "CONTENT_LENGTH": str(len(body)),
"CONTENT_TYPE": content_type}
if origin is not None:
env["HTTP_ORIGIN"] = origin
response = {}
def start(status, headers):
response.update(status=int(status.split()[0]), headers=headers)
response["body"] = b"".join(app(env, start)).decode()
return response
def test_pkce_nonce_scope_and_imported_human_session(login):
sid = finish(login)
session = login[0].session(sid)
assert session.subject == "human-fixture"
assert session.principal_type.route is Route.AUTHENTICATION
assert session.tenant.route is Route.REGISTRATION
assert session.assurance["at"] == login[1].now - 120 # Authentication, never mint time.
assert session.access_token not in repr(session)
assert login[1].params["scope"] == "openid approval:read approval:approve"
assert login[1].params["code_challenge_method"] == "S256"
assert login[1].params["nonce"] != login[1].params["state"]
@pytest.mark.parametrize("where,key,value", [
("access_token", "aud", CLIENT_ID), ("id_token", "aud", "approval-engine"),
("access_token", "aud", ["approval-engine", CLIENT_ID]),
("access_token", "iss", "https://attacker.test"),
("id_token", "nonce", "wrong"), ("id_token", "nonce", None),
("access_token", "sub", "another-human"), ("access_token", "sub", ""),
("both", "principal_type", "service"), ("both", "principal_type", "agent"),
("both", "tenant", "tenant:coulomb"), ("both", "tenant_source", None),
("both", "tenant_source", "default"), ("both", "tenant_source", "invented"),
("both", "tenant_source", ["registration"]),
("access_token", "scope", "openid approval:read approval:approve approval:consume"),
("access_token", "scope", "openid approval:approve"), ("access_token", "scope", ["openid"]),
("access_token", "roles", None), ("access_token", "roles", [1]),
("both", "assurance", {}),
("access_token", "exp", 1), ("id_token", "exp", 1),
("access_token", "iat", 9999999999), ("access_token", "exp", "9999999999"),
])
def test_invalid_identity_refused(login, where, key, value):
def change(tokens, claims):
for name in claims if where == "both" else [where]:
claims[name][key] = value
login[1].change = change
with pytest.raises(LoginError):
finish(login)
assert not login[0]._sessions
@pytest.mark.parametrize("key,value", [
("mfa", False), ("mfa", 1), ("level", "aal1"), ("methods", ["pwd"]),
("source", "self"), ("at", 0), ("at", "123"), ("at", 9999999999),
])
def test_mfa_profile_required(login, key, value):
def change(tokens, claims):
for payload in claims.values():
payload["assurance"][key] = value
login[1].change = change
with pytest.raises(LoginError, match="MFA"):
finish(login)
def test_directory_provenance_preserved(login):
def change(tokens, claims):
for payload in claims.values():
payload["tenant_source"] = "directory"
login[1].change = change
assert login[0].session(finish(login)).tenant.route is Route.DIRECTORY
@pytest.mark.parametrize("kind", ["wrong-browser", "missing-browser", "unknown-state", "expired", "missing-code"])
def test_callback_fails_before_exchange(login, kind):
state, browser = begin(login)
code = "code"
if kind == "wrong-browser": browser = "other"
if kind == "missing-browser": browser = ""
if kind == "unknown-state": state = "unknown"
if kind == "expired": login[0].clock = lambda: time.time() + 301
if kind == "missing-code": code = ""
with pytest.raises(LoginError): login[0].finish(state, browser, code)
assert not login[1].calls
def test_callback_single_use_even_after_refusal(login):
state, browser = begin(login)
login[0].finish(state, browser, "code")
count = len(login[1].calls)
with pytest.raises(LoginError): login[0].finish(state, browser, "code")
assert len(login[1].calls) == count
@pytest.mark.parametrize("value", ["broken", "", None])
def test_bad_token_never_becomes_session(login, value):
login[1].change = lambda tokens, claims: tokens.update(access_token=value)
with pytest.raises(LoginError): finish(login)
def test_wrong_signature_refused(login):
other = rsa.generate_private_key(public_exponent=65537, key_size=2048)
def change(tokens, claims):
tokens["access_token"] = jwt.encode(claims["access_token"], other, algorithm="RS256", headers={"kid": "key-1"})
login[1].change = change
with pytest.raises(LoginError): finish(login)
@pytest.mark.parametrize("algorithm,kid", [("HS256", "key-1"), ("RS256", "unknown-key")])
def test_algorithm_confusion_and_unknown_key_refused(login, algorithm, kid):
def change(tokens, claims):
key = b"synthetic-key-material-for-this-test-only" if algorithm == "HS256" else login[1].key
tokens["access_token"] = jwt.encode(claims["access_token"], key, algorithm=algorithm, headers={"kid": kid})
login[1].change = change
with pytest.raises(LoginError): finish(login)
@pytest.mark.parametrize("failure", ["token", "jwks", "expired-jwks-cache"])
def test_dependency_outage_cannot_authenticate_from_stale_keys(login, failure):
if failure == "expired-jwks-cache":
finish(login)
login[0]._keys_until = 0
state, browser = begin(login)
original = login[1].request
def request(method, url, **kwargs):
if url.endswith("/token" if failure == "token" else "/jwks"):
raise TransportError("secret-sentinel")
return original(method, url, **kwargs)
login[1].request = request
count = len(login[0]._sessions)
with pytest.raises(LoginError) as error: login[0].finish(state, browser, "code")
assert "secret-sentinel" not in str(error.value) and len(login[0]._sessions) == count
def test_browser_subject_is_html_escaped(login):
def change(tokens, claims):
for payload in claims.values(): payload["sub"] = '<script>alert("fixture")</script>'
login[1].change = change
sid = finish(login)
body = call(App(login[0]), cookie=f"{SESSION_COOKIE}={sid}")["body"]
assert "<script>" not in body and "&lt;script&gt;" in body
def test_no_session_survives_expiry_logout_or_process_restart(login):
sid = finish(login)
login[0].logout(sid)
assert login[0].session(sid) is None
sid = finish(login)
assert KeyCapeLogin(ISSUER).session(sid) is None
login[0].clock = lambda: time.time() + 901
assert login[0].session(sid) is None
def test_pending_and_session_capacity_fail_closed(login):
login[0].capacity = 1
sid = finish(login)
assert login[0].session(sid)
with pytest.raises(LoginError, match="session capacity"): finish(login)
login[0].start()
with pytest.raises(LoginError, match="login capacity"): login[0].start()
def test_callback_browser_cookie_rotation_and_token_secrecy(login):
app = App(login[0])
old_sid = finish(login)
response = call(app, "/auth/start")
headers = dict(response["headers"])
login[1].params = {k: v[0] for k, v in parse_qs(urlsplit(headers["Location"]).query).items()}
cookie = headers["Set-Cookie"].split(";")[0]
response = call(app, "/auth/callback", cookie=cookie + f"; {SESSION_COOKIE}={old_sid}",
query=urlencode({"code": "code", "state": login[1].params["state"]}))
assert response["status"] == 303
assert dict(response["headers"])["Location"] == "/"
assert login[0].session(old_sid) is None
cookies = [v for k, v in response["headers"] if k == "Set-Cookie"]
assert all("Secure; HttpOnly; SameSite=Lax" in v and "Path=/" in v for v in cookies)
assert any(v.startswith(FLOW_COOKIE + "=;") for v in cookies)
new_cookie = next(v.split(";")[0] for v in cookies if v.startswith(SESSION_COOKIE))
response = call(app, cookie=new_cookie)
session = login[0].session(new_cookie.split("=")[1])
assert "Signed in as human-fixture" in response["body"]
assert session.access_token not in str(response)
assert dict(response["headers"])["Cache-Control"] == "no-store"
def test_issuer_error_and_duplicate_params_do_not_leak_or_exchange(login):
state, browser = begin(login)
response = call(App(login[0]), "/auth/callback", cookie=f"{FLOW_COOKIE}={browser}",
query=urlencode({"state": state, "error": "denied", "error_description": "secret-sentinel"}))
assert response["status"] == 401 and "secret-sentinel" not in str(response)
assert not login[1].calls
response = call(App(login[0]), "/auth/callback", query="code=x&code=y")
assert response["status"] == 400
@pytest.mark.parametrize("origin,csrf,expected", [(ORIGIN, "correct", 303), (ORIGIN, "wrong", 403),
(None, "correct", 403), ("https://attacker.test", "correct", 403)])
def test_logout_requires_origin_and_session_csrf(login, origin, csrf, expected):
sid = finish(login)
token = login[0].session(sid).csrf if csrf == "correct" else "wrong"
response = call(App(login[0]), "/auth/logout", method="POST", cookie=f"{SESSION_COOKIE}={sid}",
body=urlencode({"csrf": token}).encode(), origin=origin)
assert response["status"] == expected
assert bool(login[0].session(sid)) == (expected != 303)
@pytest.mark.parametrize("path,method,expected", [("/healthz", "GET", 200), ("/readyz", "GET", 503),
("/approvals/approval-fixture", "GET", 404), ("/approvals/approval-fixture/accept", "POST", 404),
("/v1/approvals/approval-fixture/entries", "POST", 404), ("/auth/logout", "GET", 404)])
def test_authentication_does_not_enable_memo_or_binding_routes(login, path, method, expected):
sid = finish(login)
assert call(App(login[0]), path, method, cookie=f"{SESSION_COOKIE}={sid}")["status"] == expected
@pytest.mark.parametrize("issuer", ["http://keycape.test", "https://user:pass@keycape.test", "https://keycape.test/path",
"https://keycape.test?redirect=bad", "https://keycape.test\n"])
def test_issuer_endpoint_must_be_fixed_https_origin(issuer):
with pytest.raises(ValueError): KeyCapeLogin(issuer)
def test_transport_refuses_cleartext_before_network():
with pytest.raises(TransportError): JSONTransport().request("POST", "http://attacker.test/token", body=b"secret")

View file

@ -0,0 +1,62 @@
from contextlib import contextmanager
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
import threading
import pytest
from informed_decision.http_transport import JSONTransport, TransportError
@contextmanager
def upstream():
calls = []
class Handler(BaseHTTPRequestHandler):
def do_GET(self):
calls.append((self.path, self.headers.get("Authorization")))
if self.path == "/redirect":
self.send_response(302)
self.send_header("Location", "/must-not-receive-token")
self.end_headers()
return
status, body = {
"/ok": (200, b'{"ok":true}'), "/refuse": (403, b'{"error":"forbidden"}'),
"/broken": (200, b"broken"), "/array": (200, b"[]"),
"/large": (200, b" " * 262145),
}.get(self.path, (500, b"{}"))
self.send_response(status)
self.end_headers()
self.wfile.write(body)
def log_message(self, *args): pass
server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
try:
yield f"http://127.0.0.1:{server.server_port}", calls
finally:
server.shutdown()
server.server_close()
thread.join()
def test_redirect_never_forwards_bearer():
with upstream() as (origin, calls):
with pytest.raises(TransportError, match="redirect"):
JSONTransport(allow_internal_http=True).request("GET", origin + "/redirect",
headers={"Authorization": "Bearer synthetic-sentinel"})
assert calls == [("/redirect", "Bearer synthetic-sentinel")]
@pytest.mark.parametrize("path", ["/broken", "/array", "/large"])
def test_invalid_or_oversize_response_refused(path):
with upstream() as (origin, calls):
with pytest.raises(TransportError):
JSONTransport(allow_internal_http=True).request("GET", origin + path)
def test_json_success_and_typed_refusal_preserved():
with upstream() as (origin, calls):
client = JSONTransport(allow_internal_http=True)
assert client.request("GET", origin + "/ok") == (200, {"ok": True})
assert client.request("GET", origin + "/refuse") == (403, {"error": "forbidden"})