diff --git a/tests/test_copilot_auth.py b/tests/test_copilot_auth.py index d3bd94221..17a9d4cbe 100644 --- a/tests/test_copilot_auth.py +++ b/tests/test_copilot_auth.py @@ -4,6 +4,8 @@ import asyncio import json import time from pathlib import Path +from types import SimpleNamespace +from urllib import error as urllib_error import pytest @@ -15,6 +17,42 @@ def test_read_cached_oauth_token_prefers_env(monkeypatch: pytest.MonkeyPatch) -> assert copilot_auth.read_cached_oauth_token() == "gho-env" +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) @@ -100,6 +138,41 @@ def test_read_gh_cli_oauth_token_uses_hostname(monkeypatch: pytest.MonkeyPatch) 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.subprocess, "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.subprocess, + "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.subprocess, + "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") @@ -107,6 +180,12 @@ def test_resolve_client_bearer_token_prefers_api_token(monkeypatch: pytest.Monke 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") @@ -208,3 +287,78 @@ def test_token_provider_can_exchange_when_enabled(monkeypatch: pytest.MonkeyPatc assert first.token == "copilot-api" assert second.token == "copilot-api" assert calls["count"] == 1 + + +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