|
3 | 3 | import hashlib |
4 | 4 | import threading |
5 | 5 | from io import BytesIO |
6 | | -from typing import BinaryIO |
| 6 | +from typing import BinaryIO, cast |
7 | 7 |
|
8 | 8 | import httpx |
9 | 9 | import pytest |
@@ -98,6 +98,110 @@ async def on_progress(_: object) -> None: |
98 | 98 | assert thread_ids and all(thread_id != threading.get_ident() for thread_id in thread_ids) |
99 | 99 |
|
100 | 100 |
|
| 101 | +async def test_async_download_retries_checksum_mismatch( |
| 102 | + async_client: AsyncKernel, respx_mock: MockRouter, monkeypatch: pytest.MonkeyPatch |
| 103 | +) -> None: |
| 104 | + async def no_sleep(_: float) -> None: |
| 105 | + pass |
| 106 | + |
| 107 | + monkeypatch.setattr(audit_log_download.anyio, "sleep", no_sleep) |
| 108 | + bad = chunk_response(b"bad", rows=1, has_more=False) |
| 109 | + bad.headers["x-content-sha256"] = hashlib.sha256(b"good").hexdigest() |
| 110 | + route = respx_mock.get("/audit-logs/export/chunk").mock( |
| 111 | + side_effect=[bad, chunk_response(b"good", rows=1, has_more=False)] |
| 112 | + ) |
| 113 | + destination = BytesIO() |
| 114 | + |
| 115 | + await async_client.audit_logs.download( |
| 116 | + to=destination, |
| 117 | + start="2026-06-01T00:00:00Z", |
| 118 | + end="2026-06-02T00:00:00Z", |
| 119 | + ) |
| 120 | + |
| 121 | + assert destination.getvalue() == b"good" |
| 122 | + assert route.call_count == 2 |
| 123 | + |
| 124 | + |
| 125 | +async def test_async_download_uses_client_http_retries(async_client: AsyncKernel, respx_mock: MockRouter) -> None: |
| 126 | + route = respx_mock.get("/audit-logs/export/chunk").mock( |
| 127 | + side_effect=[ |
| 128 | + httpx.Response(500, json={"message": "temporary failure"}), |
| 129 | + chunk_response(b"good", rows=1, has_more=False), |
| 130 | + ] |
| 131 | + ) |
| 132 | + |
| 133 | + await async_client.audit_logs.download( |
| 134 | + to=BytesIO(), |
| 135 | + start="2026-06-01T00:00:00Z", |
| 136 | + end="2026-06-02T00:00:00Z", |
| 137 | + ) |
| 138 | + |
| 139 | + assert route.call_count == 2 |
| 140 | + |
| 141 | + |
| 142 | +async def test_async_download_respects_disabled_http_retries(async_client: AsyncKernel, respx_mock: MockRouter) -> None: |
| 143 | + route = respx_mock.get("/audit-logs/export/chunk").mock( |
| 144 | + return_value=httpx.Response(500, json={"message": "temporary failure"}) |
| 145 | + ) |
| 146 | + |
| 147 | + with pytest.raises(InternalServerError, match="500"): |
| 148 | + await async_client.with_options(max_retries=0).audit_logs.download( |
| 149 | + to=BytesIO(), |
| 150 | + start="2026-06-01T00:00:00Z", |
| 151 | + end="2026-06-02T00:00:00Z", |
| 152 | + ) |
| 153 | + |
| 154 | + assert route.call_count == 1 |
| 155 | + |
| 156 | + |
| 157 | +@pytest.mark.parametrize("row_count", ["", "1.0", "50001"]) |
| 158 | +def test_download_rejects_invalid_row_count(row_count: str, client: Kernel, respx_mock: MockRouter) -> None: |
| 159 | + response = chunk_response(b"chunk", rows=1, has_more=False) |
| 160 | + response.headers["x-row-count"] = row_count |
| 161 | + respx_mock.get("/audit-logs/export/chunk").mock(return_value=response) |
| 162 | + destination = BytesIO() |
| 163 | + |
| 164 | + with pytest.raises(AuditLogDownloadError, match="response missing or invalid X-Row-Count header"): |
| 165 | + client.audit_logs.download( |
| 166 | + to=destination, |
| 167 | + start="2026-06-01T00:00:00Z", |
| 168 | + end="2026-06-02T00:00:00Z", |
| 169 | + ) |
| 170 | + |
| 171 | + assert destination.getvalue() == b"" |
| 172 | + |
| 173 | + |
| 174 | +class InvalidWriteDestination: |
| 175 | + def __init__(self, result: object) -> None: |
| 176 | + self.result = result |
| 177 | + |
| 178 | + def write(self, _: object) -> object: |
| 179 | + return self.result |
| 180 | + |
| 181 | + |
| 182 | +@pytest.mark.parametrize("write_result", [float("nan"), 0.5, True]) |
| 183 | +def test_download_rejects_invalid_write_result(write_result: object, client: Kernel, respx_mock: MockRouter) -> None: |
| 184 | + respx_mock.get("/audit-logs/export/chunk").mock(return_value=chunk_response(b"chunk", rows=1, has_more=False)) |
| 185 | + |
| 186 | + with pytest.raises(AuditLogDownloadError, match="audit log download destination performed a short write"): |
| 187 | + client.audit_logs.download( |
| 188 | + to=cast(BinaryIO, InvalidWriteDestination(write_result)), |
| 189 | + start="2026-06-01T00:00:00Z", |
| 190 | + end="2026-06-02T00:00:00Z", |
| 191 | + ) |
| 192 | + |
| 193 | + |
| 194 | +@pytest.mark.parametrize("max_transfer_retries", [True, 1.5]) |
| 195 | +def test_download_rejects_invalid_transfer_retry_count(max_transfer_retries: object, client: Kernel) -> None: |
| 196 | + with pytest.raises(ValueError, match="max_transfer_retries must be a non-negative integer"): |
| 197 | + client.audit_logs.download( |
| 198 | + to=BytesIO(), |
| 199 | + start="2026-06-01T00:00:00Z", |
| 200 | + end="2026-06-02T00:00:00Z", |
| 201 | + max_transfer_retries=cast(int, max_transfer_retries), |
| 202 | + ) |
| 203 | + |
| 204 | + |
101 | 205 | def test_download_retries_checksum_mismatch( |
102 | 206 | client: Kernel, respx_mock: MockRouter, monkeypatch: pytest.MonkeyPatch |
103 | 207 | ) -> None: |
|
0 commit comments