"""Tests for muse.plugins.knowtation.icp_push_hook — Phase 7.4. Rule #0 tier coverage: * unit — registry, config parsing, retry math, canister-id validation, URL builder. * integration — mocked ICP HTTP query → fetch round-trip; sync anchor with injected commit reader. * stress — 100 concurrent push hooks (fan-out) — no deadlocks, no dropped records, every dispatched thread joins cleanly. * security — canister ID validated as IC principal pattern before URL use; unauthorized response logged but does NOT fail the push; malformed canister ID falls back to default with WARNING; push never raises even when ic-py is missing or canister is unreachable. """ from __future__ import annotations import http.server import json import os import pathlib import random import threading import time from typing import Any from unittest.mock import patch import pytest from muse.core.push_hooks import ( PushHookRegistry, PushHookResult, get_push_hook_registry, ) from muse.plugins.knowtation.icp_push_hook import ( DEFAULT_CANISTER_ID, HOOK_NAME, IC_HTTP_GATEWAY, IcpHookConfig, KnowtationICPPushHook, canister_http_get_url, compute_backoff_delay, fetch_attestation_via_http, load_icp_config, register_knowtation_icp_push_hook, ) @pytest.fixture(autouse=True) def _scrub_icp_env(monkeypatch: pytest.MonkeyPatch) -> None: """Strip MUSE_ICP_* vars so each test runs with a clean environment.""" for key in ("MUSE_ICP_ENABLED", "MUSE_ICP_CANISTER_ID", "MUSE_ICP_IDENTITY_PEM"): monkeypatch.delenv(key, raising=False) # =========================================================================== # UNIT TIER # =========================================================================== class TestPushHookRegistry: """Bare-bones registry behaviour (mirrors merge_hook tests).""" def test_register_get_unregister(self) -> None: """register → get → unregister → get returns None.""" reg = PushHookRegistry() hook = KnowtationICPPushHook() reg.register("knowtation", hook) assert reg.get("knowtation") is hook assert reg.unregister("knowtation") is True assert reg.get("knowtation") is None assert reg.unregister("knowtation") is False def test_singleton_consistency(self) -> None: """get_push_hook_registry returns the same instance every time.""" a = get_push_hook_registry() b = get_push_hook_registry() assert a is b def test_register_helper_uses_singleton(self) -> None: """register_knowtation_icp_push_hook plumbs into the singleton.""" try: hook = register_knowtation_icp_push_hook() assert get_push_hook_registry().get("knowtation") is hook finally: get_push_hook_registry().unregister("knowtation") class TestConfigParsing: """load_icp_config respects env-var contracts.""" def test_defaults_when_unset(self, monkeypatch: pytest.MonkeyPatch) -> None: """No env vars → enabled=True, default canister, no identity.""" cfg = load_icp_config({}) assert cfg.enabled is True assert cfg.canister_id == DEFAULT_CANISTER_ID assert cfg.identity_pem_path is None @pytest.mark.parametrize("value,expected", [ ("0", False), ("false", False), ("FALSE", False), ("no", False), ("off", False), ("1", True), ("true", True), ("yes", True), ("", True), ]) def test_enabled_parsing(self, value: str, expected: bool) -> None: """MUSE_ICP_ENABLED truthy/falsy spellings.""" cfg = load_icp_config({"MUSE_ICP_ENABLED": value}) assert cfg.enabled is expected def test_invalid_canister_falls_back_to_default(self, caplog: Any) -> None: """Bogus canister ID → default + WARNING log.""" cfg = load_icp_config({"MUSE_ICP_CANISTER_ID": "not-a-canister"}) assert cfg.canister_id == DEFAULT_CANISTER_ID def test_valid_custom_canister_used(self) -> None: """Valid override is honoured.""" cfg = load_icp_config({"MUSE_ICP_CANISTER_ID": "abcde-fghij-klmno-pqrst-cai"}) assert cfg.canister_id == "abcde-fghij-klmno-pqrst-cai" def test_missing_pem_path_logged_and_ignored( self, caplog: Any, tmp_path: pathlib.Path ) -> None: """Non-existent PEM path → identity_pem_path=None + WARNING log.""" cfg = load_icp_config({"MUSE_ICP_IDENTITY_PEM": str(tmp_path / "nope.pem")}) assert cfg.identity_pem_path is None def test_existing_pem_path_used(self, tmp_path: pathlib.Path) -> None: """Real PEM file path is stored.""" pem = tmp_path / "id.pem" pem.write_text("-----BEGIN PRIVATE KEY-----\nfake\n-----END PRIVATE KEY-----\n") cfg = load_icp_config({"MUSE_ICP_IDENTITY_PEM": str(pem)}) assert cfg.identity_pem_path == str(pem) class TestBackoffMath: """compute_backoff_delay matches the documented contract.""" def test_delay_doubles(self) -> None: """Mean delay doubles each attempt (jitter dropped via fixed RNG).""" rng = random.Random(0) d1 = compute_backoff_delay(1, base=1.0, jitter_pct=0.0, rng=rng) d2 = compute_backoff_delay(2, base=1.0, jitter_pct=0.0, rng=rng) d3 = compute_backoff_delay(3, base=1.0, jitter_pct=0.0, rng=rng) assert (d1, d2, d3) == (1.0, 2.0, 4.0) def test_jitter_within_bounds(self) -> None: """Jitter never exceeds ±jitter_pct of the base delay.""" for attempt in (1, 2, 3, 4): base_delay = 1.0 * (2 ** (attempt - 1)) for _ in range(100): d = compute_backoff_delay(attempt, base=1.0, jitter_pct=0.10) assert base_delay * 0.9 <= d <= base_delay * 1.1 def test_rejects_invalid_args(self) -> None: """Negative/zero attempt and out-of-range jitter rejected.""" with pytest.raises(ValueError): compute_backoff_delay(0) with pytest.raises(ValueError): compute_backoff_delay(1, jitter_pct=-0.1) with pytest.raises(ValueError): compute_backoff_delay(1, jitter_pct=1.5) class TestCanisterIdValidation: """Canister IDs must match the IC principal pattern.""" @pytest.mark.parametrize("canister,valid", [ ("dejku-syaaa-aaaaa-qgy3q-cai", True), ("rsovz-byaaa-aaaaa-qgira-cai", True), ("abcde-fghij-klmno-pqrst-cai", True), ("DEJKU-syaaa-aaaaa-qgy3q-cai", False), # uppercase ("not-a-canister", False), ("dejku-syaaa-aaaaa-qgy3q", False), # missing -cai ("a" * 64, False), # totally wrong shape ("", False), ("dejku-syaaa-aaaaa-qgy3q-cai/../../etc", False), ("dejku-syaaa-aaaaa-qgy3q-cai\n", False), # trailing newline ]) def test_pattern(self, canister: str, valid: bool) -> None: """Canister IDs are validated by a strict pattern.""" from muse.plugins.knowtation.icp_push_hook import _is_valid_canister_id assert _is_valid_canister_id(canister) is valid def test_url_builder_rejects_invalid(self) -> None: """canister_http_get_url refuses to build URLs from invalid IDs.""" with pytest.raises(ValueError): canister_http_get_url("not-a-canister", "/attest/x") def test_url_builder_rejects_relative_path(self) -> None: """Path must start with / — defends against URL splicing.""" with pytest.raises(ValueError): canister_http_get_url(DEFAULT_CANISTER_ID, "attest/x") def test_url_builder_happy_path(self) -> None: """Well-formed URL is produced for valid inputs.""" url = canister_http_get_url(DEFAULT_CANISTER_ID, "/attest/abc") assert url == f"https://{DEFAULT_CANISTER_ID}.{IC_HTTP_GATEWAY}/attest/abc" # =========================================================================== # INTEGRATION TIER # =========================================================================== class _FakeCanisterHTTP: """Tiny in-process HTTP server emulating the canister's GET /attest/.""" def __init__(self, records: dict[str, dict[str, Any]] | None = None) -> None: self.records = records or {} outer = self class _Handler(http.server.BaseHTTPRequestHandler): def do_GET(self) -> None: # noqa: N802 — stdlib name if not self.path.startswith("/attest/"): self.send_response(404) self.end_headers() return rid = self.path.split("/attest/", 1)[1] rec = outer.records.get(rid) if rec is None: self.send_response(404) self.end_headers() return body = json.dumps(rec).encode("utf-8") self.send_response(200) self.send_header("Content-Type", "application/json") self.send_header("Content-Length", str(len(body))) self.end_headers() self.wfile.write(body) def log_message(self, fmt: str, *args: Any) -> None: # noqa: ARG002 return None self._server = http.server.HTTPServer(("127.0.0.1", 0), _Handler) self.host = "127.0.0.1" self.port = self._server.server_address[1] self._thread = threading.Thread(target=self._server.serve_forever, daemon=True) self._thread.start() def stop(self) -> None: self._server.shutdown() self._server.server_close() class TestHTTPRoundTrip: """Mocked canister HTTP query interface.""" def test_fetch_returns_record_on_200(self, monkeypatch: pytest.MonkeyPatch) -> None: """200 → parsed JSON record.""" rid = "a" * 64 record = {"id": rid, "action": "write", "path": "x.md"} canister_id = "abcde-fghij-klmno-pqrst-cai" server = _FakeCanisterHTTP({rid: record}) try: # Patch the URL builder to point at the local server. def _local_url(c: str, path: str) -> str: return f"http://{server.host}:{server.port}{path}" monkeypatch.setattr( "muse.plugins.knowtation.icp_push_hook.canister_http_get_url", _local_url, ) out = fetch_attestation_via_http(canister_id, rid, timeout=2.0) assert out == record finally: server.stop() def test_fetch_returns_none_on_404(self, monkeypatch: pytest.MonkeyPatch) -> None: """404 → None, no exception.""" canister_id = "abcde-fghij-klmno-pqrst-cai" server = _FakeCanisterHTTP({}) try: def _local_url(c: str, path: str) -> str: return f"http://{server.host}:{server.port}{path}" monkeypatch.setattr( "muse.plugins.knowtation.icp_push_hook.canister_http_get_url", _local_url, ) assert fetch_attestation_via_http(canister_id, "a" * 64, timeout=2.0) is None finally: server.stop() class _FakeCommit: """Stand-in for a CommitRecord with metadata['attestation'].""" def __init__(self, attestation: dict[str, Any] | None = None) -> None: self.metadata: dict[str, str] = {} if attestation: self.metadata["attestation"] = json.dumps(attestation, sort_keys=True) class TestSyncAnchorFlow: """run_post_push_sync drives _anchor_pushed_commits end to end.""" def test_no_commits_skipped(self) -> None: """Empty commits list → skipped=True, no work.""" hook = KnowtationICPPushHook() result = hook.run_post_push_sync( pathlib.Path("/tmp/fake"), {"commits": []}, IcpHookConfig(), ) assert result.skipped is True assert result.reason == "NO_COMMITS" def test_disabled_short_circuits(self) -> None: """enabled=False → skipped=True, reason=DISABLED.""" hook = KnowtationICPPushHook() result = hook.run_post_push_sync( pathlib.Path("/tmp/fake"), {"commits": ["abc"]}, IcpHookConfig(enabled=False), ) assert result.skipped is True assert result.reason == "DISABLED" def test_anchor_walks_commits( self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path ) -> None: """Each pushed commit's attestation gets an anchor attempt.""" commits = { "c1": _FakeCommit({"id": "i1", "action": "write", "path": "a"}), "c2": _FakeCommit({"id": "i2", "action": "write", "path": "b"}), "c3": _FakeCommit(None), # no attestation — skipped silently } monkeypatch.setattr( "muse.core.store.read_commit", lambda root, cid: commits.get(cid), ) anchor_calls: list[str] = [] def _stub_anchor(self_arg: Any, cfg: IcpHookConfig, record: dict[str, Any]) -> bool: anchor_calls.append(record["id"]) return True monkeypatch.setattr( KnowtationICPPushHook, "_anchor_one_record", _stub_anchor ) hook = KnowtationICPPushHook() result = hook.run_post_push_sync( tmp_path, {"commits": ["c1", "c2", "c3"]}, IcpHookConfig(), ) assert result.fired is True assert result.anchored_count == 2 assert result.failed_count == 0 assert anchor_calls == ["i1", "i2"] # =========================================================================== # STRESS TIER (mandatory per Phase 7 spec) # =========================================================================== class TestStress: """100 concurrent push hooks — no deadlocks, no dropped records.""" def test_100_concurrent_dispatches( self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path ) -> None: """Dispatch 100 hook calls in parallel — every thread joins cleanly.""" commits = {f"c{i}": _FakeCommit({"id": f"i{i}", "action": "write", "path": "x"}) for i in range(100)} monkeypatch.setattr( "muse.core.store.read_commit", lambda root, cid: commits.get(cid), ) anchored: list[str] = [] anchored_lock = threading.Lock() def _stub_anchor(self_arg: Any, cfg: IcpHookConfig, record: dict[str, Any]) -> bool: with anchored_lock: anchored.append(record["id"]) return True monkeypatch.setattr( KnowtationICPPushHook, "_anchor_one_record", _stub_anchor ) hook = KnowtationICPPushHook() threads: list[threading.Thread] = [] results: list[PushHookResult] = [] results_lock = threading.Lock() def _do_dispatch(idx: int) -> None: r = hook.run_post_push( tmp_path, {"commits": [f"c{idx}"]}, "knowtation", None, ) with results_lock: results.append(r) for i in range(100): t = threading.Thread(target=_do_dispatch, args=(i,)) t.start() threads.append(t) for t in threads: t.join(timeout=10.0) assert not t.is_alive(), "dispatcher thread did not return" # Every dispatcher succeeded. assert len(results) == 100 assert all(r.fired and r.out_of_band for r in results) # Wait for all background anchor threads to complete. hook.wait_for_inflight(timeout=10.0) assert sorted(anchored) == sorted(f"i{i}" for i in range(100)) # =========================================================================== # SECURITY TIER (mandatory) # =========================================================================== class TestSecurity: """Phase 7 security contracts.""" def test_canister_id_validated_before_url_use(self) -> None: """Adversarial canister IDs cannot be interpolated into URLs.""" evil_ids = [ "dejku-syaaa-aaaaa-qgy3q-cai/../../evil.com", "evil.com\nGET /pwn", "evil.com#", "https://evil.com/cai", ] for evil in evil_ids: with pytest.raises(ValueError): canister_http_get_url(evil, "/attest/x") def test_unauthorized_does_not_fail_push( self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path ) -> None: """unauthorized response is logged but returns False — no exception.""" commits = {"c1": _FakeCommit({"id": "i1", "action": "write", "path": "x"})} monkeypatch.setattr( "muse.core.store.read_commit", lambda root, cid: commits.get(cid), ) from muse.plugins.knowtation.icp_push_hook import StoreResult def _unauth(canister_id: str, record: dict[str, Any], pem_path: str | None) -> StoreResult: return StoreResult( anchored=False, reason="unauthorized", message="caller not authorized", ) monkeypatch.setattr( "muse.plugins.knowtation.icp_push_hook._store_attestation_via_icpy", _unauth, ) hook = KnowtationICPPushHook() result = hook.run_post_push_sync( tmp_path, {"commits": ["c1"]}, IcpHookConfig(retries=1), ) # Critical: result.error is None — push DID NOT fail. assert result.error is None assert result.failed_count == 1 assert result.anchored_count == 0 def test_unreachable_canister_does_not_raise( self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path ) -> None: """Network failures are caught and reported as failed_count, never raise.""" commits = {"c1": _FakeCommit({"id": "i1", "action": "write", "path": "x"})} monkeypatch.setattr( "muse.core.store.read_commit", lambda root, cid: commits.get(cid), ) def _raise(canister_id: str, record: dict[str, Any], pem_path: str | None) -> Any: raise OSError("connection refused") monkeypatch.setattr( "muse.plugins.knowtation.icp_push_hook._store_attestation_via_icpy", _raise, ) # Patch sleep to make the test fast. monkeypatch.setattr(time, "sleep", lambda _x: None) hook = KnowtationICPPushHook() result = hook.run_post_push_sync( tmp_path, {"commits": ["c1"]}, IcpHookConfig(retries=2), ) assert result.error is None assert result.failed_count == 1 def test_idempotent_already_exists_treated_as_success( self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path ) -> None: """already_exists from canister → anchored=True (idempotent ledger).""" commits = {"c1": _FakeCommit({"id": "i1", "action": "write", "path": "x"})} monkeypatch.setattr( "muse.core.store.read_commit", lambda root, cid: commits.get(cid), ) from muse.plugins.knowtation.icp_push_hook import StoreResult def _already(canister_id: str, record: dict[str, Any], pem_path: str | None) -> StoreResult: return StoreResult(anchored=True, reason="already_exists", message="dup") monkeypatch.setattr( "muse.plugins.knowtation.icp_push_hook._store_attestation_via_icpy", _already, ) hook = KnowtationICPPushHook() result = hook.run_post_push_sync( tmp_path, {"commits": ["c1"]}, IcpHookConfig(retries=1), ) assert result.anchored_count == 1 assert result.failed_count == 0 def test_run_post_push_never_raises_even_on_internal_error( self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path ) -> None: """Even if the worker crashes, run_post_push returns a synthetic result.""" # Mess up the registry path so reading commits crashes. def _crash(root: pathlib.Path, cid: str) -> Any: raise RuntimeError("simulated catastrophic failure") monkeypatch.setattr("muse.core.store.read_commit", _crash) hook = KnowtationICPPushHook() result = hook.run_post_push( tmp_path, {"commits": ["c1"]}, "knowtation", None, ) # Dispatch returned successfully even though the background work would crash. assert result.fired is True assert result.error is None # Wait for the worker to finish (it should swallow its own crash). hook.wait_for_inflight(timeout=5.0)