Files
imbad0202__academic-researc…/scripts/test_semantic_scholar_client.py
T
Lixin2026 05d4ccb843 Harden review-material trust boundaries and external lookup URLs (#310)
* fix: harden security boundaries for review materials

* fix: require consent for deep-research cross-model DA

* fix(ci): skip eval comments on fork PRs
2026-06-04 20:44:03 +08:00

555 lines
25 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""Tests for the minimal Semantic Scholar client backing #105 CLI.
Mocks urllib at the transport layer (no real network). Verifies the
client honors the protocol's DOI-first + title-similarity + 429-backoff
contract and surfaces SemanticScholarUnavailable on the documented
failure modes.
"""
from __future__ import annotations
import io
import json
import sys
import unittest
import urllib.error
from pathlib import Path
from unittest.mock import patch, MagicMock
REPO_ROOT = Path(__file__).resolve().parent.parent
if str(REPO_ROOT / "scripts") not in sys.path:
sys.path.insert(0, str(REPO_ROOT / "scripts"))
import semantic_scholar_client as ssc # noqa: E402
from contamination_signals import SemanticScholarUnavailable # noqa: E402
def _mock_response(payload: dict) -> MagicMock:
"""Build a urlopen-style response context manager returning `payload`."""
body = json.dumps(payload).encode("utf-8")
resp = MagicMock()
resp.read.return_value = body
resp.__enter__ = MagicMock(return_value=resp)
resp.__exit__ = MagicMock(return_value=False)
return resp
def _mock_urlopen_returning(payload: dict) -> MagicMock:
"""Build a urlopen mock that returns `payload` JSON as the response body."""
return MagicMock(return_value=_mock_response(payload))
class DoiLookupTest(unittest.TestCase):
def test_doi_match_with_matching_title(self) -> None:
client = ssc.SemanticScholarClient()
payload = {"paperId": "abc123", "title": "AI in education"}
with patch(
"urllib.request.urlopen", _mock_urlopen_returning(payload)
):
result = client.lookup(
{"title": "AI in education", "doi": "10.1234/xyz", "year": 2024}
)
self.assertEqual(result, {"matched": True, "paperId": "abc123"})
def test_doi_lookup_quotes_doi_path_segment(self) -> None:
client = ssc.SemanticScholarClient()
captured_urls = []
def mock_urlopen(req, *args, **kwargs):
captured_urls.append(req.full_url)
return _mock_response({
"paperId": "abc123",
"title": "AI in education",
})
with patch("urllib.request.urlopen", mock_urlopen):
result = client.lookup({
"title": "AI in education",
"doi": "10.1000/foo?bar=baz",
"year": 2024,
})
self.assertEqual(result, {"matched": True, "paperId": "abc123"})
self.assertEqual(captured_urls, [
"https://api.semanticscholar.org/graph/v1/paper/DOI:10.1000%2Ffoo%3Fbar%3Dbaz?fields=title,authors,year,externalIds,venue,publicationDate"
])
def test_rejects_non_s2_api_url_before_urlopen(self) -> None:
client = ssc.SemanticScholarClient()
urlopen = MagicMock()
with patch.object(ssc, "_API_BASE", "http://evil.example"):
with patch("urllib.request.urlopen", urlopen):
with self.assertRaises(SemanticScholarUnavailable):
client.lookup({"title": "T", "doi": "10.1/y"})
self.assertEqual(urlopen.call_count, 0)
def test_rejects_wrong_s2_host_before_urlopen(self) -> None:
client = ssc.SemanticScholarClient()
urlopen = MagicMock()
with patch.object(ssc, "_API_BASE", "https://evil.example"):
with self.assertRaises(SemanticScholarUnavailable):
client.lookup({"title": "T", "doi": "10.1/y"})
self.assertEqual(urlopen.call_count, 0)
def test_doi_404_falls_back_to_title_search(self) -> None:
"""Codex R2-2 closure: v3.7.3 Vector 2 says unmatched=true only
when NEITHER DOI nor title yields a hit. DOI 404 alone is not
sufficient — must fall through to title search."""
client = ssc.SemanticScholarClient(sleep=MagicMock())
# DOI lookup 404s; title search finds match
title_payload = {
"data": [
{"paperId": "title-hit", "title": "AI in education", "year": 2024}
]
}
title_body = json.dumps(title_payload).encode("utf-8")
title_resp = MagicMock()
title_resp.read.return_value = title_body
title_resp.__enter__ = MagicMock(return_value=title_resp)
title_resp.__exit__ = MagicMock(return_value=False)
urlopen = MagicMock(side_effect=[
urllib.error.HTTPError("u", 404, "Not Found", {}, io.BytesIO(b"")),
title_resp,
])
with patch("urllib.request.urlopen", urlopen):
result = client.lookup(
{"title": "AI in education", "doi": "10.9999/bogus", "year": 2024}
)
self.assertEqual(result, {"matched": True, "paperId": "title-hit"})
def test_doi_title_mismatch_falls_back_to_title_search(self) -> None:
"""Codex R2-2 closure: DOI returns wrong paper (title mismatch).
Still must try title search before declaring unmatched."""
client = ssc.SemanticScholarClient(sleep=MagicMock())
doi_payload = {"paperId": "wrong-paper", "title": "Totally unrelated"}
title_payload = {
"data": [
{"paperId": "title-hit", "title": "AI in education", "year": 2024}
]
}
doi_body = json.dumps(doi_payload).encode("utf-8")
title_body = json.dumps(title_payload).encode("utf-8")
doi_resp = MagicMock()
doi_resp.read.return_value = doi_body
doi_resp.__enter__ = MagicMock(return_value=doi_resp)
doi_resp.__exit__ = MagicMock(return_value=False)
title_resp = MagicMock()
title_resp.read.return_value = title_body
title_resp.__enter__ = MagicMock(return_value=title_resp)
title_resp.__exit__ = MagicMock(return_value=False)
urlopen = MagicMock(side_effect=[doi_resp, title_resp])
with patch("urllib.request.urlopen", urlopen):
result = client.lookup(
{"title": "AI in education", "doi": "10.1234/xyz", "year": 2024}
)
self.assertEqual(result, {"matched": True, "paperId": "title-hit"})
def test_doi_404_and_title_404_returns_no_match(self) -> None:
"""Both endpoints miss: now legitimate unmatched."""
client = ssc.SemanticScholarClient(sleep=MagicMock())
urlopen = MagicMock(side_effect=[
urllib.error.HTTPError("u", 404, "Not Found", {}, io.BytesIO(b"")),
urllib.error.HTTPError("u", 404, "Not Found", {}, io.BytesIO(b"")),
])
with patch("urllib.request.urlopen", urlopen):
result = client.lookup(
{"title": "Truly nonexistent", "doi": "10.0000/bogus", "year": 2024}
)
self.assertEqual(result, {"matched": False, "paperId": None})
class TitleSearchTest(unittest.TestCase):
def test_title_search_above_threshold_matches(self) -> None:
client = ssc.SemanticScholarClient()
payload = {
"data": [
{"paperId": "abc123", "title": "AI in education", "year": 2024}
]
}
with patch(
"urllib.request.urlopen", _mock_urlopen_returning(payload)
):
result = client.lookup({"title": "AI in education", "year": 2024})
self.assertEqual(result, {"matched": True, "paperId": "abc123"})
def test_title_search_below_threshold_no_match(self) -> None:
client = ssc.SemanticScholarClient()
payload = {
"data": [
{"paperId": "xyz", "title": "Totally different", "year": 2024}
]
}
with patch(
"urllib.request.urlopen", _mock_urlopen_returning(payload)
):
result = client.lookup({"title": "AI in education", "year": 2024})
self.assertEqual(result, {"matched": False, "paperId": None})
def test_empty_results_no_match(self) -> None:
client = ssc.SemanticScholarClient()
with patch(
"urllib.request.urlopen", _mock_urlopen_returning({"data": []})
):
result = client.lookup({"title": "Unknown paper"})
self.assertEqual(result, {"matched": False, "paperId": None})
class FailureHandlingTest(unittest.TestCase):
def _raise_http(self, code: int):
return urllib.error.HTTPError("u", code, "msg", {}, io.BytesIO(b""))
def test_429_backoff_then_recover(self) -> None:
"""Per protocol: HTTP 429 → 2s backoff × 3 retries before
giving up. The retry is transparent — successful retry returns
a normal result without raising."""
client = ssc.SemanticScholarClient(sleep=MagicMock())
payload = {"paperId": "abc", "title": "AI in education"}
body = json.dumps(payload).encode("utf-8")
resp = MagicMock()
resp.read.return_value = body
resp.__enter__ = MagicMock(return_value=resp)
resp.__exit__ = MagicMock(return_value=False)
# First two calls 429, third succeeds
urlopen = MagicMock(side_effect=[
self._raise_http(429),
self._raise_http(429),
resp,
])
with patch("urllib.request.urlopen", urlopen):
result = client.lookup(
{"title": "AI in education", "doi": "10.1234/xyz"}
)
self.assertEqual(result, {"matched": True, "paperId": "abc"})
def test_429_after_max_retries_raises_unavailable(self) -> None:
client = ssc.SemanticScholarClient(sleep=MagicMock())
# All 4 attempts (initial + 3 retries) raise 429
urlopen = MagicMock(side_effect=[self._raise_http(429)] * 4)
with patch("urllib.request.urlopen", urlopen):
with self.assertRaises(SemanticScholarUnavailable):
client.lookup({"title": "X", "doi": "10.1/y"})
def test_5xx_raises_unavailable(self) -> None:
client = ssc.SemanticScholarClient(sleep=MagicMock())
urlopen = MagicMock(side_effect=self._raise_http(503))
with patch("urllib.request.urlopen", urlopen):
with self.assertRaises(SemanticScholarUnavailable):
client.lookup({"title": "X", "doi": "10.1/y"})
def test_404_means_no_match_not_unavailable(self) -> None:
client = ssc.SemanticScholarClient(sleep=MagicMock())
urlopen = MagicMock(side_effect=self._raise_http(404))
with patch("urllib.request.urlopen", urlopen):
result = client.lookup({"title": "X", "doi": "10.1/y"})
self.assertEqual(result, {"matched": False, "paperId": None})
def test_network_error_raises_unavailable(self) -> None:
client = ssc.SemanticScholarClient(sleep=MagicMock())
urlopen = MagicMock(side_effect=urllib.error.URLError("connection refused"))
with patch("urllib.request.urlopen", urlopen):
with self.assertRaises(SemanticScholarUnavailable):
client.lookup({"title": "X", "doi": "10.1/y"})
class TitleNormalizationTest(unittest.TestCase):
"""Codex R4-1 closure: protocol §"Query Patterns" Pattern 1 says
title matching is 'case-insensitive, stripped of punctuation'."""
def test_acronym_punctuation_clears_threshold(self) -> None:
"""'R.A.G.' vs 'RAG' originally scored below 0.70 because raw
SequenceMatcher penalized the punctuation. After normalize the
score clears the 0.70 protocol threshold."""
self.assertGreaterEqual(ssc._similarity("R.A.G.", "RAG"), 0.70)
def test_punctuation_stripped_before_similarity(self) -> None:
"""Trailing colons / em-dashes / quotes should not penalize match."""
self.assertGreater(
ssc._similarity(
"Attention Is All You Need: A Transformers Story",
"attention is all you need a transformers story",
),
0.95,
)
def test_title_normalize_collapses_whitespace(self) -> None:
"""Multiple punctuation chars become spaces; collapse them."""
self.assertEqual(ssc._normalize_title("Foo, Bar... Baz!"), "foo bar baz")
class ResponseReadTimeoutTest(unittest.TestCase):
"""Codex R4-2 closure: resp.read() can raise OSError/TimeoutError
(e.g. socket.timeout on the body read) outside the URLError handler.
Must be wrapped as SemanticScholarUnavailable so the migration
degrades gracefully rather than aborting mid-run."""
def test_response_read_oserror_raises_unavailable(self) -> None:
client = ssc.SemanticScholarClient(sleep=MagicMock())
resp = MagicMock()
resp.read.side_effect = OSError("socket read timeout")
resp.__enter__ = MagicMock(return_value=resp)
resp.__exit__ = MagicMock(return_value=False)
with patch("urllib.request.urlopen", MagicMock(return_value=resp)):
with self.assertRaises(SemanticScholarUnavailable):
client.lookup({"title": "X", "doi": "10.1/y"})
def test_response_read_timeout_error_raises_unavailable(self) -> None:
client = ssc.SemanticScholarClient(sleep=MagicMock())
resp = MagicMock()
resp.read.side_effect = TimeoutError("body read timed out")
resp.__enter__ = MagicMock(return_value=resp)
resp.__exit__ = MagicMock(return_value=False)
with patch("urllib.request.urlopen", MagicMock(return_value=resp)):
with self.assertRaises(SemanticScholarUnavailable):
client.lookup({"title": "X", "doi": "10.1/y"})
class RateLimitThrottleTest(unittest.TestCase):
"""#115 R5-2: back-to-back successful lookups must respect the
protocol's 1 req/s unauthenticated rate limit so the client doesn't
proactively hit 429 and exhaust retries on healthy corpora."""
def _good_resp(self, payload: dict) -> MagicMock:
return _mock_response(payload)
def test_first_request_does_not_sleep(self) -> None:
"""First request after construction has no prior call to pace
against; the throttle should pass through immediately. Hermetic
regardless of S2_API_KEY env (no comparison against tier)."""
sleep = MagicMock()
clock = MagicMock(return_value=1000.0)
client = ssc.SemanticScholarClient(
sleep=sleep, clock=clock, min_interval_seconds=1.0
)
with patch(
"urllib.request.urlopen",
MagicMock(return_value=self._good_resp({"paperId": "x", "title": "T"})),
):
client.lookup({"title": "T", "doi": "10.1/y"})
# Throttle sleep must NOT fire on first call (only on subsequent)
self.assertEqual(sleep.call_count, 0)
def test_back_to_back_calls_throttle_to_one_req_per_second(self) -> None:
"""Default unauthenticated tier: 1.0s min interval between calls.
Second call within the interval should sleep the remainder.
Explicit `min_interval_seconds=1.0` so the test is hermetic
against `S2_API_KEY` set in the environment (codex R1 P2)."""
sleep = MagicMock()
# clock: first request at t=1000.0, second at t=1000.3 (0.3s later
# — throttle should sleep 0.7s before issuing second request)
clock = MagicMock(side_effect=[1000.0, 1000.3, 1000.3])
client = ssc.SemanticScholarClient(
sleep=sleep, clock=clock, min_interval_seconds=1.0
)
resp_factory = lambda: self._good_resp({"paperId": "x", "title": "T"})
with patch(
"urllib.request.urlopen",
MagicMock(side_effect=[resp_factory(), resp_factory()]),
):
client.lookup({"title": "T", "doi": "10.1/y"})
client.lookup({"title": "T", "doi": "10.1/z"})
# One sleep call between the two requests
self.assertEqual(sleep.call_count, 1)
slept = sleep.call_args[0][0]
self.assertAlmostEqual(slept, 0.7, places=2)
def test_back_to_back_calls_past_interval_do_not_sleep(self) -> None:
"""If enough time elapsed naturally between calls, no extra sleep
is needed — the throttle is a floor, not a ceiling. Explicit
min_interval_seconds for env-hermeticity (codex R1 P2)."""
sleep = MagicMock()
# Second call 2.5s after first; no throttle sleep needed
clock = MagicMock(side_effect=[1000.0, 1002.5, 1002.5])
client = ssc.SemanticScholarClient(
sleep=sleep, clock=clock, min_interval_seconds=1.0
)
resp_factory = lambda: self._good_resp({"paperId": "x", "title": "T"})
with patch(
"urllib.request.urlopen",
MagicMock(side_effect=[resp_factory(), resp_factory()]),
):
client.lookup({"title": "T", "doi": "10.1/y"})
client.lookup({"title": "T", "doi": "10.1/z"})
# No throttle-sleep calls (429 retry sleeps would also count but
# we don't hit any 429 here)
self.assertEqual(sleep.call_count, 0)
def test_api_key_lowers_interval_to_authenticated_tier(self) -> None:
"""Per protocol line 6: authenticated tier is 10 req/s = 0.1s
interval. When S2_API_KEY is set, throttle drops accordingly."""
sleep = MagicMock()
clock = MagicMock(side_effect=[1000.0, 1000.03, 1000.03])
client = ssc.SemanticScholarClient(
api_key="test-key", sleep=sleep, clock=clock
)
resp_factory = lambda: self._good_resp({"paperId": "x", "title": "T"})
with patch(
"urllib.request.urlopen",
MagicMock(side_effect=[resp_factory(), resp_factory()]),
):
client.lookup({"title": "T", "doi": "10.1/y"})
client.lookup({"title": "T", "doi": "10.1/z"})
# 0.1 - 0.03 = 0.07s sleep on authenticated tier
self.assertEqual(sleep.call_count, 1)
slept = sleep.call_args[0][0]
self.assertAlmostEqual(slept, 0.07, places=2)
def test_429_retry_refreshes_throttle_anchor(self) -> None:
"""F5 closure (simplify efficiency): if `_last_request_at` is not
refreshed after a 429 backoff, the next outer call paces against
entry time + N × backoff already elapsed, then under-sleeps and
re-triggers 429. After fix: anchor refreshes to actual wake time.
Explicit min_interval_seconds for env-hermeticity (codex R1 P2)."""
sleep = MagicMock()
# Clock sequence:
# call 1 entry elapsed check (none, first call) - not used
# call 1 entry write t=1000.0
# call 1 429-retry write t=1002.0 (after 2s backoff)
# call 2 entry elapsed check t=1002.0 (no extra sleep, 0s elapsed
# from anchor with 1s interval = sleep 1.0s)
# call 2 entry write t=1003.0
clock = MagicMock(side_effect=[1000.0, 1002.0, 1002.0, 1003.0])
client = ssc.SemanticScholarClient(
sleep=sleep, clock=clock, min_interval_seconds=1.0
)
good = _mock_response({"paperId": "x", "title": "T"})
def urlopen_side(*args, **kwargs):
urlopen_side.count += 1
if urlopen_side.count == 1:
raise urllib.error.HTTPError(
"u", 429, "Too Many", {}, io.BytesIO(b"")
)
return good
urlopen_side.count = 0
with patch("urllib.request.urlopen", urlopen_side):
client.lookup({"title": "T", "doi": "10.1/y"})
client.lookup({"title": "T", "doi": "10.1/z"})
# Sleeps: one 2s backoff for the 429, one 1s for the throttle on
# the 2nd outer call (anchor is fresh at t=1002 so elapsed=0,
# remaining=1.0)
self.assertEqual(sleep.call_count, 2)
self.assertAlmostEqual(sleep.call_args_list[0][0][0], 2.0, places=2)
self.assertAlmostEqual(sleep.call_args_list[1][0][0], 1.0, places=2)
def test_explicit_min_interval_override(self) -> None:
"""Caller can override the throttle interval (e.g., for tests or
for a hypothetical higher tier)."""
sleep = MagicMock()
clock = MagicMock(side_effect=[1000.0, 1000.0, 1000.0])
client = ssc.SemanticScholarClient(
sleep=sleep, clock=clock, min_interval_seconds=0.0
)
resp_factory = lambda: self._good_resp({"paperId": "x", "title": "T"})
with patch(
"urllib.request.urlopen",
MagicMock(side_effect=[resp_factory(), resp_factory()]),
):
client.lookup({"title": "T", "doi": "10.1/y"})
client.lookup({"title": "T", "doi": "10.1/z"})
# min_interval=0 means never throttle
self.assertEqual(sleep.call_count, 0)
class OutageLatchTest(unittest.TestCase):
"""#115 R5-3: when urlopen raises URLError (network down), the client
must latch into unavailable mode so subsequent calls fail fast
without waiting on a known-dead service. Reset method allows
long-running tools to retry recovery between passports."""
def test_url_error_latches_client_unavailable(self) -> None:
"""First call hits URLError → SemanticScholarUnavailable. Second
call must raise immediately WITHOUT invoking urlopen — the
latch short-circuits the network entirely."""
client = ssc.SemanticScholarClient(sleep=MagicMock())
urlopen = MagicMock(
side_effect=urllib.error.URLError("connection refused")
)
with patch("urllib.request.urlopen", urlopen):
with self.assertRaises(SemanticScholarUnavailable):
client.lookup({"title": "T", "doi": "10.1/y"})
# urlopen was called once for the first lookup
self.assertEqual(urlopen.call_count, 1)
# Second lookup must NOT call urlopen — latched short-circuit
with self.assertRaises(SemanticScholarUnavailable):
client.lookup({"title": "Other", "doi": "10.1/z"})
self.assertEqual(urlopen.call_count, 1, "latched call must skip network")
def test_reset_outage_latch_restores_normal_behavior(self) -> None:
"""A long-running tool between passport batches can call
reset_outage_latch() to retry the network."""
client = ssc.SemanticScholarClient(sleep=MagicMock())
# First call latches
urlopen = MagicMock(side_effect=urllib.error.URLError("down"))
with patch("urllib.request.urlopen", urlopen):
with self.assertRaises(SemanticScholarUnavailable):
client.lookup({"title": "T", "doi": "10.1/y"})
# Reset latch
client.reset_outage_latch()
# Subsequent call should re-attempt urlopen (and succeed in this
# mock scenario)
urlopen_good = MagicMock(
return_value=_mock_response({"paperId": "x", "title": "T"})
)
with patch("urllib.request.urlopen", urlopen_good):
result = client.lookup({"title": "T", "doi": "10.1/y"})
self.assertEqual(result, {"matched": True, "paperId": "x"})
self.assertEqual(urlopen_good.call_count, 1)
def test_response_read_oserror_also_latches(self) -> None:
"""Codex R2 closure: protocol §"On API failure" treats transport-
level network failures uniformly, regardless of which urllib
boundary surfaces them. A socket read timeout during resp.read()
must latch the batch just like URLError at urlopen() time —
otherwise a real outage retries 30s per entry × N."""
client = ssc.SemanticScholarClient(sleep=MagicMock())
resp = MagicMock()
resp.read.side_effect = OSError("socket read timeout")
resp.__enter__ = MagicMock(return_value=resp)
resp.__exit__ = MagicMock(return_value=False)
urlopen = MagicMock(return_value=resp)
with patch("urllib.request.urlopen", urlopen):
with self.assertRaises(SemanticScholarUnavailable):
client.lookup({"title": "T", "doi": "10.1/y"})
self.assertEqual(urlopen.call_count, 1)
# Second call should short-circuit — no second urlopen
with self.assertRaises(SemanticScholarUnavailable):
client.lookup({"title": "Other", "doi": "10.1/z"})
self.assertEqual(urlopen.call_count, 1, "latched after read-timeout")
def test_http_error_does_not_latch(self) -> None:
"""HTTP 5xx is a server-side error, not a transport-level outage.
The protocol §"On API failure" says network errors skip the
remaining batch; HTTP failures don't. Make sure 5xx doesn't
falsely latch the client."""
client = ssc.SemanticScholarClient(sleep=MagicMock())
urlopen = MagicMock(side_effect=[
urllib.error.HTTPError("u", 503, "Service Unavailable", {}, io.BytesIO(b"")),
])
with patch("urllib.request.urlopen", urlopen):
with self.assertRaises(SemanticScholarUnavailable):
client.lookup({"title": "T", "doi": "10.1/y"})
# 5xx fired but client should not be latched
self.assertFalse(client._latched_unavailable)
class CLIWiringTest(unittest.TestCase):
"""Codex R1-1 closure: CLI was unrunnable because NotImplementedError
fired before reading the passport. Verify the production wiring path
now actually constructs a SemanticScholarClient instance."""
def test_build_default_ss_client_returns_real_client(self) -> None:
import migrate_literature_corpus_to_v3_7_3 as mig
client = mig._build_default_ss_client()
self.assertIsInstance(client, ssc.SemanticScholarClient)
if __name__ == "__main__":
unittest.main()