gabriel / muse public
test_knowtation_merge_hooks.py python
805 lines 32.0 KB
Raw
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6 chore(tests): add docstring to tests/__init__.py so rc14 tr… Human 26 days ago
1 """Tests for Phase 4.4 — generic pre-merge hook framework + Knowtation hook.
2
3 Covers all 7 Rule-#0 tiers:
4
5 1. **Unit** — Frozen dataclass, port validation, no-op path,
6 registry lookup, registration helper.
7 2. **Integration** — Registry round-trip, HTTP success, HTTP failure
8 captured into ``error`` field, multi-domain
9 isolation.
10 3. **End-to-end** — ``muse merge --consolidate`` on a knowtation
11 repo invokes the hook (mock); without the flag
12 the hook is silent.
13 4. **Stress** — 100 sequential no-op invocations under 1 s;
14 10-thread concurrent invocations do not deadlock.
15 5. **Data-integrity** — Hook outputs are deterministic; isolated
16 registry instances do not leak state.
17 6. **Performance** — 1 000 ``get_hook_registry()`` calls in < 100 ms.
18 7. **Security** — Port-injection rejection (``"3000; rm -rf /"``);
19 out-of-range rejection (``0`` and ``65536``);
20 background-thread exceptions never propagate.
21 """
22
23 from __future__ import annotations
24
25 import dataclasses
26 import datetime
27 import http.server
28 import json
29 import logging
30 import os
31 import pathlib
32 import socket
33 import threading
34 import time
35 import uuid
36 from typing import Any, Generator
37 from unittest import mock
38
39 import pytest
40
41 from muse.core.merge_hooks import (
42 MergeHookPlugin,
43 MergeHookRegistry,
44 MergeHookResult,
45 get_hook_registry,
46 )
47 from muse.plugins.knowtation.hooks import (
48 API_PATH,
49 DEFAULT_PORT,
50 ENV_PORT,
51 HOOK_NAME,
52 HTTP_TIMEOUT_SECONDS,
53 PORT_MAX,
54 PORT_MIN,
55 KnowtationMergeHook,
56 _resolve_port,
57 register_knowtation_hook,
58 )
59 from tests.cli_test_helper import CliRunner
60
61 # ────────────────────────────────────────────────────────────────────────────
62 # Constants and shared fixtures
63 # ────────────────────────────────────────────────────────────────────────────
64
65 _DUMMY_ROOT = pathlib.Path("/tmp/knowtation-merge-hooks-test-root")
66 _OURS_ID = "a" * 64
67 _THEIRS_ID = "b" * 64
68 _BRANCH = "feat/test"
69
70
71 @pytest.fixture
72 def fresh_registry() -> MergeHookRegistry:
73 """Return a brand-new :class:`MergeHookRegistry` for isolated tests.
74
75 Tests that need to avoid touching the process-wide singleton use
76 this fixture to guarantee no state bleeds between cases.
77 """
78 return MergeHookRegistry()
79
80
81 @pytest.fixture
82 def clean_env_port() -> Generator[None, None, None]:
83 """Remove ``KNOWTATION_HUB_PORT`` for the test, restore afterwards."""
84 original = os.environ.pop(ENV_PORT, None)
85 try:
86 yield
87 finally:
88 if original is None:
89 os.environ.pop(ENV_PORT, None)
90 else:
91 os.environ[ENV_PORT] = original
92
93
94 # ────────────────────────────────────────────────────────────────────────────
95 # Tier 1 — Unit
96 # ────────────────────────────────────────────────────────────────────────────
97
98
99 class TestMergeHookResultUnit:
100 """Frozen-dataclass invariants and field shape."""
101
102 def test_result_is_frozen_dataclass(self) -> None:
103 """Setattr must raise FrozenInstanceError per @dataclass(frozen=True)."""
104 result = MergeHookResult(
105 hook_name="x", fired=True, out_of_band=True,
106 message="ok", error=None,
107 )
108 with pytest.raises(dataclasses.FrozenInstanceError):
109 result.fired = False # type: ignore[misc]
110
111 def test_result_field_types_and_values(self) -> None:
112 """Construction requires every field; types match declared annotations."""
113 result = MergeHookResult(
114 hook_name="knowtation-consolidate",
115 fired=False,
116 out_of_band=False,
117 message="msg",
118 error=None,
119 )
120 assert result.hook_name == "knowtation-consolidate"
121 assert result.fired is False
122 assert result.out_of_band is False
123 assert result.message == "msg"
124 assert result.error is None
125
126 def test_result_supports_error_string(self) -> None:
127 """`error` accepts a string for diagnostic information."""
128 result = MergeHookResult(
129 hook_name="x", fired=False, out_of_band=False,
130 message="failed", error="boom",
131 )
132 assert result.error == "boom"
133
134 def test_result_equality_is_value_based(self) -> None:
135 """Two MergeHookResult with identical fields must compare equal."""
136 a = MergeHookResult("x", True, True, "msg", None)
137 b = MergeHookResult("x", True, True, "msg", None)
138 assert a == b
139
140
141 class TestKnowtationMergeHookUnit:
142 """Behaviour of the Knowtation-specific hook in isolation."""
143
144 def test_consolidate_false_returns_noop_result(
145 self, clean_env_port: None
146 ) -> None:
147 """When --consolidate is not requested, the hook fires no I/O."""
148 hook = KnowtationMergeHook()
149 result = hook.pre_merge_hook(
150 _DUMMY_ROOT, _OURS_ID, _THEIRS_ID, _BRANCH, consolidate=False,
151 )
152 assert result.hook_name == HOOK_NAME
153 assert result.fired is False
154 assert result.out_of_band is False
155 assert result.error is None
156 assert "not requested" in result.message
157
158 def test_port_validation_rejects_zero(
159 self, clean_env_port: None
160 ) -> None:
161 """Port 0 is reserved (IANA) and must be rejected."""
162 os.environ[ENV_PORT] = "0"
163 hook = KnowtationMergeHook()
164 result = hook.pre_merge_hook(
165 _DUMMY_ROOT, _OURS_ID, _THEIRS_ID, _BRANCH, consolidate=True,
166 )
167 assert result.fired is False
168 assert result.error is not None
169 assert "out of range" in result.error.lower()
170
171 def test_port_validation_rejects_above_max(
172 self, clean_env_port: None
173 ) -> None:
174 """Port 99999 is above the max TCP port (65535)."""
175 os.environ[ENV_PORT] = "99999"
176 hook = KnowtationMergeHook()
177 result = hook.pre_merge_hook(
178 _DUMMY_ROOT, _OURS_ID, _THEIRS_ID, _BRANCH, consolidate=True,
179 )
180 assert result.fired is False
181 assert result.error is not None
182 assert "out of range" in result.error.lower()
183
184 def test_port_validation_accepts_default(
185 self, clean_env_port: None
186 ) -> None:
187 """No env var → default port (3000)."""
188 port, err = _resolve_port()
189 assert err is None
190 assert port == DEFAULT_PORT
191
192 def test_port_validation_accepts_explicit_3000(
193 self, clean_env_port: None
194 ) -> None:
195 """Explicit 3000 round-trips cleanly."""
196 os.environ[ENV_PORT] = "3000"
197 port, err = _resolve_port()
198 assert err is None
199 assert port == 3000
200
201 def test_port_min_and_max_constants_are_correct(self) -> None:
202 """Sanity guard for the port-range literals."""
203 assert PORT_MIN == 1
204 assert PORT_MAX == 65535
205
206
207 class TestRegistrySingletonUnit:
208 """Singleton + helper-function behaviour."""
209
210 def test_get_hook_registry_returns_singleton(self) -> None:
211 """Subsequent calls must return the same instance."""
212 a = get_hook_registry()
213 b = get_hook_registry()
214 assert a is b
215
216 def test_get_returns_none_for_unregistered_domain(
217 self, fresh_registry: MergeHookRegistry
218 ) -> None:
219 """Unknown domains resolve to None, never raising."""
220 assert fresh_registry.get("nonexistent-domain-xyz") is None
221
222 def test_register_knowtation_hook_registers_under_knowtation(self) -> None:
223 """Helper must register the hook under the canonical key."""
224 register_knowtation_hook()
225 plugin = get_hook_registry().get("knowtation")
226 assert plugin is not None
227 assert isinstance(plugin, KnowtationMergeHook)
228
229 def test_register_rejects_empty_domain(
230 self, fresh_registry: MergeHookRegistry
231 ) -> None:
232 """Empty domain string must raise ValueError."""
233 with pytest.raises(ValueError):
234 fresh_registry.register("", KnowtationMergeHook())
235
236 def test_protocol_is_runtime_checkable(self) -> None:
237 """KnowtationMergeHook must satisfy MergeHookPlugin via isinstance."""
238 assert isinstance(KnowtationMergeHook(), MergeHookPlugin)
239
240
241 # ────────────────────────────────────────────────────────────────────────────
242 # Tier 2 — Integration
243 # ────────────────────────────────────────────────────────────────────────────
244
245
246 class _MockHubServer:
247 """Tiny in-process HTTP server that captures POSTed bodies for tests.
248
249 Listens on localhost on an OS-assigned free port; tests pass that
250 port via ``KNOWTATION_HUB_PORT`` and then assert that the hook
251 actually delivered the request.
252 """
253
254 def __init__(self) -> None:
255 self.received: list[dict[str, Any]] = []
256 self._lock = threading.Lock()
257 recv_lock = self._lock
258 recv_sink = self.received
259
260 class _Handler(http.server.BaseHTTPRequestHandler):
261 def do_POST(self) -> None: # noqa: N802 — http.server convention
262 length = int(self.headers.get("Content-Length", "0") or "0")
263 body = self.rfile.read(length) if length else b""
264 with recv_lock:
265 recv_sink.append({
266 "path": self.path,
267 "method": self.command,
268 "body": body,
269 "content_type": self.headers.get("Content-Type", ""),
270 })
271 self.send_response(200)
272 self.send_header("Content-Type", "application/json")
273 self.end_headers()
274 self.wfile.write(b'{"ok":true}')
275
276 def log_message(self, *args: Any, **kwargs: Any) -> None:
277 """Silence access logs during tests."""
278 return
279
280 self._handler_cls = _Handler
281 self._server = http.server.HTTPServer(("127.0.0.1", 0), self._handler_cls)
282 self.port = self._server.server_address[1]
283 self._thread = threading.Thread(
284 target=self._server.serve_forever,
285 name="mock-hub-server",
286 daemon=True,
287 )
288
289 def start(self) -> None:
290 self._thread.start()
291
292 def stop(self) -> None:
293 self._server.shutdown()
294 self._server.server_close()
295 self._thread.join(timeout=5.0)
296
297 def wait_for_request(self, timeout: float = 5.0) -> bool:
298 """Block until at least one request is captured, or *timeout*."""
299 deadline = time.monotonic() + timeout
300 while time.monotonic() < deadline:
301 with self._lock:
302 if self.received:
303 return True
304 time.sleep(0.01)
305 return False
306
307
308 @pytest.fixture
309 def mock_hub_server() -> Generator[_MockHubServer, None, None]:
310 """Start a one-shot in-process mock hub; tear down after the test."""
311 server = _MockHubServer()
312 server.start()
313 try:
314 yield server
315 finally:
316 server.stop()
317
318
319 def _free_tcp_port() -> int:
320 """Return an OS-assigned free TCP port for test use."""
321 with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
322 s.bind(("127.0.0.1", 0))
323 return s.getsockname()[1]
324
325
326 class TestRegistryIntegration:
327 """End-to-end through the registry, with mocked HTTP where appropriate."""
328
329 def test_run_pre_merge_consolidate_false_returns_noop(
330 self, fresh_registry: MergeHookRegistry, clean_env_port: None
331 ) -> None:
332 fresh_registry.register("knowtation", KnowtationMergeHook())
333 results = fresh_registry.run_pre_merge(
334 _DUMMY_ROOT, "knowtation", _OURS_ID, _THEIRS_ID, _BRANCH,
335 consolidate=False,
336 )
337 assert len(results) == 1
338 assert results[0].fired is False
339 assert results[0].out_of_band is False
340 assert results[0].error is None
341
342 def test_run_pre_merge_no_plugin_returns_empty_list(
343 self, fresh_registry: MergeHookRegistry
344 ) -> None:
345 assert fresh_registry.run_pre_merge(
346 _DUMMY_ROOT, "no-such-domain", _OURS_ID, _THEIRS_ID, _BRANCH,
347 consolidate=True,
348 ) == []
349
350 def test_run_pre_merge_consolidate_true_dispatches_http(
351 self,
352 fresh_registry: MergeHookRegistry,
353 mock_hub_server: _MockHubServer,
354 clean_env_port: None,
355 ) -> None:
356 os.environ[ENV_PORT] = str(mock_hub_server.port)
357 fresh_registry.register("knowtation", KnowtationMergeHook())
358 results = fresh_registry.run_pre_merge(
359 _DUMMY_ROOT, "knowtation", _OURS_ID, _THEIRS_ID, _BRANCH,
360 consolidate=True,
361 )
362 assert len(results) == 1
363 assert results[0].fired is True
364 assert results[0].out_of_band is True
365 assert results[0].error is None
366 assert mock_hub_server.wait_for_request(timeout=5.0)
367 captured = mock_hub_server.received[0]
368 assert captured["path"] == API_PATH
369 assert captured["method"] == "POST"
370 assert captured["body"] == b"{}"
371 assert "application/json" in captured["content_type"]
372
373 def test_run_pre_merge_http_failure_captured_in_error_via_log(
374 self,
375 fresh_registry: MergeHookRegistry,
376 clean_env_port: None,
377 caplog: pytest.LogCaptureFixture,
378 ) -> None:
379 """Connection refused must NOT raise; the warning is logged."""
380 port = _free_tcp_port()
381 os.environ[ENV_PORT] = str(port)
382 fresh_registry.register("knowtation", KnowtationMergeHook())
383 with caplog.at_level(logging.WARNING, logger="muse.plugins.knowtation.hooks"):
384 results = fresh_registry.run_pre_merge(
385 _DUMMY_ROOT, "knowtation", _OURS_ID, _THEIRS_ID, _BRANCH,
386 consolidate=True,
387 )
388 assert len(results) == 1
389 assert results[0].fired is True
390 assert results[0].out_of_band is True
391 deadline = time.monotonic() + 5.0
392 while time.monotonic() < deadline:
393 if any("knowtation-consolidate" in rec.message for rec in caplog.records):
394 break
395 time.sleep(0.05)
396 assert any(
397 "knowtation-consolidate" in rec.message and "fail" in rec.message.lower()
398 for rec in caplog.records
399 ), f"expected a WARNING log for connection failure; got: {[r.message for r in caplog.records]}"
400
401 def test_run_pre_merge_plugin_raises_is_caught_into_error(
402 self, fresh_registry: MergeHookRegistry
403 ) -> None:
404 """A buggy plugin that raises gets captured into a synthetic result."""
405
406 class _BoomPlugin:
407 def pre_merge_hook(
408 self, root, ours_commit_id, theirs_commit_id, branch,
409 *, consolidate: bool = False,
410 ) -> MergeHookResult:
411 raise RuntimeError("synthetic boom")
412
413 fresh_registry.register("buggy", _BoomPlugin()) # type: ignore[arg-type]
414 results = fresh_registry.run_pre_merge(
415 _DUMMY_ROOT, "buggy", _OURS_ID, _THEIRS_ID, _BRANCH,
416 consolidate=True,
417 )
418 assert len(results) == 1
419 assert results[0].fired is False
420 assert results[0].error is not None
421 assert "RuntimeError" in results[0].error
422 assert "synthetic boom" in results[0].error
423
424 def test_multiple_domains_register_independently(
425 self, fresh_registry: MergeHookRegistry
426 ) -> None:
427 """Multi-domain registration: each plugin is reachable by name."""
428
429 class _A:
430 def pre_merge_hook(self, root, ours_commit_id, theirs_commit_id,
431 branch, *, consolidate: bool = False
432 ) -> MergeHookResult:
433 return MergeHookResult("a", True, False, "a-msg", None)
434
435 class _B:
436 def pre_merge_hook(self, root, ours_commit_id, theirs_commit_id,
437 branch, *, consolidate: bool = False
438 ) -> MergeHookResult:
439 return MergeHookResult("b", True, False, "b-msg", None)
440
441 fresh_registry.register("alpha", _A()) # type: ignore[arg-type]
442 fresh_registry.register("beta", _B()) # type: ignore[arg-type]
443
444 a_results = fresh_registry.run_pre_merge(
445 _DUMMY_ROOT, "alpha", _OURS_ID, _THEIRS_ID, _BRANCH, consolidate=True,
446 )
447 b_results = fresh_registry.run_pre_merge(
448 _DUMMY_ROOT, "beta", _OURS_ID, _THEIRS_ID, _BRANCH, consolidate=True,
449 )
450 assert a_results[0].hook_name == "a"
451 assert b_results[0].hook_name == "b"
452 assert fresh_registry.run_pre_merge(
453 _DUMMY_ROOT, "gamma", _OURS_ID, _THEIRS_ID, _BRANCH, consolidate=True,
454 ) == []
455
456
457 # ────────────────────────────────────────────────────────────────────────────
458 # Tier 3 — End-to-end: muse merge --consolidate / without
459 # ────────────────────────────────────────────────────────────────────────────
460
461
462 def _init_knowtation_repo(tmp_path: pathlib.Path) -> tuple[pathlib.Path, str]:
463 """Create a minimal knowtation-domain repo on disk."""
464 muse_dir = tmp_path / ".muse"
465 muse_dir.mkdir()
466 repo_id = str(uuid.uuid4())
467 (muse_dir / "repo.json").write_text(
468 json.dumps({
469 "repo_id": repo_id,
470 "domain": "knowtation",
471 "default_branch": "main",
472 "created_at": "2025-01-01T00:00:00+00:00",
473 }),
474 encoding="utf-8",
475 )
476 (muse_dir / "HEAD").write_text("ref: refs/heads/main", encoding="utf-8")
477 (muse_dir / "refs" / "heads").mkdir(parents=True)
478 (muse_dir / "snapshots").mkdir()
479 (muse_dir / "commits").mkdir()
480 (muse_dir / "objects").mkdir()
481 return tmp_path, repo_id
482
483
484 def _make_simple_commit(
485 root: pathlib.Path, repo_id: str, branch: str, message: str,
486 manifest: dict[str, str] | None = None,
487 ) -> str:
488 from muse.core.snapshot import compute_commit_id, compute_snapshot_id
489 from muse.core.store import (
490 CommitRecord, SnapshotRecord, write_commit, write_snapshot,
491 )
492 ref_file = root / ".muse" / "refs" / "heads" / branch
493 parent_id = ref_file.read_text().strip() if ref_file.exists() else None
494 m = manifest or {}
495 snap_id = compute_snapshot_id(m)
496 committed_at = datetime.datetime.now(datetime.timezone.utc)
497 commit_id = compute_commit_id(
498 parent_ids=[parent_id] if parent_id else [],
499 snapshot_id=snap_id, message=message,
500 committed_at_iso=committed_at.isoformat(),
501 )
502 write_snapshot(root, SnapshotRecord(snapshot_id=snap_id, manifest=m))
503 write_commit(root, CommitRecord(
504 commit_id=commit_id, repo_id=repo_id, branch=branch,
505 snapshot_id=snap_id, message=message, committed_at=committed_at,
506 parent_commit_id=parent_id,
507 ))
508 ref_file.parent.mkdir(parents=True, exist_ok=True)
509 ref_file.write_text(commit_id, encoding="utf-8")
510 return commit_id
511
512
513 class TestCliEndToEnd:
514 """Drive ``muse merge --consolidate`` via the CLI runner."""
515
516 def test_merge_with_consolidate_invokes_hook(
517 self,
518 tmp_path: pathlib.Path,
519 mock_hub_server: _MockHubServer,
520 clean_env_port: None,
521 ) -> None:
522 os.environ[ENV_PORT] = str(mock_hub_server.port)
523 # Ensure the knowtation hook is registered on the singleton.
524 register_knowtation_hook()
525 root, repo_id = _init_knowtation_repo(tmp_path)
526 base_id = _make_simple_commit(root, repo_id, "main", "base")
527 (root / ".muse" / "refs" / "heads" / "feature").write_text(base_id)
528 _make_simple_commit(root, repo_id, "feature", "feat")
529 runner = CliRunner()
530 env = {"MUSE_REPO_ROOT": str(root), ENV_PORT: str(mock_hub_server.port)}
531 result = runner.invoke(
532 None, ["merge", "--consolidate", "--format", "json", "feature"],
533 env=env, catch_exceptions=False,
534 )
535 assert result.exit_code == 0, result.output
536 data = json.loads(result.output)
537 assert data["status"] in ("fast_forward", "up_to_date", "merged")
538 hook_record = data.get("consolidation_hook")
539 assert hook_record is not None, (
540 f"expected consolidation_hook in JSON output, got keys: {list(data)}"
541 )
542 assert hook_record["hook_name"] == HOOK_NAME
543 assert hook_record["fired"] is True
544 assert hook_record["out_of_band"] is True
545 assert hook_record["error"] is None
546 assert mock_hub_server.wait_for_request(timeout=5.0)
547
548 def test_merge_without_consolidate_does_not_fire_hook(
549 self,
550 tmp_path: pathlib.Path,
551 mock_hub_server: _MockHubServer,
552 clean_env_port: None,
553 ) -> None:
554 os.environ[ENV_PORT] = str(mock_hub_server.port)
555 register_knowtation_hook()
556 root, repo_id = _init_knowtation_repo(tmp_path)
557 base_id = _make_simple_commit(root, repo_id, "main", "base")
558 (root / ".muse" / "refs" / "heads" / "feature").write_text(base_id)
559 _make_simple_commit(root, repo_id, "feature", "feat")
560 runner = CliRunner()
561 env = {"MUSE_REPO_ROOT": str(root), ENV_PORT: str(mock_hub_server.port)}
562 result = runner.invoke(
563 None, ["merge", "--format", "json", "feature"],
564 env=env, catch_exceptions=False,
565 )
566 assert result.exit_code == 0
567 data = json.loads(result.output)
568 assert "consolidation_hook" not in data
569 time.sleep(0.5)
570 assert mock_hub_server.received == []
571
572
573 # ────────────────────────────────────────────────────────────────────────────
574 # Tier 4 — Stress
575 # ────────────────────────────────────────────────────────────────────────────
576
577
578 class TestStress:
579 """Throughput and concurrency."""
580
581 def test_100_sequential_noop_invocations_under_one_second(
582 self, fresh_registry: MergeHookRegistry, clean_env_port: None
583 ) -> None:
584 fresh_registry.register("knowtation", KnowtationMergeHook())
585 start = time.perf_counter()
586 for _ in range(100):
587 fresh_registry.run_pre_merge(
588 _DUMMY_ROOT, "knowtation", _OURS_ID, _THEIRS_ID, _BRANCH,
589 consolidate=False,
590 )
591 elapsed = time.perf_counter() - start
592 assert elapsed < 1.0, f"100 noop hooks took {elapsed:.3f}s (budget 1.0s)"
593
594 def test_concurrent_invocations_do_not_deadlock(
595 self, fresh_registry: MergeHookRegistry, clean_env_port: None
596 ) -> None:
597 fresh_registry.register("knowtation", KnowtationMergeHook())
598 completed: list[bool] = []
599 errors: list[BaseException] = []
600 completion_lock = threading.Lock()
601
602 def _worker() -> None:
603 try:
604 for _ in range(20):
605 fresh_registry.run_pre_merge(
606 _DUMMY_ROOT, "knowtation", _OURS_ID, _THEIRS_ID, _BRANCH,
607 consolidate=False,
608 )
609 with completion_lock:
610 completed.append(True)
611 except BaseException as exc: # noqa: BLE001
612 with completion_lock:
613 errors.append(exc)
614
615 threads = [threading.Thread(target=_worker) for _ in range(10)]
616 for t in threads:
617 t.start()
618 for t in threads:
619 t.join(timeout=10.0)
620 assert not t.is_alive(), "thread failed to join — possible deadlock"
621 assert errors == []
622 assert len(completed) == 10
623
624
625 # ────────────────────────────────────────────────────────────────────────────
626 # Tier 5 — Data integrity
627 # ────────────────────────────────────────────────────────────────────────────
628
629
630 class TestDataIntegrity:
631 """Determinism and isolation."""
632
633 def test_noop_result_is_deterministic_across_calls(
634 self, clean_env_port: None
635 ) -> None:
636 hook = KnowtationMergeHook()
637 a = hook.pre_merge_hook(
638 _DUMMY_ROOT, _OURS_ID, _THEIRS_ID, _BRANCH, consolidate=False,
639 )
640 b = hook.pre_merge_hook(
641 _DUMMY_ROOT, _OURS_ID, _THEIRS_ID, _BRANCH, consolidate=False,
642 )
643 assert a == b
644 assert dataclasses.asdict(a) == dataclasses.asdict(b)
645
646 def test_fresh_registries_do_not_share_state(self) -> None:
647 a = MergeHookRegistry()
648 b = MergeHookRegistry()
649 a.register("knowtation", KnowtationMergeHook())
650 assert b.get("knowtation") is None
651 assert a.get("knowtation") is not None
652
653 def test_unregister_removes_state_from_isolated_instance(self) -> None:
654 reg = MergeHookRegistry()
655 reg.register("knowtation", KnowtationMergeHook())
656 assert reg.get("knowtation") is not None
657 assert reg.unregister("knowtation") is True
658 assert reg.get("knowtation") is None
659 assert reg.unregister("knowtation") is False
660
661
662 # ────────────────────────────────────────────────────────────────────────────
663 # Tier 6 — Performance
664 # ────────────────────────────────────────────────────────────────────────────
665
666
667 class TestPerformance:
668 """Singleton lookup latency budget."""
669
670 def test_1000_get_hook_registry_calls_under_100ms(self) -> None:
671 start = time.perf_counter()
672 for _ in range(1000):
673 get_hook_registry()
674 elapsed = time.perf_counter() - start
675 assert elapsed < 0.100, (
676 f"1000 get_hook_registry() calls took {elapsed * 1000:.2f}ms "
677 f"(budget 100ms)"
678 )
679
680
681 # ────────────────────────────────────────────────────────────────────────────
682 # Tier 7 — Security
683 # ────────────────────────────────────────────────────────────────────────────
684
685
686 class TestSecurity:
687 """Input validation, injection, and error containment."""
688
689 def test_port_injection_string_with_shell_metacharacters_rejected(
690 self, clean_env_port: None
691 ) -> None:
692 """``"3000; rm -rf /"`` MUST fail integer parse and emit no request."""
693 os.environ[ENV_PORT] = "3000; rm -rf /"
694 port, err = _resolve_port()
695 assert port is None
696 assert err is not None
697 hook = KnowtationMergeHook()
698 with mock.patch(
699 "muse.plugins.knowtation.hooks._post_consolidate_in_background"
700 ) as spawn_mock:
701 result = hook.pre_merge_hook(
702 _DUMMY_ROOT, _OURS_ID, _THEIRS_ID, _BRANCH, consolidate=True,
703 )
704 spawn_mock.assert_not_called()
705 assert result.fired is False
706 assert result.error is not None
707 assert "valid integer" in result.error.lower()
708
709 def test_port_zero_rejected_via_env(self, clean_env_port: None) -> None:
710 os.environ[ENV_PORT] = "0"
711 port, err = _resolve_port()
712 assert port is None
713 assert err is not None
714 assert "out of range" in err.lower()
715
716 def test_port_65536_rejected_via_env(self, clean_env_port: None) -> None:
717 os.environ[ENV_PORT] = "65536"
718 port, err = _resolve_port()
719 assert port is None
720 assert err is not None
721 assert "out of range" in err.lower()
722
723 def test_url_is_built_only_from_validated_int_no_user_string(
724 self, clean_env_port: None, mock_hub_server: _MockHubServer
725 ) -> None:
726 """Even with an injected env value, the URL never contains user text."""
727 os.environ[ENV_PORT] = str(mock_hub_server.port)
728 captured_urls: list[str] = []
729
730 def _capture(url: str) -> threading.Thread:
731 captured_urls.append(url)
732 t = threading.Thread(target=lambda: None)
733 t.start()
734 return t
735
736 with mock.patch(
737 "muse.plugins.knowtation.hooks._post_consolidate_in_background",
738 side_effect=_capture,
739 ):
740 hook = KnowtationMergeHook()
741 hook.pre_merge_hook(
742 _DUMMY_ROOT, _OURS_ID, _THEIRS_ID, _BRANCH, consolidate=True,
743 )
744 assert len(captured_urls) == 1
745 url = captured_urls[0]
746 expected = f"http://localhost:{mock_hub_server.port}{API_PATH}"
747 assert url == expected
748 assert ";" not in url
749 assert "rm" not in url
750 assert "&" not in url
751 assert "$" not in url
752
753 def test_background_thread_exception_does_not_propagate(
754 self, clean_env_port: None
755 ) -> None:
756 """A broken HTTP call must not raise into the merge process."""
757 port = _free_tcp_port()
758 os.environ[ENV_PORT] = str(port)
759 hook = KnowtationMergeHook()
760 result = hook.pre_merge_hook(
761 _DUMMY_ROOT, _OURS_ID, _THEIRS_ID, _BRANCH, consolidate=True,
762 )
763 assert isinstance(result, MergeHookResult)
764 assert result.fired is True
765 assert result.out_of_band is True
766 time.sleep(0.5)
767
768 def test_run_pre_merge_with_buggy_plugin_returns_error_result(
769 self, fresh_registry: MergeHookRegistry
770 ) -> None:
771 class _Boom:
772 def pre_merge_hook(
773 self, root, ours_commit_id, theirs_commit_id, branch,
774 *, consolidate: bool = False,
775 ) -> MergeHookResult:
776 raise ValueError("oops")
777
778 fresh_registry.register("evil", _Boom()) # type: ignore[arg-type]
779 results = fresh_registry.run_pre_merge(
780 _DUMMY_ROOT, "evil", _OURS_ID, _THEIRS_ID, _BRANCH, consolidate=True,
781 )
782 assert len(results) == 1
783 assert results[0].error is not None
784 assert "ValueError" in results[0].error
785
786 def test_plugin_returning_wrong_type_is_caught(
787 self, fresh_registry: MergeHookRegistry
788 ) -> None:
789 """A misbehaving plugin returning the wrong type does not crash callers."""
790
791 class _BadType:
792 def pre_merge_hook(
793 self, root, ours_commit_id, theirs_commit_id, branch,
794 *, consolidate: bool = False,
795 ) -> Any:
796 return "not a MergeHookResult"
797
798 fresh_registry.register("badtype", _BadType()) # type: ignore[arg-type]
799 results = fresh_registry.run_pre_merge(
800 _DUMMY_ROOT, "badtype", _OURS_ID, _THEIRS_ID, _BRANCH,
801 consolidate=True,
802 )
803 assert len(results) == 1
804 assert results[0].error is not None
805 assert "MergeHookResult" in results[0].error
File History 2 commits
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6 chore(tests): add docstring to tests/__init__.py so rc14 tr… Human 26 days ago
sha256:d8316ffae901be06347e16ab55be11868eb519dd16ade3e8aa16a99e662f7e62 baseline: rc14 re-baseline after rc3 store corruption recovery Human patch 26 days ago