fix(web): Cloudflare UA block, Gitea path@ref, attempt 0

Three bugs the real API surfaced, none visible against a stubbed payload:

- Gitea here is proxied by Cloudflare, which answers urllib's default
  `Python-urllib/3.x` agent with 403 + "error code: 1010" before the request
  reaches Gitea — every endpoint 403s, including unauthenticated `/version`,
  so it reads exactly like a token problem. Send an explicit User-Agent.
- Gitea reports `path` as either `.gitea/workflows/lint.yml` or the
  `lint.yml@refs/heads/main` form; splitting on "/" alone rendered the latter
  as the workflow name "main". Drop the ref first.
- Gitea reports `run_attempt: 0` for a never-retried run while the job id the
  workflow builds uses github.run_attempt, which starts at 1 — so 0 must read
  as 1 or no VM would ever correlate to its run. (The existing `or 1` already
  did this; it now has a test naming why, against the real payload shape.)

HTTP failures now carry the server's own message (Gitea names the missing
scope; a Cloudflare interception says something else entirely) instead of a
bare status code that sent this investigation the wrong way once already.
This commit is contained in:
2026-07-25 15:27:01 +02:00
parent d0ea7f661d
commit 91ea90348c
3 changed files with 158 additions and 6 deletions
+95 -2
View File
@@ -100,9 +100,13 @@ class _FakeResponse:
return None
def _http_error(code: int) -> urllib.error.HTTPError:
def _http_error(code: int, body: str = "") -> urllib.error.HTTPError:
return urllib.error.HTTPError(
url="https://gitea.example", code=code, msg="nope", hdrs=None, fp=None
url="https://gitea.example",
code=code,
msg="nope",
hdrs=None, # type: ignore[arg-type]
fp=BytesIO(body.encode("utf-8")) if body else None,
)
@@ -192,6 +196,95 @@ def test_admin_payload_parsed_into_executions(
]
def test_request_sends_an_explicit_user_agent(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Cloudflare in front of Gitea 403s urllib's default agent (error 1010)."""
agents: list[str | None] = []
def _fake_urlopen(request: Any, timeout: float | None = None) -> Any:
agents.append(request.get_header("User-agent"))
return _FakeResponse({"workflow_runs": []})
monkeypatch.setattr(ex.urllib.request, "urlopen", _fake_urlopen)
list_executions(_make_config(tmp_path), [], token="tok", now=_NOW)
assert agents and agents[0]
assert "python-urllib" not in str(agents[0]).lower()
@pytest.mark.parametrize(
("path", "expected"),
[
(".gitea/workflows/build-ns7zip.yml", "build-ns7zip.yml"),
# Gitea's <file>@<ref> form: splitting on "/" alone yields "main".
("lint.yml@refs/heads/main", "lint.yml"),
(".gitea/workflows/ci.yml@refs/heads/feat/x", "ci.yml"),
("", ""),
],
)
def test_workflow_name_drops_the_ref(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, path: str, expected: str
) -> None:
_install_urlopen(monkeypatch, _admin_ok([_run(path=path)]))
view = list_executions(_make_config(tmp_path), [], token="tok", now=_NOW)
assert view.runs[0].workflow == expected
def test_zero_attempt_reads_as_one(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Gitea sends ``run_attempt: 0`` for a first run; job ids start at 1."""
_install_urlopen(monkeypatch, _admin_ok([_run(run_id=384, attempt=0)]))
vm = _vm("Clone_384-1_20260725_115000_linux")
view = list_executions(_make_config(tmp_path), [vm], token="tok", now=_NOW)
assert view.runs[0].attempt == 1
assert view.runs[0].vms == [vm]
def test_http_error_carries_the_server_message(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Gitea names the missing scope in its body -- surface it, don't guess."""
body = json.dumps(
{"message": "token does not have at least one of required scope(s)"}
)
_install_urlopen(monkeypatch, lambda _url: _http_error(500, body))
view = list_executions(_make_config(tmp_path), [], token="tok", now=_NOW)
assert view.error is not None
assert "HTTP 500" in view.error
assert "required scope(s)" in view.error
def test_non_json_error_body_is_included_verbatim(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A Cloudflare interception is HTML, not Gitea JSON -- still show it."""
_install_urlopen(monkeypatch, lambda _url: _http_error(500, "<html>error code: 1010</html>"))
view = list_executions(_make_config(tmp_path), [], token="tok", now=_NOW)
assert view.error is not None
assert "1010" in view.error
def test_empty_error_body_leaves_a_bare_status(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
_install_urlopen(monkeypatch, lambda _url: _http_error(500))
view = list_executions(_make_config(tmp_path), [], token="tok", now=_NOW)
assert view.error == "Gitea API returned HTTP 500."
def test_limit_argument_overrides_config(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None: