"""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": {}}]