// The groups reducer: isolation between groups, and the two transitions that // carry security meaning — a group may not become "verified" without a code the // user could have compared, and any membership change must drop that // confirmation rather than carry it into a new epoch. import assert from 'node:assert/strict'; const { groupsReducer, createInitialGroupState, createGroupEntry, GROUP_ACTIONS: A, GROUP_PHASE, MEMBER_STATE, decorateGroup, groupSub, groupDot, groupInitials, linkedCount, } = await import('../src/state/groupsStore.js'); const { GROUP_LIMITS } = await import('../src/group/groupCrypto.js'); const fp = (n) => String(n).repeat(64).slice(0, 64); const SELF = fp(1); const BOB = fp(2); const CAROL = fp(3); function members(...states) { const fps = [SELF, BOB, CAROL]; return fps.map((f, i) => ({ fp: f, name: ['You', 'Bob', 'Carol'][i], sessionId: i === 0 ? null : `s${i}`, state: states[i] || (i === 0 ? MEMBER_STATE.SELF : MEMBER_STATE.LINKED), })); } function withTwoGroups() { let state = createInitialGroupState(); state = groupsReducer(state, { type: A.CREATE_GROUP, entry: createGroupEntry({ id: 'g1', name: 'Field team', selfFp: SELF, adminFp: SELF, isAdmin: true, members: members() }), }); state = groupsReducer(state, { type: A.CREATE_GROUP, entry: createGroupEntry({ id: 'g2', name: 'Review', selfFp: SELF, members: members() }), }); return state; } // CREATE_GROUP activates the newest and preserves order. { const state = withTwoGroups(); assert.deepEqual(state.order, ['g1', 'g2']); assert.equal(state.activeGroupId, 'g2'); assert.equal(state.groups.g1.phase, GROUP_PHASE.FORMING, 'a new group starts unformed'); assert.equal(state.groups.g1.sasConfirmed, false); // A duplicate id is ignored rather than clobbering the live group. const again = groupsReducer(state, { type: A.CREATE_GROUP, entry: createGroupEntry({ id: 'g1', name: 'Impostor' }) }); assert.equal(again, state, 'a repeated group id is a no-op'); } // Isolation: touching g2 leaves g1 referentially untouched. { const before = withTwoGroups(); const g1Ref = before.groups.g1; const after = groupsReducer(before, { type: A.ADD_MESSAGE, id: 'g2', message: { id: 1, message: 'hi', type: 'sent' } }); assert.equal(after.groups.g1, g1Ref, 'group 1 must be the same reference after editing group 2'); assert.equal(after.groups.g2.messages.length, 1); assert.equal(before.groups.g2.messages.length, 0, 'the reducer is immutable'); } // --------------------------------------------------------------------------- // CONFIRM_SAS is the group's verification gate // --------------------------------------------------------------------------- { let state = withTwoGroups(); // Without a computed code there is nothing the user could have compared, so // the transition is refused. This is the group analogue of the 1:1 rule that // verified state is never set by an inbound message. const refused = groupsReducer(state, { type: A.CONFIRM_SAS, id: 'g1' }); assert.equal(refused, state, 'a group cannot be confirmed before a code exists'); assert.equal(state.groups.g1.sasConfirmed, false); assert.equal(state.groups.g1.phase, GROUP_PHASE.FORMING); // A code alone is not enough either: the group must be waiting on THIS code. state = groupsReducer(state, { type: A.SET_SAS, id: 'g1', code: '4820193' }); assert.equal(state.groups.g1.sasCode, '4820193'); assert.equal(state.groups.g1.sasConfirmed, false, 'showing a code is not confirming it'); assert.equal( groupsReducer(state, { type: A.CONFIRM_SAS, id: 'g1' }), state, 'a group still forming cannot be confirmed, even holding a code', ); // Waiting on the code is what makes confirmation meaningful. state = groupsReducer(state, { type: A.SET_PHASE, id: 'g1', phase: GROUP_PHASE.AWAITING_SAS }); state = groupsReducer(state, { type: A.CONFIRM_SAS, id: 'g1' }); assert.equal(state.groups.g1.sasConfirmed, true); assert.equal(state.groups.g1.phase, GROUP_PHASE.READY); // A new code (new epoch) always lands unconfirmed. state = groupsReducer(state, { type: A.SET_SAS, id: 'g1', code: '9911002' }); assert.equal(state.groups.g1.sasConfirmed, false, 'a fresh code must be compared again'); } // A ceremony that reached a code and THEN failed cannot be confirmed. { let state = withTwoGroups(); state = groupsReducer(state, { type: A.SET_PHASE, id: 'g1', phase: GROUP_PHASE.AWAITING_SAS }); state = groupsReducer(state, { type: A.SET_SAS, id: 'g1', code: '5550001' }); state = groupsReducer(state, { type: A.SET_ERROR, id: 'g1', error: 'commitment_mismatch' }); assert.equal(state.groups.g1.phase, GROUP_PHASE.FAILED); assert.equal(state.groups.g1.sasCode, '5550001', 'the code survives so the UI can explain what failed'); // The dangerous case: a failed verification must not be promotable to ready // just because a code is still lying around from before it failed. assert.equal( groupsReducer(state, { type: A.CONFIRM_SAS, id: 'g1' }), state, 'a failed ceremony must never be confirmable', ); } // Leaving READY drops the confirmation and the stale code. { let state = withTwoGroups(); state = groupsReducer(state, { type: A.SET_PHASE, id: 'g1', phase: GROUP_PHASE.AWAITING_SAS }); state = groupsReducer(state, { type: A.SET_SAS, id: 'g1', code: '1234567' }); state = groupsReducer(state, { type: A.CONFIRM_SAS, id: 'g1' }); assert.equal(state.groups.g1.sasConfirmed, true); state = groupsReducer(state, { type: A.SET_PHASE, id: 'g1', phase: GROUP_PHASE.COMMITTING }); assert.equal(state.groups.g1.sasConfirmed, false, 'a membership change un-verifies the group'); assert.equal(state.groups.g1.sasCode, '', 'and clears the code it was confirmed against'); assert.equal(state.groups.g2.sasConfirmed, false, 'sibling untouched'); } // SET_ERROR fails the group; the phase follows. { let state = withTwoGroups(); state = groupsReducer(state, { type: A.SET_ERROR, id: 'g1', error: 'commitment_mismatch' }); assert.equal(state.groups.g1.phase, GROUP_PHASE.FAILED); assert.equal(state.groups.g1.error, 'commitment_mismatch'); // Moving to any non-failed phase clears the error. state = groupsReducer(state, { type: A.SET_PHASE, id: 'g1', phase: GROUP_PHASE.FORMING }); assert.equal(state.groups.g1.error, null); } // --------------------------------------------------------------------------- // members are kept in canonical order, whatever order they arrive in // --------------------------------------------------------------------------- { let state = createInitialGroupState(); const shuffled = [ { fp: CAROL, name: 'Carol', sessionId: 's2', state: MEMBER_STATE.LINKED }, { fp: SELF, name: 'You', sessionId: null, state: MEMBER_STATE.SELF }, { fp: BOB, name: 'Bob', sessionId: 's1', state: MEMBER_STATE.LINKED }, ]; state = groupsReducer(state, { type: A.CREATE_GROUP, entry: createGroupEntry({ id: 'g', name: 'X', members: shuffled }) }); assert.deepEqual(state.groups.g.members.map((m) => m.fp), [SELF, BOB, CAROL], 'sorted on create'); state = groupsReducer(state, { type: A.SET_MEMBERS, id: 'g', members: [...shuffled].reverse(), epoch: 4 }); assert.deepEqual(state.groups.g.members.map((m) => m.fp), [SELF, BOB, CAROL], 'sorted on update'); assert.equal(state.groups.g.epoch, 4); } // PATCH_MEMBER is a no-op when nothing actually moves (link state churns). { let state = withTwoGroups(); const before = state.groups.g1; state = groupsReducer(state, { type: A.PATCH_MEMBER, id: 'g1', fp: BOB, patch: { state: MEMBER_STATE.LINKED } }); assert.equal(state.groups.g1, before, 'setting a member state to what it already is must not re-render'); state = groupsReducer(state, { type: A.PATCH_MEMBER, id: 'g1', fp: BOB, patch: { state: MEMBER_STATE.LOST } }); assert.notEqual(state.groups.g1, before); assert.equal(state.groups.g1.members.find((m) => m.fp === BOB).state, MEMBER_STATE.LOST); // An unknown fingerprint changes nothing. const after = groupsReducer(state, { type: A.PATCH_MEMBER, id: 'g1', fp: fp(9), patch: { state: MEMBER_STATE.LOST } }); assert.equal(after, state); } // --------------------------------------------------------------------------- // unread and active pointer // --------------------------------------------------------------------------- { let state = withTwoGroups(); state = groupsReducer(state, { type: A.INCREMENT_UNREAD, id: 'g1' }); state = groupsReducer(state, { type: A.INCREMENT_UNREAD, id: 'g1' }); assert.equal(state.groups.g1.unreadCount, 2); assert.equal(state.groups.g2.unreadCount, 0, 'unread does not leak between groups'); state = groupsReducer(state, { type: A.CLEAR_UNREAD, id: 'g1' }); assert.equal(state.groups.g1.unreadCount, 0); assert.equal(groupsReducer(state, { type: A.CLEAR_UNREAD, id: 'g1' }), state, 'clearing twice is a no-op'); // Removing the active group re-points to its neighbour. state = groupsReducer(state, { type: A.SET_ACTIVE_GROUP, id: 'g2' }); state = groupsReducer(state, { type: A.REMOVE_GROUP, id: 'g2' }); assert.equal(state.activeGroupId, 'g1'); assert.deepEqual(state.order, ['g1']); // A 1:1 session taking the foreground clears the active group. state = groupsReducer(state, { type: A.SET_ACTIVE_GROUP, id: null }); assert.equal(state.activeGroupId, null); } // --------------------------------------------------------------------------- // derivation for rendering — partial connectivity is surfaced, not hidden // --------------------------------------------------------------------------- { const ready = createGroupEntry({ id: 'g', name: 'Field team', selfFp: SELF, members: members() }); ready.phase = GROUP_PHASE.READY; ready.sasCode = '1234567'; ready.sasConfirmed = true; assert.equal(linkedCount(ready), 3); assert.equal(groupSub(ready), '3 members · P2P mesh'); assert.equal(groupDot(ready), '#3ecf8e'); const degraded = { ...ready, members: members(MEMBER_STATE.SELF, MEMBER_STATE.LOST, MEMBER_STATE.LINKED) }; assert.equal(linkedCount(degraded), 2); assert.equal(groupSub(degraded), '2 of 3 connected', 'an unreachable member is a member not getting your messages'); assert.equal(groupDot(degraded), '#e3b341', 'partial connectivity reads amber, not green'); const forming = createGroupEntry({ id: 'h', name: 'New', members: members() }); assert.equal(groupSub(forming), 'Forming…'); assert.equal(groupDot(forming), '#e3b341'); const failed = { ...forming, phase: GROUP_PHASE.FAILED }; assert.equal(groupDot(failed), '#e5727a'); const d = decorateGroup(ready, 'other'); assert.equal(d.kind, 'group'); assert.equal(d.mono, 'FT'); assert.equal(d.verified, true); assert.equal(d.active, false); assert.equal(d.memberCount, 3); assert.equal(d.preview, '3 members · P2P mesh', 'with no messages the preview shows the group state'); // A confirmed code alone is not "verified" if the phase regressed. assert.equal(decorateGroup({ ...ready, phase: GROUP_PHASE.COMMITTING }, 'g').verified, false); assert.equal(groupInitials('Field team'), 'FT'); assert.equal(groupInitials('Ops'), 'OP'); assert.equal(groupInitials(''), '##'); } // RENAME trims and bounds. { let state = withTwoGroups(); state = groupsReducer(state, { type: A.RENAME, id: 'g1', name: ' Ops ' }); assert.equal(state.groups.g1.name, 'Ops'); state = groupsReducer(state, { type: A.RENAME, id: 'g1', name: ' ' }); assert.equal(state.groups.g1.name, 'Ops', 'a blank rename keeps the old name'); state = groupsReducer(state, { type: A.RENAME, id: 'g1', name: 'x'.repeat(400) }); assert.equal(state.groups.g1.name.length, GROUP_LIMITS.MAX_NAME_BYTES, 'the name is bounded'); // Bounded in BYTES, not characters: a Cyrillic name is two bytes per letter, // so it must be cut at half the character count. Getting this wrong is what // let a name through the UI that the roster signing then rejected. state = groupsReducer(state, { type: A.RENAME, id: 'g1', name: 'я'.repeat(400) }); const renamed = state.groups.g1.name; assert.ok(new TextEncoder().encode(renamed).length <= GROUP_LIMITS.MAX_NAME_BYTES, 'a multi-byte name is clamped by bytes'); assert.equal(renamed.length, GROUP_LIMITS.MAX_NAME_BYTES / 2); } console.log('groups-reducer.test.mjs: all assertions passed');