gabriel / musehub public
test_mist_phase2_push_validator.py python
241 lines 9.8 KB
Raw
sha256:a34090cc4a394a78bd72cbbe34b08cc59525141e19135b6c0ab154f10611b9ef debug(push/stream): instrument O-frame decode path with INF… Sonnet 4.6 patch 121 days ago
1 """Phase 2 TDD: Mist push validator — filename security gate.
2
3 Tests are written RED first. Run before touching musehub_mist_push_validator.py
4 and musehub_wire.py to confirm they fail, then implement to make them green.
5
6 The validator is a pure service (no DB) — it takes a manifest dict of
7 ``{path: object_id}`` and returns a ``MistValidationResult`` with errors and
8 warnings. Hard errors → push rejected 422. Warnings → push accepted with
9 non-empty warnings list.
10
11 The wire stream calls the validator for every snapshot in mist-domain pushes.
12 """
13 from __future__ import annotations
14
15 import pytest
16
17 # ---------------------------------------------------------------------------
18 # 1. MistValidationResult shape
19 # ---------------------------------------------------------------------------
20
21 class TestMistValidationResult:
22 def test_valid_result_has_no_errors(self) -> None:
23 from musehub.services.musehub_mist_push_validator import MistValidationResult
24
25 r = MistValidationResult(errors=[], warnings=[])
26 assert r.valid is True
27
28 def test_result_with_errors_is_invalid(self) -> None:
29 from musehub.services.musehub_mist_push_validator import MistValidationResult
30
31 r = MistValidationResult(errors=["bad filename: '../evil'"], warnings=[])
32 assert r.valid is False
33
34 def test_result_with_only_warnings_is_valid(self) -> None:
35 from musehub.services.musehub_mist_push_validator import MistValidationResult
36
37 r = MistValidationResult(errors=[], warnings=["unknown artifact type: 'blob.bin'"])
38 assert r.valid is True
39
40
41 # ---------------------------------------------------------------------------
42 # 2. validate_mist_manifest — hard errors
43 # ---------------------------------------------------------------------------
44
45 class TestValidateMistManifestHardErrors:
46 def test_path_traversal_dot_dot_slash(self) -> None:
47 from musehub.services.musehub_mist_push_validator import validate_mist_manifest
48
49 result = validate_mist_manifest({"../evil.py": "sha256:aaa"})
50 assert not result.valid
51 assert any("../evil.py" in e for e in result.errors)
52
53 def test_path_traversal_dot_dot_only(self) -> None:
54 from musehub.services.musehub_mist_push_validator import validate_mist_manifest
55
56 result = validate_mist_manifest({"..": "sha256:aaa"})
57 assert not result.valid
58
59 def test_null_byte_in_filename(self) -> None:
60 from musehub.services.musehub_mist_push_validator import validate_mist_manifest
61
62 result = validate_mist_manifest({"evil\x00.py": "sha256:aaa"})
63 assert not result.valid
64 assert any("evil" in e for e in result.errors)
65
66 def test_ansi_escape_in_filename(self) -> None:
67 from musehub.services.musehub_mist_push_validator import validate_mist_manifest
68
69 result = validate_mist_manifest({"\x1b[31mred\x1b[0m.py": "sha256:aaa"})
70 assert not result.valid
71
72 def test_control_character_in_filename(self) -> None:
73 from musehub.services.musehub_mist_push_validator import validate_mist_manifest
74
75 result = validate_mist_manifest({"bell\x07.py": "sha256:aaa"})
76 assert not result.valid
77
78 def test_forward_slash_path_separator(self) -> None:
79 from musehub.services.musehub_mist_push_validator import validate_mist_manifest
80
81 result = validate_mist_manifest({"subdir/file.py": "sha256:aaa"})
82 assert not result.valid
83
84 def test_backslash_path_separator(self) -> None:
85 from musehub.services.musehub_mist_push_validator import validate_mist_manifest
86
87 result = validate_mist_manifest({"subdir\\file.py": "sha256:aaa"})
88 assert not result.valid
89
90 def test_filename_exceeds_255_chars(self) -> None:
91 from musehub.services.musehub_mist_push_validator import validate_mist_manifest
92
93 long_name = "a" * 256 + ".py"
94 result = validate_mist_manifest({long_name: "sha256:aaa"})
95 assert not result.valid
96
97 def test_errors_list_all_invalid_paths(self) -> None:
98 """Multiple bad paths → multiple error entries, all listed."""
99 from musehub.services.musehub_mist_push_validator import validate_mist_manifest
100
101 result = validate_mist_manifest({
102 "../a.py": "sha256:aaa",
103 "evil\x00.py": "sha256:bbb",
104 "good.py": "sha256:ccc",
105 })
106 assert not result.valid
107 assert len(result.errors) == 2
108
109 def test_empty_filename_is_rejected(self) -> None:
110 from musehub.services.musehub_mist_push_validator import validate_mist_manifest
111
112 result = validate_mist_manifest({"": "sha256:aaa"})
113 assert not result.valid
114
115
116 # ---------------------------------------------------------------------------
117 # 3. validate_mist_manifest — valid inputs
118 # ---------------------------------------------------------------------------
119
120 class TestValidateMistManifestValid:
121 def test_clean_python_filename(self) -> None:
122 from musehub.services.musehub_mist_push_validator import validate_mist_manifest
123
124 result = validate_mist_manifest({"utils.py": "sha256:abc123"})
125 assert result.valid
126 assert result.errors == []
127
128 def test_clean_typescript_filename(self) -> None:
129 from musehub.services.musehub_mist_push_validator import validate_mist_manifest
130
131 result = validate_mist_manifest({"Component.tsx": "sha256:abc123"})
132 assert result.valid
133
134 def test_clean_solidity_filename(self) -> None:
135 from musehub.services.musehub_mist_push_validator import validate_mist_manifest
136
137 result = validate_mist_manifest({"Token.sol": "sha256:abc123"})
138 assert result.valid
139
140 def test_empty_manifest_is_valid(self) -> None:
141 from musehub.services.musehub_mist_push_validator import validate_mist_manifest
142
143 result = validate_mist_manifest({})
144 assert result.valid
145 assert result.errors == []
146 assert result.warnings == []
147
148 def test_multiple_clean_filenames(self) -> None:
149 from musehub.services.musehub_mist_push_validator import validate_mist_manifest
150
151 result = validate_mist_manifest({
152 "main.py": "sha256:aaa",
153 "README.md": "sha256:bbb",
154 "schema.json": "sha256:ccc",
155 })
156 assert result.valid
157 assert result.errors == []
158
159
160 # ---------------------------------------------------------------------------
161 # 4. validate_mist_manifest — warnings (recoverable)
162 # ---------------------------------------------------------------------------
163
164 class TestValidateMistManifestWarnings:
165 def test_unknown_extension_produces_warning(self) -> None:
166 from musehub.services.musehub_mist_push_validator import validate_mist_manifest
167
168 result = validate_mist_manifest({"blob.xyz123": "sha256:aaa"})
169 assert result.valid
170 assert len(result.warnings) == 1
171 assert "blob.xyz123" in result.warnings[0]
172
173 def test_no_extension_produces_warning(self) -> None:
174 from musehub.services.musehub_mist_push_validator import validate_mist_manifest
175
176 result = validate_mist_manifest({"Makefile": "sha256:aaa"})
177 assert result.valid
178 assert len(result.warnings) >= 1
179
180 def test_known_extension_produces_no_warning(self) -> None:
181 from musehub.services.musehub_mist_push_validator import validate_mist_manifest
182
183 result = validate_mist_manifest({"app.py": "sha256:aaa"})
184 assert result.valid
185 assert result.warnings == []
186
187 def test_mix_valid_and_unknown_ext(self) -> None:
188 """Known + unknown extension → valid, one warning for the unknown."""
189 from musehub.services.musehub_mist_push_validator import validate_mist_manifest
190
191 result = validate_mist_manifest({
192 "main.py": "sha256:aaa",
193 "data.unknown_ext": "sha256:bbb",
194 })
195 assert result.valid
196 assert result.errors == []
197 assert len(result.warnings) == 1
198
199
200 # ---------------------------------------------------------------------------
201 # 5. Wire integration: mist-domain push with bad filename → 422
202 # ---------------------------------------------------------------------------
203
204 class TestWirePushMistValidation:
205 """Verify the wire push stream rejects bad mist filenames end-to-end.
206
207 These tests call the wire service function directly rather than the HTTP
208 handler to avoid the full push protocol overhead while still exercising
209 the validation gate at its insertion point.
210 """
211
212 @pytest.mark.asyncio
213 async def test_validate_manifest_called_for_mist_domain(
214 self, db_session, test_user
215 ) -> None:
216 """wire_push_stream runs MistPushValidator for mist-domain repos."""
217 from musehub.services.musehub_mist_push_validator import (
218 validate_mist_manifest,
219 MistValidationResult,
220 )
221 # The validator must be importable and the wire module must import it
222 # for mist repos (tested via module-level import resolution).
223 import musehub.services.musehub_wire as _wire
224 src = open(_wire.__file__).read()
225 assert "musehub_mist_push_validator" in src or "validate_mist_manifest" in src, (
226 "musehub_wire.py must import and call the mist push validator"
227 )
228
229 @pytest.mark.asyncio
230 async def test_domain_non_mist_skips_validator(self) -> None:
231 """validate_mist_manifest is NOT called for non-mist repos (no false rejections)."""
232 from musehub.services.musehub_mist_push_validator import validate_mist_manifest
233
234 # A code-domain manifest with path-like keys is fine for a code repo —
235 # but the validator function itself would reject them. The wire layer
236 # must only invoke the validator for domain_id="mist".
237 # We validate this by checking the function signature/module:
238 import inspect
239 sig = inspect.signature(validate_mist_manifest)
240 params = list(sig.parameters.keys())
241 assert "manifest" in params, "validate_mist_manifest must accept 'manifest' kwarg"
File History 1 commit
sha256:a34090cc4a394a78bd72cbbe34b08cc59525141e19135b6c0ab154f10611b9ef debug(push/stream): instrument O-frame decode path with INF… Sonnet 4.6 patch 121 days ago