"""Tests for cassette transport fidelity and redaction.""" import asyncio import base64 import json from collections.abc import AsyncIterator import httpx import httpx2 from recording import Httpx2RecordingTransport, HttpxRecordingTransport, redact_cassette class HttpxChunks(httpx.AsyncByteStream): """Yield fixed httpx byte chunks.""" async def __aiter__(self) -> AsyncIterator[bytes]: yield b"first-" yield b"second" class Httpx2Chunks(httpx2.AsyncByteStream): """Yield fixed httpx2 byte chunks.""" async def __aiter__(self) -> AsyncIterator[bytes]: yield b"first-" yield b"second" def test_httpx_recording_transport_preserves_chunk_boundaries() -> None: async def run() -> None: async def handler(request: httpx.Request) -> httpx.Response: return httpx.Response(200, stream=HttpxChunks()) transport = HttpxRecordingTransport(httpx.MockTransport(handler)) async with httpx.AsyncClient(transport=transport) as client: response = await client.get("https://api.anthropic.com/v1/messages") assert response.content == b"first-second" assert transport.interactions[0].response_chunks == (b"first-", b"second") asyncio.run(run()) def test_httpx2_recording_transport_preserves_chunk_boundaries() -> None: async def run() -> None: async def handler(request: httpx2.Request) -> httpx2.Response: return httpx2.Response(200, stream=Httpx2Chunks()) transport = Httpx2RecordingTransport(httpx2.MockTransport(handler)) async with httpx2.AsyncClient(transport=transport) as client: response = await client.get("https://api.spotify.com/v1/search?q=test") assert response.content == b"first-second" assert transport.interactions[0].response_chunks == (b"first-", b"second") asyncio.run(run()) def test_redaction_removes_headers_tokens_identity_and_taste() -> None: interactions = [ { "method": "POST", "url": "https://accounts.spotify.com/api/token", "request_headers": {"Authorization": "Bearer raw-token"}, "request_body_base64": _encode(b"client_secret=secret"), "response_body_base64": _encode(b'{"access_token":"raw-token"}'), "response_chunks_base64": [], "status": 200, }, { "method": "GET", "url": "https://api.spotify.com/v1/me", "response_headers": {"Set-Cookie": "secret"}, "request_body_base64": "", "response_body_base64": _encode( b'{"id":"real-account-id","display_name":"Real Listener"}' ), "response_chunks_base64": [], "status": 200, }, { "method": "GET", "url": "https://api.spotify.com/v1/me/top/artists?limit=50", "headers": {"Authorization": "Bearer raw-token"}, "request_body_base64": "", "response_body_base64": _encode(b'{"items":[{"name":"Private Artist"}]}'), "response_chunks_base64": [], "status": 200, }, { "method": "POST", "url": "https://api.anthropic.com/v1/messages", "request_body_base64": _encode( json.dumps( { "messages": [ { "content": json.dumps( { "taste_profile": "Private Artist", "account": "real-account-id", } ) } ] } ).encode() ), "response_body_base64": _encode(b"Bearer raw-token"), "response_chunks_base64": [_encode(b"Bearer raw-token")], "status": 200, }, ] redacted = redact_cassette(interactions) rendered = json.dumps(redacted) decoded_bodies = " ".join( base64.b64decode(str(item.get(field, ""))).decode(errors="replace") for item in redacted for field in ("request_body_base64", "response_body_base64") ) assert len(redacted) == 3 assert "headers" not in rendered.casefold() assert "raw-token" not in rendered + decoded_bodies assert "real-account-id" not in rendered + decoded_bodies assert "Real Listener" not in rendered + decoded_bodies assert "Private Artist" not in rendered + decoded_bodies assert "Synthetic Focus Artist" in decoded_bodies assert "accounts.spotify.com" not in rendered def _encode(value: bytes) -> str: return base64.b64encode(value).decode("ascii")