gabriel / muse public
test_knowtation_icp_push_hook.py python
529 lines 20.4 KB
Raw
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6 chore(tests): add docstring to tests/__init__.py so rc14 tr… Human 26 days ago
1 """Tests for muse.plugins.knowtation.icp_push_hook — Phase 7.4.
2
3 Rule #0 tier coverage:
4
5 * unit — registry, config parsing, retry math, canister-id validation,
6 URL builder.
7 * integration — mocked ICP HTTP query → fetch round-trip; sync anchor with
8 injected commit reader.
9 * stress — 100 concurrent push hooks (fan-out) — no deadlocks, no
10 dropped records, every dispatched thread joins cleanly.
11 * security — canister ID validated as IC principal pattern before URL use;
12 unauthorized response logged but does NOT fail the push;
13 malformed canister ID falls back to default with WARNING;
14 push never raises even when ic-py is missing or canister
15 is unreachable.
16 """
17
18 from __future__ import annotations
19
20 import http.server
21 import json
22 import os
23 import pathlib
24 import random
25 import threading
26 import time
27 from typing import Any
28 from unittest.mock import patch
29
30 import pytest
31
32 from muse.core.push_hooks import (
33 PushHookRegistry,
34 PushHookResult,
35 get_push_hook_registry,
36 )
37 from muse.plugins.knowtation.icp_push_hook import (
38 DEFAULT_CANISTER_ID,
39 HOOK_NAME,
40 IC_HTTP_GATEWAY,
41 IcpHookConfig,
42 KnowtationICPPushHook,
43 canister_http_get_url,
44 compute_backoff_delay,
45 fetch_attestation_via_http,
46 load_icp_config,
47 register_knowtation_icp_push_hook,
48 )
49
50
51 @pytest.fixture(autouse=True)
52 def _scrub_icp_env(monkeypatch: pytest.MonkeyPatch) -> None:
53 """Strip MUSE_ICP_* vars so each test runs with a clean environment."""
54 for key in ("MUSE_ICP_ENABLED", "MUSE_ICP_CANISTER_ID", "MUSE_ICP_IDENTITY_PEM"):
55 monkeypatch.delenv(key, raising=False)
56
57
58 # ===========================================================================
59 # UNIT TIER
60 # ===========================================================================
61
62
63 class TestPushHookRegistry:
64 """Bare-bones registry behaviour (mirrors merge_hook tests)."""
65
66 def test_register_get_unregister(self) -> None:
67 """register → get → unregister → get returns None."""
68 reg = PushHookRegistry()
69 hook = KnowtationICPPushHook()
70 reg.register("knowtation", hook)
71 assert reg.get("knowtation") is hook
72 assert reg.unregister("knowtation") is True
73 assert reg.get("knowtation") is None
74 assert reg.unregister("knowtation") is False
75
76 def test_singleton_consistency(self) -> None:
77 """get_push_hook_registry returns the same instance every time."""
78 a = get_push_hook_registry()
79 b = get_push_hook_registry()
80 assert a is b
81
82 def test_register_helper_uses_singleton(self) -> None:
83 """register_knowtation_icp_push_hook plumbs into the singleton."""
84 try:
85 hook = register_knowtation_icp_push_hook()
86 assert get_push_hook_registry().get("knowtation") is hook
87 finally:
88 get_push_hook_registry().unregister("knowtation")
89
90
91 class TestConfigParsing:
92 """load_icp_config respects env-var contracts."""
93
94 def test_defaults_when_unset(self, monkeypatch: pytest.MonkeyPatch) -> None:
95 """No env vars → enabled=True, default canister, no identity."""
96 cfg = load_icp_config({})
97 assert cfg.enabled is True
98 assert cfg.canister_id == DEFAULT_CANISTER_ID
99 assert cfg.identity_pem_path is None
100
101 @pytest.mark.parametrize("value,expected", [
102 ("0", False), ("false", False), ("FALSE", False),
103 ("no", False), ("off", False),
104 ("1", True), ("true", True), ("yes", True), ("", True),
105 ])
106 def test_enabled_parsing(self, value: str, expected: bool) -> None:
107 """MUSE_ICP_ENABLED truthy/falsy spellings."""
108 cfg = load_icp_config({"MUSE_ICP_ENABLED": value})
109 assert cfg.enabled is expected
110
111 def test_invalid_canister_falls_back_to_default(self, caplog: Any) -> None:
112 """Bogus canister ID → default + WARNING log."""
113 cfg = load_icp_config({"MUSE_ICP_CANISTER_ID": "not-a-canister"})
114 assert cfg.canister_id == DEFAULT_CANISTER_ID
115
116 def test_valid_custom_canister_used(self) -> None:
117 """Valid override is honoured."""
118 cfg = load_icp_config({"MUSE_ICP_CANISTER_ID": "abcde-fghij-klmno-pqrst-cai"})
119 assert cfg.canister_id == "abcde-fghij-klmno-pqrst-cai"
120
121 def test_missing_pem_path_logged_and_ignored(
122 self, caplog: Any, tmp_path: pathlib.Path
123 ) -> None:
124 """Non-existent PEM path → identity_pem_path=None + WARNING log."""
125 cfg = load_icp_config({"MUSE_ICP_IDENTITY_PEM": str(tmp_path / "nope.pem")})
126 assert cfg.identity_pem_path is None
127
128 def test_existing_pem_path_used(self, tmp_path: pathlib.Path) -> None:
129 """Real PEM file path is stored."""
130 pem = tmp_path / "id.pem"
131 pem.write_text("-----BEGIN PRIVATE KEY-----\nfake\n-----END PRIVATE KEY-----\n")
132 cfg = load_icp_config({"MUSE_ICP_IDENTITY_PEM": str(pem)})
133 assert cfg.identity_pem_path == str(pem)
134
135
136 class TestBackoffMath:
137 """compute_backoff_delay matches the documented contract."""
138
139 def test_delay_doubles(self) -> None:
140 """Mean delay doubles each attempt (jitter dropped via fixed RNG)."""
141 rng = random.Random(0)
142 d1 = compute_backoff_delay(1, base=1.0, jitter_pct=0.0, rng=rng)
143 d2 = compute_backoff_delay(2, base=1.0, jitter_pct=0.0, rng=rng)
144 d3 = compute_backoff_delay(3, base=1.0, jitter_pct=0.0, rng=rng)
145 assert (d1, d2, d3) == (1.0, 2.0, 4.0)
146
147 def test_jitter_within_bounds(self) -> None:
148 """Jitter never exceeds ±jitter_pct of the base delay."""
149 for attempt in (1, 2, 3, 4):
150 base_delay = 1.0 * (2 ** (attempt - 1))
151 for _ in range(100):
152 d = compute_backoff_delay(attempt, base=1.0, jitter_pct=0.10)
153 assert base_delay * 0.9 <= d <= base_delay * 1.1
154
155 def test_rejects_invalid_args(self) -> None:
156 """Negative/zero attempt and out-of-range jitter rejected."""
157 with pytest.raises(ValueError):
158 compute_backoff_delay(0)
159 with pytest.raises(ValueError):
160 compute_backoff_delay(1, jitter_pct=-0.1)
161 with pytest.raises(ValueError):
162 compute_backoff_delay(1, jitter_pct=1.5)
163
164
165 class TestCanisterIdValidation:
166 """Canister IDs must match the IC principal pattern."""
167
168 @pytest.mark.parametrize("canister,valid", [
169 ("dejku-syaaa-aaaaa-qgy3q-cai", True),
170 ("rsovz-byaaa-aaaaa-qgira-cai", True),
171 ("abcde-fghij-klmno-pqrst-cai", True),
172 ("DEJKU-syaaa-aaaaa-qgy3q-cai", False), # uppercase
173 ("not-a-canister", False),
174 ("dejku-syaaa-aaaaa-qgy3q", False), # missing -cai
175 ("a" * 64, False), # totally wrong shape
176 ("", False),
177 ("dejku-syaaa-aaaaa-qgy3q-cai/../../etc", False),
178 ("dejku-syaaa-aaaaa-qgy3q-cai\n", False), # trailing newline
179 ])
180 def test_pattern(self, canister: str, valid: bool) -> None:
181 """Canister IDs are validated by a strict pattern."""
182 from muse.plugins.knowtation.icp_push_hook import _is_valid_canister_id
183 assert _is_valid_canister_id(canister) is valid
184
185 def test_url_builder_rejects_invalid(self) -> None:
186 """canister_http_get_url refuses to build URLs from invalid IDs."""
187 with pytest.raises(ValueError):
188 canister_http_get_url("not-a-canister", "/attest/x")
189
190 def test_url_builder_rejects_relative_path(self) -> None:
191 """Path must start with / — defends against URL splicing."""
192 with pytest.raises(ValueError):
193 canister_http_get_url(DEFAULT_CANISTER_ID, "attest/x")
194
195 def test_url_builder_happy_path(self) -> None:
196 """Well-formed URL is produced for valid inputs."""
197 url = canister_http_get_url(DEFAULT_CANISTER_ID, "/attest/abc")
198 assert url == f"https://{DEFAULT_CANISTER_ID}.{IC_HTTP_GATEWAY}/attest/abc"
199
200
201 # ===========================================================================
202 # INTEGRATION TIER
203 # ===========================================================================
204
205
206 class _FakeCanisterHTTP:
207 """Tiny in-process HTTP server emulating the canister's GET /attest/<id>."""
208
209 def __init__(self, records: dict[str, dict[str, Any]] | None = None) -> None:
210 self.records = records or {}
211 outer = self
212
213 class _Handler(http.server.BaseHTTPRequestHandler):
214 def do_GET(self) -> None: # noqa: N802 — stdlib name
215 if not self.path.startswith("/attest/"):
216 self.send_response(404)
217 self.end_headers()
218 return
219 rid = self.path.split("/attest/", 1)[1]
220 rec = outer.records.get(rid)
221 if rec is None:
222 self.send_response(404)
223 self.end_headers()
224 return
225 body = json.dumps(rec).encode("utf-8")
226 self.send_response(200)
227 self.send_header("Content-Type", "application/json")
228 self.send_header("Content-Length", str(len(body)))
229 self.end_headers()
230 self.wfile.write(body)
231
232 def log_message(self, fmt: str, *args: Any) -> None: # noqa: ARG002
233 return None
234
235 self._server = http.server.HTTPServer(("127.0.0.1", 0), _Handler)
236 self.host = "127.0.0.1"
237 self.port = self._server.server_address[1]
238 self._thread = threading.Thread(target=self._server.serve_forever, daemon=True)
239 self._thread.start()
240
241 def stop(self) -> None:
242 self._server.shutdown()
243 self._server.server_close()
244
245
246 class TestHTTPRoundTrip:
247 """Mocked canister HTTP query interface."""
248
249 def test_fetch_returns_record_on_200(self, monkeypatch: pytest.MonkeyPatch) -> None:
250 """200 → parsed JSON record."""
251 rid = "a" * 64
252 record = {"id": rid, "action": "write", "path": "x.md"}
253 canister_id = "abcde-fghij-klmno-pqrst-cai"
254 server = _FakeCanisterHTTP({rid: record})
255 try:
256 # Patch the URL builder to point at the local server.
257 def _local_url(c: str, path: str) -> str:
258 return f"http://{server.host}:{server.port}{path}"
259 monkeypatch.setattr(
260 "muse.plugins.knowtation.icp_push_hook.canister_http_get_url",
261 _local_url,
262 )
263 out = fetch_attestation_via_http(canister_id, rid, timeout=2.0)
264 assert out == record
265 finally:
266 server.stop()
267
268 def test_fetch_returns_none_on_404(self, monkeypatch: pytest.MonkeyPatch) -> None:
269 """404 → None, no exception."""
270 canister_id = "abcde-fghij-klmno-pqrst-cai"
271 server = _FakeCanisterHTTP({})
272 try:
273 def _local_url(c: str, path: str) -> str:
274 return f"http://{server.host}:{server.port}{path}"
275 monkeypatch.setattr(
276 "muse.plugins.knowtation.icp_push_hook.canister_http_get_url",
277 _local_url,
278 )
279 assert fetch_attestation_via_http(canister_id, "a" * 64, timeout=2.0) is None
280 finally:
281 server.stop()
282
283
284 class _FakeCommit:
285 """Stand-in for a CommitRecord with metadata['attestation']."""
286
287 def __init__(self, attestation: dict[str, Any] | None = None) -> None:
288 self.metadata: dict[str, str] = {}
289 if attestation:
290 self.metadata["attestation"] = json.dumps(attestation, sort_keys=True)
291
292
293 class TestSyncAnchorFlow:
294 """run_post_push_sync drives _anchor_pushed_commits end to end."""
295
296 def test_no_commits_skipped(self) -> None:
297 """Empty commits list → skipped=True, no work."""
298 hook = KnowtationICPPushHook()
299 result = hook.run_post_push_sync(
300 pathlib.Path("/tmp/fake"),
301 {"commits": []},
302 IcpHookConfig(),
303 )
304 assert result.skipped is True
305 assert result.reason == "NO_COMMITS"
306
307 def test_disabled_short_circuits(self) -> None:
308 """enabled=False → skipped=True, reason=DISABLED."""
309 hook = KnowtationICPPushHook()
310 result = hook.run_post_push_sync(
311 pathlib.Path("/tmp/fake"),
312 {"commits": ["abc"]},
313 IcpHookConfig(enabled=False),
314 )
315 assert result.skipped is True
316 assert result.reason == "DISABLED"
317
318 def test_anchor_walks_commits(
319 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
320 ) -> None:
321 """Each pushed commit's attestation gets an anchor attempt."""
322 commits = {
323 "c1": _FakeCommit({"id": "i1", "action": "write", "path": "a"}),
324 "c2": _FakeCommit({"id": "i2", "action": "write", "path": "b"}),
325 "c3": _FakeCommit(None), # no attestation — skipped silently
326 }
327 monkeypatch.setattr(
328 "muse.core.store.read_commit",
329 lambda root, cid: commits.get(cid),
330 )
331 anchor_calls: list[str] = []
332
333 def _stub_anchor(self_arg: Any, cfg: IcpHookConfig, record: dict[str, Any]) -> bool:
334 anchor_calls.append(record["id"])
335 return True
336
337 monkeypatch.setattr(
338 KnowtationICPPushHook, "_anchor_one_record", _stub_anchor
339 )
340 hook = KnowtationICPPushHook()
341 result = hook.run_post_push_sync(
342 tmp_path,
343 {"commits": ["c1", "c2", "c3"]},
344 IcpHookConfig(),
345 )
346 assert result.fired is True
347 assert result.anchored_count == 2
348 assert result.failed_count == 0
349 assert anchor_calls == ["i1", "i2"]
350
351
352 # ===========================================================================
353 # STRESS TIER (mandatory per Phase 7 spec)
354 # ===========================================================================
355
356
357 class TestStress:
358 """100 concurrent push hooks — no deadlocks, no dropped records."""
359
360 def test_100_concurrent_dispatches(
361 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
362 ) -> None:
363 """Dispatch 100 hook calls in parallel — every thread joins cleanly."""
364 commits = {f"c{i}": _FakeCommit({"id": f"i{i}", "action": "write", "path": "x"})
365 for i in range(100)}
366 monkeypatch.setattr(
367 "muse.core.store.read_commit",
368 lambda root, cid: commits.get(cid),
369 )
370 anchored: list[str] = []
371 anchored_lock = threading.Lock()
372
373 def _stub_anchor(self_arg: Any, cfg: IcpHookConfig, record: dict[str, Any]) -> bool:
374 with anchored_lock:
375 anchored.append(record["id"])
376 return True
377
378 monkeypatch.setattr(
379 KnowtationICPPushHook, "_anchor_one_record", _stub_anchor
380 )
381
382 hook = KnowtationICPPushHook()
383 threads: list[threading.Thread] = []
384 results: list[PushHookResult] = []
385 results_lock = threading.Lock()
386
387 def _do_dispatch(idx: int) -> None:
388 r = hook.run_post_push(
389 tmp_path,
390 {"commits": [f"c{idx}"]},
391 "knowtation",
392 None,
393 )
394 with results_lock:
395 results.append(r)
396
397 for i in range(100):
398 t = threading.Thread(target=_do_dispatch, args=(i,))
399 t.start()
400 threads.append(t)
401 for t in threads:
402 t.join(timeout=10.0)
403 assert not t.is_alive(), "dispatcher thread did not return"
404 # Every dispatcher succeeded.
405 assert len(results) == 100
406 assert all(r.fired and r.out_of_band for r in results)
407 # Wait for all background anchor threads to complete.
408 hook.wait_for_inflight(timeout=10.0)
409 assert sorted(anchored) == sorted(f"i{i}" for i in range(100))
410
411
412 # ===========================================================================
413 # SECURITY TIER (mandatory)
414 # ===========================================================================
415
416
417 class TestSecurity:
418 """Phase 7 security contracts."""
419
420 def test_canister_id_validated_before_url_use(self) -> None:
421 """Adversarial canister IDs cannot be interpolated into URLs."""
422 evil_ids = [
423 "dejku-syaaa-aaaaa-qgy3q-cai/../../evil.com",
424 "evil.com\nGET /pwn",
425 "evil.com#",
426 "https://evil.com/cai",
427 ]
428 for evil in evil_ids:
429 with pytest.raises(ValueError):
430 canister_http_get_url(evil, "/attest/x")
431
432 def test_unauthorized_does_not_fail_push(
433 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
434 ) -> None:
435 """unauthorized response is logged but returns False — no exception."""
436 commits = {"c1": _FakeCommit({"id": "i1", "action": "write", "path": "x"})}
437 monkeypatch.setattr(
438 "muse.core.store.read_commit",
439 lambda root, cid: commits.get(cid),
440 )
441 from muse.plugins.knowtation.icp_push_hook import StoreResult
442
443 def _unauth(canister_id: str, record: dict[str, Any], pem_path: str | None) -> StoreResult:
444 return StoreResult(
445 anchored=False, reason="unauthorized",
446 message="caller not authorized",
447 )
448
449 monkeypatch.setattr(
450 "muse.plugins.knowtation.icp_push_hook._store_attestation_via_icpy",
451 _unauth,
452 )
453 hook = KnowtationICPPushHook()
454 result = hook.run_post_push_sync(
455 tmp_path, {"commits": ["c1"]}, IcpHookConfig(retries=1),
456 )
457 # Critical: result.error is None — push DID NOT fail.
458 assert result.error is None
459 assert result.failed_count == 1
460 assert result.anchored_count == 0
461
462 def test_unreachable_canister_does_not_raise(
463 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
464 ) -> None:
465 """Network failures are caught and reported as failed_count, never raise."""
466 commits = {"c1": _FakeCommit({"id": "i1", "action": "write", "path": "x"})}
467 monkeypatch.setattr(
468 "muse.core.store.read_commit",
469 lambda root, cid: commits.get(cid),
470 )
471
472 def _raise(canister_id: str, record: dict[str, Any], pem_path: str | None) -> Any:
473 raise OSError("connection refused")
474
475 monkeypatch.setattr(
476 "muse.plugins.knowtation.icp_push_hook._store_attestation_via_icpy",
477 _raise,
478 )
479 # Patch sleep to make the test fast.
480 monkeypatch.setattr(time, "sleep", lambda _x: None)
481 hook = KnowtationICPPushHook()
482 result = hook.run_post_push_sync(
483 tmp_path, {"commits": ["c1"]}, IcpHookConfig(retries=2),
484 )
485 assert result.error is None
486 assert result.failed_count == 1
487
488 def test_idempotent_already_exists_treated_as_success(
489 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
490 ) -> None:
491 """already_exists from canister → anchored=True (idempotent ledger)."""
492 commits = {"c1": _FakeCommit({"id": "i1", "action": "write", "path": "x"})}
493 monkeypatch.setattr(
494 "muse.core.store.read_commit",
495 lambda root, cid: commits.get(cid),
496 )
497 from muse.plugins.knowtation.icp_push_hook import StoreResult
498
499 def _already(canister_id: str, record: dict[str, Any], pem_path: str | None) -> StoreResult:
500 return StoreResult(anchored=True, reason="already_exists", message="dup")
501
502 monkeypatch.setattr(
503 "muse.plugins.knowtation.icp_push_hook._store_attestation_via_icpy",
504 _already,
505 )
506 hook = KnowtationICPPushHook()
507 result = hook.run_post_push_sync(
508 tmp_path, {"commits": ["c1"]}, IcpHookConfig(retries=1),
509 )
510 assert result.anchored_count == 1
511 assert result.failed_count == 0
512
513 def test_run_post_push_never_raises_even_on_internal_error(
514 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
515 ) -> None:
516 """Even if the worker crashes, run_post_push returns a synthetic result."""
517 # Mess up the registry path so reading commits crashes.
518 def _crash(root: pathlib.Path, cid: str) -> Any:
519 raise RuntimeError("simulated catastrophic failure")
520 monkeypatch.setattr("muse.core.store.read_commit", _crash)
521 hook = KnowtationICPPushHook()
522 result = hook.run_post_push(
523 tmp_path, {"commits": ["c1"]}, "knowtation", None,
524 )
525 # Dispatch returned successfully even though the background work would crash.
526 assert result.fired is True
527 assert result.error is None
528 # Wait for the worker to finish (it should swallow its own crash).
529 hook.wait_for_inflight(timeout=5.0)
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