Implements the protocol contract pinned in docs/mesh.md. Each peer runs a stdlib ThreadingHTTPServer; clients send Ed25519-signed envelopes carrying one of: ANNOUNCE_ROOT document_root + source_uri + versions ANNOUNCE_DERIVATION core_root <- surface_roots (proof-blob hash) ANNOUNCE_PROVIDENCE cache_key + audit_mode + answer_hash ANNOUNCE_FALSIFICATION cache_key + reason REQUEST_BODY/DELIVER_BODY pull-on-miss + Merkle-root verify WireEnvelope canonicalizes via sorted-key separator-tight JSON; the canonical bytes are what get signed. Receivers verify the signature against sender_id's sign_pub looked up in mesh_roster at the envelope's epoch_id — non-members of that epoch produce no valid signature, so they're silently rejected (401, no audit event). Each accepted ANNOUNCE_* writes one 'mesh_received' event into the local audit chain whose body is the full signed envelope (the remote's signature stays attached for non-repudiation). The receiver's chain stays internally consistent because we append in receive-order. REQUEST_BODY/DELIVER_BODY: the responder ships the document text plus per-chunk leaf hashes; the client re-derives the Merkle root from those leaves and refuses to return any body whose leaves don't reconstruct the requested root. Tests: 17 unit (envelope canonicalization, sig verify against roster, type validation, tampered-body rejection, audit-event write) + 5 end-to-end (two real HTTP peers on ephemeral ports exchanging announces and bodies). 22/22 green. Deferred to later commits, per docs/mesh.md: - CLI verbs (mesh serve, mesh sync) — pending downstream merge - per-peer chain-of-claims tracking (catchup, replay, fork detection) - optional AEAD encryption of message bodies (contract says optional)
186 lines
6.4 KiB
Python
186 lines
6.4 KiB
Python
"""Mesh wire end-to-end: two real HTTP peers exchange gossip.
|
|
|
|
Each peer binds an ephemeral port via host=127.0.0.1, port=0 and runs
|
|
ThreadingHTTPServer in a daemon thread. The other peer's MeshWireClient
|
|
talks to it over real httpx + TCP. No mocking — this exercises the
|
|
full request/response path including JSON serialization, HTTP framing,
|
|
and threaded handler dispatch.
|
|
|
|
Tests run under timeout via daemon threads; if a test hangs the worst
|
|
case is the test process exits and pytest times out the test, leaving
|
|
no zombie threads (daemon=True).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from aborist.document import Document
|
|
from aborist.ingest import ingest_source
|
|
from aborist.mesh import init_identity
|
|
from aborist.mesh.members import add_member
|
|
from aborist.mesh.wire import (
|
|
MeshWireClient,
|
|
MeshWireServer,
|
|
TYPE_ANNOUNCE_ROOT,
|
|
)
|
|
from aborist.store import connect
|
|
|
|
|
|
@pytest.fixture
|
|
def two_peers(tmp_path):
|
|
"""Spin up alice + bob with mutual rosters and HTTP servers running."""
|
|
alice_db = tmp_path / "alice.db"
|
|
bob_db = tmp_path / "bob.db"
|
|
|
|
a_conn = connect(alice_db)
|
|
try:
|
|
alice = init_identity(a_conn, group_name="t", member_id="alice")
|
|
finally:
|
|
a_conn.close()
|
|
|
|
b_conn = connect(bob_db)
|
|
try:
|
|
bob = init_identity(b_conn, group_name="t", member_id="bob")
|
|
finally:
|
|
b_conn.close()
|
|
|
|
a_conn = connect(alice_db)
|
|
try:
|
|
add_member(a_conn, member_id="bob", sign_pub=bob.sign_pub, dh_pub=bob.dh_pub)
|
|
finally:
|
|
a_conn.close()
|
|
|
|
b_conn = connect(bob_db)
|
|
try:
|
|
add_member(b_conn, member_id="alice", sign_pub=alice.sign_pub, dh_pub=alice.dh_pub)
|
|
finally:
|
|
b_conn.close()
|
|
|
|
alice_srv = MeshWireServer(alice_db, host="127.0.0.1", port=0)
|
|
bob_srv = MeshWireServer(bob_db, host="127.0.0.1", port=0)
|
|
alice_srv.start_in_thread()
|
|
bob_srv.start_in_thread()
|
|
try:
|
|
yield {
|
|
"alice": {"db": alice_db, "srv": alice_srv, "id": alice},
|
|
"bob": {"db": bob_db, "srv": bob_srv, "id": bob},
|
|
}
|
|
finally:
|
|
alice_srv.stop()
|
|
bob_srv.stop()
|
|
|
|
|
|
def test_info_endpoint_returns_member_id(two_peers):
|
|
"""GET /mesh/info returns the responder's identity (no signature required)."""
|
|
with MeshWireClient(two_peers["bob"]["db"], two_peers["alice"]["srv"].url) as c:
|
|
info = c.info()
|
|
assert info["member_id"] == "alice"
|
|
assert info["group_name"] == "t"
|
|
assert info["current_epoch"] == 1
|
|
|
|
|
|
def test_announce_root_round_trip(two_peers):
|
|
"""Bob announces a doc_root to Alice; Alice's audit chain gains a
|
|
`mesh_received` event with the announce body."""
|
|
with MeshWireClient(two_peers["bob"]["db"], two_peers["alice"]["srv"].url) as c:
|
|
resp = c.announce_root(
|
|
document_root="ab" * 32,
|
|
source_uri="https://en.wikipedia.org/wiki/Cloud_Strife",
|
|
chunking_version="tok-512-v1",
|
|
canonicalization_version="norm-v1",
|
|
schema_version="v9.8.0",
|
|
)
|
|
assert resp["ok"] is True
|
|
assert resp["wire_type"] == TYPE_ANNOUNCE_ROOT
|
|
assert isinstance(resp["audit_event_hash"], str)
|
|
assert len(resp["audit_event_hash"]) == 64
|
|
|
|
a_conn = connect(two_peers["alice"]["db"])
|
|
try:
|
|
last = a_conn.execute(
|
|
"SELECT event_type, body, subject_root FROM audit_events "
|
|
"ORDER BY seq DESC LIMIT 1"
|
|
).fetchone()
|
|
finally:
|
|
a_conn.close()
|
|
assert last["event_type"] == "mesh_received"
|
|
assert last["subject_root"] == "ab" * 32
|
|
import json
|
|
body = json.loads(last["body"])
|
|
assert body["wire_type"] == TYPE_ANNOUNCE_ROOT
|
|
assert body["sender_id"] == "bob"
|
|
assert body["envelope_body"]["source_uri"] == "https://en.wikipedia.org/wiki/Cloud_Strife"
|
|
|
|
|
|
class _OneDocSource:
|
|
"""One-shot source for tests — yields a single Document."""
|
|
|
|
source_type = "wire_e2e"
|
|
|
|
def __init__(self, uri: str, content: str, title: str):
|
|
self._doc = Document(
|
|
uri=uri, content=content, source_type=self.source_type, title=title
|
|
)
|
|
|
|
def iter_documents(self):
|
|
yield self._doc
|
|
|
|
|
|
def _ingest_one(db_path, *, uri: str, content: str, title: str) -> str:
|
|
"""Ingest one document, return its document_root."""
|
|
conn = connect(db_path)
|
|
try:
|
|
ingest_source(conn, _OneDocSource(uri, content, title))
|
|
row = conn.execute(
|
|
"SELECT document_root FROM documents WHERE document_uri=?", (uri,)
|
|
).fetchone()
|
|
finally:
|
|
conn.close()
|
|
assert row is not None, f"document not found post-ingest for {uri!r}"
|
|
return row["document_root"]
|
|
|
|
|
|
def test_request_body_pulls_doc_with_verified_merkle(two_peers):
|
|
"""Alice has a doc; Bob requests it. Client verifies the delivered
|
|
Merkle root against the requested root before returning."""
|
|
document_root = _ingest_one(
|
|
two_peers["alice"]["db"],
|
|
uri="https://en.wikipedia.org/wiki/test",
|
|
content="Hello world. The quick brown fox jumps over the lazy dog.",
|
|
title="Test",
|
|
)
|
|
with MeshWireClient(two_peers["bob"]["db"], two_peers["alice"]["srv"].url) as c:
|
|
delivered = c.request_body(root=document_root)
|
|
|
|
assert delivered["root"] == document_root
|
|
assert delivered["document_uri"] == "https://en.wikipedia.org/wiki/test"
|
|
assert "Hello world" in delivered["text"]
|
|
assert isinstance(delivered["leaves_hex"], list)
|
|
assert len(delivered["leaves_hex"]) >= 1
|
|
|
|
|
|
def test_request_body_404_on_unknown_root(two_peers):
|
|
"""Asking for a root the responder doesn't have raises HTTPStatusError."""
|
|
import httpx
|
|
with MeshWireClient(two_peers["bob"]["db"], two_peers["alice"]["srv"].url) as c:
|
|
with pytest.raises(httpx.HTTPStatusError):
|
|
c.request_body(root="ff" * 32)
|
|
|
|
|
|
def test_request_body_rejects_tampered_response(two_peers, monkeypatch):
|
|
"""If a peer returns a DELIVER_BODY whose leaves don't Merkle-derive
|
|
to the claimed root, the client raises before returning."""
|
|
document_root = _ingest_one(
|
|
two_peers["alice"]["db"],
|
|
uri="https://en.wikipedia.org/wiki/x",
|
|
content="real content of the document",
|
|
title="X",
|
|
)
|
|
with MeshWireClient(two_peers["bob"]["db"], two_peers["alice"]["srv"].url) as c:
|
|
from aborist.mesh import wire as _wire
|
|
monkeypatch.setattr(_wire, "_merkle_root_matches", lambda *a, **k: False)
|
|
with pytest.raises(ValueError, match="Merkle-derive"):
|
|
c.request_body(root=document_root)
|