aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/tests/test_rewrap_fanout.py
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-hub/tests/test_rewrap_fanout.py')
-rw-r--r--packages/meshbay-hub/tests/test_rewrap_fanout.py210
1 files changed, 210 insertions, 0 deletions
diff --git a/packages/meshbay-hub/tests/test_rewrap_fanout.py b/packages/meshbay-hub/tests/test_rewrap_fanout.py
new file mode 100644
index 0000000..03d24dc
--- /dev/null
+++ b/packages/meshbay-hub/tests/test_rewrap_fanout.py
@@ -0,0 +1,210 @@
+"""
+`MeshBayTransport.rewrapAllNodes` — the passphrase-change / recovery fan-out
+(docs/auth-confirm.md §3.2, §4.5).
+
+The real function is run under node with its two boundaries stubbed: the hub
+HTTP calls and the per-node `MeshBayTransport` handshake. What is exercised is
+the orchestration — which groups land in `updated` / `unreachable` / `failed`,
+which nodes get a `keypair_bundle_store`, and that Flow B also writes a
+recovery-wrapped copy. The WebRTC handshake itself and `connect()`'s
+recovery-copy fallback are integration territory with no harness here.
+"""
+
+import json
+import shutil
+import subprocess
+from pathlib import Path
+
+import pytest
+
+STATIC = Path(__file__).resolve().parents[1] / "src" / "meshbay_hub" / "static"
+TRANSPORT = STATIC / "transport.js"
+
+pytestmark = pytest.mark.skipif(
+ shutil.which("node") is None or not TRANSPORT.exists(),
+ reason="node or transport.js is unavailable",
+)
+
+_HARNESS = r"""
+const fs = require('fs');
+global.self = global;
+global.window = global;
+global.location = { hash: '' };
+global.addEventListener = () => {};
+global.document = {
+ hidden: false, visibilityState: 'visible', addEventListener: () => {},
+};
+global.localStorage = {
+ getItem: () => null, setItem() {}, removeItem() {}, key: () => null, length: 0,
+};
+// connect() is stubbed on the prototype below, so no WebRTC shim is needed.
+global.RTCPeerConnection = function () { throw new Error('connect() not stubbed'); };
+
+eval(fs.readFileSync(process.argv[2], 'utf8'));
+const T = window.MeshBayTransport;
+
+let deriveEncCalls = 0;
+window.MeshBayKeys = {
+ deriveEncryptionKey: async (p) => { deriveEncCalls++; return { kind: 'enc', p }; },
+ deriveEncryptionKeyV1: async (p) => ({ kind: 'encv1', p }),
+ deriveRecoveryKey: async (r) => ({ kind: 'rec', r }),
+ encryptBundleWithKey: async (_skEd, _skX, key) => 'wrapped:' + key.kind,
+};
+
+const b64 = (s) => Buffer.from(s).toString('base64');
+
+const NODES = {
+ 'n-ok': { sessionKeys: { skEdB64: b64('ed'), skXB64: b64('x') } },
+ 'n-fresh': { newNodeBundle: 'fresh', sessionKeys: { skEdB64: b64('ed'), skXB64: b64('x') } },
+ 'n-throw': { throws: 'handshake failed' },
+ 'n-noident': { sessionKeys: null },
+};
+
+const stored = [];
+const rewrapOnlySeen = [];
+T.prototype.connect = async function (nodeId) {
+ this._nodeId = nodeId;
+ rewrapOnlySeen.push(this._rewrapOnly === true);
+ const s = NODES[nodeId] || {};
+ if (s.throws) throw new Error(s.throws);
+ this._sessionKeys = s.sessionKeys || null;
+ this._newNodeBundle = s.newNodeBundle || null;
+ return { ok: true };
+};
+T.prototype.storeKeypairBundle = async function (enc, rec) {
+ stored.push({ nodeId: this._nodeId, enc, rec: rec || null });
+};
+T.prototype.close = function () {};
+
+const MINE = { groups: [
+ { id: 'gA', name: 'a', owner_username: 'ann' }, // normal node
+ { id: 'gB', name: 'b', owner_username: 'ann' }, // no online node
+ { id: 'gC', name: 'c', owner_username: 'ann' }, // /nodes errors
+ { id: 'gD', name: 'd', owner_username: 'ann' }, // connect throws
+ { id: 'gE', name: 'e', owner_username: 'ann' }, // fresh identity, nothing stranded
+ { id: 'gF', name: 'f', owner_username: 'ann' }, // identity not recovered
+] };
+const NODES_FOR = {
+ gA: { nodes: [{ node_id: 'n-ok' }] },
+ gB: { nodes: [] },
+ gC: 'ERR',
+ gD: { nodes: [{ node_id: 'n-throw' }] },
+ gE: { nodes: [{ node_id: 'n-fresh' }] },
+ gF: { nodes: [{ node_id: 'n-noident' }] },
+};
+global.fetch = async (url) => {
+ const path = url.replace(/^.*?(\/v1\/)/, '$1');
+ if (path === '/v1/groups/mine') return { ok: true, json: async () => MINE };
+ const m = path.match(/^\/v1\/groups\/([^/]+)\/nodes$/);
+ if (m) {
+ const v = NODES_FOR[m[1]];
+ if (v === 'ERR') return { ok: false, status: 503 };
+ return { ok: true, json: async () => v };
+ }
+ return { ok: false, status: 404 };
+};
+
+const names = (a) => a.map((x) => x.name).sort();
+
+(async () => {
+ const A = await T.rewrapAllNodes({
+ hubUrl: 'https://h', token: 't', username: 'u', userId: 'uid',
+ oldPassphrase: 'old', newPassphrase: 'new',
+ });
+ const storeA = stored.splice(0);
+
+ const B = await T.rewrapAllNodes({
+ hubUrl: 'https://h', token: 't', username: 'u', userId: 'uid',
+ newPassphrase: 'new', recoveryKey: 'A RECOVERY MNEMONIC',
+ });
+ const storeB = stored.splice(0);
+
+ // Flow C — Profile backfill: keep the live passphrase key, just add the
+ // recovery copy. No passphrase strings, so deriveEncryptionKey is not called.
+ deriveEncCalls = 0;
+ const C = await T.rewrapAllNodes({
+ hubUrl: 'https://h', token: 't', username: 'u', userId: 'uid',
+ bundleKey: { v2: { kind: 'bk' }, v1: { kind: 'bkv1' } },
+ recoveryKey: 'A RECOVERY MNEMONIC',
+ });
+ const storeC = stored.splice(0);
+
+ process.stdout.write(JSON.stringify({
+ a_updated: names(A.updated),
+ a_unreachable: names(A.unreachable),
+ a_failed: names(A.failed),
+ a_stored_nodes: storeA.map((s) => s.nodeId).sort(),
+ a_recovery_always_null: storeA.every((s) => s.rec === null),
+ a_new_bundle_key_kind: A.newBundleKey && A.newBundleKey.v2 && A.newBundleKey.v2.kind,
+ b_stored: storeB.map((s) => ({ node: s.nodeId, enc: s.enc, rec: s.rec })),
+ c_stored: storeC.map((s) => ({ node: s.nodeId, enc: s.enc, rec: s.rec })),
+ c_derive_enc_calls: deriveEncCalls,
+ // Every transport the fan-out builds is flagged rewrap-only, so a stored
+ // bundle it cannot open is reported, not silently replaced with a new one.
+ all_rewrap_only: rewrapOnlySeen.length > 0 && rewrapOnlySeen.every(Boolean),
+ }));
+})().catch((e) => { console.error(e); process.exit(1); });
+"""
+
+
+@pytest.fixture(scope="module")
+def result(tmp_path_factory):
+ d = tmp_path_factory.mktemp("rewrap")
+ harness = d / "harness.cjs"
+ harness.write_text(_HARNESS)
+ proc = subprocess.run(
+ ["node", str(harness), str(TRANSPORT)],
+ capture_output=True, text=True, timeout=120,
+ )
+ if proc.returncode != 0:
+ pytest.fail(f"node harness failed:\n{proc.stderr[-2000:]}")
+ return json.loads(proc.stdout)
+
+
+def test_a_reachable_node_with_an_identity_is_updated(result):
+ assert "a@ann" in result["a_updated"]
+ assert result["a_stored_nodes"] == ["n-ok"]
+
+
+def test_a_group_with_no_online_node_is_unreachable(result):
+ assert result["a_unreachable"] == ["b@ann"]
+
+
+def test_a_nodes_lookup_error_and_a_failed_handshake_land_in_failed(result):
+ assert "c@ann" in result["a_failed"] # /nodes returned 503
+ assert "d@ann" in result["a_failed"] # connect() threw
+
+
+def test_a_node_that_never_had_our_identity_is_not_written_but_not_a_failure(result):
+ # gE: connect minted a fresh identity — nothing is stranded, so the group is
+ # "updated", and no keypair_bundle_store is sent for it.
+ assert "e@ann" in result["a_updated"]
+ assert "n-fresh" not in result["a_stored_nodes"]
+
+
+def test_a_node_that_returns_no_identity_is_a_failure(result):
+ assert "f@ann" in result["a_failed"]
+
+
+def test_flow_a_writes_only_the_passphrase_copy(result):
+ assert result["a_recovery_always_null"] is True
+ assert result["a_new_bundle_key_kind"] == "enc"
+
+
+def test_flow_b_writes_both_the_passphrase_and_the_recovery_copy(result):
+ assert result["b_stored"] == [
+ {"node": "n-ok", "enc": "wrapped:enc", "rec": "wrapped:rec"},
+ ]
+
+
+def test_profile_backfill_keeps_the_live_key_and_adds_the_recovery_copy(result):
+ # bundleKey mode: the passphrase copy is re-wrapped with the same live key
+ # (kind "bk"), the recovery copy is added, and no passphrase is derived.
+ assert result["c_stored"] == [
+ {"node": "n-ok", "enc": "wrapped:bk", "rec": "wrapped:rec"},
+ ]
+ assert result["c_derive_enc_calls"] == 0
+
+
+def test_every_fanout_transport_is_rewrap_only(result):
+ assert result["all_rewrap_only"] is True