"""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