Merge-robot identity: instance-root /user + non-JSON error body handling (red-first) #216
No files matched your search
+12
-2
@@ -48,14 +48,24 @@ def _api(
|
||||
body = e.read()
|
||||
except urllib.error.URLError as e:
|
||||
raise MergeRefused(f"forge unreachable at {path}: {e.reason}") from e
|
||||
parsed: Any = json.loads(body) if body else {}
|
||||
try:
|
||||
parsed: Any = json.loads(body) if body else {}
|
||||
except json.JSONDecodeError:
|
||||
if status not in ok:
|
||||
raise MergeRefused(f"{method} {path} -> {status} (non-JSON body): {body[:200]}") from None
|
||||
parsed = body # 2xx but not JSON — pass through raw
|
||||
if method == "GET" and status not in ok:
|
||||
raise MergeRefused(f"GET {path} -> {status}: {str(parsed)[:300]}")
|
||||
return status, parsed
|
||||
|
||||
|
||||
def verify_identity(base: str, token: str) -> str:
|
||||
status, user = _api(base, token, "/user")
|
||||
# Defense in depth: whatever scope the caller hands us, identity is an
|
||||
# INSTANCE-scoped endpoint (#214 follow-up: repo-scoped base produced
|
||||
# .../repos/o/r/user -> 404 -> unhandled JSONDecodeError).
|
||||
stripped = base.rstrip("/")
|
||||
instance = stripped.split("/repos/")[0] if "/repos/" in stripped else base
|
||||
status, user = _api(instance, token, "/user")
|
||||
login = user.get("login", "?")
|
||||
print(f"merge-robot: authenticated as {login} (status {status})")
|
||||
return login
|
||||
|
||||
@@ -153,3 +153,40 @@ def test_main_exits_0_all_merged(forge, monkeypatch):
|
||||
|
||||
monkeypatch.setattr(mr, "scan", fake_scan)
|
||||
assert mr.main_with(["merge_robot.py", "tok", "base"]) == 0
|
||||
|
||||
|
||||
def test_verify_identity_uses_instance_base_not_repo_scoped(forge, monkeypatch):
|
||||
"""RED (#214 follow-up): verify_identity received the REPO-scoped base and
|
||||
built .../repos/o/r/user — a 404 with a plain-text body that crashed
|
||||
json.loads before MergeRefused could fire. Identity must be verified at the
|
||||
INSTANCE root: .../api/v1/user."""
|
||||
recorded = []
|
||||
|
||||
def spy_api(base, token, path, method="GET", data=None, ok=(200,)):
|
||||
recorded.append((base, path))
|
||||
return 200, {"login": "conductor"}
|
||||
|
||||
monkeypatch.setattr(mr, "_api", spy_api)
|
||||
mr.verify_identity("http://x/api/v1/repos/o/r", "tok")
|
||||
assert recorded == [("http://x/api/v1", "/user")], (
|
||||
"identity check must hit the instance root /user, not the repo-scoped path"
|
||||
)
|
||||
|
||||
|
||||
def test_api_handles_non_json_error_body(monkeypatch):
|
||||
# NOTE: no forge fixture — it replaces _api entirely, which would make the
|
||||
# urlopen patch unreachable. This test exercises the REAL _api.
|
||||
"""A 404 plain-text body ('404 page not found') must raise MergeRefused,
|
||||
not an unhandled JSONDecodeError."""
|
||||
import urllib.error
|
||||
|
||||
class FakeHTTPError(urllib.error.HTTPError):
|
||||
def __init__(self):
|
||||
super().__init__("url", 404, "not found", hdrs=None, fp=None)
|
||||
|
||||
def real_url(req, timeout):
|
||||
raise FakeHTTPError()
|
||||
|
||||
monkeypatch.setattr(mr.urllib.request, "urlopen", real_url)
|
||||
with pytest.raises(mr.MergeRefused):
|
||||
mr._api("http://x/api/v1", "tok", "/user")
|
||||
Reference in new issue
Block a user