Newer
Older
navi-1 / tests / unit / overlay / test_validate.py
"""Overlay validation — all-or-nothing for behaviour, warn-and-drop for shape."""

import pytest

from navi.overlay.validate import validate_envelope, validate_layers
from navi.settings_model import Settings


@pytest.fixture()
def base() -> Settings:
    return Settings(_env_file=None)


@pytest.fixture(autouse=True)
def _empty_mcp(monkeypatch):
    # Validation merges against the local server definitions; the tests must
    # not depend on whatever this checkout happens to have in mcp_servers.d.
    import navi.mcp.config as mcp_config

    monkeypatch.setattr(mcp_config, "load_mcp_servers", lambda path=None: {})


def _layers(*layers):
    return [{"name": f"layer{i}", "config": cfg} for i, cfg in enumerate(layers)]


def test_empty_document_is_a_valid_empty_overlay(base):
    result = validate_layers(base, [])
    assert result.ok
    assert result.merged == {}


def test_unknown_section_warns_and_is_dropped(base):
    result = validate_layers(base, _layers({"nonsense": {"a": 1}}))
    assert result.ok
    assert result.merged == {}
    assert any("nonsense" in w for w in result.warnings)


def test_unknown_setting_is_dropped_with_a_warning(base):
    result = validate_layers(base, _layers({"settings": {"no_such_field": 1}}))
    assert result.ok
    assert result.merged == {}
    assert "settings.no_such_field" in result.unknown_fields


def test_denied_setting_is_refused_not_applied(base):
    result = validate_layers(
        base, _layers({"settings": {"database_url": "postgresql://evil/navidb"}})
    )
    assert result.ok
    assert result.merged == {}
    assert "settings.database_url" in result.forbidden_fields


def test_invalid_setting_value_rejects_the_whole_document(base):
    result = validate_layers(
        base,
        _layers(
            {"settings": {"llm_complete_timeout": 30}},
            {"settings": {"llm_complete_timeout": "not-a-number"}},
        ),
    )
    # The good layer would have been useful, but a document that is half-broken
    # is not a document anyone wrote — nothing from it is applied.
    assert not result.ok
    assert result.merged == {}
    assert any("llm_complete_timeout" in e for e in result.errors)


def test_layers_merge_in_order(base):
    result = validate_layers(
        base,
        _layers(
            {"settings": {"llm_complete_timeout": 30, "llm_complete_retries": 1}},
            {"settings": {"llm_complete_timeout": 45}},
        ),
    )
    assert result.ok
    assert result.merged["settings"] == {
        "llm_complete_timeout": 45,
        "llm_complete_retries": 1,
    }


# ── profiles ────────────────────────────────────────────────────────────────


def test_unknown_profile_is_ignored_with_a_warning(base):
    result = validate_layers(base, _layers({"profiles": {"nobody": {"temperature": 0.1}}}))
    assert result.ok
    assert result.merged == {}
    assert any("nobody" in w for w in result.warnings)


def test_unknown_profile_field_is_dropped(base):
    result = validate_layers(
        base, _layers({"profiles": {"assistant": {"not_a_field": True}}})
    )
    assert result.ok
    assert result.merged == {}
    assert "profiles.assistant.not_a_field" in result.unknown_fields


def test_profile_change_is_kept(base):
    result = validate_layers(
        base, _layers({"profiles": {"assistant": {"temperature": 0.25}}})
    )
    assert result.ok
    assert result.merged["profiles"]["assistant"] == {"temperature": 0.25}


def test_invalid_profile_value_rejects_the_document(base):
    result = validate_layers(
        base, _layers({"profiles": {"assistant": {"temperature": "hot"}}})
    )
    assert not result.ok
    assert result.merged == {}


def test_profile_id_cannot_be_changed(base):
    result = validate_layers(base, _layers({"profiles": {"assistant": {"id": "other"}}}))
    assert not result.ok
    assert any("id" in e for e in result.errors)


def test_tool_lists_are_validated_by_scope(base):
    result = validate_layers(
        base,
        _layers(
            {
                "profiles": {
                    "assistant": {
                        "tools": {
                            "agent": {"native": ["filesystem", "terminal"]},
                            "bogus_scope": {"native": []},
                            "subagent": {"nonsense": []},
                        }
                    }
                }
            }
        ),
    )
    assert result.ok
    assert result.merged["profiles"]["assistant"]["tools"] == {
        "agent": {"native": ["filesystem", "terminal"]},
        "subagent": {},
    }
    assert "profiles.assistant.tools.bogus_scope" in result.unknown_fields
    assert "profiles.assistant.tools.subagent.nonsense" in result.unknown_fields


# ── llm ─────────────────────────────────────────────────────────────────────


def test_llm_patch_is_merged_over_the_local_file(base, tmp_path):
    local = tmp_path / "llm.json"
    local.write_text(
        '{"targets": {"home": {"host": "http://127.0.0.1:11434"}},'
        ' "routes": {"default": [{"model": "m", "target": "home"}]}}'
    )
    base = base.model_copy(update={"llm_config_file": str(local)})
    result = validate_layers(
        base,
        _layers({"llm": {"targets": {"home": {"host": "http://192.168.1.129:11434"}}}}),
    )
    assert result.ok
    assert result.merged["llm"] == {
        "targets": {"home": {"host": "http://192.168.1.129:11434"}}
    }


def test_llm_unknown_key_is_dropped(base):
    result = validate_layers(base, _layers({"llm": {"routes": {}, "extra": 1}}))
    assert result.ok
    assert "llm.extra" in result.unknown_fields


def test_broken_llm_route_rejects_the_document(base, tmp_path):
    local = tmp_path / "llm.json"
    local.write_text(
        '{"targets": {"home": {"host": "http://127.0.0.1:11434"}},'
        ' "routes": {"default": [{"model": "m", "target": "home"}]}}'
    )
    base = base.model_copy(update={"llm_config_file": str(local)})
    result = validate_layers(
        base, _layers({"llm": {"routes": {"default": [{"model": "m", "target": "nowhere"}]}}})
    )
    assert not result.ok
    assert any("nowhere" in e for e in result.errors)


def test_broken_local_llm_file_does_not_block_a_fix_from_above(base, tmp_path):
    local = tmp_path / "llm.json"
    local.write_text("{not json")
    base = base.model_copy(update={"llm_config_file": str(local)})
    result = validate_layers(
        base,
        _layers(
            {
                "llm": {
                    "targets": {"home": {"host": "http://127.0.0.1:11434"}},
                    "routes": {"default": [{"model": "m", "target": "home"}]},
                }
            }
        ),
    )
    assert result.ok
    assert any("llm.json" in w for w in result.warnings)


# ── mcp ─────────────────────────────────────────────────────────────────────


def test_mcp_server_field_is_merged(base, monkeypatch):
    import navi.mcp.config as mcp_config

    existing = mcp_config.McpServerConfig(transport="stdio", command="old")
    monkeypatch.setattr(
        mcp_config, "load_mcp_servers", lambda path=None: {"srv": existing}
    )
    result = validate_layers(
        base, _layers({"mcp_servers": {"srv": {"command": "new"}}})
    )
    assert result.ok
    assert result.merged["mcp_servers"] == {"srv": {"command": "new"}}


def test_mcp_null_is_a_merge_patch_delete_not_a_removal(base, monkeypatch):
    """``null`` drops the key at the merge level, so it never reaches _clean_mcp.

    This is documented behaviour: the overlay can add or override a server
    definition, never remove one.
    """
    result = validate_layers(base, _layers({"mcp_servers": {"srv": None}}))
    assert result.ok
    assert result.merged == {}


def test_mcp_unknown_field_is_dropped(base, monkeypatch):
    result = validate_layers(base, _layers({"mcp_servers": {"new": {"nope": 1}}}))
    assert result.ok
    assert "mcp_servers.new.nope" in result.unknown_fields


def test_invalid_mcp_config_rejects_the_document(base, monkeypatch):
    result = validate_layers(base, _layers({"mcp_servers": {"srv": {"transport": "carrier-pigeon"}}}))
    assert not result.ok


# ── envelope ────────────────────────────────────────────────────────────────


def test_envelope_rejects_non_list():
    with pytest.raises(ValueError):
        validate_envelope({"layers": []})


def test_envelope_rejects_layer_without_config():
    with pytest.raises(ValueError):
        validate_envelope([{"name": "x"}])


def test_envelope_defaults_missing_name():
    usable, _ = validate_envelope([{"config": {}}])
    assert usable == [{"name": "layer0", "config": {}}]