Files
oh-my-pi/tests/test_proxy_client.py
T
can1357 dc2eb47297 security: added slot_uid-aware git env and workspace ownership handling
- Added optional slot_uid handling to push requests and git operations, validating IDs 1..65535.
- Passed slot-specific subprocess kwargs into git helpers, including safe.directory, HOME, user/group and umask.
- Injected slot-safe repo env helpers to scrub secrets, force git identity, and disable terminal prompts.
- Adjusted workspace and cache permissioning so slot users own workspaces with targeted chmod/chown behavior.
- Added tests for slot UID propagation, safe-directory env, and cross-slot push/retry behavior in unit and e2e suites.
2026-05-16 19:03:56 +02:00

567 lines
20 KiB
Python

"""Coverage for `GitHubProxyClient` + `ProxyGitTransport` against an
ASGI-wrapped proxy app and a hand-rolled `httpx.MockTransport`."""
from __future__ import annotations
import asyncio
import json
import os
import subprocess
from collections.abc import Callable
from pathlib import Path
import httpx
import pytest
from pydantic import SecretStr
from robomp.config import Settings
from robomp.git_ops import HeadDriftError
from robomp.github_client import (
CommentInfo,
GitHubClient,
GitHubError,
IssueInfo,
IssueSummary,
PullRequestInfo,
PullRequestReviewInfo,
ReactionInfo,
RepoInfo,
ReviewCommentInfo,
)
from robomp.proxy.server import create_proxy_app
from robomp.proxy_client import GitHubProxyClient, ProxyGitTransport
from robomp.proxy_hmac import HEADER_SIGNATURE, HEADER_TIMESTAMP, verify
from robomp.sandbox import workspace_key
_HMAC = "test-hmac-key-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
_HMAC_BYTES = _HMAC.encode("utf-8")
_TOKEN = "ghp_test_token_value"
# ---------- shared helpers ----------
def _build_settings(tmp_path: Path) -> Settings:
cfg = Settings.model_construct(
github_token=SecretStr(_TOKEN),
github_webhook_secret=SecretStr("webhook-secret"),
bot_login="robomp-bot",
git_author_email="robomp-bot@example.invalid",
repo_allowlist_raw="octo/widget",
gh_proxy_url=None,
gh_proxy_hmac_key=SecretStr(_HMAC),
gh_proxy_bind_host="0.0.0.0",
gh_proxy_bind_port=8081,
workspace_root=tmp_path / "workspaces",
sqlite_path=tmp_path / "robomp.sqlite",
log_dir=tmp_path / "logs",
)
cfg.ensure_paths()
return cfg
@pytest.fixture
def proxy_settings(tmp_path: Path) -> Settings:
return _build_settings(tmp_path)
def _git(args: list[str], cwd: Path) -> subprocess.CompletedProcess[str]:
return subprocess.run(
["git", *args],
cwd=str(cwd),
check=True,
capture_output=True,
text=True,
env=os.environ
| {
"GIT_AUTHOR_NAME": "t",
"GIT_AUTHOR_EMAIL": "t@t",
"GIT_COMMITTER_NAME": "t",
"GIT_COMMITTER_EMAIL": "t@t",
},
)
@pytest.fixture
def upstream_repo(tmp_path: Path) -> Path:
repo = tmp_path / "upstream.git"
repo.mkdir()
_git(["init", "--initial-branch=main", "--bare", str(repo)], tmp_path)
seed = tmp_path / "seed"
seed.mkdir()
_git(["init", "--initial-branch=main", str(seed)], tmp_path)
(seed / "README.md").write_text("hello\n", encoding="utf-8")
_git(["-C", str(seed), "add", "."], tmp_path)
_git(["-C", str(seed), "commit", "-m", "init"], tmp_path)
_git(["-C", str(seed), "remote", "add", "origin", str(repo)], tmp_path)
_git(["-C", str(seed), "push", "origin", "main"], tmp_path)
return repo
def _stage_workspace(cfg: Settings, upstream: Path, repo: str, number: int, branch: str) -> tuple[Path, str]:
ws_dir = Path(cfg.workspace_root) / workspace_key(repo, number)
ws_dir.mkdir(parents=True, exist_ok=True)
repo_dir = ws_dir / "repo"
_git(["clone", str(upstream), str(repo_dir)], ws_dir)
_git(["-C", str(repo_dir), "config", "user.email", "t@t"], ws_dir)
_git(["-C", str(repo_dir), "config", "user.name", "t"], ws_dir)
_git(["-C", str(repo_dir), "checkout", "-b", branch], ws_dir)
(repo_dir / "x.txt").write_text("x", encoding="utf-8")
_git(["-C", str(repo_dir), "add", "."], ws_dir)
_git(["-C", str(repo_dir), "commit", "-m", "x"], ws_dir)
proc = _git(["-C", str(repo_dir), "rev-parse", "HEAD"], ws_dir)
return repo_dir, proc.stdout.strip()
def _bare_has_branch(bare: Path, branch: str) -> bool:
proc = subprocess.run(
["git", "-C", str(bare), "branch", "--list", branch],
capture_output=True,
text=True,
check=False,
)
return bool(proc.stdout.strip())
def _attach_gh(app, handler: Callable[[httpx.Request], httpx.Response]) -> None:
app.state.github = GitHubClient(_TOKEN, transport=httpx.MockTransport(handler))
# Sync httpx.Client cannot accept httpx.ASGITransport (which is async-only).
# Bridge by running the async transport inside a one-shot event loop per call.
class _SyncASGIBridge(httpx.BaseTransport):
def __init__(self, app) -> None:
self._async = httpx.ASGITransport(app=app)
def handle_request(self, request: httpx.Request) -> httpx.Response: # type: ignore[override]
async def _drain() -> tuple[int, httpx.Headers, bytes]:
async_resp = await self._async.handle_async_request(request)
body = await async_resp.aread()
await async_resp.aclose()
return async_resp.status_code, async_resp.headers, body
status, headers, body = asyncio.run(_drain())
# Wrap the bytes in a fresh sync Response so httpx.Client's
# `isinstance(response.stream, SyncByteStream)` assertion holds.
return httpx.Response(
status_code=status,
headers=headers,
content=body,
request=request,
)
# ============================================================================
# 1. HMAC headers + signature verify
# ============================================================================
async def test_signed_headers_present_and_verify() -> None:
captured: list[httpx.Request] = []
def handler(request: httpx.Request) -> httpx.Response:
captured.append(request)
# Echo a minimal valid payload for whichever endpoint was hit.
return httpx.Response(
200,
json={
"full_name": "octo/widget",
"default_branch": "main",
"clone_url": "https://example/octo/widget.git",
"private": False,
},
)
client = GitHubProxyClient(
base_url="http://proxy.test",
hmac_key=_HMAC,
transport=httpx.MockTransport(handler),
)
info = await client.get_repo("octo/widget")
assert isinstance(info, RepoInfo)
assert len(captured) == 1
req = captured[0]
ts = req.headers.get(HEADER_TIMESTAMP)
sig = req.headers.get(HEADER_SIGNATURE)
assert ts is not None and sig is not None
raw_query = req.url.query.decode("ascii")
target = f"{req.url.path}?{raw_query}" if raw_query else req.url.path
result = verify(
method=req.method,
path=target,
body=req.content or b"",
timestamp=ts,
signature=sig,
key=_HMAC_BYTES,
)
assert result.ok, result.reason
# ============================================================================
# 2. Round-trip via ASGI against a real proxy app
# ============================================================================
@pytest.fixture
def round_trip_app(proxy_settings: Settings):
"""A proxy app whose GitHub-side `app.state.github` answers every GH
endpoint the GitHubProxyClient exercises in the round-trip test."""
app = create_proxy_app(proxy_settings)
app.state.settings = proxy_settings
def gh(req: httpx.Request) -> httpx.Response:
path = req.url.path
if path == "/repos/octo/widget":
return httpx.Response(
200,
json={
"full_name": "octo/widget",
"default_branch": "main",
"clone_url": "https://example/octo/widget.git",
"private": False,
},
)
if path == "/repos/octo/widget/issues/1" and req.method == "GET":
return httpx.Response(
200,
json={
"number": 1,
"title": "T",
"body": "B",
"state": "open",
"user": {"login": "alice"},
"labels": [{"name": "bug"}],
},
)
if path == "/repos/octo/widget/issues" and req.method == "GET":
return httpx.Response(
200,
json=[
{
"number": 1,
"title": "first",
"state": "open",
"user": {"login": "alice"},
"labels": [],
"comments": 0,
"updated_at": "2026-01-01T00:00:00Z",
"created_at": "2026-01-01T00:00:00Z",
"html_url": "https://example/1",
}
],
)
if path == "/repos/octo/widget/issues/1/comments" and req.method == "GET":
return httpx.Response(
200,
json=[
{"id": 7, "user": {"login": "u"}, "body": "hi", "created_at": "2026-01-01T00:00:00Z"},
],
)
if path == "/repos/octo/widget/issues/1/comments" and req.method == "POST":
return httpx.Response(
201,
json={"id": 11, "user": {"login": "bot"}, "body": "posted", "created_at": "2026-01-01T00:00:00Z"},
)
if path == "/repos/octo/widget/pulls/2/comments":
return httpx.Response(
200,
json=[
{
"id": 9,
"user": {"login": "rev"},
"body": "nit",
"path": "a.py",
"line": 5,
"created_at": "2026-01-01T00:00:00Z",
}
],
)
if path == "/repos/octo/widget/pulls/2/reviews":
return httpx.Response(
200,
json=[
{
"id": 12,
"user": {"login": "rev"},
"body": "approved",
"state": "APPROVED",
"submitted_at": "2026-01-01T00:00:00Z",
}
],
)
if path == "/user":
return httpx.Response(200, json={"login": "robomp-bot"})
if path == "/repos/octo/widget/pulls/4" and req.method == "GET":
return httpx.Response(
200,
json={
"number": 4,
"html_url": "https://example/4",
"head": {"ref": "feat", "repo": {"full_name": "octo/widget"}},
"base": {"ref": "main"},
"state": "open",
"user": {"login": "robomp-bot"},
},
)
if path == "/repos/octo/widget/pulls" and req.method == "POST":
return httpx.Response(
201,
json={
"number": 4,
"html_url": "https://example/4",
"head": {"ref": "feat"},
"base": {"ref": "main"},
"state": "open",
},
)
if path == "/repos/octo/widget/pulls/4/requested_reviewers":
return httpx.Response(201, json={})
if path == "/repos/octo/widget/issues/1/labels":
return httpx.Response(200, json=[{"name": "triage"}])
if path == "/repos/octo/widget/issues/1/assignees":
return httpx.Response(201, json={})
return httpx.Response(404, json={"message": f"unrouted {req.method} {path}"})
_attach_gh(app, gh)
return app
async def test_round_trip_all_endpoints(round_trip_app) -> None:
client = GitHubProxyClient(
base_url="http://proxy.test",
hmac_key=_HMAC,
transport=httpx.ASGITransport(app=round_trip_app),
)
repo = await client.get_repo("octo/widget")
assert isinstance(repo, RepoInfo)
assert repo.full_name == "octo/widget"
issue = await client.get_issue("octo/widget", 1)
assert isinstance(issue, IssueInfo)
assert issue.labels == ("bug",)
issues = await client.list_issues("octo/widget")
assert len(issues) == 1 and isinstance(issues[0], IssueSummary)
comments = await client.list_comments("octo/widget", 1)
assert len(comments) == 1 and isinstance(comments[0], CommentInfo)
rcs = await client.list_review_comments("octo/widget", 2)
assert len(rcs) == 1 and isinstance(rcs[0], ReviewCommentInfo)
assert rcs[0].line == 5
prs = await client.list_pr_reviews("octo/widget", 2)
assert len(prs) == 1 and isinstance(prs[0], PullRequestReviewInfo)
assert await client.get_authenticated_login() == "robomp-bot"
existing_pr = await client.get_pull_request("octo/widget", 4)
assert isinstance(existing_pr, PullRequestInfo)
assert existing_pr.head_ref == "feat"
assert existing_pr.author == "robomp-bot"
posted = await client.post_comment("octo/widget", 1, "hi")
assert isinstance(posted, CommentInfo)
assert posted.id == 11
pr = await client.open_pull_request(repo="octo/widget", head="feat", base="main", title="t", body="b")
assert isinstance(pr, PullRequestInfo)
assert pr.number == 4
# request_reviewers returns None on success.
assert await client.request_reviewers(repo="octo/widget", pr_number=4, reviewers=["alice"]) is None
labels = await client.add_issue_labels("octo/widget", 1, ["triage"])
assert labels == ("triage",)
assert await client.add_assignees("octo/widget", 1, ["alice"]) is None
async def test_list_comment_reactions_round_trip(proxy_settings: Settings) -> None:
app = create_proxy_app(proxy_settings)
app.state.settings = proxy_settings
def gh(req: httpx.Request) -> httpx.Response:
if req.url.path == "/repos/octo/widget/issues/comments/999/reactions":
assert req.url.params.get("content") == "-1"
return httpx.Response(
200,
json=[
{"content": "-1", "user": {"login": "alice", "type": "User"}},
],
)
return httpx.Response(404, json={"message": "unrouted"})
_attach_gh(app, gh)
client = GitHubProxyClient(
base_url="http://proxy.test",
hmac_key=_HMAC,
transport=httpx.ASGITransport(app=app),
)
reactions = await client.list_comment_reactions("octo/widget", 999)
assert reactions == (ReactionInfo(content="-1", user_login="alice", user_type="User"),)
async def test_close_issue_round_trip(proxy_settings: Settings) -> None:
captured: dict[str, object] = {}
app = create_proxy_app(proxy_settings)
app.state.settings = proxy_settings
def gh(req: httpx.Request) -> httpx.Response:
if req.url.path == "/repos/octo/widget/issues/7" and req.method == "PATCH":
captured["body"] = json.loads(req.content)
return httpx.Response(200, json={})
return httpx.Response(404, json={"message": "unrouted"})
_attach_gh(app, gh)
client = GitHubProxyClient(
base_url="http://proxy.test",
hmac_key=_HMAC,
transport=httpx.ASGITransport(app=app),
)
assert await client.close_issue("octo/widget", 7) is None
assert captured["body"] == {"state": "closed", "state_reason": "completed"}
# ============================================================================
# 3. Error decode
# ============================================================================
async def test_error_decode_github_422() -> None:
def handler(_: httpx.Request) -> httpx.Response:
return httpx.Response(
422,
json={"error": {"kind": "github", "status": 422, "message": "x"}},
)
client = GitHubProxyClient(
base_url="http://proxy.test",
hmac_key=_HMAC,
transport=httpx.MockTransport(handler),
)
with pytest.raises(GitHubError) as exc:
await client.post_comment("octo/widget", 1, "hi")
assert exc.value.status == 422
assert exc.value.message == "x"
# ============================================================================
# 4 + 5. ProxyGitTransport push (happy + HEAD drift)
# ============================================================================
def test_proxy_git_transport_push_happy(proxy_settings: Settings, upstream_repo: Path) -> None:
branch = "farm/abc/feat"
_, head = _stage_workspace(proxy_settings, upstream_repo, "octo/widget", 1, branch)
app = create_proxy_app(proxy_settings)
app.state.settings = proxy_settings
_attach_gh(app, lambda _: httpx.Response(500, json={"message": "should not be hit"}))
transport = ProxyGitTransport(
base_url="http://proxy.test",
hmac_key=_HMAC,
transport=_SyncASGIBridge(app),
)
result = transport.push_branch(
repo="octo/widget",
workspace_key=workspace_key("octo/widget", 1),
repo_dir=Path(proxy_settings.workspace_root) / workspace_key("octo/widget", 1) / "repo",
branch=branch,
expected_head=head,
)
assert result.head == head
assert result.branch == branch
assert _bare_has_branch(upstream_repo, branch)
def test_proxy_git_transport_push_head_drift(proxy_settings: Settings, upstream_repo: Path) -> None:
branch = "farm/abc/drift"
_, _ = _stage_workspace(proxy_settings, upstream_repo, "octo/widget", 1, branch)
app = create_proxy_app(proxy_settings)
app.state.settings = proxy_settings
_attach_gh(app, lambda _: httpx.Response(500, json={"message": "should not be hit"}))
transport = ProxyGitTransport(
base_url="http://proxy.test",
hmac_key=_HMAC,
transport=_SyncASGIBridge(app),
)
with pytest.raises(HeadDriftError):
transport.push_branch(
repo="octo/widget",
workspace_key=workspace_key("octo/widget", 1),
repo_dir=Path(proxy_settings.workspace_root) / workspace_key("octo/widget", 1) / "repo",
branch=branch,
expected_head="0" * 40,
)
assert not _bare_has_branch(upstream_repo, branch)
def test_proxy_git_transport_push_slot_uid_body() -> None:
captured: list[dict[str, object]] = []
def handler(request: httpx.Request) -> httpx.Response:
captured.append(json.loads(request.content))
return httpx.Response(200, json={"head": "abc123", "branch": "farm/abc/feat"})
transport = ProxyGitTransport(
base_url="http://proxy.test",
hmac_key=_HMAC,
transport=httpx.MockTransport(handler),
)
transport.push_branch(
repo="octo/widget",
workspace_key="octo__widget__1",
repo_dir=Path("/unused"),
branch="farm/abc/feat",
expected_head="abc123",
slot_uid=2001,
)
transport.push_branch(
repo="octo/widget",
workspace_key="octo__widget__1",
repo_dir=Path("/unused"),
branch="farm/abc/feat",
expected_head="abc123",
)
assert captured[0]["slot_uid"] == 2001
assert "slot_uid" not in captured[1]
# Sanity: signed POST headers from ProxyGitTransport._post verify cleanly.
def test_proxy_git_transport_post_headers_verify() -> None:
captured: list[httpx.Request] = []
def handler(request: httpx.Request) -> httpx.Response:
captured.append(request)
return httpx.Response(200, json={"pool_dir": "/tmp/x"})
transport = ProxyGitTransport(
base_url="http://proxy.test",
hmac_key=_HMAC,
transport=httpx.MockTransport(handler),
)
transport.clone_pool(
repo="octo/widget",
clone_url="https://example/widget.git",
default_branch="main",
target=Path("/tmp/unused"),
)
assert len(captured) == 1
req = captured[0]
ts = req.headers.get(HEADER_TIMESTAMP)
sig = req.headers.get(HEADER_SIGNATURE)
assert ts and sig
result = verify(
method="POST",
path="/gh/v1/git/clone",
body=req.content or b"",
timestamp=ts,
signature=sig,
key=_HMAC_BYTES,
)
assert result.ok, result.reason
assert json.loads(req.content)["repo"] == "octo/widget"