Files
arc53-machine 7b516ade82 Add a built-in GitHub connector for repository sync and read-only tools
One GitHub connection feeds both a Knowledge source and an agent tool.
Users connect with a personal access token, checked against GitHub and
named after the account, or, when an admin registers a GitHub App
(GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET, GITHUB_APP_SLUG), with Sign in
with GitHub. App tokens expire after eight hours and are refreshed before
each sync or tool call; tokens with no expiry are never treated as expired.

The catalog gains an optional second sign-in method (oauth_settings,
exposed as sign_in_methods) without changing any other connector.

Sync lists the repositories the connection can read (the token's own, or
the App installations') and ingests one with the connection's token. The
tool is GitHub's read-only MCP server (api.githubcopilot.com/mcp/readonly):
setup discovers its actions and creates it bound to the connection, and
the executor sends the connection's token only to that server.
2026-09-29 10:41:03 +01:00

129 lines
5.4 KiB
Python

"""Tests for the GitHub App user sign-in."""
from __future__ import annotations
import datetime
from unittest.mock import MagicMock, patch
from urllib.parse import parse_qs, urlparse
import pytest
@pytest.fixture
def configured(monkeypatch):
from docsgpt.core.settings import settings
monkeypatch.setattr(settings, "GITHUB_CLIENT_ID", "Iv1.client")
monkeypatch.setattr(settings, "GITHUB_CLIENT_SECRET", "app-secret")
monkeypatch.setattr(settings, "GITHUB_APP_SLUG", "docsgpt-acme")
monkeypatch.setattr(settings, "CONNECTOR_REDIRECT_BASE_URI", "https://docs.example/api/connectors/callback")
def _response(payload, status=200):
response = MagicMock(status_code=status)
response.json.return_value = payload
response.raise_for_status.return_value = None
return response
def test_needs_the_app_settings(monkeypatch):
from docsgpt.core.settings import settings
from docsgpt.parser.connectors.github.auth import GitHubAuth
monkeypatch.setattr(settings, "GITHUB_CLIENT_ID", None)
with pytest.raises(ValueError, match="GITHUB_CLIENT_ID"):
GitHubAuth()
def test_authorization_url_carries_state_and_callback(configured):
from docsgpt.parser.connectors.github.auth import GitHubAuth
url = urlparse(GitHubAuth().get_authorization_url(state="st"))
query = parse_qs(url.query)
assert f"{url.scheme}://{url.netloc}{url.path}" == "https://github.com/login/oauth/authorize"
assert query["client_id"] == ["Iv1.client"]
assert query["state"] == ["st"]
assert query["redirect_uri"] == ["https://docs.example/api/connectors/callback"]
def test_installation_url_carries_state(configured):
from docsgpt.parser.connectors.github.auth import GitHubAuth
assert GitHubAuth().get_installation_url(state="st") == (
"https://github.com/apps/docsgpt-acme/installations/new?state=st"
)
def test_exchange_returns_expiring_tokens_and_the_login(configured):
from docsgpt.parser.connectors.github.auth import GitHubAuth
token = _response({
"access_token": "ghu_a", "refresh_token": "ghr_r", "expires_in": 28800, "token_type": "bearer",
})
user = _response({"login": "octocat", "name": "The Octocat"})
with patch("docsgpt.parser.connectors.github.auth.requests.post", return_value=token) as post, \
patch("docsgpt.parser.connectors.github.auth.requests.get", return_value=user):
info = GitHubAuth().exchange_code_for_tokens("code-1")
assert post.call_args.kwargs["data"]["code"] == "code-1"
assert post.call_args.kwargs["headers"]["Accept"] == "application/json"
assert info["access_token"] == "ghu_a" and info["refresh_token"] == "ghr_r"
assert info["user_info"]["login"] == "octocat"
expiry = datetime.datetime.fromisoformat(info["expiry"])
assert expiry > datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(hours=7)
def test_exchange_error_in_a_200_body_raises(configured):
"""GitHub reports a bad code with 200 and an ``error`` field."""
from docsgpt.parser.connectors.github.auth import GitHubAuth
with patch("docsgpt.parser.connectors.github.auth.requests.post",
return_value=_response({"error": "bad_verification_code"})):
with pytest.raises(ValueError, match="bad_verification_code"):
GitHubAuth().exchange_code_for_tokens("stale")
def test_refused_refresh_raises(configured):
from docsgpt.parser.connectors.github.auth import GitHubAuth
with patch("docsgpt.parser.connectors.github.auth.requests.post",
return_value=_response({"error": "bad_refresh_token"})):
with pytest.raises(ValueError, match="bad_refresh_token"):
GitHubAuth().refresh_access_token("ghr_old")
def test_refresh_rotates_the_refresh_token(configured):
from docsgpt.parser.connectors.github.auth import GitHubAuth
with patch("docsgpt.parser.connectors.github.auth.requests.post", return_value=_response({
"access_token": "ghu_b", "refresh_token": "ghr_new", "expires_in": 28800,
})) as post:
info = GitHubAuth().refresh_access_token("ghr_old")
assert post.call_args.kwargs["data"]["grant_type"] == "refresh_token"
assert info["access_token"] == "ghu_b" and info["refresh_token"] == "ghr_new"
class TestExpiry:
def test_token_without_expiry_never_expires(self, configured):
"""An app with token expiry turned off issues tokens with no expiry: they stay valid."""
from docsgpt.parser.connectors.github.auth import GitHubAuth
assert GitHubAuth().is_token_expired({"access_token": "ghu_a"}) is False
def test_expired_and_fresh(self, configured):
from docsgpt.parser.connectors.github.auth import GitHubAuth
now = datetime.datetime.now(datetime.timezone.utc)
auth = GitHubAuth()
assert auth.is_token_expired({"access_token": "a", "expiry": (now - datetime.timedelta(minutes=1)).isoformat()})
assert not auth.is_token_expired({"access_token": "a", "expiry": (now + datetime.timedelta(hours=1)).isoformat()})
assert auth.is_token_expired({})
def test_registered_as_an_auth_provider_but_not_a_file_connector():
"""GitHub signs in like the OAuth connectors but ingests through the remote loader."""
from docsgpt.parser.connectors.connector_creator import ConnectorCreator
assert ConnectorCreator.has_auth("github")
assert not ConnectorCreator.is_supported("github")
assert "github" not in ConnectorCreator.get_supported_connectors()