| 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 |