host.controller_web_editor.tests.test_config_roundtrip

Round-trip tests for the JSON <-> FullConfig conversion.

Generated by Claude

Confirms the web editor can load any of the committed YAML configs and serialize them back without losing information. Also exercises the path allowlist to catch traversal mistakes early.

  1"""Round-trip tests for the JSON <-> FullConfig conversion.
  2
  3# Generated by Claude
  4
  5Confirms the web editor can load any of the committed YAML configs and
  6serialize them back without losing information.  Also exercises the
  7path allowlist to catch traversal mistakes early.
  8"""
  9
 10import os
 11import sys
 12
 13import pytest
 14
 15
 16# Make ``host.controller_web_editor`` importable when pytest is run from any cwd.
 17_REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", ".."))
 18if _REPO_ROOT not in sys.path:
 19    sys.path.insert(0, _REPO_ROOT)
 20
 21
 22from host.controller_web_editor.paths import (  # noqa: E402
 23    PathNotAllowedError,
 24    list_yaml_configs,
 25    safe_resolve,
 26)
 27from host.controller_web_editor.routes.config import (  # noqa: E402
 28    config_from_json,
 29    config_to_json,
 30    load_as_json,
 31    save_from_json,
 32)
 33from utils.controller.config_io import load_config  # noqa: E402
 34
 35
 36_DATA_INPUTS = os.path.join(_REPO_ROOT, "data", "inputs")
 37
 38
 39def _config_paths() -> list[str]:
 40    return [
 41        os.path.join(_DATA_INPUTS, name)
 42        for name in os.listdir(_DATA_INPUTS)
 43        if name.endswith(".yaml")
 44    ]
 45
 46
 47class TestPathAllowlist:
 48    def test_allows_data_paths(self):
 49        resolved = safe_resolve("data/inputs/controller.yaml")
 50        assert resolved.is_file()
 51
 52    def test_rejects_outside_paths(self):
 53        with pytest.raises(PathNotAllowedError):
 54            safe_resolve("pyproject.toml")
 55
 56    def test_rejects_traversal(self):
 57        with pytest.raises(PathNotAllowedError):
 58            safe_resolve("data/../pyproject.toml")
 59
 60    def test_rejects_empty(self):
 61        with pytest.raises(PathNotAllowedError):
 62            safe_resolve("")
 63
 64    def test_list_includes_controller_yaml(self):
 65        configs = list_yaml_configs()
 66        assert "data/inputs/controller.yaml" in configs
 67
 68
 69class TestJsonRoundtrip:
 70    @pytest.mark.parametrize("path", _config_paths())
 71    def test_load_returns_jsonable(self, path):
 72        payload = load_as_json(path)
 73        # All values must survive json.dumps without a custom encoder.
 74        import json
 75        json.dumps(payload)
 76        assert payload["version"]
 77        assert isinstance(payload["actions"], dict)
 78        assert isinstance(payload["controllers"], dict)
 79
 80    @pytest.mark.parametrize("path", _config_paths())
 81    def test_roundtrip_preserves_actions(self, path):
 82        original = load_config(path)
 83        payload = config_to_json(original)
 84        rebuilt = config_from_json(payload)
 85
 86        assert set(rebuilt.actions) == set(original.actions)
 87        for qname, action in original.actions.items():
 88            other = rebuilt.actions[qname]
 89            assert other.input_type == action.input_type
 90            assert other.trigger_mode == action.trigger_mode
 91            assert other.description == action.description
 92            assert other.group == action.group
 93            assert other.deadband == action.deadband
 94            assert other.threshold == action.threshold
 95            assert other.inversion == action.inversion
 96            assert other.slew_rate == action.slew_rate
 97            assert other.scale == action.scale
 98            assert other.extra == action.extra
 99
100    @pytest.mark.parametrize("path", _config_paths())
101    def test_roundtrip_preserves_controllers(self, path):
102        original = load_config(path)
103        rebuilt = config_from_json(config_to_json(original))
104        assert set(rebuilt.controllers) == set(original.controllers)
105        for port, ctrl in original.controllers.items():
106            other = rebuilt.controllers[port]
107            assert other.name == ctrl.name
108            assert other.controller_type == ctrl.controller_type
109            assert other.bindings == ctrl.bindings
110
111    def test_save_then_load_matches(self, tmp_path):
112        src = os.path.join(_DATA_INPUTS, "controller.yaml")
113        payload = load_as_json(src)
114        dst = tmp_path / "out.yaml"
115        save_from_json(dst, payload)
116        reloaded = load_as_json(dst)
117        # Compare the substantive fields; version comes from CONFIG_VERSION
118        # either way so equality is safe.
119        assert reloaded["actions"] == payload["actions"]
120        assert reloaded["controllers"] == payload["controllers"]
121        assert set(reloaded["empty_groups"]) == set(payload["empty_groups"])
class TestPathAllowlist:
48class TestPathAllowlist:
49    def test_allows_data_paths(self):
50        resolved = safe_resolve("data/inputs/controller.yaml")
51        assert resolved.is_file()
52
53    def test_rejects_outside_paths(self):
54        with pytest.raises(PathNotAllowedError):
55            safe_resolve("pyproject.toml")
56
57    def test_rejects_traversal(self):
58        with pytest.raises(PathNotAllowedError):
59            safe_resolve("data/../pyproject.toml")
60
61    def test_rejects_empty(self):
62        with pytest.raises(PathNotAllowedError):
63            safe_resolve("")
64
65    def test_list_includes_controller_yaml(self):
66        configs = list_yaml_configs()
67        assert "data/inputs/controller.yaml" in configs
def test_allows_data_paths(self):
49    def test_allows_data_paths(self):
50        resolved = safe_resolve("data/inputs/controller.yaml")
51        assert resolved.is_file()
def test_rejects_outside_paths(self):
53    def test_rejects_outside_paths(self):
54        with pytest.raises(PathNotAllowedError):
55            safe_resolve("pyproject.toml")
def test_rejects_traversal(self):
57    def test_rejects_traversal(self):
58        with pytest.raises(PathNotAllowedError):
59            safe_resolve("data/../pyproject.toml")
def test_rejects_empty(self):
61    def test_rejects_empty(self):
62        with pytest.raises(PathNotAllowedError):
63            safe_resolve("")
def test_list_includes_controller_yaml(self):
65    def test_list_includes_controller_yaml(self):
66        configs = list_yaml_configs()
67        assert "data/inputs/controller.yaml" in configs
class TestJsonRoundtrip:
 70class TestJsonRoundtrip:
 71    @pytest.mark.parametrize("path", _config_paths())
 72    def test_load_returns_jsonable(self, path):
 73        payload = load_as_json(path)
 74        # All values must survive json.dumps without a custom encoder.
 75        import json
 76        json.dumps(payload)
 77        assert payload["version"]
 78        assert isinstance(payload["actions"], dict)
 79        assert isinstance(payload["controllers"], dict)
 80
 81    @pytest.mark.parametrize("path", _config_paths())
 82    def test_roundtrip_preserves_actions(self, path):
 83        original = load_config(path)
 84        payload = config_to_json(original)
 85        rebuilt = config_from_json(payload)
 86
 87        assert set(rebuilt.actions) == set(original.actions)
 88        for qname, action in original.actions.items():
 89            other = rebuilt.actions[qname]
 90            assert other.input_type == action.input_type
 91            assert other.trigger_mode == action.trigger_mode
 92            assert other.description == action.description
 93            assert other.group == action.group
 94            assert other.deadband == action.deadband
 95            assert other.threshold == action.threshold
 96            assert other.inversion == action.inversion
 97            assert other.slew_rate == action.slew_rate
 98            assert other.scale == action.scale
 99            assert other.extra == action.extra
100
101    @pytest.mark.parametrize("path", _config_paths())
102    def test_roundtrip_preserves_controllers(self, path):
103        original = load_config(path)
104        rebuilt = config_from_json(config_to_json(original))
105        assert set(rebuilt.controllers) == set(original.controllers)
106        for port, ctrl in original.controllers.items():
107            other = rebuilt.controllers[port]
108            assert other.name == ctrl.name
109            assert other.controller_type == ctrl.controller_type
110            assert other.bindings == ctrl.bindings
111
112    def test_save_then_load_matches(self, tmp_path):
113        src = os.path.join(_DATA_INPUTS, "controller.yaml")
114        payload = load_as_json(src)
115        dst = tmp_path / "out.yaml"
116        save_from_json(dst, payload)
117        reloaded = load_as_json(dst)
118        # Compare the substantive fields; version comes from CONFIG_VERSION
119        # either way so equality is safe.
120        assert reloaded["actions"] == payload["actions"]
121        assert reloaded["controllers"] == payload["controllers"]
122        assert set(reloaded["empty_groups"]) == set(payload["empty_groups"])
@pytest.mark.parametrize('path', _config_paths())
def test_load_returns_jsonable(self, path):
71    @pytest.mark.parametrize("path", _config_paths())
72    def test_load_returns_jsonable(self, path):
73        payload = load_as_json(path)
74        # All values must survive json.dumps without a custom encoder.
75        import json
76        json.dumps(payload)
77        assert payload["version"]
78        assert isinstance(payload["actions"], dict)
79        assert isinstance(payload["controllers"], dict)
@pytest.mark.parametrize('path', _config_paths())
def test_roundtrip_preserves_actions(self, path):
81    @pytest.mark.parametrize("path", _config_paths())
82    def test_roundtrip_preserves_actions(self, path):
83        original = load_config(path)
84        payload = config_to_json(original)
85        rebuilt = config_from_json(payload)
86
87        assert set(rebuilt.actions) == set(original.actions)
88        for qname, action in original.actions.items():
89            other = rebuilt.actions[qname]
90            assert other.input_type == action.input_type
91            assert other.trigger_mode == action.trigger_mode
92            assert other.description == action.description
93            assert other.group == action.group
94            assert other.deadband == action.deadband
95            assert other.threshold == action.threshold
96            assert other.inversion == action.inversion
97            assert other.slew_rate == action.slew_rate
98            assert other.scale == action.scale
99            assert other.extra == action.extra
@pytest.mark.parametrize('path', _config_paths())
def test_roundtrip_preserves_controllers(self, path):
101    @pytest.mark.parametrize("path", _config_paths())
102    def test_roundtrip_preserves_controllers(self, path):
103        original = load_config(path)
104        rebuilt = config_from_json(config_to_json(original))
105        assert set(rebuilt.controllers) == set(original.controllers)
106        for port, ctrl in original.controllers.items():
107            other = rebuilt.controllers[port]
108            assert other.name == ctrl.name
109            assert other.controller_type == ctrl.controller_type
110            assert other.bindings == ctrl.bindings
def test_save_then_load_matches(self, tmp_path):
112    def test_save_then_load_matches(self, tmp_path):
113        src = os.path.join(_DATA_INPUTS, "controller.yaml")
114        payload = load_as_json(src)
115        dst = tmp_path / "out.yaml"
116        save_from_json(dst, payload)
117        reloaded = load_as_json(dst)
118        # Compare the substantive fields; version comes from CONFIG_VERSION
119        # either way so equality is safe.
120        assert reloaded["actions"] == payload["actions"]
121        assert reloaded["controllers"] == payload["controllers"]
122        assert set(reloaded["empty_groups"]) == set(payload["empty_groups"])