aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/tests/test_index_seal_client.py
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-hub/tests/test_index_seal_client.py')
-rw-r--r--packages/meshbay-hub/tests/test_index_seal_client.py143
1 files changed, 143 insertions, 0 deletions
diff --git a/packages/meshbay-hub/tests/test_index_seal_client.py b/packages/meshbay-hub/tests/test_index_seal_client.py
new file mode 100644
index 0000000..ca2c7a2
--- /dev/null
+++ b/packages/meshbay-hub/tests/test_index_seal_client.py
@@ -0,0 +1,143 @@
+"""
+The browser half of MNP 1.0's sealed index, measured rather than read.
+
+`test_index_no_cleartext.py` proves the node sends no filename in the clear. This
+proves the client can still read one — and, the part that matters more, that it
+*stops* when it cannot instead of reporting an empty group.
+
+Driven through `harness/index_seal_probe.mjs`, which runs the shipped
+`transport.js` over the shipped `crypto.js` and is fed real frames built here.
+A source-reading test could show that `openGroup` is called; only this can show
+what a waiting `fetchIndex()` is told when it throws.
+"""
+
+import json
+import shutil
+import struct
+import subprocess
+import tempfile
+from pathlib import Path
+
+import msgpack
+import pytest
+from meshbay_common.crypto import generate_gek
+from meshbay_common.groupbox import PURPOSE_INDEX, seal
+
+STATIC = Path(__file__).resolve().parents[1] / "src" / "meshbay_hub" / "static"
+PROBE = Path(__file__).resolve().parent / "harness" / "index_seal_probe.mjs"
+
+pytestmark = pytest.mark.skipif(
+ shutil.which("node") is None or not PROBE.exists(),
+ reason="node unavailable — the client half cannot be measured",
+)
+
+GROUP = "g-probe"
+GEK = generate_gek()
+
+
+def _entry(name: str) -> dict:
+ return {"id": "ab" * 32, "name": name, "path": "library", "size": 10,
+ "type": "file", "added_at": 0, "uploader_id": ""}
+
+
+def _frame(msg: dict) -> str:
+ body = msgpack.packb(msg, use_bin_type=True)
+ return (struct.pack(">I", len(body)) + body).hex()
+
+
+def _sync_frame(gek: bytes, *names: str, version: int = 3) -> str:
+ payload = {"version": version, "entries": [_entry(n) for n in names],
+ "dirs": ["library"], "roots": [{"name": "library"}]}
+ return _frame({"type": "index_sync", "v": "1.0", "group_id": GROUP,
+ **seal(gek, PURPOSE_INDEX, "index_sync", GROUP, payload)})
+
+
+def _delta_frame(gek: bytes, name: str, base: int, version: int) -> str:
+ payload = {"base_version": base, "version": version,
+ "additions": [_entry(name)], "deletions": [], "updates": []}
+ return _frame({"type": "index_delta", "v": "1.0", "group_id": GROUP,
+ **seal(gek, PURPOSE_INDEX, "index_delta", GROUP, payload)})
+
+
+def _run(frames: list[str], gek: bytes = GEK) -> dict:
+ with tempfile.TemporaryDirectory() as tmp:
+ vectors = Path(tmp) / "vectors.json"
+ vectors.write_text(json.dumps(
+ {"gek": gek.hex(), "group_id": GROUP, "frames": frames}))
+ proc = subprocess.run(
+ ["node", str(PROBE), str(STATIC), str(vectors)],
+ capture_output=True, text=True, timeout=120)
+ if proc.returncode != 0:
+ pytest.fail(f"probe failed:\n{proc.stderr}")
+ return json.loads(proc.stdout)
+
+
+def test_a_sealed_index_reaches_the_consumer_intact():
+ out = _run([_sync_frame(GEK, "a-film.mkv", "another.mkv")])
+
+ assert [e["event"] for e in out["events"]] == ["index_sync"]
+ sync = out["events"][0]
+ assert sync["entries"] == ["a-film.mkv", "another.mkv"]
+ assert sync["dirs"] == ["library"]
+ # Moved inside the payload (D4) and still delivered flat, so no consumer had
+ # to change: it reads the same message it always read.
+ assert sync["version"] == 3
+ assert not sync["hasCiphertext"], "the envelope's own fields leaked to consumers"
+
+ # And the waiting caller gets the opened form, not the envelope.
+ assert out["fetchIndex"]["state"] == "resolved"
+ assert out["fetchIndex"]["entries"] == ["a-film.mkv", "another.mkv"]
+
+
+def test_an_index_that_does_not_open_ends_the_session():
+ """
+ §3.4, and the reason it is a rule rather than a preference. An empty
+ `entries` is a legitimate state — a group whose operator has shared nothing
+ yet — so a client that fell back to one would show the same screen for
+ "nothing here" and for "we could not decrypt anything this node sent".
+ """
+ out = _run([_sync_frame(generate_gek(), "a-film.mkv")])
+
+ kinds = [e["event"] for e in out["events"]]
+ assert "index_sync" not in kinds, "a failed decrypt was reported as an index"
+ assert kinds == ["session_failed"]
+ assert "index_sync" in out["events"][0]["message"], (
+ "the failure must name the message type that could not be opened")
+
+ # The caller is told, rather than left to time out 30 s later.
+ assert out["fetchIndex"]["state"] == "rejected"
+ assert "index_sync" in out["fetchIndex"]["message"]
+ assert out["closed"] == 1, "the session carried on after an unopenable message"
+
+
+def test_a_delta_that_does_not_open_ends_the_session_too():
+ """
+ The delta has no caller waiting on it — it is pushed — so a silent failure
+ here would leave a browser showing a stale index with nothing wrong on
+ screen, which is the worst of the three shapes.
+ """
+ out = _run([_sync_frame(GEK, "a-film.mkv"),
+ _delta_frame(generate_gek(), "new.mkv", 3, 4)])
+
+ assert [e["event"] for e in out["events"]] == ["index_sync", "session_failed"]
+ assert "index_delta" in out["events"][1]["message"]
+ assert out["closed"] == 1
+
+
+def test_deltas_are_applied_in_arrival_order():
+ """
+ Opening is asynchronous and `_dispatch` is not. Two messages opened
+ independently settle in whichever order WebCrypto finishes them, and a delta
+ applied before the one it follows is a wrong view of the group that nothing
+ reports. Three deltas in one burst is the cheapest way to force the race.
+ """
+ frames = [_sync_frame(GEK, "a-film.mkv")]
+ frames += [_delta_frame(GEK, f"added-{i}.mkv", 3 + i, 4 + i) for i in range(3)]
+
+ out = _run(frames)
+
+ assert [e["event"] for e in out["events"]] == [
+ "index_sync", "index_delta", "index_delta", "index_delta"]
+ assert [e["additions"][0] for e in out["events"][1:]] == [
+ "added-0.mkv", "added-1.mkv", "added-2.mkv"]
+ assert [e["base_version"] for e in out["events"][1:]] == [3, 4, 5]