Files
Simone 39abfcef80 fix(web): Run column shows run_id, the real global progressive
run.number only counts within its own repo/workflow -- meaningless once
the admin endpoint mixes every repo into one list, and already not what
the row order is sorted by. run_id is Gitea's actual instance-wide,
strictly-increasing id, so the displayed number now agrees with "most
recent globally". The link still goes to Gitea's own per-repo run page
(html_url); only the label text changed.
2026-07-26 01:20:44 +02:00

1028 lines
34 KiB
Python

"""Tests for the Executions card: route, template, and token handling.
Three separate contracts live here:
1. ``GET /executions`` renders run rows with their VMs, and degrades to a
notice + the plain VM rows whenever Gitea cannot be reached.
2. The Gitea access token from an OIDC login is kept **server-side only** --
never in the signed-but-unencrypted session cookie (SECURITY-CRITICAL,
see ``web/auth.py``'s module docstring).
3. A ``401`` from Gitea triggers exactly one refresh attempt.
No network, no keyring, no real ``vmrun``: the backend is injected, the
keyring is monkeypatched, and Gitea is either stubbed at
``operations.executions.list_executions`` (template-level tests) or at
``urlopen`` (one end-to-end pass).
"""
from __future__ import annotations
import json
import shutil
import urllib.error
from collections import namedtuple
from datetime import UTC, datetime
from io import BytesIO
from pathlib import Path
from typing import Any
import pytest
from fastapi.responses import RedirectResponse
from fastapi.testclient import TestClient
import ci_orchestrator.operations.executions as ex
import ci_orchestrator.web.app as app_module
from ci_orchestrator.backends.protocol import VmHandle
from ci_orchestrator.config import (
BackendConfig,
CargoCacheConfig,
Config,
Paths,
WebConfig,
WebOidcConfig,
)
from ci_orchestrator.credentials import Credential, KeyringCredentialStore
from ci_orchestrator.operations.executions import Execution, ExecutionsView
from ci_orchestrator.operations.vms import LiveVm
from ci_orchestrator.web.app import create_app, get_backend
from ci_orchestrator.web.auth import gitea_token_for, store_gitea_token
_SESSION_CRED_NAME = "TestWebSessionSecret"
_OIDC_CRED_NAME = "TestWebOidcClientSecret"
_PAT_CRED_NAME = "TestGiteaApiToken"
_FAKE_PAT = "pat-do-not-leak-123"
# ── fixtures / fakes ────────────────────────────────────────────────────────
def _install_fake_keyring(monkeypatch: pytest.MonkeyPatch) -> None:
secrets = {
_SESSION_CRED_NAME: "sess-secret",
_OIDC_CRED_NAME: "oidc-client-secret",
_PAT_CRED_NAME: _FAKE_PAT,
}
def fake_get(self: KeyringCredentialStore, target: str) -> Credential:
if target not in secrets:
raise KeyError(target)
return Credential(username=target, password=secrets[target])
monkeypatch.setattr(KeyringCredentialStore, "get", fake_get)
def _quiet_host(monkeypatch: pytest.MonkeyPatch) -> None:
_DiskUsage = namedtuple("_DiskUsage", ["total", "used", "free"])
gb = 1024**3
monkeypatch.setattr(
shutil, "disk_usage", lambda _p: _DiskUsage(total=100 * gb, used=40 * gb, free=60 * gb)
)
monkeypatch.setattr(shutil, "which", lambda _name: None)
def _make_config(
root: Path,
*,
oidc: bool = False,
gitea_url: str | None = "https://gitea.example.lan",
token_cred: str | None = None,
) -> Config:
oidc_config = None
if oidc:
oidc_config = WebOidcConfig(
issuer="https://gitea.example.lan",
client_id="ci-web",
redirect_url="http://127.0.0.1:8800/auth/callback",
client_secret_cred=_OIDC_CRED_NAME,
scopes=["openid", "profile", "read:repository"],
)
return Config(
paths=Paths(
root=root,
templates=root / "templates",
build_vms=root / "build-vms",
artifacts=root / "artifacts",
keys=root / "keys",
),
backend=BackendConfig(),
cargo_cache=CargoCacheConfig(
enabled=True, root=root / "cache" / "cargo", retention_days=30
),
web=WebConfig(
enabled=True,
allowed_users=["Simone"],
session_secret_cred=_SESSION_CRED_NAME,
oidc=oidc_config,
gitea_url=gitea_url,
gitea_token_cred=token_cred,
),
)
class _FakeBackend:
"""Stub ``VmBackend`` -- proves no real ``vmrun`` is ever invoked."""
def __init__(self, handles: list[VmHandle]) -> None:
self._handles = handles
def list_running(self) -> list[VmHandle]:
return list(self._handles)
def get_ip(self, _handle: VmHandle, timeout: float = 0.0) -> str | None:
return "192.168.79.201"
def _make_clone_vmx(build_vms: Path, name: str) -> Path:
vmx = build_vms / name / f"{name}.vmx"
vmx.parent.mkdir(parents=True, exist_ok=True)
vmx.write_text('guestOS = "ubuntu-64"\n', encoding="utf-8")
return vmx
def _vm(name: str) -> LiveVm:
return LiveVm(
name=name,
vmx=f"/var/lib/ci/build-vms/{name}/{name}.vmx",
ip="192.168.79.201",
guest_os="ubuntu-64",
uptime_sec=180,
is_orphan=False,
)
def _execution(**overrides: Any) -> Execution:
base: dict[str, Any] = {
"run_id": 412,
"number": 12,
"attempt": 1,
"workflow": "build-ns7zip.yml",
"repo": "simone/local-ci-cd-system",
"branch": "main",
"commit": "a1b2c3d4e5f60718293a4b5c6d7e8f9012345678",
"event": "push",
"actor": "Simone",
"status": "running",
"started": datetime(2026, 7, 25, 11, 50, tzinfo=UTC),
"completed": None,
"duration_sec": 600,
"html_url": "https://gitea.example.lan/simone/local-ci-cd-system/actions/runs/12",
"vms": [],
}
base.update(overrides)
return Execution(**base)
def _stub_view(monkeypatch: pytest.MonkeyPatch, view: ExecutionsView) -> list[str | None]:
"""Replace the operations call; return the log of tokens it was given."""
tokens: list[str | None] = []
def fake_list_executions(
_config: Config, _vms: list[LiveVm], *, token: str | None, **_kw: Any
) -> ExecutionsView:
tokens.append(token)
return view
monkeypatch.setattr(app_module, "list_executions", fake_list_executions)
return tokens
@pytest.fixture(autouse=True)
def _clear_executions_cache() -> Any:
ex.clear_cache()
yield
ex.clear_cache()
# ── 1. rendering ────────────────────────────────────────────────────────────
def test_card_renders_run_row_with_its_vm(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
vm = _vm("Clone_412-1_20260725_115000_linux")
_stub_view(
monkeypatch,
ExecutionsView(
runs=[_execution(vms=[vm])], unmatched_vms=[], source="admin", error=None
),
)
app = create_app(_make_config(tmp_path))
app.dependency_overrides[get_backend] = lambda: _FakeBackend([])
client = TestClient(app)
resp = client.get("/executions")
assert resp.status_code == 200
assert "#412" in resp.text
assert "build-ns7zip.yml" in resp.text
assert "simone/local-ci-cd-system" in resp.text
assert "a1b2c3d" in resp.text # commit shortened to 7 chars
assert "badge-info" in resp.text # running badge
assert ">running<" in resp.text # never colour-only
assert vm.name in resp.text
assert "Remove" in resp.text
def test_attempt_shown_only_when_greater_than_one(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
_stub_view(
monkeypatch,
ExecutionsView(
runs=[_execution(attempt=2)], unmatched_vms=[], source="admin", error=None
),
)
app = create_app(_make_config(tmp_path))
app.dependency_overrides[get_backend] = lambda: _FakeBackend([])
client = TestClient(app)
resp = client.get("/executions")
assert "#412.2" in resp.text
@pytest.mark.parametrize(
("status", "badge"),
[
("success", "badge-success"),
("failure", "badge-danger"),
("queued", "badge-muted"),
("cancelled", "badge-muted"),
("unknown", "badge-muted"),
],
)
def test_status_badges(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, status: str, badge: str
) -> None:
_stub_view(
monkeypatch,
ExecutionsView(
runs=[_execution(status=status)], unmatched_vms=[], source="admin", error=None
),
)
app = create_app(_make_config(tmp_path))
app.dependency_overrides[get_backend] = lambda: _FakeBackend([])
client = TestClient(app)
resp = client.get("/executions")
assert badge in resp.text
assert f">{status}<" in resp.text
@pytest.mark.parametrize(
("status", "icon"),
[
("success", "i-check"),
("failure", "i-x"),
],
)
def test_status_badge_carries_a_check_or_x_icon(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, status: str, icon: str
) -> None:
"""A pass/fail outcome should read without needing the badge colour."""
_stub_view(
monkeypatch,
ExecutionsView(
runs=[_execution(status=status)], unmatched_vms=[], source="admin", error=None
),
)
app = create_app(_make_config(tmp_path))
app.dependency_overrides[get_backend] = lambda: _FakeBackend([])
client = TestClient(app)
resp = client.get("/executions")
assert f'href="#{icon}"' in resp.text
def test_status_badge_carries_a_play_icon_for_running(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""In progress is its own state, not a verdict -- but still not bare text."""
_stub_view(
monkeypatch,
ExecutionsView(
runs=[_execution(status="running")], unmatched_vms=[], source="admin", error=None
),
)
app = create_app(_make_config(tmp_path))
app.dependency_overrides[get_backend] = lambda: _FakeBackend([])
client = TestClient(app)
resp = client.get("/executions")
# The card's own header icon is also #i-play -- count, don't just look
# for presence, or this assertion is trivially true either way.
assert resp.text.count('href="#i-play"') == 2
@pytest.mark.parametrize("status", ["queued", "cancelled", "unknown"])
def test_status_badge_has_no_icon_for_non_terminal_states(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, status: str
) -> None:
"""queued/cancelled/unknown are neither a verdict nor in progress -- plain text."""
_stub_view(
monkeypatch,
ExecutionsView(
runs=[_execution(status=status)], unmatched_vms=[], source="admin", error=None
),
)
app = create_app(_make_config(tmp_path))
app.dependency_overrides[get_backend] = lambda: _FakeBackend([])
client = TestClient(app)
resp = client.get("/executions")
assert '<td><span class="badge badge-' in resp.text
assert "#i-check" not in resp.text
assert "#i-x" not in resp.text
assert "#i-x" not in resp.text
def test_started_cell_is_a_click_to_toggle_button(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Both the relative and absolute renderings are pre-rendered into the
button's data attributes -- app.js swaps textContent/title on click, no
round trip. Server-side, this test can only pin the markup the toggle
depends on, not the click itself.
The id must be stable across the card's own 5s poll (run_id/attempt, not
anything generated fresh per render) -- app.js keys its client-side
"which format is showing" memory off it to survive the next poll's
server-rendered reset back to relative.
"""
_stub_view(
monkeypatch,
ExecutionsView(
runs=[_execution(run_id=412, attempt=1, started="2026-07-25T11:50:00Z")],
unmatched_vms=[],
source="admin",
error=None,
),
)
app = create_app(_make_config(tmp_path))
app.dependency_overrides[get_backend] = lambda: _FakeBackend([])
client = TestClient(app)
resp = client.get("/executions")
assert 'id="started-412-1"' in resp.text
assert 'class="time-toggle text-dim"' in resp.text
assert 'data-showing="relative"' in resp.text
assert 'data-relative="' in resp.text
assert 'data-absolute="' in resp.text
def test_no_cell_stacks_two_facts_on_separate_lines(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Run/Repo, Branch/Commit, Trigger/Actor, Duration/Started and VM/IP each
get their own column now -- no cell relies on a <br> to show two facts."""
vm = _vm("Clone_412-1_20260725_115000_linux")
_stub_view(
monkeypatch,
ExecutionsView(
runs=[_execution(vms=[vm])], unmatched_vms=[], source="admin", error=None
),
)
app = create_app(_make_config(tmp_path))
app.dependency_overrides[get_backend] = lambda: _FakeBackend([])
client = TestClient(app)
resp = client.get("/executions")
assert resp.status_code == 200
assert "<br>" not in resp.text
for header in ("Repo", "Trigger", "Actor", "Started", "IP / Uptime"):
assert f"<th>{header}</th>" in resp.text
assert "Simone" in resp.text # actor, now its own column
assert vm.ip in resp.text
def test_empty_state_when_no_runs_and_no_vms(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
_stub_view(
monkeypatch,
ExecutionsView(runs=[], unmatched_vms=[], source="admin", error=None),
)
app = create_app(_make_config(tmp_path))
app.dependency_overrides[get_backend] = lambda: _FakeBackend([])
client = TestClient(app)
resp = client.get("/executions")
assert "No recent executions" in resp.text
assert "<table>" not in resp.text
def test_unmatched_vm_keeps_its_row_and_remove_button(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A powered-on VM with no listed run must not vanish from the dashboard."""
vm = _vm("Clone_370-1_20260722_101500_linux")
_stub_view(
monkeypatch,
ExecutionsView(runs=[], unmatched_vms=[vm], source="admin", error=None),
)
app = create_app(_make_config(tmp_path))
app.dependency_overrides[get_backend] = lambda: _FakeBackend([])
client = TestClient(app)
resp = client.get("/executions")
assert vm.name in resp.text
assert "370-1" in resp.text # job id parsed out of the clone name
assert "Remove" in resp.text
# Same word, same column as a run's "running" status pill -- same icon.
# The card's own header icon is also #i-play, hence count == 2 not 1.
assert resp.text.count('href="#i-play"') == 2
def test_end_to_end_against_a_stubbed_gitea(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""No stubbing of the operations layer: only ``urlopen`` is faked."""
build_vms = tmp_path / "build-vms"
vmx = _make_clone_vmx(build_vms, "Clone_412-1_20260725_115000_abc")
payload = {
"workflow_runs": [
{
"id": 412,
"run_number": 12,
"run_attempt": 1,
"status": "in_progress",
"head_branch": "main",
"path": ".gitea/workflows/build-ns7zip.yml",
"repository": {"full_name": "simone/local-ci-cd-system"},
"html_url": "https://gitea.example.lan/run/12",
}
]
}
class _Resp:
def __enter__(self) -> BytesIO:
return BytesIO(json.dumps(payload).encode("utf-8"))
def __exit__(self, *_exc: object) -> None:
return None
monkeypatch.setattr(ex.urllib.request, "urlopen", lambda _r, timeout=None: _Resp())
_install_fake_keyring(monkeypatch)
app = create_app(_make_config(tmp_path, token_cred=_PAT_CRED_NAME))
app.dependency_overrides[get_backend] = lambda: _FakeBackend(
[VmHandle(identifier=str(vmx))]
)
client = TestClient(app)
resp = client.get("/executions")
assert resp.status_code == 200
assert "#412" in resp.text
assert "build-ns7zip.yml" in resp.text
# The clone is attached to its run, so it is not listed as "no run".
assert "no run" not in resp.text
# ── row-count selector ──────────────────────────────────────────────────────
def _stub_limits(monkeypatch: pytest.MonkeyPatch) -> list[int | None]:
"""Replace the operations call; return the log of limits it was given."""
limits: list[int | None] = []
def fake_list_executions(
_config: Config,
_vms: list[LiveVm],
*,
token: str | None,
limit: int | None = None,
**_kw: Any,
) -> ExecutionsView:
limits.append(limit)
return ExecutionsView(runs=[], unmatched_vms=[], source="admin", error=None)
monkeypatch.setattr(app_module, "list_executions", fake_list_executions)
return limits
def test_default_limit_is_ten(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
limits = _stub_limits(monkeypatch)
_install_fake_keyring(monkeypatch)
app = create_app(_make_config(tmp_path, token_cred=_PAT_CRED_NAME))
app.dependency_overrides[get_backend] = lambda: _FakeBackend([])
client = TestClient(app)
resp = client.get("/executions")
assert limits == [10]
assert '<option value="10" selected>10</option>' in resp.text
# The selector lives inside the card's header bar, not in a strip below it.
head = resp.text.split("</h2>", 1)[0]
assert "head-select" in head
assert "<label" not in resp.text
def test_selector_offers_the_choices_and_marks_the_current_one(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
limits = _stub_limits(monkeypatch)
_install_fake_keyring(monkeypatch)
app = create_app(_make_config(tmp_path, token_cred=_PAT_CRED_NAME))
app.dependency_overrides[get_backend] = lambda: _FakeBackend([])
client = TestClient(app)
resp = client.get("/executions?limit=50")
assert limits == [50]
for choice in (10, 25, 50, 100):
assert f'value="{choice}"' in resp.text
assert '<option value="50" selected>50</option>' in resp.text
# The re-rendered card polls with the chosen limit, so it survives refreshes.
assert 'hx-get="/executions?limit=50"' in resp.text
def test_config_default_outside_the_choices_still_appears(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
_stub_limits(monkeypatch)
_install_fake_keyring(monkeypatch)
config = _make_config(tmp_path, token_cred=_PAT_CRED_NAME)
object.__setattr__(config.web, "executions_limit", 7)
app = create_app(config)
app.dependency_overrides[get_backend] = lambda: _FakeBackend([])
client = TestClient(app)
resp = client.get("/executions")
assert '<option value="7" selected>7</option>' in resp.text
@pytest.mark.parametrize(
("query", "expected"),
[
("?limit=25", 25),
("?limit=7", 7), # not offered, but honoured
("?limit=0", 1), # clamped up
("?limit=9999", 100), # clamped down
("?limit=-5", 1),
("?limit=abc", 10), # unparseable -> config default
("?limit=", 10),
("", 10),
],
)
def test_limit_query_is_parsed_permissively(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, query: str, expected: int
) -> None:
"""A stale or hand-edited polling URL must never fail the card."""
limits = _stub_limits(monkeypatch)
_install_fake_keyring(monkeypatch)
app = create_app(_make_config(tmp_path, token_cred=_PAT_CRED_NAME))
app.dependency_overrides[get_backend] = lambda: _FakeBackend([])
client = TestClient(app)
resp = client.get(f"/executions{query}")
assert resp.status_code == 200
assert limits == [expected]
def test_dashboard_renders_the_selector_at_the_config_default(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
_stub_limits(monkeypatch)
_install_fake_keyring(monkeypatch)
_quiet_host(monkeypatch)
app = create_app(_make_config(tmp_path, token_cred=_PAT_CRED_NAME))
app.dependency_overrides[get_backend] = lambda: _FakeBackend([])
client = TestClient(app)
resp = client.get("/")
assert resp.status_code == 200
assert 'hx-get="/executions?limit=10"' in resp.text
# ── 2. degraded states ──────────────────────────────────────────────────────
def test_no_token_shows_notice_and_still_lists_vms(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
build_vms = tmp_path / "build-vms"
vmx = _make_clone_vmx(build_vms, "Clone_412-1_20260725_115000_abc")
app = create_app(_make_config(tmp_path)) # no token_cred, no login
app.dependency_overrides[get_backend] = lambda: _FakeBackend(
[VmHandle(identifier=str(vmx))]
)
client = TestClient(app)
resp = client.get("/executions")
assert resp.status_code == 200
assert "token not configured" in resp.text
assert "Clone_412-1_20260725_115000_abc" in resp.text
assert "Remove" in resp.text
def test_gitea_unreachable_degrades(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(
ex.urllib.request,
"urlopen",
lambda _r, timeout=None: (_ for _ in ()).throw(urllib.error.URLError("down")),
)
_install_fake_keyring(monkeypatch)
app = create_app(_make_config(tmp_path, token_cred=_PAT_CRED_NAME))
app.dependency_overrides[get_backend] = lambda: _FakeBackend([])
client = TestClient(app)
resp = client.get("/executions")
assert resp.status_code == 200
assert "unreachable" in resp.text
def test_auth_failure_offers_a_relogin_link(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
_stub_view(
monkeypatch,
ExecutionsView(
runs=[],
unmatched_vms=[],
source="none",
error="Gitea API returned HTTP 401.",
auth_failed=True,
),
)
app = create_app(_make_config(tmp_path))
app.dependency_overrides[get_backend] = lambda: _FakeBackend([])
client = TestClient(app)
resp = client.get("/executions")
assert 'href="/login"' in resp.text
def test_operations_layer_blowing_up_does_not_500(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
def boom(*_args: Any, **_kwargs: Any) -> ExecutionsView:
raise RuntimeError("unexpected")
monkeypatch.setattr(app_module, "list_executions", boom)
app = create_app(_make_config(tmp_path))
app.dependency_overrides[get_backend] = lambda: _FakeBackend([])
client = TestClient(app)
resp = client.get("/executions")
assert resp.status_code == 200
assert "Executions unavailable" in resp.text
def test_missing_pat_keyring_entry_degrades(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A configured-but-absent keyring PAT must not 500 the card."""
_install_fake_keyring(monkeypatch)
app = create_app(_make_config(tmp_path, token_cred="NoSuchCred"))
app.dependency_overrides[get_backend] = lambda: _FakeBackend([])
client = TestClient(app)
resp = client.get("/executions")
assert resp.status_code == 200
assert "token not configured" in resp.text
def test_pat_is_used_when_configured(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
tokens = _stub_view(
monkeypatch,
ExecutionsView(runs=[], unmatched_vms=[], source="admin", error=None),
)
_install_fake_keyring(monkeypatch)
app = create_app(_make_config(tmp_path, token_cred=_PAT_CRED_NAME))
app.dependency_overrides[get_backend] = lambda: _FakeBackend([])
client = TestClient(app)
client.get("/executions")
assert tokens == [_FAKE_PAT]
def test_old_vms_route_is_gone(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
app = create_app(_make_config(tmp_path))
app.dependency_overrides[get_backend] = lambda: _FakeBackend([])
client = TestClient(app)
assert client.get("/vms").status_code == 404
# ── 3. token handling (SECURITY-CRITICAL) ──────────────────────────────────
class _StubOAuthClient:
"""Fake Authlib ``gitea`` client -- no network, chosen token/claims."""
def __init__(self, token: dict[str, Any], refreshed: dict[str, Any] | None = None) -> None:
self.token = token
self.refreshed = refreshed
self.refresh_calls: list[dict[str, Any]] = []
async def authorize_redirect(
self, request: Any, redirect_uri: str | None = None, **kwargs: Any
) -> RedirectResponse:
return RedirectResponse("https://gitea.example.lan/authorize", status_code=302)
async def authorize_access_token(self, request: Any, **kwargs: Any) -> dict[str, Any]:
return self.token
async def userinfo(self, **kwargs: Any) -> dict[str, Any]:
return {"preferred_username": "Simone"}
async def fetch_access_token(self, **kwargs: Any) -> dict[str, Any]:
self.refresh_calls.append(kwargs)
if self.refreshed is None:
raise RuntimeError("refresh refused")
return self.refreshed
def _login(app: Any, client: TestClient) -> None:
"""Drive the OIDC callback with the stubbed client."""
resp = client.get("/auth/callback?code=x&state=y", follow_redirects=False)
assert resp.status_code == 303
def test_login_keeps_token_server_side_and_out_of_the_cookie(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""SECURITY: the session cookie is signed, NOT encrypted -- no token in it."""
_install_fake_keyring(monkeypatch)
secret = "gitea-access-token-do-not-leak"
app = create_app(_make_config(tmp_path, oidc=True))
app.state.oauth.gitea = _StubOAuthClient(
{"access_token": secret, "refresh_token": "r3fr3sh", "expires_at": 9999999999}
)
app.dependency_overrides[get_backend] = lambda: _FakeBackend([])
client = TestClient(app)
_login(app, client)
stored = gitea_token_for(app, "Simone")
assert stored is not None
assert stored.access_token == secret
assert stored.refresh_token == "r3fr3sh"
# Nothing token-shaped anywhere in the cookie jar.
cookies = "".join(f"{name}={value}" for name, value in client.cookies.items())
assert secret not in cookies
assert "r3fr3sh" not in cookies
def test_logout_drops_the_stored_token(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
_install_fake_keyring(monkeypatch)
app = create_app(_make_config(tmp_path, oidc=True))
app.state.oauth.gitea = _StubOAuthClient({"access_token": "tok"})
app.dependency_overrides[get_backend] = lambda: _FakeBackend([])
client = TestClient(app)
_login(app, client)
assert gitea_token_for(app, "Simone") is not None
client.get("/logout", follow_redirects=False)
assert gitea_token_for(app, "Simone") is None
def test_login_token_is_used_for_the_card(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
_install_fake_keyring(monkeypatch)
tokens = _stub_view(
monkeypatch,
ExecutionsView(runs=[], unmatched_vms=[], source="admin", error=None),
)
_quiet_host(monkeypatch)
app = create_app(_make_config(tmp_path, oidc=True, token_cred=_PAT_CRED_NAME))
app.state.oauth.gitea = _StubOAuthClient({"access_token": "login-token"})
app.dependency_overrides[get_backend] = lambda: _FakeBackend([])
client = TestClient(app)
_login(app, client)
client.get("/executions")
# The login token wins over the configured PAT fallback.
assert tokens == ["login-token"]
def test_scope_refused_login_token_falls_back_to_the_pat(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A logged-in operator whose token lacks API scope must still get runs."""
_install_fake_keyring(monkeypatch)
app = create_app(_make_config(tmp_path, oidc=True, token_cred=_PAT_CRED_NAME))
app.state.oauth.gitea = _StubOAuthClient({"access_token": "scope-less"})
app.dependency_overrides[get_backend] = lambda: _FakeBackend([])
client = TestClient(app)
_login(app, client)
seen: list[str | None] = []
def fake_list_executions(
_config: Config, _vms: list[LiveVm], *, token: str | None, **_kw: Any
) -> ExecutionsView:
seen.append(token)
if token == "scope-less":
return ExecutionsView(
runs=[],
unmatched_vms=[],
source="none",
error="no API read scope",
refused=True,
)
return ExecutionsView(
runs=[_execution()], unmatched_vms=[], source="admin", error=None
)
monkeypatch.setattr(app_module, "list_executions", fake_list_executions)
resp = client.get("/executions")
assert seen == ["scope-less", _FAKE_PAT]
assert "#412" in resp.text
def test_no_pat_fallback_when_none_configured(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Without a PAT there is nothing to retry with -- one call, then degrade."""
_install_fake_keyring(monkeypatch)
app = create_app(_make_config(tmp_path, oidc=True))
app.state.oauth.gitea = _StubOAuthClient({"access_token": "scope-less"})
app.dependency_overrides[get_backend] = lambda: _FakeBackend([])
client = TestClient(app)
_login(app, client)
tokens = _stub_view(
monkeypatch,
ExecutionsView(
runs=[],
unmatched_vms=[],
source="none",
error="no API read scope",
refused=True,
),
)
resp = client.get("/executions")
assert tokens == ["scope-less"]
assert "no API read scope" in resp.text
def test_pat_is_not_retried_against_itself(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A refused PAT (no login token in play) must not be sent twice."""
_install_fake_keyring(monkeypatch)
tokens = _stub_view(
monkeypatch,
ExecutionsView(
runs=[],
unmatched_vms=[],
source="none",
error="no API read scope",
refused=True,
),
)
app = create_app(_make_config(tmp_path, token_cred=_PAT_CRED_NAME))
app.dependency_overrides[get_backend] = lambda: _FakeBackend([])
client = TestClient(app)
client.get("/executions")
assert tokens == [_FAKE_PAT]
def test_401_triggers_one_refresh_then_retries(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
_install_fake_keyring(monkeypatch)
app = create_app(_make_config(tmp_path, oidc=True))
stub = _StubOAuthClient(
{"access_token": "stale", "refresh_token": "r3fr3sh"},
refreshed={"access_token": "fresh"},
)
app.state.oauth.gitea = stub
app.dependency_overrides[get_backend] = lambda: _FakeBackend([])
client = TestClient(app)
_login(app, client)
seen: list[str | None] = []
def fake_list_executions(
_config: Config, _vms: list[LiveVm], *, token: str | None, **_kw: Any
) -> ExecutionsView:
seen.append(token)
if token == "stale":
return ExecutionsView(
runs=[],
unmatched_vms=[],
source="none",
error="Gitea API returned HTTP 401.",
auth_failed=True,
)
return ExecutionsView(
runs=[_execution()], unmatched_vms=[], source="admin", error=None
)
monkeypatch.setattr(app_module, "list_executions", fake_list_executions)
resp = client.get("/executions")
assert seen == ["stale", "fresh"]
assert len(stub.refresh_calls) == 1
assert "#412" in resp.text
assert gitea_token_for(app, "Simone") is not None
def test_failed_refresh_drops_the_token_and_degrades(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
_install_fake_keyring(monkeypatch)
app = create_app(_make_config(tmp_path, oidc=True))
app.state.oauth.gitea = _StubOAuthClient(
{"access_token": "stale", "refresh_token": "r3fr3sh"}, refreshed=None
)
app.dependency_overrides[get_backend] = lambda: _FakeBackend([])
client = TestClient(app)
_login(app, client)
_stub_view(
monkeypatch,
ExecutionsView(
runs=[],
unmatched_vms=[],
source="none",
error="Gitea API returned HTTP 401.",
auth_failed=True,
),
)
resp = client.get("/executions")
assert resp.status_code == 200
assert 'href="/login"' in resp.text
assert gitea_token_for(app, "Simone") is None
def test_expired_token_is_refreshed_before_use(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
_install_fake_keyring(monkeypatch)
app = create_app(_make_config(tmp_path, oidc=True))
stub = _StubOAuthClient(
{"access_token": "old", "refresh_token": "r3fr3sh", "expires_at": 1},
refreshed={"access_token": "renewed"},
)
app.state.oauth.gitea = stub
app.dependency_overrides[get_backend] = lambda: _FakeBackend([])
client = TestClient(app)
_login(app, client)
tokens = _stub_view(
monkeypatch,
ExecutionsView(runs=[], unmatched_vms=[], source="admin", error=None),
)
client.get("/executions")
assert tokens == ["renewed"]
def test_token_without_access_token_is_not_stored(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A login that yields no usable token still logs in; the card degrades."""
_install_fake_keyring(monkeypatch)
app = create_app(_make_config(tmp_path, oidc=True))
app.state.oauth.gitea = _StubOAuthClient({"userinfo": {"preferred_username": "Simone"}})
app.dependency_overrides[get_backend] = lambda: _FakeBackend([])
client = TestClient(app)
_login(app, client)
assert gitea_token_for(app, "Simone") is None
def test_store_and_lookup_helpers_are_per_username(tmp_path: Path) -> None:
app = create_app(_make_config(tmp_path))
store_gitea_token(app, "a", {"access_token": "tok-a"})
store_gitea_token(app, "b", {"access_token": "tok-b"})
assert gitea_token_for(app, "a") is not None
assert gitea_token_for(app, "b") is not None
assert gitea_token_for(app, "a").access_token == "tok-a" # type: ignore[union-attr]
assert gitea_token_for(app, None) is None
assert gitea_token_for(app, "nobody") is None