cfe3ff6755
Harden gitea_auth.api_request: add a per-request timeout (env GITEA_HTTP_TIMEOUT), convert timeouts and DNS/network failures (URLError/TimeoutError) into clear RuntimeErrors, give 502/503/504 an explicit 'upstream unavailable' message, convert malformed success JSON into a clean error, and redact credential-like substrings from all error text. Preserves the success path and existing 429 retry/backoff. Add shared gitea_auth.api_get_all: page-based pagination that tolerates missing/malformed metadata (relies on page length, not Link/X-Total-Count headers), honors an optional overall limit, and caps pages. Wire it into the read-only list tools gitea_list_issues, gitea_list_prs, and gitea_list_labels (return shape unchanged). Add tests/test_api_reliability.py (18 cases) and update the three list-tool tests to the new call path. No auth/profile/merge/review/tracker behavior changed. No modular #65 refactor. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
206 lines
8.5 KiB
Python
206 lines
8.5 KiB
Python
"""Unit coverage for shared API pagination and failure handling (#67).
|
|
|
|
Covers gitea_auth.api_request failure conversion (timeouts, DNS/network,
|
|
502/503, malformed error payloads, malformed success JSON, no-secret leakage,
|
|
preserved success + 429 behavior) and gitea_auth.api_get_all pagination
|
|
(single/multi page, missing/malformed metadata, limit cap, max_pages, query
|
|
handling). Everything is mocked — no real network calls are made.
|
|
"""
|
|
import io
|
|
import unittest
|
|
import urllib.error
|
|
from unittest.mock import patch
|
|
|
|
import gitea_auth
|
|
import gitea_audit
|
|
|
|
FAKE_AUTH = "Basic ZmFrZTpmYWtl" # not a real credential
|
|
URL = "https://gitea.example.com/api/v1/repos/o/r/issues"
|
|
|
|
|
|
class FakeResp:
|
|
"""Minimal context-manager stand-in for a urlopen response."""
|
|
|
|
def __init__(self, body):
|
|
self._body = body.encode("utf-8") if isinstance(body, str) else body
|
|
|
|
def read(self):
|
|
return self._body
|
|
|
|
def __enter__(self):
|
|
return self
|
|
|
|
def __exit__(self, *exc):
|
|
return False
|
|
|
|
|
|
def http_error(code, body="", headers=None):
|
|
return urllib.error.HTTPError(
|
|
url=URL, code=code, msg="err",
|
|
hdrs=headers or {}, fp=io.BytesIO(body.encode("utf-8")),
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# api_request — success path preserved
|
|
# ---------------------------------------------------------------------------
|
|
class TestApiRequestSuccess(unittest.TestCase):
|
|
|
|
@patch("gitea_auth.urllib.request.urlopen")
|
|
def test_success_returns_parsed_json(self, mock_open):
|
|
mock_open.return_value = FakeResp('{"number": 1, "title": "ok"}')
|
|
self.assertEqual(
|
|
gitea_auth.api_request("GET", URL, FAKE_AUTH),
|
|
{"number": 1, "title": "ok"},
|
|
)
|
|
|
|
@patch("gitea_auth.urllib.request.urlopen")
|
|
def test_empty_body_returns_none(self, mock_open):
|
|
mock_open.return_value = FakeResp("")
|
|
self.assertIsNone(gitea_auth.api_request("GET", URL, FAKE_AUTH))
|
|
|
|
@patch("gitea_auth.urllib.request.urlopen")
|
|
def test_429_then_success_still_retries(self, mock_open):
|
|
mock_open.side_effect = [http_error(429), FakeResp('{"ok": true}')]
|
|
calls = []
|
|
result = gitea_auth.api_request(
|
|
"GET", URL, FAKE_AUTH,
|
|
sleep_func=lambda d: calls.append(d), rand_func=lambda: 0.0,
|
|
)
|
|
self.assertEqual(result, {"ok": True})
|
|
self.assertEqual(len(calls), 1) # slept once between the two attempts
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# api_request — failure handling
|
|
# ---------------------------------------------------------------------------
|
|
class TestApiRequestFailures(unittest.TestCase):
|
|
|
|
@patch("gitea_auth.urllib.request.urlopen")
|
|
def test_timeout_converted_to_runtimeerror(self, mock_open):
|
|
mock_open.side_effect = TimeoutError("timed out")
|
|
with self.assertRaises(RuntimeError) as ctx:
|
|
gitea_auth.api_request("GET", URL, FAKE_AUTH)
|
|
self.assertIn("network error contacting Gitea", str(ctx.exception))
|
|
|
|
@patch("gitea_auth.urllib.request.urlopen")
|
|
def test_dns_network_failure_converted(self, mock_open):
|
|
mock_open.side_effect = urllib.error.URLError("Name or service not known")
|
|
with self.assertRaises(RuntimeError) as ctx:
|
|
gitea_auth.api_request("GET", URL, FAKE_AUTH)
|
|
self.assertIn("network error contacting Gitea", str(ctx.exception))
|
|
|
|
@patch("gitea_auth.urllib.request.urlopen")
|
|
def test_502_upstream_message(self, mock_open):
|
|
mock_open.side_effect = http_error(502, "bad gateway")
|
|
with self.assertRaises(RuntimeError) as ctx:
|
|
gitea_auth.api_request("GET", URL, FAKE_AUTH)
|
|
msg = str(ctx.exception)
|
|
self.assertIn("HTTP 502", msg)
|
|
self.assertIn("upstream unavailable", msg)
|
|
|
|
@patch("gitea_auth.urllib.request.urlopen")
|
|
def test_503_upstream_message(self, mock_open):
|
|
mock_open.side_effect = http_error(503, "")
|
|
with self.assertRaises(RuntimeError) as ctx:
|
|
gitea_auth.api_request("GET", URL, FAKE_AUTH)
|
|
self.assertIn("HTTP 503", str(ctx.exception))
|
|
self.assertIn("upstream unavailable", str(ctx.exception))
|
|
|
|
@patch("gitea_auth.urllib.request.urlopen")
|
|
def test_malformed_error_payload_does_not_crash(self, mock_open):
|
|
# Non-JSON garbage error body must still yield a clean RuntimeError.
|
|
mock_open.side_effect = http_error(500, "<html>garbage</html>")
|
|
with self.assertRaises(RuntimeError) as ctx:
|
|
gitea_auth.api_request("GET", URL, FAKE_AUTH)
|
|
self.assertIn("HTTP 500", str(ctx.exception))
|
|
|
|
@patch("gitea_auth.urllib.request.urlopen")
|
|
def test_malformed_success_json_raises_clean_error(self, mock_open):
|
|
mock_open.return_value = FakeResp("not json{")
|
|
with self.assertRaises(RuntimeError) as ctx:
|
|
gitea_auth.api_request("GET", URL, FAKE_AUTH)
|
|
self.assertIn("malformed JSON response", str(ctx.exception))
|
|
|
|
@patch("gitea_auth.urllib.request.urlopen")
|
|
def test_no_secret_leak_in_error_body(self, mock_open):
|
|
mock_open.side_effect = http_error(
|
|
400, "failed: token supersecret123 rejected")
|
|
with self.assertRaises(RuntimeError) as ctx:
|
|
gitea_auth.api_request("GET", URL, FAKE_AUTH)
|
|
msg = str(ctx.exception)
|
|
self.assertNotIn("supersecret123", msg)
|
|
self.assertIn(gitea_audit.REDACTED, msg)
|
|
|
|
@patch("gitea_auth.urllib.request.urlopen")
|
|
def test_auth_header_never_in_error(self, mock_open):
|
|
mock_open.side_effect = http_error(400, "bad request")
|
|
with self.assertRaises(RuntimeError) as ctx:
|
|
gitea_auth.api_request("GET", URL, FAKE_AUTH)
|
|
self.assertNotIn(FAKE_AUTH, str(ctx.exception))
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# api_get_all — pagination
|
|
# ---------------------------------------------------------------------------
|
|
class TestApiGetAll(unittest.TestCase):
|
|
|
|
@patch("gitea_auth.api_request")
|
|
def test_single_page(self, mock_req):
|
|
mock_req.return_value = [{"id": 1}, {"id": 2}] # short page (< page_size)
|
|
result = gitea_auth.api_get_all(URL, FAKE_AUTH, page_size=50)
|
|
self.assertEqual(result, [{"id": 1}, {"id": 2}])
|
|
self.assertEqual(mock_req.call_count, 1)
|
|
|
|
@patch("gitea_auth.api_request")
|
|
def test_multi_page(self, mock_req):
|
|
mock_req.side_effect = [
|
|
[{"id": 1}, {"id": 2}], # full page
|
|
[{"id": 3}, {"id": 4}], # full page
|
|
[{"id": 5}], # short page -> stop
|
|
]
|
|
result = gitea_auth.api_get_all(URL, FAKE_AUTH, page_size=2)
|
|
self.assertEqual([r["id"] for r in result], [1, 2, 3, 4, 5])
|
|
self.assertEqual(mock_req.call_count, 3)
|
|
|
|
@patch("gitea_auth.api_request")
|
|
def test_missing_metadata_none_page_ends(self, mock_req):
|
|
mock_req.return_value = None # empty/malformed metadata -> treated as end
|
|
self.assertEqual(gitea_auth.api_get_all(URL, FAKE_AUTH), [])
|
|
self.assertEqual(mock_req.call_count, 1)
|
|
|
|
@patch("gitea_auth.api_request")
|
|
def test_malformed_metadata_non_list_raises(self, mock_req):
|
|
mock_req.return_value = {"message": "not a list"}
|
|
with self.assertRaises(RuntimeError) as ctx:
|
|
gitea_auth.api_get_all(URL, FAKE_AUTH)
|
|
self.assertIn("expected a list page", str(ctx.exception))
|
|
|
|
@patch("gitea_auth.api_request")
|
|
def test_limit_caps_results(self, mock_req):
|
|
mock_req.side_effect = [[{"id": 1}, {"id": 2}], [{"id": 3}, {"id": 4}]]
|
|
result = gitea_auth.api_get_all(URL, FAKE_AUTH, page_size=2, limit=3)
|
|
self.assertEqual([r["id"] for r in result], [1, 2, 3])
|
|
|
|
@patch("gitea_auth.api_request")
|
|
def test_max_pages_safety_cap(self, mock_req):
|
|
mock_req.side_effect = [
|
|
[{"id": 1}, {"id": 2}], [{"id": 3}, {"id": 4}], [{"id": 5}, {"id": 6}],
|
|
]
|
|
result = gitea_auth.api_get_all(URL, FAKE_AUTH, page_size=2, max_pages=2)
|
|
self.assertEqual(len(result), 4)
|
|
self.assertEqual(mock_req.call_count, 2)
|
|
|
|
@patch("gitea_auth.api_request")
|
|
def test_query_params_appended_and_preserved(self, mock_req):
|
|
mock_req.return_value = [] # first (empty) page ends immediately
|
|
gitea_auth.api_get_all(URL + "?state=open", FAKE_AUTH, page_size=2)
|
|
called_url = mock_req.call_args[0][1]
|
|
self.assertIn("state=open", called_url)
|
|
self.assertIn("page=1", called_url)
|
|
self.assertIn("limit=2", called_url)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|