summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/tests/test_spa_ordering.py
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-hub/tests/test_spa_ordering.py')
-rw-r--r--packages/meshbay-hub/tests/test_spa_ordering.py103
1 files changed, 103 insertions, 0 deletions
diff --git a/packages/meshbay-hub/tests/test_spa_ordering.py b/packages/meshbay-hub/tests/test_spa_ordering.py
new file mode 100644
index 0000000..0ef34fc
--- /dev/null
+++ b/packages/meshbay-hub/tests/test_spa_ordering.py
@@ -0,0 +1,103 @@
+"""
+Ordering guards for the SPA's connect() flow.
+
+These are source-level checks, which is not how one would normally test
+behaviour. They exist because a specific class of bug shipped to a live browser
+twice and no other test could see it: `connect()` is a long sequence in which
+later steps read values earlier steps set, and the Python end-to-end client in
+QE/deploy/ cannot catch a mistake there — it is a different implementation,
+written in the right order by construction, so it passes while the browser fails.
+
+Concretely: join_request signs a transcript over the node key and the node nonce,
+and runs *before* the GEK proof, because a first-time member has no GEK to prove.
+Both values were being read further down, next to the proof that also uses them,
+so every invited member hit "Handshake incomplete — reconnect and retry".
+
+If you restructure connect(), these will fail. Check the invariant still holds —
+that nothing reads a value assigned later — and then move the markers.
+"""
+
+from pathlib import Path
+
+import pytest
+
+STATIC = (Path(__file__).resolve().parents[1]
+ / "src" / "meshbay_hub" / "static")
+TRANSPORT = STATIC / "transport.js"
+
+pytestmark = pytest.mark.skipif(
+ not TRANSPORT.exists(), reason="SPA sources not present")
+
+
+def _positions(*needles: str) -> list[int]:
+ source = TRANSPORT.read_text()
+ out = []
+ for needle in needles:
+ idx = source.find(needle)
+ assert idx != -1, f"{needle!r} is gone from transport.js — update this test"
+ out.append(idx)
+ return out
+
+
+def test_challenge_values_are_captured_before_joining():
+ """
+ joinGroup() signs over node_pk and nonce_node, so both must be recorded when
+ the challenge arrives — not later, beside the proof.
+ """
+ # Deliberately loose markers: what matters is where the assignment happens,
+ # not how it is spelled, so a reordering fails on the ordering assertion
+ # below rather than on a missing string.
+ node_pk, nonce_node, join_call = _positions(
+ "this.nodePk = reply.node_pk",
+ "this._nonceNode = ",
+ "await this.joinGroup(",
+ )
+ assert node_pk < join_call, (
+ "node_pk is read from the challenge after joinGroup() runs — the join "
+ "would sign a transcript naming nothing")
+ assert nonce_node < join_call, (
+ "nonce_node is captured after joinGroup() runs — the join would not be "
+ "bound to this connection")
+
+
+def test_join_happens_before_the_gek_proof():
+ """
+ The whole point of joining in the pre-proof window: someone who has never
+ held the group key cannot produce a proof, so the key has to arrive first.
+ """
+ join_call, proof = _positions(
+ "await this.joinGroup(",
+ "await C.handshakeProof(",
+ )
+ assert join_call < proof, (
+ "the join must happen before the GEK proof — a first-time member has no "
+ "key to prove with")
+
+
+def test_keys_are_recovered_before_the_join_is_attempted():
+ """
+ A second browser holds nothing but a password. It recovers its identity keys
+ from the node's encrypted keypair bundle, and only then can it sign a join —
+ so the recovery has to come first. Getting this order wrong is invisible on
+ the browser that registered, and breaks every other one.
+ """
+ recover, join_call = _positions(
+ "type: 'keypair_bundle_fetch'",
+ "await this.joinGroup(",
+ )
+ assert recover < join_call, (
+ "the keypair bundle must be fetched before joinGroup() — otherwise a "
+ "browser that did not register has no key to sign the join with")
+
+
+def test_the_ack_still_verifies_the_announced_node_key():
+ """
+ Taking node_pk from the challenge is only safe because the ack proves it and
+ the client compares the two. Losing that check would leave the announcement
+ trusted on its own.
+ """
+ source = TRANSPORT.read_text()
+ assert "Node identity changed during the handshake" in source, (
+ "the challenge's node_pk must be checked against the ack's")
+ assert "verifyNodeSignature" in source, (
+ "the ack's signature over the handshake transcript must still be verified")