274 lines
11 KiB
Python
274 lines
11 KiB
Python
"""Tests for ``omnigent/onboarding/cursor_auth.py`` — the Cursor API-key store.
|
|
|
|
Cursor's ``CURSOR_API_KEY`` lives in a dedicated top-level ``cursor:`` 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.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
import yaml
|
|
|
|
from omnigent.onboarding import cursor_auth, extra_install
|
|
from omnigent.onboarding import secrets as secret_store
|
|
from omnigent.onboarding.cursor_auth import (
|
|
CURSOR_SECRET_NAME,
|
|
cursor_api_key_configured,
|
|
cursor_api_key_ref,
|
|
cursor_api_key_settings,
|
|
cursor_install_command,
|
|
cursor_sdk_installed,
|
|
install_cursor_sdk,
|
|
looks_like_cursor_api_key,
|
|
resolve_cursor_api_key,
|
|
)
|
|
|
|
|
|
@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")
|
|
monkeypatch.delenv("CURSOR_API_KEY", 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))
|
|
|
|
|
|
def test_looks_like_cursor_api_key() -> None:
|
|
"""The soft prefix check accepts ``crsr_`` keys and rejects others."""
|
|
assert looks_like_cursor_api_key("crsr_AbC123")
|
|
assert not looks_like_cursor_api_key("sk-ant-123")
|
|
assert not looks_like_cursor_api_key("")
|
|
|
|
|
|
def test_unconfigured_reads_as_none(_isolate: Path) -> None:
|
|
"""With no ``cursor:`` block, every accessor reports "not configured"."""
|
|
assert cursor_api_key_ref() is None
|
|
assert resolve_cursor_api_key() is None
|
|
assert cursor_api_key_configured() is False
|
|
|
|
|
|
def test_keychain_ref_resolves(_isolate: Path) -> None:
|
|
"""A ``keychain:`` ref resolves to the secret stored under that name."""
|
|
secret_store.store_secret(CURSOR_SECRET_NAME, "crsr_stored")
|
|
_write_config(_isolate, {"cursor": {"api_key_ref": "keychain:cursor"}})
|
|
assert cursor_api_key_ref() == "keychain:cursor"
|
|
assert resolve_cursor_api_key() == "crsr_stored"
|
|
assert cursor_api_key_configured() is True
|
|
|
|
|
|
def test_env_ref_resolves(_isolate: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""An ``env:`` ref resolves from the environment (no secret-store entry)."""
|
|
monkeypatch.setenv("MY_CURSOR_KEY", "crsr_fromenv")
|
|
_write_config(_isolate, {"cursor": {"api_key_ref": "env:MY_CURSOR_KEY"}})
|
|
assert resolve_cursor_api_key() == "crsr_fromenv"
|
|
assert cursor_api_key_configured() is True
|
|
|
|
|
|
def test_env_ref_strips_surrounding_whitespace(
|
|
_isolate: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
"""A whitespace-padded ``env:`` key validates and resolves cleanly (F103).
|
|
|
|
A ``CURSOR_API_KEY`` exported with a stray leading/trailing newline (a
|
|
common ``export $(…)`` mishap) must strip before the ``crsr_`` prefix
|
|
check and before forwarding to the SDK — else the padding fails the
|
|
``looks_like`` validation and reaches the harness verbatim.
|
|
"""
|
|
monkeypatch.setenv("CURSOR_API_KEY", "\ncrsr_test\n")
|
|
_write_config(_isolate, {"cursor": {"api_key_ref": "env:CURSOR_API_KEY"}})
|
|
resolved = resolve_cursor_api_key()
|
|
assert resolved == "crsr_test"
|
|
assert looks_like_cursor_api_key(resolved)
|
|
assert cursor_api_key_configured() is True
|
|
|
|
|
|
def test_empty_env_ref_reads_as_unconfigured(
|
|
_isolate: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
"""A configured ``env:`` ref pointing at an EMPTY var reads as not-configured.
|
|
|
|
The shared ``resolve_secret`` ``env:`` branch only raises on an *unset*
|
|
variable, so an exported-but-empty ``CURSOR_API_KEY=""`` (or all-whitespace)
|
|
resolves to ``""``. The cursor layer must fold that to ``None`` so the setup
|
|
readout (``cursor_api_key_configured``) and the spawn-env builder agree —
|
|
both treat it as unset rather than claiming a key the runtime won't forward.
|
|
"""
|
|
monkeypatch.setenv("CURSOR_API_KEY", "")
|
|
_write_config(_isolate, {"cursor": {"api_key_ref": "env:CURSOR_API_KEY"}})
|
|
assert resolve_cursor_api_key() is None
|
|
assert cursor_api_key_configured() is False
|
|
|
|
|
|
def test_whitespace_only_env_ref_reads_as_unconfigured(
|
|
_isolate: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
"""A configured ``env:`` ref pointing at an all-whitespace var is not-configured."""
|
|
monkeypatch.setenv("CURSOR_API_KEY", " \n\t ")
|
|
_write_config(_isolate, {"cursor": {"api_key_ref": "env:CURSOR_API_KEY"}})
|
|
assert resolve_cursor_api_key() is None
|
|
assert cursor_api_key_configured() is False
|
|
|
|
|
|
def test_inline_api_key_field_accepted(_isolate: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""A hand-edited inline ``api_key: $VAR`` is honored as a fallback shape."""
|
|
monkeypatch.setenv("INLINE_CURSOR", "crsr_inline")
|
|
_write_config(_isolate, {"cursor": {"api_key": "$INLINE_CURSOR"}})
|
|
assert resolve_cursor_api_key() == "crsr_inline"
|
|
|
|
|
|
def test_dangling_keychain_ref_is_soft_none(_isolate: Path) -> None:
|
|
"""A reference to a never-stored keychain entry resolves softly to ``None``.
|
|
|
|
Failure (an ``OmnigentError`` escaping) would crash a cursor run / the
|
|
setup readout on a deleted secret instead of falling back to cursor's own
|
|
login.
|
|
"""
|
|
_write_config(_isolate, {"cursor": {"api_key_ref": "keychain:cursor"}})
|
|
assert resolve_cursor_api_key() is None
|
|
assert cursor_api_key_configured() is False
|
|
|
|
|
|
def test_unset_env_ref_is_soft_none(_isolate: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""An ``env:`` ref to an unset variable resolves softly to ``None``."""
|
|
monkeypatch.delenv("NOPE_CURSOR_KEY", raising=False)
|
|
_write_config(_isolate, {"cursor": {"api_key_ref": "env:NOPE_CURSOR_KEY"}})
|
|
assert resolve_cursor_api_key() is None
|
|
|
|
|
|
def test_settings_shape() -> None:
|
|
"""``cursor_api_key_settings`` builds the dedicated ``cursor:`` block."""
|
|
assert cursor_api_key_settings("keychain:cursor") == {
|
|
"cursor": {"api_key_ref": "keychain:cursor"}
|
|
}
|
|
|
|
|
|
# ── SDK-extra detection + install (the optional ``cursor`` extra) ─────────────
|
|
# ``cursor-sdk`` is now an OPTIONAL extra, so setup must detect a missing SDK
|
|
# and offer to install it.
|
|
|
|
|
|
def test_cursor_sdk_installed_true_when_spec_found(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""Detection returns True when ``find_spec`` resolves ``cursor_sdk``."""
|
|
monkeypatch.setattr(
|
|
cursor_auth.importlib.util,
|
|
"find_spec",
|
|
lambda name: object(),
|
|
)
|
|
assert cursor_sdk_installed() is True
|
|
|
|
|
|
def test_cursor_sdk_installed_false_when_spec_missing(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""Detection returns False when ``find_spec`` returns ``None`` (extra absent)."""
|
|
monkeypatch.setattr(cursor_auth.importlib.util, "find_spec", lambda name: None)
|
|
assert cursor_sdk_installed() is False
|
|
|
|
|
|
def test_cursor_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 'cursor_sdk'")
|
|
|
|
monkeypatch.setattr(cursor_auth.importlib.util, "find_spec", _raise)
|
|
assert cursor_sdk_installed() is False
|
|
|
|
|
|
def test_cursor_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 = cursor_install_command()
|
|
assert cmd == ["uv", "pip", "install", "omnigent[cursor]"]
|
|
assert not any("index" in part or "://" in part for part in cmd)
|
|
|
|
|
|
def test_cursor_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 = cursor_install_command()
|
|
assert cmd == [
|
|
extra_install.sys.executable,
|
|
"-m",
|
|
"pip",
|
|
"install",
|
|
"omnigent[cursor]",
|
|
]
|
|
assert not any("index" in part or "://" in part for part in cmd)
|
|
|
|
|
|
def test_cursor_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 = cursor_install_command()
|
|
assert cmd == [
|
|
"uv",
|
|
"tool",
|
|
"install",
|
|
"--with",
|
|
"omnigent[cursor]",
|
|
"omnigent",
|
|
"--force",
|
|
]
|
|
|
|
|
|
def test_install_cursor_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(cursor_auth.subprocess, "run", _run)
|
|
monkeypatch.setattr(cursor_auth, "cursor_sdk_installed", lambda: state["installed"])
|
|
|
|
assert install_cursor_sdk() is True
|
|
assert calls == [[extra_install.sys.executable, "-m", "pip", "install", "omnigent[cursor]"]]
|
|
|
|
|
|
def test_install_cursor_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(cursor_auth.subprocess, "run", _boom)
|
|
assert install_cursor_sdk() is False
|