Newer
Older
navi-1 / tests / unit / api / test_mcp_keys.py
"""Per-user MCP keys REST — GET listing, PUT/DELETE round-trip."""

from datetime import datetime, timezone
from unittest.mock import AsyncMock, MagicMock

import pytest
from fastapi.testclient import TestClient

from navi.config import Settings
from navi.mcp.config import McpServerConfig, McpUserKey


@pytest.fixture
def mcp_keys_client(monkeypatch):
    """Anonymous-admin test client with faked configs and key store pool."""
    import types

    import navi.api.routes.mcp_keys as keys_mod
    import navi.auth.deps as auth_deps
    import navi.mcp.config as cfg_mod
    from navi.main import app

    # Anonymous admin (auth disabled) — routes resolve without a cookie.
    monkeypatch.setattr(
        auth_deps, "settings",
        Settings(_env_file=None, navi_persona_file="", navi_auth_enabled=False),
    )
    monkeypatch.setattr(keys_mod, "_pool", AsyncMock(return_value=MagicMock()))

    # Four servers: two with a user_key slot (http-header, stdio-env), one
    # with a slot but referenced by no profile, one keyless but referenced.
    cfgs = {
        "http-server": McpServerConfig(
            transport="streamable_http",
            url="https://http",
            headers={"Authorization": "Bearer default"},
            user_key=McpUserKey(header="Authorization", prefix="Bearer "),
        ),
        "stdio-server": McpServerConfig(
            transport="stdio",
            command="uvx",
            env={"API_KEY": "default"},
            user_key=McpUserKey(env="API_KEY"),
        ),
        "unreferenced": McpServerConfig(
            transport="sse",
            url="https://unref",
            user_key=McpUserKey(header="Authorization"),
        ),
        "no-slider": McpServerConfig(transport="sse", url="https://plain"),
    }
    monkeypatch.setattr(cfg_mod, "load_mcp_servers", lambda path=None: cfgs)

    profile = types.SimpleNamespace(
        id="server_admin",
        tools=types.SimpleNamespace(
            agent=types.SimpleNamespace(
                mcp={"http-server": [], "stdio-server": [], "no-slider": []},
            ),
            subagent=types.SimpleNamespace(mcp={"http-server": []}),
        ),
    )

    class _Registry:
        def all(self):
            return [profile]

    import navi.api.deps as deps_mod

    monkeypatch.setattr(deps_mod, "get_profile_registry", lambda: _Registry())

    return TestClient(app)


def test_get_lists_every_referenced_server(mcp_keys_client, monkeypatch):
    import navi.mcp.keystore as keystore_mod

    store = MagicMock()
    store.list_servers = AsyncMock(return_value={})
    monkeypatch.setattr(keystore_mod, "McpKeyStore", MagicMock(return_value=store))

    data = mcp_keys_client.get("/mcp-keys").json()
    names = [i["server_name"] for i in data["items"]]
    # Keyed servers first (actionable rows), then the keyless one; the
    # unreferenced server never shows up.
    assert names == ["http-server", "stdio-server", "no-slider"]

    by_name = {i["server_name"]: i for i in data["items"]}
    assert by_name["http-server"]["accepts_user_key"] is True
    assert by_name["http-server"]["key_type"] == "header"
    assert by_name["http-server"]["key_location"] == "Authorization"
    assert by_name["http-server"]["prefix"] == "Bearer "
    assert by_name["http-server"]["has_key"] is False
    assert by_name["http-server"]["updated_at"] is None
    assert by_name["http-server"]["profiles"] == ["server_admin"]
    assert by_name["stdio-server"]["key_type"] == "env"
    assert by_name["stdio-server"]["key_location"] == "API_KEY"

    # A keyless server is listed as a plain row: no slot, nothing to save.
    assert by_name["no-slider"]["accepts_user_key"] is False
    assert by_name["no-slider"]["key_type"] is None
    assert by_name["no-slider"]["key_location"] is None
    assert by_name["no-slider"]["has_key"] is False

    # The shared credential value itself is never present anywhere.
    assert "default" not in mcp_keys_client.get("/mcp-keys").text


def test_get_empty_when_nothing_is_referenced(mcp_keys_client, monkeypatch):
    import navi.mcp.config as cfg_mod

    monkeypatch.setattr(
        cfg_mod, "load_mcp_servers",
        lambda path=None: {"nobody-uses-me": McpServerConfig(transport="sse", url="https://x")},
    )
    assert mcp_keys_client.get("/mcp-keys").json() == {"items": []}


def test_get_skips_the_key_store_without_any_slot(mcp_keys_client, monkeypatch):
    """No server accepts a key → no per-user lookup, but the rows still show."""
    import navi.mcp.config as cfg_mod
    import navi.mcp.keystore as keystore_mod

    monkeypatch.setattr(
        cfg_mod, "load_mcp_servers",
        lambda path=None: {"no-slider": McpServerConfig(transport="sse", url="https://x")},
    )
    store_cls = MagicMock()
    monkeypatch.setattr(keystore_mod, "McpKeyStore", store_cls)
    # The fixture connects "no-slider" through its profile.
    data = mcp_keys_client.get("/mcp-keys").json()
    assert [i["server_name"] for i in data["items"]] == ["no-slider"]
    store_cls.assert_not_called()


def test_get_lists_saved_key_presence(mcp_keys_client, monkeypatch):
    import navi.mcp.keystore as keystore_mod

    ts = datetime(2026, 10, 7, tzinfo=timezone.utc)
    store = MagicMock()
    store.list_servers = AsyncMock(return_value={"http-server": ts})
    monkeypatch.setattr(keystore_mod, "McpKeyStore", MagicMock(return_value=store))

    data = mcp_keys_client.get("/mcp-keys").json()
    by_name = {i["server_name"]: i for i in data["items"]}
    assert by_name["http-server"]["has_key"] is True
    assert by_name["http-server"]["updated_at"] == ts.isoformat()
    assert by_name["stdio-server"]["has_key"] is False
    assert by_name["no-slider"]["has_key"] is False


class TestPut:
    def test_save_and_invalidate(self, mcp_keys_client, monkeypatch):
        import navi.mcp.keystore as keystore_mod

        store = MagicMock()
        store = MagicMock()
        store.set = AsyncMock(return_value=datetime(2026, 10, 7, tzinfo=timezone.utc))
        invalidated: list = []

        class FakeResolver:
            def invalidate(self, user_id, server_name):
                invalidated.append((user_id, server_name))

        monkeypatch.setattr(keystore_mod, "McpKeyStore", MagicMock(return_value=store))
        monkeypatch.setattr(keystore_mod, "get_key_resolver", lambda: FakeResolver())

        resp = mcp_keys_client.put("/mcp-keys/http-server", json={"key": "sk-user"})
        assert resp.status_code == 200
        body = resp.json()
        assert body["server_name"] == "http-server"
        assert body["has_key"] is True
        store.set.assert_awaited_once()
        # key passed stripped, value never logged in the response
        assert store.set.await_args.args[2] == "sk-user"
        assert "sk-user" not in resp.text
        assert len(invalidated) == 1

    def test_save_404_on_unknown_server(self, mcp_keys_client):
        resp = mcp_keys_client.put("/mcp-keys/nope", json={"key": "sk"})
        assert resp.status_code == 404

    def test_save_404_on_keyless_server(self, mcp_keys_client):
        resp = mcp_keys_client.put("/mcp-keys/no-slider", json={"key": "sk"})
        assert resp.status_code == 404

    def test_save_422_on_empty_key(self, mcp_keys_client):
        resp = mcp_keys_client.put("/mcp-keys/http-server", json={"key": ""})
        assert resp.status_code == 422


class TestDelete:
    def test_delete_204(self, mcp_keys_client, monkeypatch):
        import navi.mcp.keystore as keystore_mod

        store = MagicMock()
        store.delete = AsyncMock(return_value=True)
        invalidated: list = []

        class FakeResolver:
            def invalidate(self, user_id, server_name):
                invalidated.append((user_id, server_name))

        monkeypatch.setattr(keystore_mod, "McpKeyStore", MagicMock(return_value=store))
        monkeypatch.setattr(keystore_mod, "get_key_resolver", lambda: FakeResolver())

        resp = mcp_keys_client.delete("/mcp-keys/http-server")
        assert resp.status_code == 204
        assert len(invalidated) == 1

    def test_missing_key_is_still_204(self, mcp_keys_client, monkeypatch):
        import navi.mcp.keystore as keystore_mod

        store = MagicMock()
        store.delete = AsyncMock(return_value=False)
        monkeypatch.setattr(keystore_mod, "McpKeyStore", MagicMock(return_value=store))

        resp = mcp_keys_client.delete("/mcp-keys/http-server")
        assert resp.status_code == 204

    def test_delete_404_on_unknown_server(self, mcp_keys_client):
        resp = mcp_keys_client.delete("/mcp-keys/nope")
        assert resp.status_code == 404