4b6817381b
Benchmark image — build + push to ECR (any adapter) / build + push (push) Waiting to run
CI / quality (ubuntu-latest) (push) Waiting to run
CI / test (tools-runtime) (push) Waiting to run
CI / test (e2e-general) (push) Waiting to run
CI / test (cli-runtime) (push) Waiting to run
CI / test (e2e-provider-and-openclaw) (push) Waiting to run
CI / test (integrations-and-misc) (push) Waiting to run
CI / coverage-report (push) Blocked by required conditions
CI / test-kubernetes (push) Waiting to run
CI / should-run-thorough (push) Waiting to run
CI / test-thorough (cloudwatch-demo) (push) Blocked by required conditions
CI / test-thorough (flink-ecs) (push) Blocked by required conditions
CI / test-thorough (upstream-lambda) (push) Blocked by required conditions
CI / test-thorough (prefect-ecs-fargate) (push) Blocked by required conditions
CodeQL / Analyze (python) (push) Waiting to run
Release / build-binaries (zip, opensre.exe, onefile, windows-latest, windows-x64) (push) Blocked by required conditions
Release / publish-release (push) Blocked by required conditions
Release / publish-main-release (push) Blocked by required conditions
Release / prepare (push) Waiting to run
Release / verify (push) Blocked by required conditions
Release / build-python-dist (push) Blocked by required conditions
Release / build-binaries (tar.gz, opensre, onedir, macos-15-intel, darwin-x64) (push) Blocked by required conditions
Release / build-binaries (tar.gz, opensre, onedir, macos-latest, darwin-arm64) (push) Blocked by required conditions
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04, linux-x64) (push) Blocked by required conditions
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04-arm, linux-arm64) (push) Blocked by required conditions
Synthetic Deterministic Tests / Synthetic offline (deterministic) (push) Waiting to run
Interactive Shell Live (PR + post-merge) / turn-checks (no-LLM) (push) Waiting to run
Interactive Shell Live (PR + post-merge) / turn-live shard ${{ matrix.shard_index }} (push) Waiting to run
CI (OpenClaw E2E) / openclaw test (push) Has been cancelled
199 lines
6.3 KiB
Python
199 lines
6.3 KiB
Python
"""Tests for GitHub MCP OAuth device-flow authorization."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import Any
|
|
|
|
import pytest
|
|
|
|
import integrations.github.mcp_oauth as oauth
|
|
|
|
|
|
class _FakeResponse:
|
|
def __init__(self, payload: Any, status: int = 200) -> None:
|
|
self._payload = payload
|
|
self.status_code = status
|
|
|
|
def raise_for_status(self) -> None:
|
|
if self.status_code >= 400:
|
|
raise RuntimeError(f"HTTP {self.status_code}")
|
|
|
|
def json(self) -> Any:
|
|
return self._payload
|
|
|
|
|
|
def _device_code(expires_in: int = 900, interval: int = 5) -> oauth.GitHubDeviceCode:
|
|
return oauth.GitHubDeviceCode(
|
|
device_code="dev-123",
|
|
user_code="WXYZ-1234",
|
|
verification_uri="https://github.com/login/device",
|
|
expires_in=expires_in,
|
|
interval=interval,
|
|
)
|
|
|
|
|
|
# --- client id resolution ---
|
|
|
|
|
|
def test_resolve_client_id_prefers_explicit(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
monkeypatch.setenv("OPENSRE_GITHUB_OAUTH_CLIENT_ID", "env-id")
|
|
assert oauth.resolve_github_oauth_client_id("explicit-id") == "explicit-id"
|
|
|
|
|
|
def test_resolve_client_id_falls_back_to_env(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
monkeypatch.setenv("OPENSRE_GITHUB_OAUTH_CLIENT_ID", "env-id")
|
|
assert oauth.resolve_github_oauth_client_id() == "env-id"
|
|
|
|
|
|
def test_resolve_client_id_empty_when_unset(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
monkeypatch.delenv("OPENSRE_GITHUB_OAUTH_CLIENT_ID", raising=False)
|
|
monkeypatch.setattr(oauth, "DEFAULT_GITHUB_OAUTH_CLIENT_ID", "")
|
|
assert oauth.resolve_github_oauth_client_id() == ""
|
|
|
|
|
|
# --- request_github_device_code ---
|
|
|
|
|
|
def test_request_device_code_parses_payload(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
captured: dict[str, Any] = {}
|
|
|
|
def _fake_post(url: str, **kwargs: Any) -> _FakeResponse:
|
|
captured["url"] = url
|
|
captured["data"] = kwargs.get("data")
|
|
return _FakeResponse(
|
|
{
|
|
"device_code": "dev-123",
|
|
"user_code": "WXYZ-1234",
|
|
"verification_uri": "https://github.com/login/device",
|
|
"expires_in": 899,
|
|
"interval": 7,
|
|
}
|
|
)
|
|
|
|
monkeypatch.setattr(oauth.httpx, "post", _fake_post)
|
|
|
|
code = oauth.request_github_device_code(client_id="cid", scopes=["repo", "read:org"])
|
|
|
|
assert captured["url"] == oauth.GITHUB_DEVICE_CODE_URL
|
|
assert captured["data"] == {"client_id": "cid", "scope": "repo read:org"}
|
|
assert code.device_code == "dev-123"
|
|
assert code.user_code == "WXYZ-1234"
|
|
assert code.interval == 7
|
|
assert code.expires_in == 899
|
|
|
|
|
|
def test_request_device_code_raises_on_disabled_device_flow(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
monkeypatch.setattr(
|
|
oauth.httpx,
|
|
"post",
|
|
lambda *_a, **_k: _FakeResponse({"error": "device_flow_disabled"}),
|
|
)
|
|
with pytest.raises(oauth.GitHubDeviceFlowError, match="Device flow is not enabled"):
|
|
oauth.request_github_device_code(client_id="cid")
|
|
|
|
|
|
# --- poll_github_device_token ---
|
|
|
|
|
|
def test_poll_returns_token_after_pending(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
responses = iter(
|
|
[
|
|
_FakeResponse({"error": "authorization_pending"}),
|
|
_FakeResponse({"access_token": "gho_abc", "token_type": "bearer", "scope": "repo"}),
|
|
]
|
|
)
|
|
monkeypatch.setattr(oauth.httpx, "post", lambda *_a, **_k: next(responses))
|
|
|
|
token = oauth.poll_github_device_token(
|
|
client_id="cid",
|
|
device_code=_device_code(),
|
|
sleep=lambda _s: None,
|
|
monotonic=lambda: 0.0,
|
|
)
|
|
|
|
assert token.access_token == "gho_abc"
|
|
assert token.scope == "repo"
|
|
|
|
|
|
def test_poll_handles_slow_down_then_succeeds(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
slept: list[float] = []
|
|
responses = iter(
|
|
[
|
|
_FakeResponse({"error": "slow_down", "interval": 12}),
|
|
_FakeResponse({"access_token": "gho_xyz"}),
|
|
]
|
|
)
|
|
monkeypatch.setattr(oauth.httpx, "post", lambda *_a, **_k: next(responses))
|
|
|
|
token = oauth.poll_github_device_token(
|
|
client_id="cid",
|
|
device_code=_device_code(interval=5),
|
|
sleep=slept.append,
|
|
monotonic=lambda: 0.0,
|
|
)
|
|
|
|
assert token.access_token == "gho_xyz"
|
|
# First sleep uses the original interval, the second uses the backed-off one.
|
|
assert slept[0] == 5.0
|
|
assert slept[1] == 12.0
|
|
|
|
|
|
def test_poll_raises_on_access_denied(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
monkeypatch.setattr(
|
|
oauth.httpx, "post", lambda *_a, **_k: _FakeResponse({"error": "access_denied"})
|
|
)
|
|
with pytest.raises(oauth.GitHubDeviceFlowError, match="denied in the browser"):
|
|
oauth.poll_github_device_token(
|
|
client_id="cid",
|
|
device_code=_device_code(),
|
|
sleep=lambda _s: None,
|
|
monotonic=lambda: 0.0,
|
|
)
|
|
|
|
|
|
def test_poll_raises_when_expired(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
monkeypatch.setattr(
|
|
oauth.httpx,
|
|
"post",
|
|
lambda *_a, **_k: pytest.fail("should not poll after expiry"),
|
|
)
|
|
with pytest.raises(oauth.GitHubDeviceFlowError, match="expired"):
|
|
oauth.poll_github_device_token(
|
|
client_id="cid",
|
|
device_code=_device_code(expires_in=0),
|
|
sleep=lambda _s: None,
|
|
monotonic=lambda: 100.0,
|
|
)
|
|
|
|
|
|
# --- authorize_github_via_device_flow ---
|
|
|
|
|
|
def test_authorize_raises_without_client_id(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
monkeypatch.delenv("OPENSRE_GITHUB_OAUTH_CLIENT_ID", raising=False)
|
|
monkeypatch.setattr(oauth, "DEFAULT_GITHUB_OAUTH_CLIENT_ID", "")
|
|
with pytest.raises(oauth.GitHubDeviceFlowError, match="No GitHub OAuth client id"):
|
|
oauth.authorize_github_via_device_flow()
|
|
|
|
|
|
def test_authorize_happy_path_invokes_prompt(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
code = _device_code()
|
|
monkeypatch.setattr(oauth, "request_github_device_code", lambda **_k: code)
|
|
monkeypatch.setattr(
|
|
oauth,
|
|
"poll_github_device_token",
|
|
lambda **_k: oauth.GitHubDeviceToken(access_token="gho_final", scope="repo"),
|
|
)
|
|
prompted: list[oauth.GitHubDeviceCode] = []
|
|
|
|
token = oauth.authorize_github_via_device_flow(
|
|
client_id="cid",
|
|
open_browser=False,
|
|
on_prompt=prompted.append,
|
|
)
|
|
|
|
assert token.access_token == "gho_final"
|
|
assert prompted == [code]
|