// A whole group, formed end to end over a virtual mesh. // // The topology is deliberately INCOMPLETE: Alice (the admin) holds a link to // Bob and a link to Carol, and Bob and Carol have no link to each other. That is // the real shape of a group at the moment it is created, and it is what forces // the relay path — so this file covers both the happy case and the case the // design actually has to survive. // // What is asserted, in order of how much it matters: // 1. every member computes the SAME safety code, and only after a complete // commit round; // 2. a member the admin relays for still receives and verifies messages; // 3. a forged or tampered message does not verify; // 4. a member who sends two different bodies under one sequence number is // caught. import assert from 'node:assert/strict'; const { GroupSession, GROUP_FRAMES, groupFrameType, decodeEnvelope, encodeEnvelope } = await import('../src/group/GroupSession.js'); const { GROUP_PHASE, MEMBER_STATE } = await import('../src/state/groupsStore.js'); const { toB64, fromB64, hashBody, signGroupMessage, generateGroupIdentity } = await import('../src/group/groupCrypto.js'); const subtle = crypto.subtle; // --------------------------------------------------------------------------- // a virtual mesh // --------------------------------------------------------------------------- /** * Nodes are wired by named links. `send(sessionId, frame)` delivers to whichever * endpoint of that link is not the sender, awaiting the handler so the whole * cascade settles before the test continues. */ function makeMesh() { const links = new Map(); // sessionId -> [nodeA, nodeB] const nodes = new Map(); // name -> node const events = new Map(); // name -> [{ event, payload }] function connect(a, b, sessionId) { links.set(sessionId, [a, b]); } function makeNode(name, { isAdmin, groupId, groupName }) { const log = []; events.set(name, log); const node = { name, session: null, dropRelays: false, events: log, }; const send = async (sessionId, frame) => { const pair = links.get(sessionId); // A link that does not exist rejects, exactly as sendGroupFrame does // for a session with no manager. Returning quietly would let a test // pass on a send that never happened. if (!pair) throw new Error('no such link'); const other = pair[0] === name ? pair[1] : pair[0]; const target = nodes.get(other); if (!target) return; if (node.dropRelays && groupFrameType(frame) === GROUP_FRAMES.RELAY) return; // A frame is JSON on the wire; round-trip it so nothing in the test // shares an object reference the real transport would have severed. const wire = JSON.parse(JSON.stringify(frame)); if (groupFrameType(wire) === GROUP_FRAMES.INVITE && !target.session) { const invite = decodeEnvelope(wire); target.session = new GroupSession({ groupId: invite.gid, name: invite.name, isAdmin: false, subtle, send: target.send, emit: target.emit, }); await target.session.acceptInvite(sessionId, invite); return; } if (!target.session) return; await target.session.handleFrame(sessionId, wire); }; const emit = (event, payload) => { log.push({ event, payload }); }; node.send = send; node.emit = emit; if (isAdmin) { node.session = new GroupSession({ groupId, name: groupName, isAdmin: true, subtle, send, emit }); } nodes.set(name, node); return node; } return { connect, makeNode, nodes }; } const last = (node, event) => [...node.events].reverse().find((e) => e.event === event)?.payload; const all = (node, event) => node.events.filter((e) => e.event === event).map((e) => e.payload); // --------------------------------------------------------------------------- // form a three-member group over an incomplete mesh // --------------------------------------------------------------------------- async function formGroup() { const mesh = makeMesh(); const gid = GroupSession.newId(); const alice = mesh.makeNode('alice', { isAdmin: true, groupId: gid, groupName: 'Field team' }); const bob = mesh.makeNode('bob', {}); const carol = mesh.makeNode('carol', {}); // Alice knows both. Bob and Carol do not know each other — the relay case. mesh.connect('alice', 'bob', 'A-B'); mesh.connect('alice', 'carol', 'A-C'); await alice.session.init(); await alice.session.invite([ { sessionId: 'A-B', name: 'Bob' }, { sessionId: 'A-C', name: 'Carol' }, ]); return { mesh, gid, alice, bob, carol }; } { const { alice, bob, carol } = await formGroup(); // Everyone adopted the roster and reached the code. for (const node of [alice, bob, carol]) { assert.equal(node.session.phase, GROUP_PHASE.AWAITING_SAS, `${node.name} must reach the safety-code step`); assert.equal(node.session.members.size, 3, `${node.name} must see three members`); } // THE assertion: one group, one code. const codes = [alice, bob, carol].map((n) => n.session.sasCode); assert.equal(new Set(codes).size, 1, 'every member must read the same digits'); assert.match(codes[0], /^\d{7}$/); // Bob and Carol learned about each other only through the admin's signed // roster, and neither has a direct link to the other. const bobsCarol = [...bob.session.members.values()].find((m) => m.fp === carol.session.selfFp); assert.ok(bobsCarol, 'Bob must know Carol from the roster'); assert.equal(bobsCarol.state, MEMBER_STATE.PENDING, 'with no direct link, Carol is pending for Bob'); assert.equal(bobsCarol.sessionId, null); // Nothing may be sent before the humans confirm. await assert.rejects(() => bob.session.sendText('too early'), /group code has not been confirmed/); // Confirm everywhere. for (const node of [alice, bob, carol]) node.session.confirmSas(); for (const node of [alice, bob, carol]) { assert.equal(node.session.phase, GROUP_PHASE.READY); assert.equal(node.session.sasConfirmed, true); assert.ok(last(node, 'confirmed'), 'the app is told the group is ready'); } // ---- delivery over a direct link ---- await alice.session.sendText('roll call'); assert.equal(last(bob, 'message').body, 'roll call'); assert.equal(last(carol, 'message').body, 'roll call'); assert.equal(last(bob, 'message').fp, alice.session.selfFp, 'attributed to the signer'); // ---- delivery THROUGH the admin, between two members with no link ---- const result = await bob.session.sendText('on my way'); assert.equal(result.delivered, 2, 'Bob reaches both members: Alice directly, Carol relayed'); assert.equal(last(alice, 'message').body, 'on my way'); assert.equal(last(carol, 'message').body, 'on my way', 'a relayed message must arrive and verify'); assert.equal(last(carol, 'message').fp, bob.session.selfFp, 'the relay does not become the author'); // The relay is single-hop: Carol never forwards what she received. assert.equal(carol.session._pendingCeremony.length, 0); // ---- a duplicate is absorbed, not shown twice ---- const before = all(carol, 'message').length; await bob.session._sendTo(carol.session.selfFp, { type: GROUP_FRAMES.MESSAGE, gid: bob.session.groupId, epoch: bob.session.epoch, seq: result.seq, fp: bob.session.selfFp, ts: Date.now(), body: 'on my way', sig: toB64(await signGroupMessage(subtle, bob.session.identity.keyPair.privateKey, { groupId: bob.session.groupId, epoch: bob.session.epoch, seq: result.seq, senderFp: bob.session.selfFp, bodyHash: await hashBody(subtle, 'on my way'), })), }); assert.equal(all(carol, 'message').length, before, 'a repeated frame is absorbed'); } // --------------------------------------------------------------------------- // the reveal gate really is closed until every commitment is in // --------------------------------------------------------------------------- { const mesh = makeMesh(); const gid = GroupSession.newId(); const alice = mesh.makeNode('alice', { isAdmin: true, groupId: gid, groupName: 'Slow' }); const bob = mesh.makeNode('bob', {}); const carol = mesh.makeNode('carol', {}); mesh.connect('alice', 'bob', 'A-B'); mesh.connect('alice', 'carol', 'A-C'); // Alice refuses to carry anything between Bob and Carol, so Carol's // commitment never reaches Bob and Bob's never reaches Carol. alice.dropRelays = true; await alice.session.init(); await alice.session.invite([ { sessionId: 'A-B', name: 'Bob' }, { sessionId: 'A-C', name: 'Carol' }, ]); assert.equal(bob.session.phase, GROUP_PHASE.COMMITTING, 'an incomplete commit round leaves the member waiting, never revealing'); assert.equal(bob.session.ceremony.revealed, false, 'no nonce left the device'); assert.equal(bob.session.sasCode, '', 'and no code was produced'); assert.throws(() => bob.session.confirmSas(), /no group code to confirm/); } // --------------------------------------------------------------------------- // forged and tampered messages // --------------------------------------------------------------------------- { const { alice, bob, carol } = await formGroup(); for (const node of [alice, bob, carol]) node.session.confirmSas(); const gid = bob.session.groupId; const epoch = bob.session.epoch; // A body swapped after signing does not verify. const bodyHash = await hashBody(subtle, 'the real text'); const sig = await signGroupMessage(subtle, bob.session.identity.keyPair.privateKey, { groupId: gid, epoch, seq: 40, senderFp: bob.session.selfFp, bodyHash, }); await assert.rejects( () => carol.session._onMessage({ type: GROUP_FRAMES.MESSAGE, gid, epoch, seq: 40, fp: bob.session.selfFp, ts: Date.now(), body: 'the SWAPPED text', sig: toB64(sig), }), /signature did not verify/, ); // Alice cannot sign as Bob, however well placed she is to relay for him. const aliceSig = await signGroupMessage(subtle, alice.session.identity.keyPair.privateKey, { groupId: gid, epoch, seq: 41, senderFp: bob.session.selfFp, bodyHash, }); await assert.rejects( () => carol.session._onMessage({ type: GROUP_FRAMES.MESSAGE, gid, epoch, seq: 41, fp: bob.session.selfFp, ts: Date.now(), body: 'the real text', sig: toB64(aliceSig), }), /signature did not verify/, ); // An outsider with a perfectly valid signature is still not a member. const outsider = await generateGroupIdentity(subtle); const outsiderSig = await signGroupMessage(subtle, outsider.keyPair.privateKey, { groupId: gid, epoch, seq: 1, senderFp: outsider.fingerprint, bodyHash, }); await assert.rejects( () => carol.session._onMessage({ type: GROUP_FRAMES.MESSAGE, gid, epoch, seq: 1, fp: outsider.fingerprint, ts: Date.now(), body: 'the real text', sig: toB64(outsiderSig), }), /message from a non-member/, ); // A message from an epoch the group has left is refused even though it verifies. await assert.rejects( () => carol.session._onMessage({ type: GROUP_FRAMES.MESSAGE, gid, epoch: epoch + 5, seq: 42, fp: bob.session.selfFp, ts: Date.now(), body: 'the real text', sig: toB64(sig), }), /another epoch/, ); } // --------------------------------------------------------------------------- // a member telling two halves of the group different things is detectable // --------------------------------------------------------------------------- { const { alice, bob, carol } = await formGroup(); for (const node of [alice, bob, carol]) node.session.confirmSas(); const gid = bob.session.groupId; const epoch = bob.session.epoch; const seq = 7; const signed = async (body) => toB64(await signGroupMessage( subtle, bob.session.identity.keyPair.privateKey, { groupId: gid, epoch, seq, senderFp: bob.session.selfFp, bodyHash: await hashBody(subtle, body) }, )); const frame = (body, sig) => ({ type: GROUP_FRAMES.MESSAGE, gid, epoch, seq, fp: bob.session.selfFp, ts: Date.now(), body, sig, }); const sellFrame = frame('sell', await signed('sell')); const buyFrame = frame('buy', await signed('buy')); await carol.session._onMessage(sellFrame); assert.equal(last(carol, 'message').body, 'sell'); // The second, differently-signed body under the same sequence number is the // split. Both signatures are valid, which is exactly what makes it provable. await assert.rejects( () => carol.session._onMessage(buyFrame), /conflicting messages under one sequence number/, ); const flagged = last(carol, 'inconsistency'); assert.ok(flagged, 'the app is told which member did it'); assert.equal(flagged.fp, bob.session.selfFp); assert.equal(flagged.seq, seq); } // --------------------------------------------------------------------------- // removing a member re-keys the group // --------------------------------------------------------------------------- { const { alice, bob, carol } = await formGroup(); for (const node of [alice, bob, carol]) node.session.confirmSas(); const codeBefore = alice.session.sasCode; const epochBefore = alice.session.epoch; await alice.session.removeMember(carol.session.selfFp); assert.equal(alice.session.epoch, epochBefore + 1, 'removal opens a new epoch'); assert.equal(alice.session.members.size, 2); assert.equal(bob.session.members.size, 2, 'the remaining member adopted the new roster'); assert.ok(!bob.session.members.has(carol.session.selfFp), 'Carol is gone from Bob’s roster'); // A new epoch means a new code, and it must be compared again before // anything is sent. assert.equal(alice.session.phase, GROUP_PHASE.AWAITING_SAS); assert.equal(bob.session.phase, GROUP_PHASE.AWAITING_SAS); assert.notEqual(alice.session.sasCode, codeBefore, 're-keying must change the digits'); assert.equal(alice.session.sasCode, bob.session.sasCode, 'and both remaining members agree'); assert.equal(alice.session.sasConfirmed, false); await assert.rejects(() => alice.session.sendText('still here?'), /has not been confirmed/); } // --------------------------------------------------------------------------- // relay hygiene // --------------------------------------------------------------------------- { const { alice, bob, carol } = await formGroup(); for (const node of [alice, bob, carol]) node.session.confirmSas(); // A relay envelope already marked as hopped is not forwarded again. let forwarded = 0; const realSend = alice.session._send; alice.session._send = async (sid, frame) => { forwarded++; return realSend(sid, frame); }; await alice.session._onRelay('A-B', { type: GROUP_FRAMES.RELAY, gid: alice.session.groupId, to: carol.session.selfFp, hopped: true, inner: { type: GROUP_FRAMES.MESSAGE, gid: alice.session.groupId }, }); assert.equal(forwarded, 0, 'a second hop is refused'); // A nested relay is refused outright. await alice.session._onRelay('A-B', { type: GROUP_FRAMES.RELAY, gid: alice.session.groupId, to: carol.session.selfFp, hopped: false, inner: { type: GROUP_FRAMES.RELAY, gid: alice.session.groupId, to: bob.session.selfFp }, }); assert.equal(forwarded, 0, 'relays do not nest'); alice.session._send = realSend; // A frame for another group is ignored entirely. const otherGid = GroupSession.newId(); await alice.session.handleFrame('A-B', { type: GROUP_FRAMES.MESSAGE, gid: otherGid, epoch: 1, seq: 1 }); // (no throw, no message emitted) assert.equal(all(alice, 'message').length, 0); } // --------------------------------------------------------------------------- // the envelope: what keeps a signature intact through the chat path // --------------------------------------------------------------------------- { /** * Stand-in for EnhancedSecureCryptoUtils.sanitizeMessage, which every group * frame passes through on its way out: DOMPurify escapes the HTML-significant * characters, control characters go, blank runs collapse, the string is * trimmed and then cut to 2000 characters. */ const sanitizeLikeTheChatPath = (s) => String(s) .replace(/&/g, '&').replace(//g, '>') .replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F-\u009F]/g, '') .replace(/\r\n?/g, '\n') .replace(/\n{3,}/g, '\n\n') .trim() .substring(0, 2000); const { alice, bob, carol } = await formGroup(); for (const node of [alice, bob, carol]) node.session.confirmSas(); // A body full of exactly the characters that path rewrites. const hostile = 'if a < b && c > d then "quote" \n\n\n trailing '; const gid = bob.session.groupId; const epoch = bob.session.epoch; const bodyHash = await hashBody(subtle, hostile); const sig = await signGroupMessage(subtle, bob.session.identity.keyPair.privateKey, { groupId: gid, epoch, seq: 99, senderFp: bob.session.selfFp, bodyHash, }); const frame = { type: GROUP_FRAMES.MESSAGE, gid, epoch, seq: 99, fp: bob.session.selfFp, ts: Date.now(), body: hostile, sig: toB64(sig), }; // Without the envelope the chat path would rewrite the body and the // signature would no longer match — the failure this design exists to avoid. const bare = JSON.parse(sanitizeLikeTheChatPath(JSON.stringify(frame))); assert.notEqual(bare.body, hostile, 'the chat path really does rewrite a raw body'); // With it, every byte survives. const wrapped = encodeEnvelope(frame); const throughTheWire = JSON.parse(sanitizeLikeTheChatPath(JSON.stringify(wrapped))); assert.deepEqual(throughTheWire, wrapped, 'the envelope passes through untouched'); const recovered = decodeEnvelope(throughTheWire); assert.equal(recovered.body, hostile, 'the body arrives byte for byte'); // And it verifies on the far side. await carol.session._onMessage(recovered); assert.equal(last(carol, 'message').body, hostile); // The routing hints outside the encoding carry no authority. assert.throws(() => decodeEnvelope({ ...wrapped, gid: GroupSession.newId() }), /group id does not match/); assert.throws(() => decodeEnvelope({ ...wrapped, t: GROUP_FRAMES.ROSTER }), /type does not match/); assert.throws(() => decodeEnvelope({ type: 'g_env', gid, t: 'g_msg', d: 'bm90IGpzb24=' }), /Unexpected token|not valid JSON|no recognisable frame/); // A frame that would be truncated by the chat path is refused at the source, // where it is still a clear error rather than a corrupt payload. assert.throws( () => encodeEnvelope({ ...frame, body: 'x'.repeat(4000) }), /exceeds the transport budget/, ); // Every frame the protocol actually emits fits the budget, including the // largest one: a roster for a full group. const bigRoster = { type: GROUP_FRAMES.ROSTER, gid, epoch: 1, op: 'create', name: 'x'.repeat(64), adminSpki: toB64(new Uint8Array(120)), members: Array.from({ length: 8 }, () => 'ab'.repeat(32)), sig: toB64(new Uint8Array(96)), }; assert.doesNotThrow(() => encodeEnvelope(bigRoster), 'a full 8-member roster must fit'); } // --------------------------------------------------------------------------- // nobody reveals before their own commitment is on the wire // --------------------------------------------------------------------------- { // Ordering regression. Draining held frames before broadcasting our own // commitment could complete the round on the spot and put a reveal out ahead // of the commitment it belongs to. The peer then had a reveal it could not // check and had to hold it, so the ceremony only finished if that one later // frame arrived — and when it did not, every member sat at "exchanging // nonces" waiting on a nonce that had already been sent. const mesh = makeMesh(); const gid = GroupSession.newId(); const alice = mesh.makeNode('alice', { isAdmin: true, groupId: gid, groupName: 'Pair' }); const bob = mesh.makeNode('bob', {}); mesh.connect('alice', 'bob', 'A-B'); const wire = []; for (const node of [alice, bob]) { const inner = node.send; node.send = async (sid, frame) => { wire.push(`${node.name}:${groupFrameType(frame)}`); return inner(sid, frame); }; } // Bob's session is built later, from `bob.send`, so it picks the wrapper up // on its own. Alice's already exists and captured the original at // construction — spy on the session itself. alice.session._send = alice.send; await alice.session.init(); await alice.session.invite([{ sessionId: 'A-B', name: 'Bob' }]); for (const node of [alice, bob]) { const commit = wire.indexOf(`${node.name}:${GROUP_FRAMES.COMMIT}`); const reveal = wire.indexOf(`${node.name}:${GROUP_FRAMES.REVEAL}`); assert.ok(commit >= 0, `${node.name} must broadcast a commitment`); assert.ok(reveal >= 0, `${node.name} must broadcast a reveal`); assert.ok(commit < reveal, `${node.name} must commit before revealing (wire: ${wire.join(' ')})`); assert.equal(node.session._pendingCeremony.length, 0, `${node.name} should not need to hold any ceremony frame`); } // A two-member group is the smallest one, and it must still converge. assert.equal(alice.session.phase, GROUP_PHASE.AWAITING_SAS); assert.equal(bob.session.phase, GROUP_PHASE.AWAITING_SAS); assert.equal(alice.session.sasCode, bob.session.sasCode); assert.match(alice.session.sasCode, /^\d{7}$/); } // --------------------------------------------------------------------------- // leaving a group frees its members for the next one // --------------------------------------------------------------------------- { const { alice, bob, carol } = await formGroup(); for (const node of [alice, bob, carol]) node.session.confirmSas(); // A group of three losing one is still a group; losing two is not, and the // admin must be told it ended rather than throwing mid-teardown. await alice.session.removeMember(carol.session.selfFp); assert.equal(alice.session.members.size, 2); await assert.rejects( () => alice.session.removeMember(bob.session.selfFp), /cannot drop below two members/, ); // When the admin leaves, the remaining member's group is over: nobody else // can sign a roster, so there is no next epoch and no code to compare again. await alice.session.leave(); assert.ok(last(bob, 'ended'), 'the remaining member is told the group ended'); assert.equal(last(bob, 'ended').reason, 'admin_left'); assert.ok(!bob.session.members.has(alice.session.selfFp)); } // --------------------------------------------------------------------------- // a member who leaves disappears from everyone's list // --------------------------------------------------------------------------- { const { alice, bob, carol } = await formGroup(); for (const node of [alice, bob, carol]) node.session.confirmSas(); const carolFp = carol.session.selfFp; await carol.session.leave(); // The admin's own view is what regressed: it deleted the member internally // but never told the UI, so a member who had visibly left stayed on screen. assert.ok(!alice.session.members.has(carolFp), 'the admin drops the departed member'); const adminView = last(alice, 'members'); assert.ok(adminView, 'the admin must emit a member list when someone leaves'); assert.ok(!adminView.members.some((m) => m.fp === carolFp), 'the departed member must be gone from the list the admin renders'); assert.equal(adminView.members.length, 2); // And the remaining member learns it through the signed roster. assert.ok(!bob.session.members.has(carolFp), 'the other member drops her too'); assert.ok(!last(bob, 'members').members.some((m) => m.fp === carolFp)); // Membership changed, so the code must be compared again. assert.equal(alice.session.phase, GROUP_PHASE.AWAITING_SAS); assert.equal(alice.session.sasCode, bob.session.sasCode); } // --------------------------------------------------------------------------- // the admin can invite into a group that is already running // --------------------------------------------------------------------------- { const mesh = makeMesh(); const gid = GroupSession.newId(); const alice = mesh.makeNode('alice', { isAdmin: true, groupId: gid, groupName: 'Field team' }); const bob = mesh.makeNode('bob', {}); const dana = mesh.makeNode('dana', {}); mesh.connect('alice', 'bob', 'A-B'); mesh.connect('alice', 'dana', 'A-D'); await alice.session.init(); await alice.session.invite([{ sessionId: 'A-B', name: 'Bob' }]); alice.session.confirmSas(); bob.session.confirmSas(); const firstCode = alice.session.sasCode; const firstEpoch = alice.session.epoch; assert.equal(alice.session.members.size, 2); // The group stays usable while the invitation is outstanding — nothing about // the membership has changed yet. await alice.session.sendText('before dana'); assert.equal(last(bob, 'message').body, 'before dana'); await alice.session.addMembers([{ sessionId: 'A-D', name: 'Dana' }]); for (const node of [alice, bob, dana]) { assert.equal(node.session.members.size, 3, `${node.name} sees three members`); assert.equal(node.session.epoch, firstEpoch + 1, `${node.name} moved to the next epoch`); assert.equal(node.session.phase, GROUP_PHASE.AWAITING_SAS, `${node.name} must compare a new code`); } // A changed member set means a changed code — the old one no longer says // anything about who is in the room. const codes = [alice, bob, dana].map((n) => n.session.sasCode); assert.equal(new Set(codes).size, 1, 'all three agree on the new code'); assert.notEqual(codes[0], firstCode, 'adding a member must change the code'); // And nothing flows until it is confirmed again, including for the member // who was already verified a moment ago. await assert.rejects(() => bob.session.sendText('too early'), /has not been confirmed/); for (const node of [alice, bob, dana]) node.session.confirmSas(); await alice.session.sendText('welcome dana'); assert.equal(last(dana, 'message').body, 'welcome dana'); assert.equal(last(bob, 'message').body, 'welcome dana'); // Dana can reach Bob even with no direct link, relayed by the admin. const result = await dana.session.sendText('hello bob'); assert.equal(result.delivered, 2); assert.equal(last(bob, 'message').body, 'hello bob'); assert.equal(last(bob, 'message').fp, dana.session.selfFp); } // --------------------------------------------------------------------------- // invitation rounds that should not happen // --------------------------------------------------------------------------- { const { alice, bob, carol } = await formGroup(); for (const node of [alice, bob, carol]) node.session.confirmSas(); // Only the admin invites. await assert.rejects( () => bob.session.addMembers([{ sessionId: 'X', name: 'Nope' }]), /only the admin invites/, ); // A session already carrying a member cannot be invited again — it would // answer with a second identity key and take two slots in the safety code. const bobsSession = [...alice.session.members.values()].find((m) => m.fp === bob.session.selfFp).sessionId; await assert.rejects( () => alice.session.addMembers([{ sessionId: bobsSession, name: 'Bob again' }]), /already a member/, ); // The ceiling is enforced before anything is sent. await assert.rejects( () => alice.session.addMembers(Array.from({ length: 7 }, (_, i) => ({ sessionId: `s${i}`, name: 'x' }))), /limited to 8 members/, ); // An add nobody answers leaves the group exactly as it was. const epochBefore = alice.session.epoch; const membersBefore = alice.session.members.size; mesh_unreachable: { // A link the mesh does not know about: the send fails, so the round is // abandoned at once rather than half-applied. await assert.rejects( () => alice.session.addMembers([{ sessionId: 'nowhere', name: 'Ghost' }]), /could not be sent/, ); } assert.equal(alice.session.epoch, epochBefore, 'a failed invitation does not open an epoch'); assert.equal(alice.session.members.size, membersBefore, 'and does not change the membership'); assert.equal(alice.session._pendingAdd, null, 'the round is cleared'); } // --------------------------------------------------------------------------- // a member going offline is not a member leaving // --------------------------------------------------------------------------- { const { alice, bob, carol } = await formGroup(); for (const node of [alice, bob, carol]) node.session.confirmSas(); const carolFp = carol.session.selfFp; const carolsSession = [...alice.session.members.values()].find((m) => m.fp === carolFp).sessionId; const epochBefore = alice.session.epoch; const codeBefore = alice.session.sasCode; alice.session.setSessionState(carolsSession, false); // Still a member. Membership is a signed, epoch-ordered fact and a dropped // connection does not change it — re-keying the group every time someone's // network hiccups would make everyone re-compare a code for nothing. assert.ok(alice.session.members.has(carolFp), 'an offline member is still a member'); assert.equal(alice.session.epoch, epochBefore, 'no new epoch for a dropped link'); assert.equal(alice.session.sasCode, codeBefore, 'and no new code to compare'); assert.equal(alice.session.phase, GROUP_PHASE.READY, 'the group stays usable'); // But unmistakably unreachable, and the UI is told. assert.equal(alice.session.members.get(carolFp).state, MEMBER_STATE.LOST); const view = last(alice, 'members'); assert.equal(view.members.find((m) => m.fp === carolFp).state, MEMBER_STATE.LOST, 'the rendered list marks them lost rather than dropping or hiding them'); // Sending reports the shortfall instead of pretending it reached everyone, // and it names WHO was missed. A count alone cannot tell one absent member // from another, which is what the app needs to know to stop repeating // itself under every message for as long as somebody stays away. const result = await alice.session.sendText('anyone there?'); assert.equal(result.total, 2); assert.ok(result.delivered < result.total, 'delivery to an offline member is reported, not assumed'); assert.deepEqual(result.unreachable.map((m) => m.fp), [carolFp], 'the member who was missed is named, not just counted'); // The same shortfall reported twice is the same shortfall: the app compares // these sets, so they have to be stable for an unchanged situation. const again = await alice.session.sendText('still anyone there?'); assert.deepEqual(again.unreachable.map((m) => m.fp), [carolFp]); // Coming back restores the link with no ceremony at all. alice.session.setSessionState(carolsSession, true); assert.equal(alice.session.members.get(carolFp).state, MEMBER_STATE.LINKED); assert.equal(alice.session.epoch, epochBefore, 'reconnecting does not re-key either'); // Removing them, by contrast, IS a membership change and does re-key. await alice.session.removeMember(carolFp); assert.equal(alice.session.epoch, epochBefore + 1); assert.ok(!alice.session.members.has(carolFp)); assert.equal(alice.session.phase, GROUP_PHASE.AWAITING_SAS, 'a real removal makes everyone compare again'); } console.log('group-session-e2e.test.mjs: all assertions passed');