From 4aedd937d7b6eaa3d94a3ca9efe097af11d75251 Mon Sep 17 00:00:00 2001 From: Simone Date: Sat, 25 Jul 2026 15:03:25 +0200 Subject: [PATCH] fix(web): name the scope problem behind a Gitea 403 Both listing endpoints answering 403 means Gitea authenticated the token and then rejected its scope -- exactly what an OIDC login token with only `openid profile` gets. The card showed a bare "Gitea API returned HTTP 403", which sends the operator hunting the wrong thing (admin rights, allowlist, URL). Say what it is and name both fixes: add an API read scope to `[web.oidc] scopes`, or configure the keyring PAT fallback. A 403 on the admin listing alone still falls through to the user listing as before, and a 404 there keeps its plain message. --- src/ci_orchestrator/operations/executions.py | 14 +++++++++++++ tests/python/test_operations_executions.py | 21 +++++++++++++++++++- 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/src/ci_orchestrator/operations/executions.py b/src/ci_orchestrator/operations/executions.py index 56186f5..bc729c8 100644 --- a/src/ci_orchestrator/operations/executions.py +++ b/src/ci_orchestrator/operations/executions.py @@ -54,6 +54,15 @@ _RUNNING = "running" _QUEUED = "queued" _UNKNOWN = "unknown" +# Shown when Gitea accepts the token but refuses both listing endpoints: the +# token carries no API read scope. Names both fixes, since which one applies +# depends on whether the operator can add scopes to the OAuth2 app. +_SCOPE_HINT = ( + "Gitea accepted the token but refused it (HTTP 403): it carries no API " + "read scope. Add read:repository (or read:admin) to [web.oidc] scopes and " + "log in again, or configure a keyring PAT via [web] gitea_token_cred." +) + # Gitea's ``status`` field, for runs that have not reached a conclusion yet. _STATUS_MAP = { "in_progress": _RUNNING, @@ -250,6 +259,11 @@ def _fetch_runs( if exc.code in (403, 404) and source == "admin": # Token is not admin-scoped: try the per-user listing. continue + if exc.code == 403: + # Both endpoints refused an otherwise-valid token: Gitea + # authenticated it and then rejected its scope. Say so -- + # "HTTP 403" alone sends the operator hunting the wrong thing. + last_error = _SCOPE_HINT break except (urllib.error.URLError, TimeoutError, OSError) as exc: last_error = f"Gitea unreachable: {exc}." diff --git a/tests/python/test_operations_executions.py b/tests/python/test_operations_executions.py index 0bdf7cd..d90852e 100644 --- a/tests/python/test_operations_executions.py +++ b/tests/python/test_operations_executions.py @@ -330,9 +330,10 @@ def test_admin_denied_falls_back_to_user_endpoint( ] -def test_both_endpoints_denied_degrades( +def test_both_endpoints_denied_names_the_scope_problem( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: + """403 on both listings means "valid token, no API scope" -- say that.""" _install_urlopen(monkeypatch, lambda _url: _http_error(403)) view = list_executions(_make_config(tmp_path), [], token="tok", now=_NOW) @@ -341,6 +342,24 @@ def test_both_endpoints_denied_degrades( assert view.source == "none" assert view.error is not None assert "403" in view.error + assert "read:repository" in view.error + assert "gitea_token_cred" in view.error + assert view.auth_failed is False # a refresh would not fix a scope problem + + +def test_user_endpoint_404_keeps_the_plain_http_error( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Only a 403 on the *user* listing implies a scope problem, not a 404.""" + + def handler(url: str) -> Any: + return _http_error(403 if "/admin/" in url else 404) + + _install_urlopen(monkeypatch, handler) + + view = list_executions(_make_config(tmp_path), [], token="tok", now=_NOW) + + assert view.error == "Gitea API returned HTTP 404." # ── degraded paths ──────────────────────────────────────────────────────────