hermes: accept verified Forgejo draft update acknowledgments
This commit is contained in:
parent
33e6f84a64
commit
73618ca8f8
@ -10,6 +10,8 @@ import deadline_http
|
|||||||
from gitea_api import CANONICAL_BASE_URL
|
from gitea_api import CANONICAL_BASE_URL
|
||||||
from gitea_api_policy import PolicyError, _draft_title, _validate_body, _validate_pr_number
|
from gitea_api_policy import PolicyError, _draft_title, _validate_body, _validate_pr_number
|
||||||
|
|
||||||
|
UPDATE_SUCCESS_STATUSES = {200, 201}
|
||||||
|
|
||||||
|
|
||||||
def request_fields(value: dict[str, Any], token: str) -> tuple[str, int, str, str]:
|
def request_fields(value: dict[str, Any], token: str) -> tuple[str, int, str, str]:
|
||||||
if set(value) != {"grant", "pr_number", "title", "body"} or not isinstance(value["grant"], str):
|
if set(value) != {"grant", "pr_number", "title", "body"} or not isinstance(value["grant"], str):
|
||||||
@ -39,10 +41,25 @@ def update(token: str, repo: str, number: int, title: str, body: str) -> bytes:
|
|||||||
method="PATCH", headers={"Authorization": f"Basic {auth}", "Content-Type": "application/json", "Accept": "application/json"},
|
method="PATCH", headers={"Authorization": f"Basic {auth}", "Content-Type": "application/json", "Accept": "application/json"},
|
||||||
)
|
)
|
||||||
with deadline_http.open_bounded(request, maximum=2 * 1024 * 1024, timeout=30) as response:
|
with deadline_http.open_bounded(request, maximum=2 * 1024 * 1024, timeout=30) as response:
|
||||||
if getattr(response, "status", None) != 200 or response.headers.get_content_type() != "application/json":
|
if getattr(response, "status", None) not in UPDATE_SUCCESS_STATUSES or response.headers.get_content_type() != "application/json":
|
||||||
raise PolicyError("draft update upstream response is invalid")
|
raise PolicyError("draft update upstream response is invalid")
|
||||||
result = response.read(2 * 1024 * 1024 + 1)
|
result = response.read(2 * 1024 * 1024 + 1)
|
||||||
if len(result) > 2 * 1024 * 1024 or token.encode() in result:
|
if len(result) > 2 * 1024 * 1024 or token.encode() in result:
|
||||||
raise PolicyError("draft update upstream response is invalid")
|
raise PolicyError("draft update upstream response is invalid")
|
||||||
json.loads(result)
|
try:
|
||||||
|
payload = json.loads(result)
|
||||||
|
except json.JSONDecodeError as error:
|
||||||
|
raise PolicyError("draft update upstream response is invalid") from error
|
||||||
|
try:
|
||||||
|
response_number = _validate_pr_number(
|
||||||
|
payload.get("number") if isinstance(payload, dict) else None
|
||||||
|
)
|
||||||
|
except PolicyError as error:
|
||||||
|
raise PolicyError("draft update upstream response is invalid") from error
|
||||||
|
if (
|
||||||
|
not isinstance(payload, dict)
|
||||||
|
or response_number != number
|
||||||
|
or payload.get("html_url") != f"{CANONICAL_BASE_URL}/titan/{repo}/pulls/{number}"
|
||||||
|
):
|
||||||
|
raise PolicyError("draft update upstream response is invalid")
|
||||||
return result
|
return result
|
||||||
|
|||||||
@ -160,6 +160,85 @@ def test_draft_refresh_requires_exact_owned_pr_and_has_no_mutating_fields():
|
|||||||
drafts.request_fields({"grant": "x", "pr_number": 55, "title": "x", "body": "x", "state": "closed"}, "token")
|
drafts.request_fields({"grant": "x", "pr_number": 55, "title": "x", "body": "x", "state": "closed"}, "token")
|
||||||
|
|
||||||
|
|
||||||
|
def _draft_update_response(status: int, payload: bytes, content_type: str = "application/json"):
|
||||||
|
class Response:
|
||||||
|
def __init__(self):
|
||||||
|
self.headers = Message()
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
self.status = status
|
||||||
|
self.headers["Content-Type"] = content_type
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *_args):
|
||||||
|
return False
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def read(_limit):
|
||||||
|
return payload
|
||||||
|
|
||||||
|
return Response()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("status", [200, 201])
|
||||||
|
def test_draft_update_accepts_only_known_success_statuses_with_matching_pr_shape(
|
||||||
|
monkeypatch, status
|
||||||
|
):
|
||||||
|
drafts = _load("scm_task_drafts")
|
||||||
|
body = json.dumps({
|
||||||
|
"number": 55,
|
||||||
|
"html_url": "https://scm.bstein.dev/titan/atlas-iac/pulls/55",
|
||||||
|
}).encode()
|
||||||
|
seen = []
|
||||||
|
|
||||||
|
def open_bounded(request, maximum, timeout):
|
||||||
|
seen.append((request, maximum, timeout))
|
||||||
|
return _draft_update_response(status, body)
|
||||||
|
|
||||||
|
monkeypatch.setattr(drafts.deadline_http, "open_bounded", open_bounded)
|
||||||
|
|
||||||
|
assert drafts.update("token", "atlas-iac", 55, "WIP: refreshed", "evidence") == body
|
||||||
|
request, maximum, timeout = seen[0]
|
||||||
|
assert request.get_method() == "PATCH"
|
||||||
|
assert request.full_url.endswith("/api/v1/repos/titan/atlas-iac/pulls/55")
|
||||||
|
assert json.loads(request.data) == {"title": "WIP: refreshed", "body": "evidence"}
|
||||||
|
assert maximum == 2 * 1024 * 1024 and timeout == 30
|
||||||
|
|
||||||
|
|
||||||
|
def test_draft_update_rejects_unapproved_success_status(monkeypatch):
|
||||||
|
drafts = _load("scm_task_drafts")
|
||||||
|
body = b'{"number":55,"html_url":"https://scm.bstein.dev/titan/atlas-iac/pulls/55"}'
|
||||||
|
monkeypatch.setattr(
|
||||||
|
drafts.deadline_http,
|
||||||
|
"open_bounded",
|
||||||
|
lambda *_args, **_kwargs: _draft_update_response(202, body),
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(drafts.PolicyError, match="upstream response"):
|
||||||
|
drafts.update("token", "atlas-iac", 55, "WIP: refreshed", "evidence")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"payload",
|
||||||
|
[
|
||||||
|
b"[]",
|
||||||
|
b'{"number":56,"html_url":"https://scm.bstein.dev/titan/atlas-iac/pulls/55"}',
|
||||||
|
b'{"number":55,"html_url":"https://evil.example/titan/atlas-iac/pulls/55"}',
|
||||||
|
b"{not-json",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_draft_update_rejects_malformed_success_response_shape(monkeypatch, payload):
|
||||||
|
drafts = _load("scm_task_drafts")
|
||||||
|
monkeypatch.setattr(
|
||||||
|
drafts.deadline_http,
|
||||||
|
"open_bounded",
|
||||||
|
lambda *_args, **_kwargs: _draft_update_response(201, payload),
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(drafts.PolicyError, match="upstream response"):
|
||||||
|
drafts.update("token", "atlas-iac", 55, "WIP: refreshed", "evidence")
|
||||||
|
|
||||||
|
|
||||||
def _control_handler(broker, body: dict):
|
def _control_handler(broker, body: dict):
|
||||||
"""Build the broker's real control handler without opening a TCP listener."""
|
"""Build the broker's real control handler without opening a TCP listener."""
|
||||||
encoded = json.dumps(body).encode()
|
encoded = json.dumps(body).encode()
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user