257 lines
10 KiB
Python
257 lines
10 KiB
Python
"""Tests for ``omnigent/onboarding/copilot_auth.py`` — the Copilot token store.
|
|
|
|
Copilot's GitHub token lives in a dedicated top-level ``copilot:`` config block
|
|
(not the shared global ``auth:``) and the omnigent secret store, resolved with
|
|
the same ``resolve_secret`` resolver the provider families use. These tests
|
|
isolate the config + secret store to a tmp dir (file backend, no OS keychain)
|
|
and assert the read/resolve/configured helpers behave — including the **soft**
|
|
resolution that returns ``None`` on a dangling reference instead of raising, so
|
|
a run / setup readout falls back rather than crashing. Mirrors
|
|
``test_cursor_auth.py``.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
import yaml
|
|
|
|
from omnigent.onboarding import copilot_auth, extra_install
|
|
from omnigent.onboarding import secrets as secret_store
|
|
from omnigent.onboarding.copilot_auth import (
|
|
COPILOT_SECRET_NAME,
|
|
copilot_github_token_configured,
|
|
copilot_github_token_ref,
|
|
copilot_github_token_settings,
|
|
copilot_install_command,
|
|
copilot_sdk_installed,
|
|
install_copilot_sdk,
|
|
looks_like_github_copilot_token,
|
|
resolve_copilot_github_token,
|
|
)
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _isolate(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> Path:
|
|
"""Isolate config + secrets to tmp with the file secret backend.
|
|
|
|
:returns: The tmp config-home dir, so a test can write a ``config.yaml``.
|
|
"""
|
|
monkeypatch.setenv("OMNIGENT_CONFIG_HOME", str(tmp_path))
|
|
monkeypatch.setenv("OMNIGENT_DISABLE_KEYRING", "1")
|
|
for var in ("COPILOT_GITHUB_TOKEN", "GH_TOKEN", "GITHUB_TOKEN"):
|
|
monkeypatch.delenv(var, raising=False)
|
|
return tmp_path
|
|
|
|
|
|
def _write_config(tmp_path: Path, block: dict[str, object]) -> None:
|
|
"""Write *block* as the isolated ``config.yaml``."""
|
|
(tmp_path / "config.yaml").write_text(yaml.safe_dump(block))
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Token-shape check
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_looks_like_github_copilot_token_accepts_pat_and_oauth() -> None:
|
|
assert looks_like_github_copilot_token("github_pat_ABC123")
|
|
assert looks_like_github_copilot_token("gho_ABC123")
|
|
assert looks_like_github_copilot_token("ghu_ABC123")
|
|
assert looks_like_github_copilot_token("ghs_ABC123")
|
|
|
|
|
|
def test_looks_like_github_copilot_token_rejects_classic_pat_and_junk() -> None:
|
|
# Classic ``ghp_`` PATs are not accepted by Copilot.
|
|
assert not looks_like_github_copilot_token("ghp_ABC123")
|
|
assert not looks_like_github_copilot_token("")
|
|
assert not looks_like_github_copilot_token("sk-not-a-github-token")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# ref / resolve / configured
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_ref_and_resolve_none_when_no_block(tmp_path: Path) -> None:
|
|
assert copilot_github_token_ref() is None
|
|
assert resolve_copilot_github_token() is None
|
|
assert copilot_github_token_configured() is False
|
|
|
|
|
|
def test_ref_prefers_token_ref_over_inline(tmp_path: Path) -> None:
|
|
_write_config(
|
|
tmp_path,
|
|
{"copilot": {"github_token_ref": "env:GH_TOKEN", "github_token": "literal"}},
|
|
)
|
|
assert copilot_github_token_ref() == "env:GH_TOKEN"
|
|
|
|
|
|
def test_resolve_from_env_reference(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
_write_config(tmp_path, {"copilot": {"github_token_ref": "env:GH_TOKEN"}})
|
|
monkeypatch.setenv("GH_TOKEN", "gho_resolved")
|
|
assert resolve_copilot_github_token() == "gho_resolved"
|
|
assert copilot_github_token_configured() is True
|
|
|
|
|
|
def test_resolve_from_keychain_reference(tmp_path: Path) -> None:
|
|
secret_store.store_secret(COPILOT_SECRET_NAME, "gho_stored")
|
|
_write_config(tmp_path, {"copilot": {"github_token_ref": f"keychain:{COPILOT_SECRET_NAME}"}})
|
|
assert resolve_copilot_github_token() == "gho_stored"
|
|
assert copilot_github_token_configured() is True
|
|
|
|
|
|
def test_dangling_reference_is_soft_none(tmp_path: Path) -> None:
|
|
# An ``env:`` ref to an unset var resolves to None (never raises) and reads
|
|
# as not-configured, so a run / readout falls back instead of crashing.
|
|
_write_config(tmp_path, {"copilot": {"github_token_ref": "env:GH_TOKEN_MISSING"}})
|
|
assert resolve_copilot_github_token() is None
|
|
assert copilot_github_token_configured() is False
|
|
|
|
|
|
def test_resolve_from_inline_github_token_only(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
# A hand-edited config with only the inline ``github_token`` (no
|
|
# ``github_token_ref``) still resolves via the right-hand ``or`` fallback.
|
|
_write_config(tmp_path, {"copilot": {"github_token": "env:GH_TOKEN"}})
|
|
monkeypatch.setenv("GH_TOKEN", "gho_inline")
|
|
assert copilot_github_token_ref() == "env:GH_TOKEN"
|
|
assert resolve_copilot_github_token() == "gho_inline"
|
|
assert copilot_github_token_configured() is True
|
|
|
|
|
|
def test_dangling_keychain_reference_is_soft_none(tmp_path: Path) -> None:
|
|
# A keychain ref to a name that was never stored resolves to None (never
|
|
# raises) and reads as not-configured — the realistic "deleted keychain
|
|
# entry" case, distinct from the env-var dangling case above.
|
|
_write_config(tmp_path, {"copilot": {"github_token_ref": "keychain:copilot-never-stored"}})
|
|
assert resolve_copilot_github_token() is None
|
|
assert copilot_github_token_configured() is False
|
|
|
|
|
|
def test_settings_shape() -> None:
|
|
assert copilot_github_token_settings("keychain:copilot") == {
|
|
"copilot": {"github_token_ref": "keychain:copilot"}
|
|
}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Optional-SDK extra (parity with cursor / antigravity)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_copilot_extra_install_command_targets_extra() -> None:
|
|
"""The install command targets the optional ``copilot`` extra."""
|
|
cmd = copilot_install_command()
|
|
assert "omnigent[copilot]" in cmd
|
|
assert "install" in cmd
|
|
|
|
|
|
def test_copilot_sdk_installed_true_when_spec_found(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""Detection returns True when ``find_spec`` resolves ``copilot``."""
|
|
monkeypatch.setattr(copilot_auth.importlib.util, "find_spec", lambda name: object())
|
|
assert copilot_sdk_installed() is True
|
|
|
|
|
|
def test_copilot_sdk_installed_false_when_spec_missing(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""Detection returns False when ``find_spec`` returns ``None`` (extra absent)."""
|
|
monkeypatch.setattr(copilot_auth.importlib.util, "find_spec", lambda name: None)
|
|
assert copilot_sdk_installed() is False
|
|
|
|
|
|
def test_copilot_sdk_installed_false_when_module_not_found(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""A ``ModuleNotFoundError`` from ``find_spec`` reads as not-installed.
|
|
|
|
``find_spec`` can raise (not return ``None``) when a parent package is
|
|
absent; the guard must swallow that rather than crash setup.
|
|
"""
|
|
|
|
def _raise(name: str) -> object:
|
|
raise ModuleNotFoundError("No module named 'copilot'")
|
|
|
|
monkeypatch.setattr(copilot_auth.importlib.util, "find_spec", _raise)
|
|
assert copilot_sdk_installed() is False
|
|
|
|
|
|
def test_copilot_install_command_prefers_uv(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""With ``uv`` on PATH, the install runs ``uv pip install`` — no index URL."""
|
|
monkeypatch.setattr(extra_install, "_is_uv_tool_install", lambda: False)
|
|
monkeypatch.setattr(extra_install.shutil, "which", lambda name: "/usr/bin/uv")
|
|
cmd = copilot_install_command()
|
|
assert cmd == ["uv", "pip", "install", "omnigent[copilot]"]
|
|
assert not any("index" in part or "://" in part for part in cmd)
|
|
|
|
|
|
def test_copilot_install_command_falls_back_to_pip(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""Without ``uv``, it falls back to this interpreter's pip — still no index."""
|
|
monkeypatch.setattr(extra_install, "_is_uv_tool_install", lambda: False)
|
|
monkeypatch.setattr(extra_install.shutil, "which", lambda name: None)
|
|
cmd = copilot_install_command()
|
|
assert cmd == [
|
|
extra_install.sys.executable,
|
|
"-m",
|
|
"pip",
|
|
"install",
|
|
"omnigent[copilot]",
|
|
]
|
|
assert not any("index" in part or "://" in part for part in cmd)
|
|
|
|
|
|
def test_copilot_install_command_uv_tool(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""Inside a ``uv tool`` venv, the install uses ``uv tool install --with``."""
|
|
monkeypatch.setattr(extra_install, "_is_uv_tool_install", lambda: True)
|
|
monkeypatch.setattr(extra_install, "_installed_vcs_url", lambda: None)
|
|
cmd = copilot_install_command()
|
|
assert cmd == [
|
|
"uv",
|
|
"tool",
|
|
"install",
|
|
"--with",
|
|
"omnigent[copilot]",
|
|
"omnigent",
|
|
"--force",
|
|
]
|
|
|
|
|
|
def test_install_copilot_sdk_runs_command_then_rechecks(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""Shells the install argv, then reports the post-install detection verdict.
|
|
|
|
Mocks the subprocess (never really installs): the SDK "appears" only after
|
|
the install runs, so the function must re-check and return True.
|
|
"""
|
|
import subprocess
|
|
|
|
calls: list[list[str]] = []
|
|
state = {"installed": False}
|
|
|
|
def _run(argv: list[str], *, check: bool = False, timeout: float | None = None):
|
|
calls.append(argv)
|
|
state["installed"] = True
|
|
return subprocess.CompletedProcess(args=argv, returncode=0)
|
|
|
|
monkeypatch.setattr(extra_install, "_is_uv_tool_install", lambda: False)
|
|
monkeypatch.setattr(extra_install.shutil, "which", lambda name: None)
|
|
monkeypatch.setattr(copilot_auth.subprocess, "run", _run)
|
|
monkeypatch.setattr(copilot_auth, "copilot_sdk_installed", lambda: state["installed"])
|
|
|
|
assert install_copilot_sdk() is True
|
|
assert calls == [[extra_install.sys.executable, "-m", "pip", "install", "omnigent[copilot]"]]
|
|
|
|
|
|
def test_install_copilot_sdk_false_on_spawn_failure(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""A subprocess that can't spawn (OSError) is caught and reported as False."""
|
|
|
|
def _boom(*args: object, **kwargs: object) -> object:
|
|
raise OSError("no pip")
|
|
|
|
monkeypatch.setattr(extra_install, "_is_uv_tool_install", lambda: False)
|
|
monkeypatch.setattr(extra_install.shutil, "which", lambda name: None)
|
|
monkeypatch.setattr(copilot_auth.subprocess, "run", _boom)
|
|
assert install_copilot_sdk() is False
|