Newer
Older
navi-1 / tests / unit / mcp / test_secrets.py
"""Unit tests for `${VAR}` credential resolution in MCP server configs."""

import pytest

from navi.mcp.config import McpServerConfig
from navi.mcp.secrets import env_values, resolve_secrets


def _http(authorization: str) -> McpServerConfig:
    return McpServerConfig(
        transport="streamable_http",
        url="https://creds.example.com/mcp",
        headers={"Authorization": authorization},
    )


class TestSubstitution:
    def test_header_placeholder_is_resolved(self):
        resolved = resolve_secrets(
            _http("Bearer ${NAVI_TEST_TOKEN}"), {"NAVI_TEST_TOKEN": "s3cret"}, server="demo"
        )
        assert resolved.headers["Authorization"] == "Bearer s3cret"

    def test_stdio_env_placeholder_is_resolved(self):
        cfg = McpServerConfig(
            transport="stdio", command="python", env={"API_KEY": "${NAVI_TEST_KEY}"}
        )
        assert resolve_secrets(cfg, {"NAVI_TEST_KEY": "abc"}).env["API_KEY"] == "abc"

    def test_several_placeholders_in_one_value(self):
        cfg = McpServerConfig(transport="stdio", command="python", env={"PAIR": "${A}:${B}"})
        assert resolve_secrets(cfg, {"A": "1", "B": "2"}).env["PAIR"] == "1:2"

    def test_bare_dollar_names_are_left_alone(self):
        """Header values legitimately contain `$` (nginx-style) — only the
        braced form opts in."""
        resolved = resolve_secrets(_http("Bearer $NAVI_TEST_TOKEN"), {"NAVI_TEST_TOKEN": "x"})
        assert resolved.headers["Authorization"] == "Bearer $NAVI_TEST_TOKEN"

    def test_only_headers_and_env_are_scanned(self):
        cfg = McpServerConfig(
            transport="streamable_http", url="https://${HOST}/mcp", command="${CMD}"
        )
        resolved = resolve_secrets(cfg, {"HOST": "creds.example.com", "CMD": "python"})
        assert resolved.url == "https://${HOST}/mcp"
        assert resolved.command == "${CMD}"

    def test_values_without_placeholders_are_untouched(self):
        cfg = McpServerConfig(
            transport="stdio",
            command="python",
            env={"FLAG": "a/b", "SESSION_FILES_DIR": "./session_files"},
        )
        assert resolve_secrets(cfg, {}).env == {
            "FLAG": "a/b",
            "SESSION_FILES_DIR": "./session_files",
        }

    def test_does_not_mutate_the_original(self):
        """`save_mcp_servers` writes configs back to the file: a resolved copy
        leaking into it would bake the credential into a tracked file."""
        cfg = _http("Bearer ${NAVI_TEST_TOKEN}")
        resolve_secrets(cfg, {"NAVI_TEST_TOKEN": "s3cret"})
        assert cfg.headers["Authorization"] == "Bearer ${NAVI_TEST_TOKEN}"

    def test_resolution_defaults_to_the_environment(self, monkeypatch):
        monkeypatch.setenv("NAVI_TEST_FROM_PROCESS", "from-process")
        resolved = resolve_secrets(_http("Bearer ${NAVI_TEST_FROM_PROCESS}"))
        assert resolved.headers["Authorization"] == "Bearer from-process"


class TestMissingVariable:
    """A missing value fails loudly instead of dropping the header: a server may
    answer an unauthenticated request as an anonymous user rather than 401."""

    def test_raises_naming_the_server_and_the_variable(self):
        with pytest.raises(ValueError) as excinfo:
            resolve_secrets(
                _http("Bearer ${NAVI_MCP_GNTODO_TOKEN}"), {}, server="gntodo"
            )
        message = str(excinfo.value)
        assert "gntodo" in message
        assert "NAVI_MCP_GNTODO_TOKEN" in message

    def test_names_the_field_that_references_it(self):
        with pytest.raises(ValueError) as excinfo:
            resolve_secrets(_http("${NAVI_TEST_MISSING}"), {}, server="demo")
        assert "headers.Authorization" in str(excinfo.value)


class TestEnvValues:
    def test_process_environment_beats_the_env_file(self, monkeypatch, tmp_path):
        monkeypatch.chdir(tmp_path)
        (tmp_path / ".env").write_text("NAVI_TEST_PREC=from-file\n")
        monkeypatch.setenv("NAVI_TEST_PREC", "from-env")
        assert env_values()["NAVI_TEST_PREC"] == "from-env"

    def test_blank_value_in_the_env_file_is_treated_as_missing(self, monkeypatch, tmp_path):
        monkeypatch.chdir(tmp_path)
        (tmp_path / ".env").write_text("NAVI_TEST_BLANK=\nNAVI_TEST_FILLED=ok\n")
        monkeypatch.delenv("NAVI_TEST_BLANK", raising=False)

        values = env_values()
        assert "NAVI_TEST_BLANK" not in values
        assert values["NAVI_TEST_FILLED"] == "ok"

    def test_missing_env_file_falls_back_to_the_process_environment(self, monkeypatch, tmp_path):
        monkeypatch.chdir(tmp_path)  # no .env here
        monkeypatch.setenv("NAVI_TEST_ONLY_ENV", "v")
        assert env_values()["NAVI_TEST_ONLY_ENV"] == "v"