0ef5fcb1c5
Security / Dependency audit (pip-audit) (push) Has been cancelled
Security / CodeQL (javascript-typescript) (push) Has been cancelled
Security / CodeQL (python) (push) Has been cancelled
Security / Secret scan (gitleaks) (push) Has been cancelled
rust / test (ubuntu) (push) Has been cancelled
rust / simulator e2e (macos-latest) (push) Has been cancelled
rust / simulator e2e (ubuntu-latest) (push) Has been cancelled
rust / simulator e2e (windows-latest) (push) Has been cancelled
rust / wheels (aarch64-apple-darwin) (push) Has been cancelled
rust / wheels (x86_64-unknown-linux-gnu) (push) Has been cancelled
rust / wheels (x86_64-apple-darwin) (push) Has been cancelled
rust / audit (push) Has been cancelled
rust / parity (nightly, allowed to fail during Phase 0) (push) Has been cancelled
CI / commitlint (push) Has been skipped
Dev Containers / validate (.devcontainer/devcontainer.json, default) (push) Failing after 0s
Dev Containers / validate (.devcontainer/memory-stack/devcontainer.json, memory-stack) (push) Failing after 0s
Dev Containers / validate-worktree (push) Failing after 0s
CI / changes (push) Failing after 4s
Deploy Documentation / validate (push) Has been skipped
Deploy Documentation / deploy (push) Failing after 1s
Init Native E2E / init-native (ubuntu-latest, claude) (push) Failing after 1s
Init Native E2E / init-native (ubuntu-latest, codex) (push) Failing after 1s
Install Native E2E / install-native (ubuntu-latest) (push) Failing after 1s
OpenCode Plugin / typecheck + build + test (push) Failing after 1s
Init Native E2E / init-native (ubuntu-latest, copilot) (push) Failing after 1s
Release Please / release-please (push) Failing after 1s
Wrap E2E / docker-wrap-e2e (push) Failing after 1s
Wrap Native E2E / wrap-native (ubuntu-latest) (push) Failing after 1s
Init E2E / docker-init-e2e (push) Failing after 4s
Merge Conflicts / merge-conflicts (push) Failing after 4s
CI / lint (push) Has been cancelled
CI / build-wheel (push) Has been cancelled
CI / build-wheel-windows (push) Has been cancelled
CI / prefetch-model (push) Has been cancelled
CI / test-dashboard-ui (push) Has been cancelled
CI / test (1) (push) Has been cancelled
CI / test (2) (push) Has been cancelled
CI / test (3) (push) Has been cancelled
CI / test (4) (push) Has been cancelled
CI / test-extras (push) Has been cancelled
CI / test-agno (push) Has been cancelled
CI / build (push) Has been cancelled
CI / workflow-validation (push) Has been cancelled
CI / docker-native-e2e (push) Has been cancelled
CI / windows-native-wrapper (push) Has been cancelled
CI / macos-native-wrapper (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-code-nonroot name:code-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-code-slim name:code-slim]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-code-slim-nonroot name:code-slim-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-nonroot name:nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-slim name:slim]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-slim-nonroot name:slim-nonroot]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime name:]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-code name:code]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-code-nonroot name:code-nonroot]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-code-slim name:code-slim]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-code-slim-nonroot name:code-slim-nonroot]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-nonroot name:nonroot]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-slim name:slim]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-slim-nonroot name:slim-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime name:]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-code name:code]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-code-nonroot name:code-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-code-slim name:code-slim]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-code-slim-nonroot name:code-slim-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-nonroot name:nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-slim name:slim]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-slim-nonroot name:slim-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime name:]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-code name:code]) (push) Has been cancelled
Docker / promote-latest (push) Has been cancelled
Init Native E2E / init-native (macos-latest, claude) (push) Has been cancelled
Init Native E2E / init-native (macos-latest, codex) (push) Has been cancelled
Init Native E2E / init-native (macos-latest, copilot) (push) Has been cancelled
Install Native E2E / install-native (macos-latest) (push) Has been cancelled
Wrap Native E2E / wrap-native (macos-latest) (push) Has been cancelled
1064 lines
39 KiB
Python
1064 lines
39 KiB
Python
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import json
|
|
import time
|
|
from pathlib import Path
|
|
from types import SimpleNamespace
|
|
from urllib import error as urllib_error
|
|
|
|
import pytest
|
|
|
|
from headroom import copilot_auth
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _isolated_copilot_auth(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
|
"""Keep Copilot auth tests away from user secret stores and real auth files."""
|
|
|
|
for var in (
|
|
"GITHUB_COPILOT_API_TOKEN",
|
|
"COPILOT_PROVIDER_BEARER_TOKEN",
|
|
"GITHUB_COPILOT_GITHUB_TOKEN",
|
|
"GITHUB_COPILOT_TOKEN",
|
|
"COPILOT_GITHUB_TOKEN",
|
|
"GH_TOKEN",
|
|
"GITHUB_TOKEN",
|
|
"GITHUB_COPILOT_API_URL",
|
|
"GITHUB_COPILOT_ENTERPRISE_URL",
|
|
"GITHUB_COPILOT_ENTERPRISE_DOMAIN",
|
|
"GITHUB_COPILOT_TOKEN_EXCHANGE_URL",
|
|
"GITHUB_COPILOT_USER_INFO_URL",
|
|
"GITHUB_COPILOT_USER_AGENT",
|
|
"GITHUB_COPILOT_EDITOR_VERSION",
|
|
"GITHUB_COPILOT_EDITOR_PLUGIN_VERSION",
|
|
"GITHUB_COPILOT_INTEGRATION_ID",
|
|
):
|
|
monkeypatch.delenv(var, raising=False)
|
|
monkeypatch.setattr(copilot_auth, "_provider", None)
|
|
monkeypatch.setenv("HEADROOM_COPILOT_AUTH_FILE", str(tmp_path / "copilot_auth.json"))
|
|
monkeypatch.setattr(copilot_auth, "read_macos_keychain_token", lambda *, host: None)
|
|
monkeypatch.setattr(copilot_auth, "read_linux_secret_token", lambda *, host: None)
|
|
|
|
|
|
def test_read_cached_oauth_token_prefers_env(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
monkeypatch.setenv("GITHUB_COPILOT_TOKEN", "gho-env")
|
|
assert copilot_auth.read_cached_oauth_token() == "gho-env"
|
|
|
|
|
|
def test_read_cached_oauth_token_prefers_headroom_login(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
monkeypatch.setenv("GITHUB_COPILOT_TOKEN", "gho-env")
|
|
copilot_auth.save_headroom_copilot_oauth_token("gho-headroom")
|
|
|
|
assert copilot_auth.read_cached_oauth_token() == "gho-headroom"
|
|
|
|
|
|
def test_read_cached_oauth_token_prefers_copilot_cli_before_generic_github_token(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
monkeypatch.delenv("GITHUB_COPILOT_GITHUB_TOKEN", raising=False)
|
|
monkeypatch.delenv("GITHUB_COPILOT_TOKEN", raising=False)
|
|
monkeypatch.delenv("COPILOT_GITHUB_TOKEN", raising=False)
|
|
monkeypatch.setenv("GITHUB_TOKEN", "ghp-generic")
|
|
monkeypatch.setattr(copilot_auth, "_read_windows_copilot_cli_oauth_token", lambda: None)
|
|
monkeypatch.setattr(copilot_auth, "_read_macos_keychain_oauth_token", lambda: "gho-keychain")
|
|
monkeypatch.setattr(copilot_auth, "_read_gh_cli_oauth_token", lambda: None)
|
|
|
|
assert copilot_auth.read_cached_oauth_token() == "gho-keychain"
|
|
|
|
|
|
def test_iter_oauth_token_candidates_preserves_sources(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
monkeypatch.delenv("GITHUB_COPILOT_GITHUB_TOKEN", raising=False)
|
|
monkeypatch.delenv("GITHUB_COPILOT_TOKEN", raising=False)
|
|
monkeypatch.delenv("COPILOT_GITHUB_TOKEN", raising=False)
|
|
monkeypatch.setenv("GITHUB_TOKEN", "ghp-generic")
|
|
monkeypatch.setattr(copilot_auth, "_read_windows_copilot_cli_oauth_token", lambda: None)
|
|
monkeypatch.setattr(copilot_auth, "_read_macos_keychain_oauth_token", lambda: "gho-keychain")
|
|
monkeypatch.setattr(copilot_auth, "_read_file_oauth_token_candidates", lambda: [])
|
|
monkeypatch.setattr(copilot_auth, "_read_gh_cli_oauth_token", lambda: None)
|
|
|
|
candidates = copilot_auth.iter_oauth_token_candidates()
|
|
|
|
assert [(candidate.source, candidate.token) for candidate in candidates] == [
|
|
("macos-keychain:copilot-cli", "gho-keychain"),
|
|
("env:GITHUB_TOKEN", "ghp-generic"),
|
|
]
|
|
|
|
|
|
def test_resolve_subscription_bearer_token_skips_invalid_generic_token(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
monkeypatch.delenv("GITHUB_COPILOT_API_TOKEN", raising=False)
|
|
monkeypatch.delenv("COPILOT_PROVIDER_BEARER_TOKEN", raising=False)
|
|
monkeypatch.setattr(
|
|
copilot_auth, "_subscription_resolution_from_token_exchange", lambda _: None
|
|
)
|
|
monkeypatch.setattr(
|
|
copilot_auth,
|
|
"iter_oauth_token_candidates",
|
|
lambda: [
|
|
copilot_auth.CopilotTokenCandidate(
|
|
token="ghp-generic",
|
|
source="env:GITHUB_TOKEN",
|
|
confidence="generic-github",
|
|
),
|
|
copilot_auth.CopilotTokenCandidate(
|
|
token="tid_copilot",
|
|
source="macos-keychain:copilot-cli",
|
|
confidence="high",
|
|
),
|
|
],
|
|
)
|
|
monkeypatch.setattr(
|
|
copilot_auth,
|
|
"_fetch_copilot_user_info",
|
|
lambda token: (
|
|
{"endpoints": {"api": "https://api.individual.githubcopilot.com"}}
|
|
if token == "tid_copilot"
|
|
else None
|
|
),
|
|
)
|
|
|
|
assert copilot_auth.resolve_subscription_bearer_token() == "tid_copilot"
|
|
|
|
|
|
def test_resolve_subscription_bearer_token_does_not_fallback_to_unexchanged_oauth(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
monkeypatch.delenv("GITHUB_COPILOT_API_TOKEN", raising=False)
|
|
monkeypatch.delenv("COPILOT_PROVIDER_BEARER_TOKEN", raising=False)
|
|
monkeypatch.setattr(
|
|
copilot_auth, "_subscription_resolution_from_token_exchange", lambda _: None
|
|
)
|
|
monkeypatch.setattr(
|
|
copilot_auth,
|
|
"iter_oauth_token_candidates",
|
|
lambda: [
|
|
copilot_auth.CopilotTokenCandidate(
|
|
token="gho-copilot",
|
|
source="macos-keychain:copilot-cli",
|
|
confidence="high",
|
|
),
|
|
],
|
|
)
|
|
monkeypatch.setattr(
|
|
copilot_auth,
|
|
"_fetch_copilot_user_info",
|
|
lambda token: (
|
|
{"endpoints": {"api": "https://api.individual.githubcopilot.com"}}
|
|
if token == "gho-copilot"
|
|
else None
|
|
),
|
|
)
|
|
|
|
assert copilot_auth.resolve_subscription_bearer_token() is None
|
|
|
|
|
|
def test_resolve_subscription_bearer_token_details_exchanges_oauth_candidate(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
monkeypatch.delenv("GITHUB_COPILOT_API_TOKEN", raising=False)
|
|
monkeypatch.delenv("COPILOT_PROVIDER_BEARER_TOKEN", raising=False)
|
|
monkeypatch.delenv("GITHUB_COPILOT_API_URL", raising=False)
|
|
monkeypatch.delenv("GITHUB_COPILOT_ENTERPRISE_URL", raising=False)
|
|
monkeypatch.delenv("GITHUB_COPILOT_ENTERPRISE_DOMAIN", raising=False)
|
|
monkeypatch.delenv("GITHUB_COPILOT_TOKEN_EXCHANGE_URL", raising=False)
|
|
monkeypatch.setattr(
|
|
copilot_auth,
|
|
"iter_oauth_token_candidates",
|
|
lambda: [
|
|
copilot_auth.CopilotTokenCandidate(
|
|
token="gho-oauth",
|
|
source="headroom-copilot-auth:/tmp/copilot_auth.json",
|
|
confidence="copilot-oauth",
|
|
),
|
|
],
|
|
)
|
|
captured: dict[str, str] = {}
|
|
|
|
def fake_exchange(headers: dict[str, str]) -> dict[str, object]:
|
|
captured.update(headers)
|
|
return {
|
|
"token": "copilot-api",
|
|
"expires_at": int(time.time()) + 3600,
|
|
"endpoints": {"api": "https://api.business.githubcopilot.com"},
|
|
}
|
|
|
|
monkeypatch.setattr(
|
|
copilot_auth.CopilotTokenProvider,
|
|
"_exchange_token_sync",
|
|
staticmethod(fake_exchange),
|
|
)
|
|
|
|
resolution = copilot_auth.resolve_subscription_bearer_token_details()
|
|
|
|
assert resolution is not None
|
|
assert resolution.token == "copilot-api"
|
|
assert resolution.source == "headroom-copilot-auth:/tmp/copilot_auth.json:token-exchange"
|
|
assert resolution.confidence == "copilot-token-exchange"
|
|
assert resolution.api_url == "https://api.business.githubcopilot.com"
|
|
assert resolution.token_fingerprint == copilot_auth.token_fingerprint("copilot-api")
|
|
assert captured == {
|
|
"Accept": "application/json",
|
|
"Authorization": "Bearer gho-oauth",
|
|
"User-Agent": "GitHubCopilotChat/0.35.0",
|
|
"Editor-Version": "vscode/1.107.0",
|
|
"Editor-Plugin-Version": "copilot-chat/0.35.0",
|
|
"Copilot-Integration-Id": "vscode-chat",
|
|
}
|
|
|
|
|
|
def test_resolve_subscription_exchange_uses_cloud_enterprise_advertised_api(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
monkeypatch.delenv("GITHUB_COPILOT_API_TOKEN", raising=False)
|
|
monkeypatch.delenv("COPILOT_PROVIDER_BEARER_TOKEN", raising=False)
|
|
monkeypatch.delenv("GITHUB_COPILOT_API_URL", raising=False)
|
|
monkeypatch.delenv("GITHUB_COPILOT_ENTERPRISE_DOMAIN", raising=False)
|
|
monkeypatch.delenv("GITHUB_COPILOT_TOKEN_EXCHANGE_URL", raising=False)
|
|
monkeypatch.setenv("GITHUB_COPILOT_ENTERPRISE_URL", "github.com/enterprises/cbcrc")
|
|
monkeypatch.setattr(
|
|
copilot_auth,
|
|
"iter_oauth_token_candidates",
|
|
lambda: [
|
|
copilot_auth.CopilotTokenCandidate(
|
|
token="gho-oauth",
|
|
source="env:GITHUB_COPILOT_TOKEN",
|
|
confidence="explicit",
|
|
),
|
|
],
|
|
)
|
|
monkeypatch.setattr(
|
|
copilot_auth.CopilotTokenProvider,
|
|
"_exchange_token_sync",
|
|
staticmethod(lambda _headers: {"token": "copilot-api"}),
|
|
)
|
|
monkeypatch.setattr(
|
|
copilot_auth,
|
|
"_fetch_copilot_user_info",
|
|
lambda _token: {"endpoints": {"api": "https://api.business.githubcopilot.com"}},
|
|
)
|
|
|
|
resolution = copilot_auth.resolve_subscription_bearer_token_details()
|
|
|
|
assert resolution is not None
|
|
assert resolution.api_url == "https://api.business.githubcopilot.com"
|
|
assert copilot_auth._token_exchange_url() == "https://api.github.com/copilot_internal/v2/token"
|
|
|
|
|
|
def test_api_url_from_exchange_payload_rejects_non_copilot_host(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
monkeypatch.delenv("GITHUB_COPILOT_API_URL", raising=False)
|
|
monkeypatch.delenv("GITHUB_COPILOT_ENTERPRISE_URL", raising=False)
|
|
monkeypatch.delenv("GITHUB_COPILOT_ENTERPRISE_DOMAIN", raising=False)
|
|
monkeypatch.setattr(
|
|
copilot_auth,
|
|
"_fetch_copilot_user_info",
|
|
lambda _token: {"endpoints": {"api": "https://api.business.githubcopilot.com"}},
|
|
)
|
|
|
|
resolved = copilot_auth._api_url_from_exchange_payload(
|
|
{"endpoints": {"api": "https://api.openai.com/v1"}},
|
|
oauth_token="gho-oauth",
|
|
)
|
|
|
|
assert resolved == "https://api.business.githubcopilot.com"
|
|
|
|
|
|
def test_api_url_from_exchange_payload_normalizes_individual_public_host(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
monkeypatch.delenv("GITHUB_COPILOT_API_URL", raising=False)
|
|
monkeypatch.delenv("GITHUB_COPILOT_ENTERPRISE_URL", raising=False)
|
|
monkeypatch.delenv("GITHUB_COPILOT_ENTERPRISE_DOMAIN", raising=False)
|
|
|
|
resolved = copilot_auth._api_url_from_exchange_payload(
|
|
{"endpoints": {"api": "https://api.individual.githubcopilot.com"}},
|
|
oauth_token="gho-oauth",
|
|
)
|
|
|
|
assert resolved == copilot_auth.DEFAULT_API_URL
|
|
|
|
|
|
def test_api_url_from_exchange_payload_rejects_non_copilot_host_without_user_info(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
monkeypatch.delenv("GITHUB_COPILOT_API_URL", raising=False)
|
|
monkeypatch.delenv("GITHUB_COPILOT_ENTERPRISE_URL", raising=False)
|
|
monkeypatch.delenv("GITHUB_COPILOT_ENTERPRISE_DOMAIN", raising=False)
|
|
monkeypatch.setattr(copilot_auth, "_fetch_copilot_user_info", lambda _token: None)
|
|
|
|
resolved = copilot_auth._api_url_from_exchange_payload(
|
|
{"endpoints": {"api": "https://api.openai.com/v1"}},
|
|
oauth_token="gho-oauth",
|
|
)
|
|
|
|
assert resolved == copilot_auth.DEFAULT_API_URL
|
|
|
|
|
|
def test_enterprise_domain_routes_token_exchange_and_user_info_together(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
monkeypatch.delenv("GITHUB_COPILOT_TOKEN_EXCHANGE_URL", raising=False)
|
|
monkeypatch.delenv("GITHUB_COPILOT_USER_INFO_URL", raising=False)
|
|
monkeypatch.delenv("GITHUB_COPILOT_ENTERPRISE_URL", raising=False)
|
|
monkeypatch.setenv("GITHUB_COPILOT_ENTERPRISE_DOMAIN", "ghe.example.com")
|
|
|
|
assert (
|
|
copilot_auth._token_exchange_url()
|
|
== "https://api.ghe.example.com/copilot_internal/v2/token"
|
|
)
|
|
assert copilot_auth._user_info_url() == "https://api.ghe.example.com/copilot_internal/user"
|
|
|
|
|
|
def test_user_info_url_override_wins_over_enterprise_domain(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
monkeypatch.setenv("GITHUB_COPILOT_ENTERPRISE_DOMAIN", "ghe.example.com")
|
|
monkeypatch.setenv(
|
|
"GITHUB_COPILOT_USER_INFO_URL",
|
|
"https://custom.example.com/copilot_internal/user",
|
|
)
|
|
|
|
assert copilot_auth._user_info_url() == "https://custom.example.com/copilot_internal/user"
|
|
|
|
|
|
def test_copilot_api_url_from_enterprise_url_supports_enterprise_server_domain() -> None:
|
|
assert (
|
|
copilot_auth.copilot_api_url_from_enterprise_url("https://ghe.example.com/")
|
|
== "https://copilot-api.ghe.example.com"
|
|
)
|
|
assert (
|
|
copilot_auth.copilot_api_url_from_enterprise_url("https://api.ghe.example.com/")
|
|
== "https://copilot-api.ghe.example.com"
|
|
)
|
|
assert (
|
|
copilot_auth.copilot_api_url_from_enterprise_url("https://copilot-api.ghe.example.com/")
|
|
== "https://copilot-api.ghe.example.com"
|
|
)
|
|
|
|
|
|
def test_copilot_api_url_from_enterprise_url_ignores_github_cloud_enterprise_path() -> None:
|
|
assert (
|
|
copilot_auth.copilot_api_url_from_enterprise_url("https://github.com/enterprises/cbcrc/")
|
|
== copilot_auth.DEFAULT_API_URL
|
|
)
|
|
|
|
|
|
def test_should_exchange_oauth_token_supports_truthy_values(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
for raw in ("1", "true", "YES", "On"):
|
|
monkeypatch.setenv("GITHUB_COPILOT_USE_TOKEN_EXCHANGE", raw)
|
|
assert copilot_auth._should_exchange_oauth_token() is True
|
|
|
|
monkeypatch.setenv("GITHUB_COPILOT_USE_TOKEN_EXCHANGE", "off")
|
|
assert copilot_auth._should_exchange_oauth_token() is False
|
|
|
|
|
|
def test_resolve_token_file_paths_prefers_override(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
monkeypatch.setenv("GITHUB_COPILOT_TOKEN_FILE", "~/custom-token.json")
|
|
|
|
paths = copilot_auth._resolve_token_file_paths()
|
|
|
|
assert paths == [Path("~/custom-token.json").expanduser()]
|
|
|
|
|
|
def test_resolve_token_file_paths_includes_localappdata_and_config(
|
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
) -> None:
|
|
monkeypatch.delenv("GITHUB_COPILOT_TOKEN_FILE", raising=False)
|
|
monkeypatch.setenv("LOCALAPPDATA", str(tmp_path / "local"))
|
|
monkeypatch.setattr(copilot_auth.Path, "home", staticmethod(lambda: tmp_path / "home"))
|
|
|
|
paths = copilot_auth._resolve_token_file_paths()
|
|
|
|
assert paths == [
|
|
tmp_path / "local" / "github-copilot" / "apps.json",
|
|
tmp_path / "local" / "github-copilot" / "hosts.json",
|
|
tmp_path / "home" / ".config" / "github-copilot" / "apps.json",
|
|
tmp_path / "home" / ".config" / "github-copilot" / "hosts.json",
|
|
]
|
|
|
|
|
|
def test_read_cached_oauth_token_falls_back_to_gh_cli(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
monkeypatch.delenv("GITHUB_COPILOT_GITHUB_TOKEN", raising=False)
|
|
monkeypatch.delenv("GITHUB_COPILOT_TOKEN", raising=False)
|
|
monkeypatch.delenv("GITHUB_TOKEN", raising=False)
|
|
monkeypatch.delenv("COPILOT_GITHUB_TOKEN", raising=False)
|
|
monkeypatch.setattr(copilot_auth, "_read_windows_copilot_cli_oauth_token", lambda: None)
|
|
monkeypatch.setattr(copilot_auth, "_read_macos_keychain_oauth_token", lambda: None)
|
|
monkeypatch.setattr(copilot_auth, "_read_gh_cli_oauth_token", lambda: "gho-gh-cli")
|
|
|
|
assert copilot_auth.read_cached_oauth_token() == "gho-gh-cli"
|
|
|
|
|
|
def test_read_cached_oauth_token_prefers_copilot_cli_windows_token(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
monkeypatch.delenv("GITHUB_COPILOT_GITHUB_TOKEN", raising=False)
|
|
monkeypatch.delenv("GITHUB_COPILOT_TOKEN", raising=False)
|
|
monkeypatch.delenv("GITHUB_TOKEN", raising=False)
|
|
monkeypatch.delenv("COPILOT_GITHUB_TOKEN", raising=False)
|
|
monkeypatch.setattr(
|
|
copilot_auth, "_read_windows_copilot_cli_oauth_token", lambda: "gho-copilot"
|
|
)
|
|
monkeypatch.setattr(copilot_auth, "_read_gh_cli_oauth_token", lambda: "gho-gh-cli")
|
|
|
|
assert copilot_auth.read_cached_oauth_token() == "gho-copilot"
|
|
|
|
|
|
def test_read_cached_oauth_token_prefers_macos_keychain_before_gh(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
monkeypatch.delenv("GITHUB_COPILOT_GITHUB_TOKEN", raising=False)
|
|
monkeypatch.delenv("GITHUB_COPILOT_TOKEN", raising=False)
|
|
monkeypatch.delenv("COPILOT_GITHUB_TOKEN", raising=False)
|
|
monkeypatch.delenv("GH_TOKEN", raising=False)
|
|
monkeypatch.delenv("GITHUB_TOKEN", raising=False)
|
|
monkeypatch.setattr(copilot_auth, "_read_windows_copilot_cli_oauth_token", lambda: None)
|
|
monkeypatch.setattr(copilot_auth, "_read_macos_keychain_oauth_token", lambda: "gho-keychain")
|
|
monkeypatch.setattr(copilot_auth, "_read_gh_cli_oauth_token", lambda: "gho-gh-cli")
|
|
|
|
assert copilot_auth.read_cached_oauth_token() == "gho-keychain"
|
|
|
|
|
|
def test_read_macos_keychain_oauth_token_uses_security(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
calls: list[str] = []
|
|
|
|
def fake_read(*, host: str) -> str:
|
|
calls.append(host)
|
|
return "gho-keychain"
|
|
|
|
monkeypatch.setattr(copilot_auth, "read_macos_keychain_token", fake_read)
|
|
assert copilot_auth._read_macos_keychain_oauth_token() == "gho-keychain"
|
|
assert calls == ["github.com"]
|
|
|
|
|
|
def test_read_cached_oauth_token_reads_hosts_file(
|
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
) -> None:
|
|
hosts = tmp_path / "hosts.json"
|
|
hosts.write_text(
|
|
json.dumps(
|
|
{
|
|
"github.com": {
|
|
"oauth_token": "gho-file",
|
|
"expires_at": "2999-01-01T00:00:00Z",
|
|
}
|
|
}
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
monkeypatch.delenv("GITHUB_COPILOT_TOKEN", raising=False)
|
|
monkeypatch.setenv("GITHUB_COPILOT_TOKEN_FILE", str(hosts))
|
|
monkeypatch.setattr(copilot_auth, "_read_windows_copilot_cli_oauth_token", lambda: None)
|
|
monkeypatch.setattr(copilot_auth, "_read_macos_keychain_oauth_token", lambda: None)
|
|
monkeypatch.setattr(copilot_auth, "_read_gh_cli_oauth_token", lambda: None)
|
|
|
|
assert copilot_auth.read_cached_oauth_token() == "gho-file"
|
|
|
|
|
|
def test_read_cached_oauth_token_skips_expired_entries(
|
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
) -> None:
|
|
hosts = tmp_path / "hosts.json"
|
|
hosts.write_text(
|
|
json.dumps({"github.com": {"oauthToken": "gho-old", "expiresAt": 1}}),
|
|
encoding="utf-8",
|
|
)
|
|
monkeypatch.setenv("GITHUB_COPILOT_TOKEN_FILE", str(hosts))
|
|
monkeypatch.setattr(copilot_auth, "_read_windows_copilot_cli_oauth_token", lambda: None)
|
|
monkeypatch.setattr(copilot_auth, "_read_macos_keychain_oauth_token", lambda: None)
|
|
monkeypatch.setattr(copilot_auth, "_read_gh_cli_oauth_token", lambda: None)
|
|
|
|
assert copilot_auth.read_cached_oauth_token() is None
|
|
|
|
|
|
def test_read_gh_cli_oauth_token_uses_hostname(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
calls: list[list[str]] = []
|
|
|
|
class CompletedProcess:
|
|
def __init__(self) -> None:
|
|
self.returncode = 0
|
|
self.stdout = "gho-gh-cli\n"
|
|
|
|
def fake_run(*args: object, **kwargs: object) -> CompletedProcess:
|
|
calls.append(list(args[0]))
|
|
assert kwargs["capture_output"] is True
|
|
assert kwargs["check"] is False
|
|
return CompletedProcess()
|
|
|
|
monkeypatch.setenv("GITHUB_COPILOT_HOST", "example.ghe.com")
|
|
monkeypatch.setattr(copilot_auth, "run", fake_run)
|
|
|
|
assert copilot_auth._read_gh_cli_oauth_token() == "gho-gh-cli"
|
|
assert calls == [["gh", "auth", "token", "--hostname", "example.ghe.com"]]
|
|
|
|
|
|
def test_read_gh_cli_oauth_token_returns_none_when_invocation_fails(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
def fake_run(*args: object, **kwargs: object) -> None: # noqa: ANN002, ANN003
|
|
raise OSError("gh missing")
|
|
|
|
monkeypatch.setattr(copilot_auth, "run", fake_run)
|
|
|
|
assert copilot_auth._read_gh_cli_oauth_token() is None
|
|
|
|
|
|
def test_read_gh_cli_oauth_token_returns_none_for_nonzero_exit(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
monkeypatch.setattr(
|
|
copilot_auth,
|
|
"run",
|
|
lambda *args, **kwargs: SimpleNamespace(returncode=1, stdout="ignored"),
|
|
)
|
|
|
|
assert copilot_auth._read_gh_cli_oauth_token() is None
|
|
|
|
|
|
def test_read_gh_cli_oauth_token_returns_none_for_blank_stdout(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
monkeypatch.setattr(
|
|
copilot_auth,
|
|
"run",
|
|
lambda *args, **kwargs: SimpleNamespace(returncode=0, stdout=" \n"),
|
|
)
|
|
|
|
assert copilot_auth._read_gh_cli_oauth_token() is None
|
|
|
|
|
|
def test_resolve_client_bearer_token_prefers_api_token(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
monkeypatch.setenv("GITHUB_COPILOT_API_TOKEN", "copilot-api")
|
|
monkeypatch.setenv("GITHUB_COPILOT_TOKEN", "gho-oauth")
|
|
|
|
assert copilot_auth.resolve_client_bearer_token() == "copilot-api"
|
|
|
|
|
|
def test_has_oauth_auth_false_when_no_tokens(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
monkeypatch.setattr(copilot_auth, "resolve_client_bearer_token", lambda: None)
|
|
|
|
assert copilot_auth.has_oauth_auth() is False
|
|
|
|
|
|
def test_is_copilot_api_url_matches_expected_hosts() -> None:
|
|
assert copilot_auth.is_copilot_api_url("https://api.githubcopilot.com/v1/chat/completions")
|
|
assert copilot_auth.is_copilot_api_url("wss://api.githubcopilot.com/v1/responses")
|
|
assert not copilot_auth.is_copilot_api_url("https://api.openai.com/v1/chat/completions")
|
|
|
|
|
|
def test_is_copilot_api_url_matches_ghe_copilot_hosts() -> None:
|
|
assert copilot_auth.is_copilot_api_url("https://copilot-api.acme.ghe.com/v1/responses")
|
|
assert copilot_auth.is_copilot_api_url("https://copilot-api.ghe.com/v1/chat/completions")
|
|
assert not copilot_auth.is_copilot_api_url("https://api.acme.ghe.com/v1/responses")
|
|
assert not copilot_auth.is_copilot_api_url("https://not-copilot-api.acme.ghe.com/v1/responses")
|
|
|
|
|
|
def test_is_copilot_api_url_trusts_configured_enterprise_api_url(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
monkeypatch.setenv("GITHUB_COPILOT_ENTERPRISE_DOMAIN", "ghe.example.com")
|
|
|
|
assert copilot_auth.is_copilot_api_url("https://copilot-api.ghe.example.com/v1/responses")
|
|
assert not copilot_auth.is_copilot_api_url("https://copilot-api.other.example.com/v1/responses")
|
|
|
|
|
|
def test_build_copilot_upstream_url_strips_v1_only_for_copilot_hosts() -> None:
|
|
assert (
|
|
copilot_auth.build_copilot_upstream_url(
|
|
"https://api.githubcopilot.com",
|
|
"/v1/chat/completions",
|
|
)
|
|
== "https://api.githubcopilot.com/chat/completions"
|
|
)
|
|
assert (
|
|
copilot_auth.build_copilot_upstream_url(
|
|
"https://api.openai.com",
|
|
"/v1/chat/completions",
|
|
)
|
|
== "https://api.openai.com/v1/chat/completions"
|
|
)
|
|
|
|
|
|
def test_build_copilot_upstream_url_strips_v1_for_ghe_copilot_hosts() -> None:
|
|
assert (
|
|
copilot_auth.build_copilot_upstream_url(
|
|
"https://copilot-api.acme.ghe.com",
|
|
"/v1/responses",
|
|
)
|
|
== "https://copilot-api.acme.ghe.com/responses"
|
|
)
|
|
assert (
|
|
copilot_auth.build_copilot_upstream_url(
|
|
"https://api.acme.ghe.com",
|
|
"/v1/responses",
|
|
)
|
|
== "https://api.acme.ghe.com/v1/responses"
|
|
)
|
|
|
|
|
|
def test_build_copilot_upstream_url_strips_v1_for_configured_enterprise_api_url(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
monkeypatch.setenv("GITHUB_COPILOT_ENTERPRISE_DOMAIN", "ghe.example.com")
|
|
|
|
assert (
|
|
copilot_auth.build_copilot_upstream_url(
|
|
"https://copilot-api.ghe.example.com",
|
|
"/v1/responses",
|
|
)
|
|
== "https://copilot-api.ghe.example.com/responses"
|
|
)
|
|
|
|
|
|
def test_apply_copilot_api_auth_replaces_authorization(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
async def fake_get_api_token() -> copilot_auth.CopilotAPIToken:
|
|
return copilot_auth.CopilotAPIToken(
|
|
token="copilot-session",
|
|
expires_at=time.time() + 3600,
|
|
api_url=copilot_auth.DEFAULT_API_URL,
|
|
)
|
|
|
|
monkeypatch.setattr(
|
|
copilot_auth.get_copilot_token_provider(),
|
|
"get_api_token",
|
|
fake_get_api_token,
|
|
)
|
|
|
|
headers = asyncio.run(
|
|
copilot_auth.apply_copilot_api_auth(
|
|
{"authorization": "Bearer downstream-token", "x-api-key": "sk-downstream"},
|
|
url="https://api.githubcopilot.com/v1/chat/completions",
|
|
)
|
|
)
|
|
|
|
assert headers["Authorization"] == "Bearer copilot-session"
|
|
assert "authorization" not in headers
|
|
assert "x-api-key" not in headers
|
|
assert headers["User-Agent"] == "GitHubCopilotChat/0.35.0"
|
|
assert headers["Editor-Version"] == "vscode/1.107.0"
|
|
assert headers["Editor-Plugin-Version"] == "copilot-chat/0.35.0"
|
|
assert headers["Copilot-Integration-Id"] == "vscode-chat"
|
|
|
|
|
|
def test_apply_copilot_api_auth_passes_through_existing_api_token(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
async def fake_get_api_token() -> copilot_auth.CopilotAPIToken:
|
|
raise AssertionError("provider should not be called for existing API token")
|
|
|
|
monkeypatch.setattr(
|
|
copilot_auth.get_copilot_token_provider(),
|
|
"get_api_token",
|
|
fake_get_api_token,
|
|
)
|
|
|
|
headers = asyncio.run(
|
|
copilot_auth.apply_copilot_api_auth(
|
|
{
|
|
"authorization": "Bearer tid_existing_copilot_token",
|
|
"x-api-key": "sk-downstream",
|
|
},
|
|
url="https://api.githubcopilot.com/v1/chat/completions",
|
|
)
|
|
)
|
|
|
|
assert headers["authorization"] == "Bearer tid_existing_copilot_token"
|
|
assert "x-api-key" not in headers
|
|
|
|
|
|
def test_apply_copilot_api_auth_passes_through_github_oauth_bearer(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""A caller-supplied GitHub OAuth (gho_/ghs_/ghp_/github_pat_) bearer
|
|
token must be forwarded unchanged, not replaced.
|
|
|
|
Regression test for headroomlabs-ai/headroom#1813: this function used
|
|
to treat any gho_-prefixed token as "not a suitable Copilot API
|
|
token" and silently replace it with Headroom's own independently
|
|
fetched/exchanged credential. That broke both:
|
|
- a live Copilot CLI session (its own gho_ token worked directly for
|
|
model "claude-sonnet-5", but got 400 model_not_supported once
|
|
Headroom substituted a differently-entitled token), and
|
|
- OpenCode's native GitHub Copilot integration (#1813): replacing its
|
|
gho_ token changed the effective client/integrator lane Copilot's
|
|
backend sees, breaking model discovery/inference parity with
|
|
native (non-proxied) behavior.
|
|
"""
|
|
|
|
async def fail_if_called() -> copilot_auth.CopilotAPIToken:
|
|
raise AssertionError(
|
|
"get_api_token() must not be called for a forwardable gho_ bearer token"
|
|
)
|
|
|
|
monkeypatch.setattr(
|
|
copilot_auth.get_copilot_token_provider(),
|
|
"get_api_token",
|
|
fail_if_called,
|
|
)
|
|
|
|
headers = asyncio.run(
|
|
copilot_auth.apply_copilot_api_auth(
|
|
{"authorization": "Bearer gho_liveClientToken123"},
|
|
url="https://api.githubcopilot.com/v1/chat/completions",
|
|
)
|
|
)
|
|
|
|
assert headers["authorization"] == "Bearer gho_liveClientToken123"
|
|
|
|
|
|
def test_apply_copilot_api_auth_replaces_non_bearer_auth(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
async def fake_get_api_token() -> copilot_auth.CopilotAPIToken:
|
|
return copilot_auth.CopilotAPIToken(
|
|
token="copilot-session",
|
|
expires_at=time.time() + 3600,
|
|
api_url=copilot_auth.DEFAULT_API_URL,
|
|
)
|
|
|
|
monkeypatch.setattr(
|
|
copilot_auth.get_copilot_token_provider(),
|
|
"get_api_token",
|
|
fake_get_api_token,
|
|
)
|
|
|
|
headers = asyncio.run(
|
|
copilot_auth.apply_copilot_api_auth(
|
|
{"authorization": "Basic abc123"},
|
|
url="https://api.githubcopilot.com/v1/chat/completions",
|
|
)
|
|
)
|
|
|
|
assert headers["Authorization"] == "Bearer copilot-session"
|
|
assert "authorization" not in headers
|
|
|
|
|
|
def test_is_copilot_api_token_matches_expected_prefixes() -> None:
|
|
assert copilot_auth._is_copilot_api_token("tid_session_token") is True
|
|
assert copilot_auth._is_copilot_api_token("gho_oauth") is False
|
|
assert copilot_auth._is_copilot_api_token("ghs_oauth") is False
|
|
assert copilot_auth._is_copilot_api_token("ghp_oauth") is False
|
|
assert copilot_auth._is_copilot_api_token("github_pat_example") is False
|
|
assert copilot_auth._is_copilot_api_token("Bearer maybe") is False
|
|
|
|
|
|
def test_is_forwardable_copilot_bearer_token_matches_expected_prefixes() -> None:
|
|
"""The inference-path helper (unlike _is_copilot_api_token, which is
|
|
scoped only to subscription/user-info resolution) accepts BOTH
|
|
short-lived Copilot API tokens (tid_) AND GitHub OAuth tokens
|
|
(gho_/ghs_/ghp_/github_pat_) as forwardable -- see
|
|
_is_forwardable_copilot_bearer_token()'s docstring and
|
|
headroomlabs-ai/headroom#1813 for why GitHub OAuth tokens must be
|
|
forwardable for chat-completion/inference requests.
|
|
"""
|
|
assert copilot_auth._is_forwardable_copilot_bearer_token("tid_session_token") is True
|
|
assert copilot_auth._is_forwardable_copilot_bearer_token("gho_oauth") is True
|
|
assert copilot_auth._is_forwardable_copilot_bearer_token("ghs_oauth") is True
|
|
assert copilot_auth._is_forwardable_copilot_bearer_token("ghp_oauth") is True
|
|
assert copilot_auth._is_forwardable_copilot_bearer_token("github_pat_example") is True
|
|
assert copilot_auth._is_forwardable_copilot_bearer_token("sk-unrelated-anthropic-key") is False
|
|
assert copilot_auth._is_forwardable_copilot_bearer_token("") is False
|
|
assert copilot_auth._is_forwardable_copilot_bearer_token(" ") is False
|
|
|
|
|
|
def test_apply_copilot_api_auth_injects_required_headers(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
async def fake_get_api_token() -> copilot_auth.CopilotAPIToken:
|
|
return copilot_auth.CopilotAPIToken(
|
|
token="copilot-session",
|
|
expires_at=time.time() + 3600,
|
|
api_url=copilot_auth.DEFAULT_API_URL,
|
|
)
|
|
|
|
monkeypatch.setattr(
|
|
copilot_auth.get_copilot_token_provider(),
|
|
"get_api_token",
|
|
fake_get_api_token,
|
|
)
|
|
monkeypatch.delenv("GITHUB_COPILOT_INTEGRATION_ID", raising=False)
|
|
monkeypatch.delenv("GITHUB_COPILOT_EDITOR_VERSION", raising=False)
|
|
|
|
headers = asyncio.run(
|
|
copilot_auth.apply_copilot_api_auth(
|
|
{},
|
|
url="https://api.githubcopilot.com/v1/chat/completions",
|
|
)
|
|
)
|
|
|
|
assert headers["Authorization"] == "Bearer copilot-session"
|
|
assert headers["Copilot-Integration-Id"] == "vscode-chat"
|
|
assert headers["Editor-Version"] == "vscode/1.107.0"
|
|
assert headers["Editor-Plugin-Version"] == "copilot-chat/0.35.0"
|
|
|
|
|
|
def test_apply_copilot_api_auth_preserves_existing_copilot_headers(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
async def fake_get_api_token() -> copilot_auth.CopilotAPIToken:
|
|
return copilot_auth.CopilotAPIToken(
|
|
token="copilot-session",
|
|
expires_at=time.time() + 3600,
|
|
api_url=copilot_auth.DEFAULT_API_URL,
|
|
)
|
|
|
|
monkeypatch.setattr(
|
|
copilot_auth.get_copilot_token_provider(),
|
|
"get_api_token",
|
|
fake_get_api_token,
|
|
)
|
|
monkeypatch.setenv("GITHUB_COPILOT_INTEGRATION_ID", "should-not-override")
|
|
monkeypatch.setenv("GITHUB_COPILOT_EDITOR_VERSION", "should-not-override")
|
|
|
|
headers = asyncio.run(
|
|
copilot_auth.apply_copilot_api_auth(
|
|
{
|
|
"Authorization": "Bearer downstream-token",
|
|
"Copilot-Integration-Id": "custom-integration",
|
|
"Editor-Version": "custom-editor",
|
|
},
|
|
url="https://api.githubcopilot.com/v1/chat/completions",
|
|
)
|
|
)
|
|
|
|
assert headers["Copilot-Integration-Id"] == "custom-integration"
|
|
assert headers["Editor-Version"] == "custom-editor"
|
|
assert headers["Authorization"] == "Bearer copilot-session"
|
|
|
|
|
|
def test_apply_copilot_api_auth_preserves_existing_headers_case_insensitively(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
async def fake_get_api_token() -> copilot_auth.CopilotAPIToken:
|
|
return copilot_auth.CopilotAPIToken(
|
|
token="copilot-session",
|
|
expires_at=time.time() + 3600,
|
|
api_url=copilot_auth.DEFAULT_API_URL,
|
|
)
|
|
|
|
monkeypatch.setattr(
|
|
copilot_auth.get_copilot_token_provider(),
|
|
"get_api_token",
|
|
fake_get_api_token,
|
|
)
|
|
|
|
headers = asyncio.run(
|
|
copilot_auth.apply_copilot_api_auth(
|
|
{
|
|
"authorization": "Bearer downstream-token",
|
|
"user-agent": "custom-agent",
|
|
"editor-version": "custom-editor",
|
|
"editor-plugin-version": "custom-plugin",
|
|
"copilot-integration-id": "custom-integration",
|
|
},
|
|
url="https://api.githubcopilot.com/v1/chat/completions",
|
|
)
|
|
)
|
|
|
|
assert headers["Authorization"] == "Bearer copilot-session"
|
|
assert "authorization" not in headers
|
|
assert headers["user-agent"] == "custom-agent"
|
|
assert headers["editor-version"] == "custom-editor"
|
|
assert headers["editor-plugin-version"] == "custom-plugin"
|
|
assert headers["copilot-integration-id"] == "custom-integration"
|
|
assert "User-Agent" not in headers
|
|
assert "Editor-Version" not in headers
|
|
assert "Editor-Plugin-Version" not in headers
|
|
assert "Copilot-Integration-Id" not in headers
|
|
|
|
|
|
def test_token_provider_reuses_oauth_token_without_exchange(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
monkeypatch.setenv("GITHUB_COPILOT_TOKEN", "gho-oauth")
|
|
|
|
provider = copilot_auth.CopilotTokenProvider()
|
|
calls = {"count": 0}
|
|
|
|
def fake_exchange(headers: dict[str, str]) -> dict[str, object]:
|
|
calls["count"] += 1
|
|
return {
|
|
"token": "copilot-api",
|
|
"expires_at": int(time.time()) + 3600,
|
|
"refresh_in": 1200,
|
|
"endpoints": {"api": "https://api.githubcopilot.com"},
|
|
"sku": "copilot_individual",
|
|
}
|
|
|
|
monkeypatch.setattr(provider, "_exchange_token_sync", staticmethod(fake_exchange))
|
|
|
|
first = asyncio.run(provider.get_api_token())
|
|
second = asyncio.run(provider.get_api_token())
|
|
|
|
assert first.token == "gho-oauth"
|
|
assert second.token == "gho-oauth"
|
|
assert calls["count"] == 0
|
|
|
|
|
|
def test_token_provider_can_exchange_when_enabled(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
monkeypatch.setenv("GITHUB_COPILOT_TOKEN", "gho-oauth")
|
|
monkeypatch.setenv("GITHUB_COPILOT_USE_TOKEN_EXCHANGE", "true")
|
|
|
|
provider = copilot_auth.CopilotTokenProvider()
|
|
calls = {"count": 0}
|
|
captured: dict[str, str] = {}
|
|
|
|
def fake_exchange(headers: dict[str, str]) -> dict[str, object]:
|
|
calls["count"] += 1
|
|
captured.update(headers)
|
|
return {
|
|
"token": "copilot-api",
|
|
"expires_at": int(time.time()) + 3600,
|
|
"refresh_in": 1200,
|
|
"endpoints": {"api": "https://api.githubcopilot.com"},
|
|
"sku": "copilot_individual",
|
|
}
|
|
|
|
monkeypatch.setattr(provider, "_exchange_token_sync", staticmethod(fake_exchange))
|
|
|
|
first = asyncio.run(provider.get_api_token())
|
|
second = asyncio.run(provider.get_api_token())
|
|
|
|
assert first.token == "copilot-api"
|
|
assert second.token == "copilot-api"
|
|
assert calls["count"] == 1
|
|
assert captured["Authorization"] == "Bearer gho-oauth"
|
|
assert captured["User-Agent"] == "GitHubCopilotChat/0.35.0"
|
|
assert captured["Editor-Version"] == "vscode/1.107.0"
|
|
assert captured["Editor-Plugin-Version"] == "copilot-chat/0.35.0"
|
|
assert captured["Copilot-Integration-Id"] == "vscode-chat"
|
|
|
|
|
|
def test_token_provider_prefers_explicit_api_token(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
monkeypatch.setenv("GITHUB_COPILOT_API_TOKEN", "copilot-api")
|
|
monkeypatch.setenv("GITHUB_COPILOT_API_URL", "https://api.githubcopilot.com")
|
|
|
|
token = asyncio.run(copilot_auth.CopilotTokenProvider().get_api_token())
|
|
|
|
assert token.token == "copilot-api"
|
|
assert token.api_url == "https://api.githubcopilot.com"
|
|
|
|
|
|
def test_token_provider_raises_without_oauth_token(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
monkeypatch.delenv("GITHUB_COPILOT_API_TOKEN", raising=False)
|
|
monkeypatch.setattr(copilot_auth, "read_cached_oauth_token", lambda: None)
|
|
|
|
with pytest.raises(RuntimeError, match="No GitHub Copilot OAuth token"):
|
|
asyncio.run(copilot_auth.CopilotTokenProvider().get_api_token())
|
|
|
|
|
|
def test_exchange_token_raises_when_exchange_returns_empty_token(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
provider = copilot_auth.CopilotTokenProvider()
|
|
monkeypatch.setattr(
|
|
provider,
|
|
"_exchange_token_sync",
|
|
staticmethod(lambda headers: {"token": "", "expires_at": int(time.time()) + 1}),
|
|
)
|
|
|
|
with pytest.raises(RuntimeError, match="empty token"):
|
|
asyncio.run(provider._exchange_token("gho-oauth"))
|
|
|
|
|
|
def test_exchange_token_sync_raises_for_http_error(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
class DummyResponse:
|
|
def read(self) -> bytes:
|
|
return b'{"message":"Not Found"}'
|
|
|
|
def close(self) -> None:
|
|
return None
|
|
|
|
def fake_urlopen(request, timeout: float): # noqa: ANN001, ANN202
|
|
raise urllib_error.HTTPError(
|
|
url=request.full_url,
|
|
code=404,
|
|
msg="Not Found",
|
|
hdrs=None,
|
|
fp=DummyResponse(),
|
|
)
|
|
|
|
monkeypatch.setattr(copilot_auth.urllib_request, "urlopen", fake_urlopen)
|
|
|
|
with pytest.raises(RuntimeError, match="HTTP 404"):
|
|
copilot_auth.CopilotTokenProvider._exchange_token_sync({"Authorization": "token test"})
|
|
|
|
|
|
def test_apply_copilot_api_auth_returns_original_headers_for_non_copilot_url() -> None:
|
|
headers = asyncio.run(
|
|
copilot_auth.apply_copilot_api_auth(
|
|
{"authorization": "Bearer downstream-token"},
|
|
url="https://api.openai.com/v1/chat/completions",
|
|
)
|
|
)
|
|
|
|
assert headers == {"authorization": "Bearer downstream-token"}
|
|
|
|
|
|
def test_read_windows_copilot_cli_oauth_token_returns_none_without_windll(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
monkeypatch.setattr(copilot_auth.os, "name", "nt")
|
|
monkeypatch.delattr(copilot_auth.ctypes, "WinDLL", raising=False)
|
|
|
|
assert copilot_auth._read_windows_copilot_cli_oauth_token() is None
|
|
|
|
|
|
def test_is_copilot_api_token_returns_false_for_empty_string() -> None:
|
|
assert copilot_auth._is_copilot_api_token("") is False
|
|
assert copilot_auth._is_copilot_api_token(" ") is False
|
|
|
|
|
|
def test_token_kind_returns_known_prefixes() -> None:
|
|
assert copilot_auth._token_kind("tid_x") == "tid_***" # noqa: S105
|
|
assert copilot_auth._token_kind("gho_x") == "gho_***" # noqa: S105
|
|
assert copilot_auth._token_kind("ghs_x") == "ghs_***" # noqa: S105
|
|
assert copilot_auth._token_kind("ghp_x") == "ghp_***" # noqa: S105
|
|
assert copilot_auth._token_kind("github_pat_x") == "github_pat_***" # noqa: S105
|
|
|
|
|
|
def test_token_kind_returns_unknown_for_unrecognised_token() -> None:
|
|
assert copilot_auth._token_kind("some_random_token") == "unknown"
|
|
|
|
|
|
def test_token_kind_returns_empty_for_blank_token() -> None:
|
|
assert copilot_auth._token_kind("") == "empty"
|
|
assert copilot_auth._token_kind(" ") == "empty"
|
|
|
|
|
|
def test_exchange_token_sync_returns_payload_on_success(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
payload = {"token": "copilot-api", "expires_at": int(time.time()) + 3600}
|
|
|
|
class FakeResponse:
|
|
def read(self) -> bytes:
|
|
return json.dumps(payload).encode()
|
|
|
|
def __enter__(self):
|
|
return self
|
|
|
|
def __exit__(self, *args):
|
|
pass
|
|
|
|
monkeypatch.setattr(
|
|
copilot_auth.urllib_request,
|
|
"urlopen",
|
|
lambda *args, **kwargs: FakeResponse(),
|
|
)
|
|
|
|
result = copilot_auth.CopilotTokenProvider._exchange_token_sync(
|
|
{"Authorization": "Bearer gho_test"} # noqa: S105
|
|
)
|
|
|
|
assert result == payload
|