Files
openworker/tests/test_github_installs.py

617 lines
22 KiB
Python

"""Managed GitHub relay, desktop side (github-relay-spec §13 Step 3, MG3a).
Install callback → per-installation profiles (metadata only, NO tokens at
rest), the shared-hub adapter (fan-out by provider tag), the memory-only
installation-token client, and tool auth resolution (minted token for managed,
PAT untouched for manual). Hermetic: fake transports, stubbed broker calls.
"""
from __future__ import annotations
import asyncio
import json
import pytest
from fastapi.testclient import TestClient
from coworker import cloud
from coworker.connectors import github_installs
from coworker.connectors.base import MessageEvent
from coworker.connectors.config import is_authorized, load_settings
from coworker.connectors.github_relay import GitHubRelayAdapter, split_thread
from coworker.connectors.relay_client import RelayHub, SlackRelayAdapter
from coworker.secrets import SecretStore
from coworker.server import SessionManager, create_app
@pytest.fixture(autouse=True)
def _fresh_token_cache():
cloud._GITHUB_TOKEN_CACHE.clear()
yield
cloud._GITHUB_TOKEN_CACHE.clear()
@pytest.fixture
def client(tmp_path, monkeypatch):
monkeypatch.setenv("COWORKER_STATE_DIR", str(tmp_path / "state"))
manager = SessionManager(workspace=tmp_path)
app = create_app(manager)
with TestClient(app) as c:
c.manager = manager
yield c
def _install_form(installation_id: str, *, login="octocat", account="acme") -> dict:
"""The broker's loopback POST — deliberately NO token fields (§4)."""
state = f"github-{installation_id}"
cloud._pending_managed_states[state] = cloud._now()
return {
"connector": "github",
"installation_id": installation_id,
"account_login": account,
"account_type": "Organization",
"github_login": login,
"repo_selection": "selected",
"connection_id": f"conn_{installation_id}",
"app_state": state,
}
def _no_cloud(monkeypatch):
calls: list[str] = []
monkeypatch.setattr(
cloud,
"github_disconnect_installation",
lambda s, c, installation_id: calls.append(installation_id),
)
return calls
# --- install callback → profiles (mirror of the Slack workspace tests) --------
def test_managed_callback_installs_and_hot_reloads(client, monkeypatch):
refreshes = []
async def _refresh():
refreshes.append(True)
return []
monkeypatch.setattr(client.manager, "refresh_gateway", _refresh)
resp = client.post("/oauth/callback", data=_install_form("101"))
assert resp.status_code == 200 and "GitHub connected" in resp.text
profile = client.manager.secrets.get("github:install:101")
assert profile["account_login"] == "acme"
assert profile["github_login"] == "octocat"
assert profile["repo_selection"] == "selected"
# No token of any shape at rest — installation tokens are minted on demand.
blob = json.dumps(profile)
assert "ghs_" not in blob and "ghu_" not in blob and "token" not in blob
assert client.manager.secrets.get("github:default")["mode"] == "relay"
assert refreshes # hot-add, like a Slack workspace
gh = [
c
for c in client.get("/v1/connectors").json()["connectors"]
if c["name"] == "github"
][0]
assert gh["connected"] is True
assert [i["installation_id"] for i in gh["installations"]] == ["101"]
assert gh["installations"][0]["account_login"] == "acme"
# a second installation lands alongside, not instead
client.post("/oauth/callback", data=_install_form("202", account="hooli"))
assert client.manager.secrets.get("github:install:101") is not None
assert client.manager.secrets.get("github:install:202") is not None
def test_disconnect_one_installation_keeps_the_other(client, monkeypatch):
cloud_calls = _no_cloud(monkeypatch)
for iid in ("101", "202"):
client.post("/oauth/callback", data=_install_form(iid))
body = client.post("/v1/connectors/github/installations/101/disconnect").json()
assert body["ok"] is True and body["remaining_installs"] == 1
assert cloud_calls == ["101"]
assert client.manager.secrets.get("github:install:101") is None
assert client.manager.secrets.get("github:install:202") is not None
gh = next(c for c in client.manager.list_connectors() if c["name"] == "github")
assert gh["connected"] is True
assert [i["installation_id"] for i in gh["installations"]] == ["202"]
def test_disconnect_last_installation_never_resurrects_manual_pat(client, monkeypatch):
_no_cloud(monkeypatch)
# A manual PAT stored BEFORE the managed install must stay stored but the
# relay must not survive the last installation's removal.
client.manager.secrets.put(
"github:default", {"type": "token", "token": "ghp_manual", "enabled": True}
)
client.post("/oauth/callback", data=_install_form("101"))
body = client.post("/v1/connectors/github/installations/101/disconnect").json()
assert body["ok"] is True and body["remaining_installs"] == 0
default = client.manager.secrets.get("github:default")
assert default["token"] == "ghp_manual" # kept for a manual re-enable
assert default["enabled"] is False and "mode" not in default
# --- allow-list: per-installation scope (the per-workspace pattern) -----------
def test_github_settings_carry_per_installation_allowlists(tmp_path, monkeypatch):
monkeypatch.setenv("COWORKER_STATE_DIR", str(tmp_path / "state"))
secrets = SecretStore()
github_installs.managed_connect_install(secrets, _install_form("101"))
secrets.put(
"github:install:101",
{**secrets.get("github:install:101"), "allowed_users": ["octocat"]},
)
settings = load_settings(secrets)["github"]
assert settings.enabled is True
class Src:
platform = "github"
team_id = "101"
user_id = "octocat"
assert is_authorized(settings, Src()) is True
Src.user_id = "stranger"
assert is_authorized(settings, Src()) is False # parks, not delivered
Src.team_id = "999"
assert is_authorized(settings, Src()) is False # unknown installation
# --- adapter: shared hub fan-out, dispatch, lifecycle frames -------------------
class FakeTransport:
def __init__(self, frames):
self._q: asyncio.Queue = asyncio.Queue()
for f in frames:
self._q.put_nowait(f)
async def open(self):
pass
async def recv(self):
if not self._q.empty():
return self._q.get_nowait()
await asyncio.Event().wait()
async def close(self):
pass
def _gh_frame(**over):
frame = {
"provider": "github",
"installation_id": "101",
"owner_repo": "acme/site",
"number": 7,
"kind": "mention",
"sender": "octocat",
"title": "Broken build",
"body": "@ocw please take a look",
"url": "https://github.com/acme/site/issues/7",
"address": "github:acme/site#7",
"event_id": "d-1",
}
frame.update(over)
return frame
def _slack_frame():
return {
"provider": "slack",
"team_id": "T1",
"channel": "C1",
"event_id": "Ev1",
"event": {"type": "app_mention", "user": "U_A", "channel": "C1", "text": "hi"},
}
async def test_one_hub_fans_out_to_both_adapters(monkeypatch):
"""THE step-3 invariant: slack + github share one relay socket; frames land
on their own adapter by provider tag."""
monkeypatch.setenv("SLACK_API_URL", "http://127.0.0.1:9/")
hub = RelayHub(
"wss://relay.test/ws",
lambda: "jwt",
transport_factory=lambda: FakeTransport([_gh_frame(), _slack_frame()]),
)
slack = SlackRelayAdapter(
"wss://relay.test/ws",
lambda: "jwt",
teams={"T1": {"bot_token": "xoxb-1", "bot_user_id": "UBOT"}},
hub=hub,
)
github = GitHubRelayAdapter(hub, installs={"101": {"account_login": "acme"}})
slack_events: list[MessageEvent] = []
github_events: list[MessageEvent] = []
async def on_slack(e):
slack_events.append(e)
async def on_github(e):
github_events.append(e)
slack.set_message_handler(on_slack)
github.set_message_handler(on_github)
assert await slack.connect() is True
assert await github.connect() is True # joins the running socket
try:
await hub.wait_dispatched(2)
finally:
await github.disconnect()
await slack.disconnect()
assert len(slack_events) == 1 and slack_events[0].source.platform == "slack"
assert len(github_events) == 1
gh = github_events[0]
assert gh.source.platform == "github"
assert gh.source.chat_id == "acme/site#7"
assert gh.source.target == "github:acme/site#7" # reply handle roundtrip
assert gh.source.team_id == "101" # allow-list scope = installation
assert gh.source.user_id == "octocat"
assert "Broken build" in gh.text and "@ocw please take a look" in gh.text
async def test_adapter_missed_and_revoked_frames():
hub = RelayHub(
"wss://x",
lambda: "jwt",
transport_factory=lambda: FakeTransport(
[
{
"provider": "github",
"kind": "missed",
"channel": "acme/site",
"count": 3,
},
{"provider": "github", "kind": "revoked", "installation_id": "101"},
]
),
)
adapter = GitHubRelayAdapter(hub, installs={"101": {"account_login": "acme"}})
await adapter.connect()
try:
await hub.wait_dispatched(2)
finally:
await adapter.disconnect()
assert adapter.missed == {"acme/site": 3}
assert adapter.status()["installs"] == {} # revoked → dropped
def test_addressing_roundtrip():
assert split_thread("acme/site#7") == ("acme/site", 7)
assert split_thread("acme/site") == ("acme/site", None)
async def test_send_posts_comment_with_minted_token(monkeypatch):
"""Replies mint the right installation's token and post as the bot."""
hub = RelayHub(
"wss://x", lambda: "jwt", transport_factory=lambda: FakeTransport([])
)
minted: list[str] = []
async def token_client(installation_id: str) -> str:
minted.append(installation_id)
return "ghs_live-token"
adapter = GitHubRelayAdapter(
hub, installs={"101": {"account_login": "acme"}}, token_client=token_client
)
adapter._repo_installs["acme/site"] = "101"
posted = {}
class FakeResp:
status_code = 201
def json(self):
return {"id": 987}
class FakeClient:
def __init__(self, **kw):
pass
async def __aenter__(self):
return self
async def __aexit__(self, *a):
return False
async def post(self, url, **kw):
posted.update({"url": url, **kw})
return FakeResp()
import httpx
monkeypatch.setattr(httpx, "AsyncClient", FakeClient)
result = await adapter.send("acme/site#7", "on it — as ocw[bot]")
assert result.ok and result.message_id == "987"
assert minted == ["101"]
assert posted["url"].endswith("/repos/acme/site/issues/7/comments")
assert posted["json"] == {"body": "on it — as ocw[bot]"}
assert posted["headers"]["Authorization"] == "Bearer ghs_live-token"
# --- token client: memory cache + re-mint (fake broker) ------------------------
def _stub_broker_mint(monkeypatch, tokens: list[str]):
"""cloud.github_installation_token's HTTP leg, one token per mint call."""
calls = []
it = iter(tokens)
class Resp:
status_code = 200
def json(self):
return {"token": next(it), "expires_at": "2099-01-01T00:00:00Z"}
def fake_post(url, **kw):
calls.append(url)
assert url.endswith("/v1/github/token")
return Resp()
monkeypatch.setattr(cloud.httpx, "post", fake_post)
monkeypatch.setattr(cloud, "fresh_access_token", lambda s, c: "signin-jwt")
return calls
def test_token_client_caches_and_force_remints(tmp_path, monkeypatch):
monkeypatch.setenv("COWORKER_STATE_DIR", str(tmp_path / "state"))
from coworker.config import load_config
secrets = SecretStore()
calls = _stub_broker_mint(monkeypatch, ["ghs_first", "ghs_second"])
t1 = cloud.github_installation_token(secrets, load_config(), "101")
t2 = cloud.github_installation_token(secrets, load_config(), "101")
assert t1 == t2 == "ghs_first"
assert len(calls) == 1 # cache hit, no second mint
t3 = cloud.github_installation_token(secrets, load_config(), "101", force=True)
assert t3 == "ghs_second" and len(calls) == 2
# NEVER at rest: nothing github-token-shaped in the secret store.
blob = json.dumps([m for m in secrets.status()])
assert "ghs_" not in blob
# --- tools: minted token for managed, PAT untouched for manual -----------------
def _capture_requests(monkeypatch):
from coworker.connectors import integration_tools
seen: list[dict] = []
def fake_request(method, url, *, headers=None, params=None, json=None, auth=None):
seen.append({"method": method, "url": url, "headers": headers or {}})
return {"ok": True, "data": {"items": []}}
monkeypatch.setattr(integration_tools, "_request", fake_request)
return seen
def _tool(secrets, name):
from coworker.connectors.integration_tools import make_integration_tools
tools = make_integration_tools(secrets)
return next(t for t in tools if t.__name__ == name)
def test_manual_pat_path_untouched(tmp_path, monkeypatch):
monkeypatch.setenv("COWORKER_STATE_DIR", str(tmp_path / "state"))
secrets = SecretStore()
secrets.put("github:default", {"type": "token", "token": "ghp_manual"})
seen = _capture_requests(monkeypatch)
out = _tool(secrets, "github_get_issue")("acme", "site", 7)
assert out["ok"] is True
assert seen[0]["headers"]["Authorization"] == "Bearer ghp_manual"
def test_managed_tools_use_minted_token_by_owner(tmp_path, monkeypatch):
monkeypatch.setenv("COWORKER_STATE_DIR", str(tmp_path / "state"))
secrets = SecretStore()
github_installs.managed_connect_install(secrets, _install_form("101"))
github_installs.managed_connect_install(
secrets, _install_form("202", account="hooli")
)
seen = _capture_requests(monkeypatch)
minted = []
def fake_mint(s, c, installation_id, *, force=False):
minted.append((installation_id, force))
return f"ghs_for-{installation_id}"
monkeypatch.setattr(cloud, "github_installation_token", fake_mint)
# The repo owner picks the installation (hooli ≠ the default 101).
out = _tool(secrets, "github_reply")("hooli", "app", 3, "done")
assert out["ok"] is True
assert minted == [("202", False)]
assert seen[0]["headers"]["Authorization"] == "Bearer ghs_for-202"
assert seen[0]["url"].endswith("/repos/hooli/app/issues/3/comments")
def test_managed_401_reminted_once(tmp_path, monkeypatch):
monkeypatch.setenv("COWORKER_STATE_DIR", str(tmp_path / "state"))
from coworker.connectors import integration_tools
secrets = SecretStore()
github_installs.managed_connect_install(secrets, _install_form("101"))
minted = []
monkeypatch.setattr(
cloud,
"github_installation_token",
lambda s, c, iid, *, force=False: minted.append(force) or "ghs_x",
)
responses = iter([{"error": "HTTP 401"}, {"ok": True, "data": {}}])
monkeypatch.setattr(integration_tools, "_request", lambda *a, **k: next(responses))
out = _tool(secrets, "github_review")("acme", "site", 5, "APPROVE")
assert out["ok"] is True
assert minted == [False, True] # expired cache → one forced re-mint
def test_review_event_validated(tmp_path, monkeypatch):
monkeypatch.setenv("COWORKER_STATE_DIR", str(tmp_path / "state"))
secrets = SecretStore()
out = _tool(secrets, "github_review")("acme", "site", 5, "MERGE")
assert "event must be" in out["error"]
# --- commits + clone/pull (activity summaries + local code exploration) --------
def test_list_commits_filters_and_trims(tmp_path, monkeypatch):
monkeypatch.setenv("COWORKER_STATE_DIR", str(tmp_path / "state"))
from coworker.connectors import integration_tools
secrets = SecretStore()
secrets.put("github:default", {"type": "token", "token": "ghp_x"})
seen = {}
def fake_request(method, url, *, headers=None, params=None, json=None, auth=None):
seen.update({"url": url, "params": params})
return {
"ok": True,
"data": [
{
"sha": "a" * 40,
"commit": {
"author": {"name": "Rohit", "date": "2026-07-08T10:00:00Z"},
"message": "Fix the flaky relay test\n\nlong body " * 40,
},
"author": {"login": "rohit-dev"},
}
],
}
monkeypatch.setattr(integration_tools, "_request", fake_request)
out = _tool(secrets, "github_list_commits")(
"acme",
"site",
since="2026-07-06T00:00:00Z",
author="rohit-dev",
max_results=200,
)
assert seen["url"].endswith("/repos/acme/site/commits")
assert seen["params"]["since"] == "2026-07-06T00:00:00Z"
assert seen["params"]["author"] == "rohit-dev"
assert seen["params"]["per_page"] == 100 # capped
(c,) = out["commits"]
assert c["sha"] == "a" * 12 and c["author"] == "Rohit"
assert len(c["message"]) <= 500 # trimmed for the model
def _git(args, cwd):
import subprocess
return subprocess.run(
["git", "-c", "user.name=t", "-c", "user.email=t@t", *args],
cwd=cwd,
check=True,
capture_output=True,
text=True,
)
@pytest.fixture
def _origin(tmp_path):
"""A local 'GitHub': a bare repo at <base>/acme/site.git reachable via the
GITHUB_GIT_URL override, plus a work repo to push new commits from."""
base = tmp_path / "githost"
bare = base / "acme" / "site.git"
bare.mkdir(parents=True)
_git(["init", "--bare", "--initial-branch=main", str(bare)], cwd=tmp_path)
work = tmp_path / "work"
work.mkdir()
_git(["init", "--initial-branch=main"], cwd=work)
(work / "README.md").write_text("hello")
_git(["add", "."], cwd=work)
_git(["commit", "-m", "first"], cwd=work)
_git(["remote", "add", "origin", str(bare)], cwd=work)
_git(["push", "origin", "main"], cwd=work)
return {"base": base, "work": work}
def _clone_tools(secrets, tmp_path):
from coworker.connectors.integration_tools import make_integration_tools
from coworker.roots import RootDir
granted = tmp_path / "granted"
granted.mkdir(exist_ok=True)
tools = make_integration_tools(secrets, roots=[RootDir(granted, writable=True)])
by_name = {t.__name__: t for t in tools}
return granted, by_name
def test_clone_pull_roundtrip_and_no_token_at_rest(tmp_path, monkeypatch, _origin):
"""Clone into the granted root, then pull a new upstream commit. The
minted-token header must never persist anywhere in the clone."""
monkeypatch.setenv("COWORKER_STATE_DIR", str(tmp_path / "state"))
monkeypatch.setenv("GITHUB_GIT_URL", f"file://{_origin['base']}")
secrets = SecretStore()
github_installs.managed_connect_install(secrets, _install_form("101"))
monkeypatch.setattr(
cloud, "github_installation_token", lambda s, c, iid, *, force=False: "ghs_live"
)
granted, tools = _clone_tools(secrets, tmp_path)
out = tools["github_clone"]("acme", "site")
assert out.get("ok") is True, out
clone = granted / "site"
assert (clone / "README.md").read_text() == "hello"
# The no-token-at-rest rule, verified on disk (not just in code review):
for f in (clone / ".git").rglob("*"):
if f.is_file() and f.stat().st_size < 100_000:
blob = f.read_bytes()
assert b"ghs_" not in blob and b"AUTHORIZATION" not in blob, f
(_origin["work"] / "next.txt").write_text("more")
_git(["add", "."], cwd=_origin["work"])
_git(["commit", "-m", "second"], cwd=_origin["work"])
_git(["push", "origin", "main"], cwd=_origin["work"])
out = tools["github_pull"](str(clone))
assert out.get("ok") is True, out
assert (clone / "next.txt").read_text() == "more"
def test_clone_refuses_paths_outside_granted_roots(tmp_path, monkeypatch, _origin):
monkeypatch.setenv("COWORKER_STATE_DIR", str(tmp_path / "state"))
monkeypatch.setenv("GITHUB_GIT_URL", f"file://{_origin['base']}")
secrets = SecretStore()
_granted, tools = _clone_tools(secrets, tmp_path)
out = tools["github_clone"]("acme", "site", directory=str(tmp_path / "elsewhere"))
assert "outside the session's writable directories" in out["error"]
assert not (tmp_path / "elsewhere").exists()
# and with no writable root at all → a clear error, no filesystem writes
from coworker.connectors.integration_tools import make_integration_tools
bare_tools = {t.__name__: t for t in make_integration_tools(secrets, roots=[])}
out = bare_tools["github_clone"]("acme", "site")
assert "no writable session directory" in out["error"]
def test_clone_refuses_non_empty_target(tmp_path, monkeypatch, _origin):
monkeypatch.setenv("COWORKER_STATE_DIR", str(tmp_path / "state"))
monkeypatch.setenv("GITHUB_GIT_URL", f"file://{_origin['base']}")
secrets = SecretStore()
granted, tools = _clone_tools(secrets, tmp_path)
(granted / "site").mkdir()
(granted / "site" / "keep.txt").write_text("existing work")
out = tools["github_clone"]("acme", "site")
assert "not empty" in out["error"]
assert (granted / "site" / "keep.txt").read_text() == "existing work"