返回 last30days-skill
test_http_fixtures.py
根目录 / tests / test_http_fixtures.py
1 from __future__ import annotations
2
3 import json
4 import os
5 from unittest.mock import MagicMock
6
7 import pytest
8
9 import last30days as cli
10 from lib import http, pipeline, schema
11
12
13 def _response(body: str):
14 response = MagicMock()
15 response.__enter__.return_value = response
16 response.__exit__.return_value = False
17 response.read.return_value = body.encode("utf-8")
18 response.status = 200
19 return response
20
21
22 def test_http_recording_scrubs_credentials_and_replays_offline(tmp_path, monkeypatch):
23 monkeypatch.setattr(http.urllib.request, "urlopen", lambda *_args, **_kwargs: _response('{"items": [{"url": "https://example.test/item"}]}'))
24 fixture_dir = tmp_path / "fixture"
25
26 with http.recording_requests(fixture_dir):
27 live = http.get("https://api.example.test/search?api_key=live-secret&q=agents")
28
29 fixture_text = (fixture_dir / "http.json").read_text(encoding="utf-8")
30 assert "live-secret" not in fixture_text
31 assert "%3Credacted%3E" in fixture_text
32
33 monkeypatch.setattr(
34 http.urllib.request,
35 "urlopen",
36 lambda *_args, **_kwargs: pytest.fail("fixture replay attempted the network"),
37 )
38 with http.replaying_requests(fixture_dir):
39 replayed = http.get("https://api.example.test/search?api_key=another-secret&q=agents")
40
41 assert replayed == live
42
43
44 def test_http_recording_redacts_credentials_echoed_in_response_values(tmp_path, monkeypatch):
45 monkeypatch.setattr(
46 http.urllib.request,
47 "urlopen",
48 lambda *_args, **_kwargs: _response('{"echo": "Bearer live-secret"}'),
49 )
50 fixture_dir = tmp_path / "fixture"
51
52 with http.recording_requests(fixture_dir):
53 http.get(
54 "https://api.example.test/profile",
55 headers={"Authorization": "Bearer live-secret"},
56 )
57
58 fixture_text = (fixture_dir / "http.json").read_text(encoding="utf-8")
59 assert "live-secret" not in fixture_text
60 assert '"echo": "<redacted>"' in fixture_text
61
62
63 def test_http_recording_scrubs_app_password_and_session_jwts(tmp_path, monkeypatch):
64 """A Bluesky session exchange puts the app password in the request body and
65 both JWTs in the response. Redaction is key-name driven, so every one of
66 those names has to be recognized."""
67 monkeypatch.setattr(
68 http.urllib.request,
69 "urlopen",
70 lambda *_args, **_kwargs: _response(json.dumps({
71 "accessJwt": "eyJhbGciOi.ACCESS-SENTINEL",
72 "refreshJwt": "eyJhbGciOi.REFRESH-SENTINEL",
73 "handle": "me.bsky.social",
74 })),
75 )
76 fixture_dir = tmp_path / "fixture"
77
78 with http.recording_requests(fixture_dir):
79 http.post(
80 "https://bsky.social/xrpc/com.atproto.server.createSession",
81 json_data={
82 "identifier": "me.bsky.social",
83 "password": "abcd-efgh-ijkl-SENTINEL",
84 },
85 )
86
87 fixture_path = fixture_dir / "http.json"
88 fixture_text = fixture_path.read_text(encoding="utf-8")
89 assert "abcd-efgh-ijkl-SENTINEL" not in fixture_text
90 assert "ACCESS-SENTINEL" not in fixture_text
91 assert "REFRESH-SENTINEL" not in fixture_text
92 # Non-secret fields still round-trip, so the fixture stays useful.
93 assert "me.bsky.social" in fixture_text
94 # And the file is not world-readable.
95 if os.name != "nt":
96 assert fixture_path.stat().st_mode & 0o777 == 0o600
97
98
99 def test_recorded_fixture_is_private_from_creation_not_after_a_chmod(
100 tmp_path, monkeypatch
101 ):
102 """Tightening the mode after writing leaves the credentials in a
103 world-readable file for the length of the write. Assert the temp file is
104 opened 0600, since a final-mode check passes either way."""
105 monkeypatch.setattr(
106 http.urllib.request,
107 "urlopen",
108 lambda *_args, **_kwargs: _response('{"ok": true}'),
109 )
110 opened: list[tuple[str, int]] = []
111 real_open = os.open
112
113 def _recording_open(path, flags, mode=0o777, **kwargs):
114 opened.append((str(path), mode))
115 return real_open(path, flags, mode, **kwargs)
116
117 monkeypatch.setattr(http.os, "open", _recording_open)
118
119 fixture_dir = tmp_path / "fixture"
120 with http.recording_requests(fixture_dir):
121 http.get("https://api.example.test/thing")
122
123 tmp_opens = [
124 (path, mode) for path, mode in opened if path.endswith(".http.json.tmp")
125 ]
126 assert tmp_opens, "the fixture temp file must be created via os.open with a mode"
127 assert all(mode == 0o600 for _path, mode in tmp_opens), tmp_opens
128
129
130 def test_is_secret_key_covers_credential_names_without_over_matching():
131 for name in (
132 "password", "passwd", "app_password", "BSKY_APP_PASSWORD",
133 "accessJwt", "refreshJwt", "jwt", "passphrase", "credential",
134 "api_key", "apiKey", "x_api_key", "Authorization", "cookie",
135 "secret", "token", "access_token",
136 ):
137 assert http._is_secret_key(name), name
138 for name in ("monkey", "handle", "identifier", "url", "title", "did"):
139 assert not http._is_secret_key(name), name
140
141
142 def test_aborted_recording_does_not_overwrite_existing_fixture(tmp_path):
143 fixture = tmp_path / "http.json"
144 fixture.write_text("existing fixture\n", encoding="utf-8")
145
146 with pytest.raises(RuntimeError, match="capture failed"):
147 with http.recording_requests(fixture):
148 raise RuntimeError("capture failed")
149
150 assert fixture.read_text(encoding="utf-8") == "existing fixture\n"
151
152
153 def test_http_replay_rejects_unrecorded_requests(tmp_path):
154 fixture = tmp_path / "http.json"
155 fixture.write_text(
156 json.dumps({"format": "last30days-http-fixture/v1", "exchanges": []}),
157 encoding="utf-8",
158 )
159
160 with pytest.raises(AssertionError, match="Unrecorded HTTP request"), \
161 http.replaying_requests(fixture):
162 http.get("https://example.test/not-recorded")
163
164
165 def test_cli_backed_source_results_record_at_the_module_seam(tmp_path):
166 fixture_dir = tmp_path / "fixture"
167 request = {
168 "source": "digg",
169 "topic": "agents",
170 "search_query": "agents",
171 "date_range": ["2026-06-10", "2026-07-10"],
172 "depth": "quick",
173 }
174 value = [[{"url": "https://di.gg/ai/example"}], {"provider": "fixture"}]
175
176 with http.recording_requests(fixture_dir):
177 http.fixture_source_record(request, value)
178
179 with http.replaying_requests(fixture_dir):
180 matched, replayed = http.fixture_source_replay(request)
181
182 assert matched is True
183 assert replayed == value
184
185
186 def test_module_seam_capture_omits_nested_http_exchanges(tmp_path, monkeypatch):
187 monkeypatch.setattr(http.urllib.request, "urlopen", lambda *_args, **_kwargs: _response('{"ok": true}'))
188 fixture_dir = tmp_path / "fixture"
189 request = {
190 "source": "youtube",
191 "topic": "agents",
192 "search_query": "agents",
193 "date_range": ["2026-06-10", "2026-07-10"],
194 "depth": "quick",
195 }
196
197 with http.recording_requests(fixture_dir):
198 with http.fixture_module_capture(True):
199 http.get("https://api.example.test/nested-enrichment")
200 http.fixture_source_record(request, [[], {}])
201
202 payload = json.loads((fixture_dir / "http.json").read_text(encoding="utf-8"))
203 assert payload["exchanges"] == []
204 assert len(payload["source_exchanges"]) == 1
205
206
207 def test_module_seam_records_and_replays_adapter_failures(tmp_path, monkeypatch):
208 fixture_dir = tmp_path / "fixture"
209 kwargs = {
210 "source": "digg",
211 "topic": "agents",
212 "subquery": type("SubQuery", (), {"search_query": "agents"})(),
213 "date_range": ("2026-06-10", "2026-07-10"),
214 "depth": "quick",
215 }
216 monkeypatch.setattr(
217 pipeline,
218 "_retrieve_stream_impl",
219 lambda **_kwargs: (_ for _ in ()).throw(RuntimeError("adapter failed")),
220 )
221
222 with http.recording_requests(fixture_dir):
223 with pytest.raises(RuntimeError, match="adapter failed"):
224 pipeline._retrieve_stream(**kwargs)
225
226 payload = json.loads((fixture_dir / "http.json").read_text(encoding="utf-8"))
227 assert payload["source_exchanges"] == [
228 {
229 "request": {
230 "source": "digg",
231 "topic": "agents",
232 "search_query": "agents",
233 "date_range": ["2026-06-10", "2026-07-10"],
234 "depth": "quick",
235 },
236 "type": "error",
237 "error": {
238 "exception_type": "RuntimeError",
239 "message": "adapter failed",
240 "outcome_state": None,
241 },
242 }
243 ]
244
245 monkeypatch.setattr(
246 pipeline,
247 "_retrieve_stream_impl",
248 lambda **_kwargs: pytest.fail("replay called the live adapter"),
249 )
250 with http.replaying_requests(fixture_dir):
251 with pytest.raises(http.RecordedSourceError, match="adapter failed") as replayed:
252 pipeline._retrieve_stream(**kwargs)
253
254 assert replayed.value.exception_type == "RuntimeError"
255
256
257 @pytest.mark.parametrize("source", ["youtube", "digg"])
258 def test_post_ranking_cli_enrichment_records_and_replays(
259 tmp_path,
260 monkeypatch,
261 source,
262 ):
263 fixture_dir = tmp_path / source
264 item = schema.SourceItem(
265 item_id="item-1",
266 source=source,
267 title="Fixture item",
268 body="Fixture body",
269 url=f"https://example.test/{source}/item-1",
270 engagement={"postCount": 1} if source == "digg" else {},
271 metadata={"clusterUrlId": "cluster-1"} if source == "digg" else {},
272 )
273 if source == "youtube":
274 def enrich(items, **_kwargs):
275 items[0].metadata["transcript_snippet"] = "recorded transcript"
276
277 monkeypatch.setattr(pipeline.youtube_yt, "backfill_transcripts", enrich)
278 else:
279 def enrich(items, **_kwargs):
280 items[0].metadata["posts"] = [{"url": "https://x.com/example/status/1"}]
281 return items
282
283 monkeypatch.setattr(pipeline.digg, "enrich_source_items", enrich)
284
285 with http.recording_requests(fixture_dir):
286 recorded = pipeline._finalize_items_by_source(
287 {source: [item]}, topic="agents", depth="quick",
288 )
289
290 expected_metadata = recorded[source][0].metadata
291 replay_item = schema.SourceItem(
292 item_id="item-1",
293 source=source,
294 title="Fixture item",
295 body="Fixture body",
296 url=f"https://example.test/{source}/item-1",
297 engagement={"postCount": 1} if source == "digg" else {},
298 metadata={"clusterUrlId": "cluster-1"} if source == "digg" else {},
299 )
300 monkeypatch.setattr(
301 pipeline.youtube_yt if source == "youtube" else pipeline.digg,
302 "backfill_transcripts" if source == "youtube" else "enrich_source_items",
303 lambda *_args, **_kwargs: pytest.fail("replay executed CLI enrichment"),
304 )
305
306 with http.replaying_requests(fixture_dir):
307 replayed = pipeline._finalize_items_by_source(
308 {source: [replay_item]}, topic="agents", depth="quick",
309 )
310
311 assert replayed[source][0].metadata == expected_metadata
312
313
314 def test_record_fixtures_flag_is_dev_only_and_hidden_from_help():
315 parser = cli.build_parser()
316 args = parser.parse_args(["topic", "--record-fixtures", "tmp/eval-topic"])
317
318 assert args.record_fixtures == "tmp/eval-topic"
319 assert "--record-fixtures" not in parser.format_help()
320
320 lines PYTHON