Files
securebit-chat/tests/groups-reducer.test.mjs
T

272 lines
12 KiB
JavaScript
Raw Normal View History

// 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');