From 593b328dbb8efbb57673f9b03eaffb0b9d83add5 Mon Sep 17 00:00:00 2001 From: chengyongru Date: Tue, 30 Jun 2026 10:13:47 +0800 Subject: [PATCH] test: cover Copilot enterprise overrides Maintainer edit: add mocked coverage for the enterprise endpoint and client ID override paths, and document the environment variables users must set before OAuth login. --- docs/configuration.md | 10 ++ .../test_github_copilot_enterprise.py | 111 ++++++++++++++++++ 2 files changed, 121 insertions(+) diff --git a/docs/configuration.md b/docs/configuration.md index 8ef04260..dbe03c60 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -677,6 +677,16 @@ nanobot agent -c ~/.nanobot-telegram/config.json -w /tmp/nanobot-telegram-test - GitHub Copilot uses OAuth instead of API keys. Requires a [GitHub account with a plan](https://github.com/features/copilot/plans) configured. No `providers.githubCopilot` block is needed in `config.json`; `nanobot provider login` stores the OAuth session outside config. +For GitHub Enterprise / Copilot for Business, set the endpoint overrides you need before login: +```bash +export NANOBOT_GITHUB_COPILOT_CLIENT_ID="your-enterprise-client-id" +export NANOBOT_GITHUB_DEVICE_CODE_URL="https://ghe.example/login/device/code" +export NANOBOT_GITHUB_ACCESS_TOKEN_URL="https://ghe.example/login/oauth/access_token" +export NANOBOT_GITHUB_USER_URL="https://api.ghe.example/user" +export NANOBOT_COPILOT_TOKEN_URL="https://api.ghe.example/copilot_internal/v2/token" +export NANOBOT_COPILOT_BASE_URL="https://copilot-api.ghe.example" +``` + **1. Login:** ```bash nanobot provider login github-copilot diff --git a/tests/providers/test_github_copilot_enterprise.py b/tests/providers/test_github_copilot_enterprise.py index 27ad28d4..c7a19b97 100644 --- a/tests/providers/test_github_copilot_enterprise.py +++ b/tests/providers/test_github_copilot_enterprise.py @@ -2,6 +2,10 @@ from __future__ import annotations +from types import SimpleNamespace + +import pytest + from nanobot.providers import github_copilot_provider as gc @@ -30,3 +34,110 @@ def test_provider_api_base_honors_env_override(monkeypatch): monkeypatch.setenv("NANOBOT_COPILOT_BASE_URL", "https://copilot-api.acme.ghe.com") provider = gc.GitHubCopilotProvider() assert provider.api_base == "https://copilot-api.acme.ghe.com" + + +def test_login_uses_enterprise_endpoint_overrides(monkeypatch): + monkeypatch.setenv("NANOBOT_GITHUB_COPILOT_CLIENT_ID", "enterprise-client-id") + monkeypatch.setenv("NANOBOT_GITHUB_DEVICE_CODE_URL", "https://ghe.example/login/device/code") + monkeypatch.setenv( + "NANOBOT_GITHUB_ACCESS_TOKEN_URL", + "https://ghe.example/login/oauth/access_token", + ) + monkeypatch.setenv("NANOBOT_GITHUB_USER_URL", "https://api.ghe.example/user") + monkeypatch.setattr(gc.webbrowser, "open", lambda _url: None) + + calls = [] + saved = [] + + class FakeResponse: + def __init__(self, payload): + self._payload = payload + + def raise_for_status(self): + pass + + def json(self): + return self._payload + + class FakeClient: + def __init__(self, *args, **kwargs): + pass + + def __enter__(self): + return self + + def __exit__(self, *args): + return False + + def post(self, url, *, headers, data): + calls.append(("post", url, data)) + if url.endswith("/device/code"): + return FakeResponse( + { + "device_code": "device-code", + "user_code": "user-code", + "verification_uri": "https://ghe.example/device", + "interval": 1, + "expires_in": 60, + } + ) + return FakeResponse({"access_token": "github-token", "expires_in": 3600}) + + def get(self, url, *, headers): + calls.append(("get", url, headers)) + return FakeResponse({"login": "enterprise-user"}) + + monkeypatch.setattr(gc.httpx, "Client", FakeClient) + monkeypatch.setattr(gc, "get_storage", lambda: SimpleNamespace(save=saved.append)) + + token = gc.login_github_copilot(print_fn=lambda _message: None) + + assert token.access == "github-token" + assert saved[0].account_id == "enterprise-user" + assert calls[0] == ( + "post", + "https://ghe.example/login/device/code", + {"client_id": "enterprise-client-id", "scope": gc.GITHUB_COPILOT_SCOPE}, + ) + assert calls[1][0:2] == ("post", "https://ghe.example/login/oauth/access_token") + assert calls[1][2]["client_id"] == "enterprise-client-id" + assert calls[2][0:2] == ("get", "https://api.ghe.example/user") + + +@pytest.mark.asyncio +async def test_copilot_token_exchange_uses_enterprise_endpoint_override(monkeypatch): + monkeypatch.setenv( + "NANOBOT_COPILOT_TOKEN_URL", + "https://api.ghe.example/copilot_internal/v2/token", + ) + monkeypatch.setattr(gc, "_load_github_token", lambda: SimpleNamespace(access="github-token")) + + calls = [] + + class FakeResponse: + def raise_for_status(self): + pass + + def json(self): + return {"token": "copilot-token", "refresh_in": 120} + + class FakeAsyncClient: + def __init__(self, *args, **kwargs): + pass + + async def __aenter__(self): + return self + + async def __aexit__(self, *args): + return False + + async def get(self, url, *, headers): + calls.append((url, headers)) + return FakeResponse() + + monkeypatch.setattr(gc.httpx, "AsyncClient", FakeAsyncClient) + + provider = gc.GitHubCopilotProvider() + + assert await provider._get_copilot_access_token() == "copilot-token" + assert calls[0][0] == "https://api.ghe.example/copilot_internal/v2/token"