Files
securebit-chat/tests/group-app-integration.test.mjs
T

203 lines
9.3 KiB
JavaScript
Raw Normal View History

// The seam between GroupSession and the reducer.
//
// Every other group test drives GroupSession directly and reads its fields. That
// left the path the UI actually renders from — emitted event, dispatched action,
// reducer state — completely uncovered, and it is where two bugs hid: a group
// name that the create dialog accepted but the protocol rejected, and a failure
// that reached the store but was rendered as "still working".
//
// This file mirrors app.jsx's groupEmitter exactly, so a change there that stops
// the code reaching the store fails here rather than on a user's screen.
import assert from 'node:assert/strict';
const { GroupSession, GROUP_FRAMES, groupFrameType, decodeEnvelope } =
await import('../src/group/GroupSession.js');
const {
groupsReducer, createInitialGroupState, createGroupEntry,
GROUP_ACTIONS: GA, GROUP_PHASE,
} = await import('../src/state/groupsStore.js');
const { GROUP_LIMITS, assertName } = await import('../src/group/groupCrypto.js');
const subtle = crypto.subtle;
const bytes = (s) => new TextEncoder().encode(s).length;
/**
* A store plus the emitter app.jsx installs on every group. Kept structurally
* identical to the real one: same actions, same order, same SET_ACTIVE_GROUP on
* a code arriving.
*/
function makeStore() {
let state = createInitialGroupState();
const dispatch = (action) => { state = groupsReducer(state, action); };
const emitterFor = (gid) => (event, payload = {}) => {
switch (event) {
case 'phase': dispatch({ type: GA.SET_PHASE, id: gid, phase: payload.phase }); break;
case 'members': dispatch({ type: GA.SET_MEMBERS, id: gid, members: payload.members, epoch: payload.epoch }); break;
case 'roster': dispatch({ type: GA.RENAME, id: gid, name: payload.name }); break;
case 'sas':
dispatch({ type: GA.SET_SAS, id: gid, code: payload.code });
dispatch({ type: GA.SET_ACTIVE_GROUP, id: gid });
break;
case 'confirmed': dispatch({ type: GA.CONFIRM_SAS, id: gid }); break;
case 'error': dispatch({ type: GA.SET_ERROR, id: gid, error: payload.error }); break;
default: break;
}
};
return { get: () => state, dispatch, emitterFor };
}
/** Two peers on one link, each with its own store, formed end to end. */
async function formPair(groupName) {
const links = new Map();
const nodes = new Map();
const gid = GroupSession.newId();
const make = (name, isAdmin) => {
const store = makeStore();
const node = { name, store, session: null, errors: [] };
node.send = async (sid, frame) => {
const pair = links.get(sid);
if (!pair) return;
const other = pair[0] === name ? pair[1] : pair[0];
const target = nodes.get(other);
if (!target) return;
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.store.emitterFor(invite.gid),
});
await target.session.init();
// Exactly what handleAcceptInvite does, in the same order.
target.store.dispatch({
type: GA.CREATE_GROUP,
entry: createGroupEntry({ id: invite.gid, name: invite.name, selfFp: target.session.selfFp }),
});
await target.session.acceptInvite(sid, invite);
return;
}
if (!target.session) return;
// The app does not await this and swallows nothing — it routes the
// rejection into SET_ERROR. Mirror that.
try {
await target.session.handleFrame(sid, wire);
} catch (error) {
target.errors.push(error?.code || 'unknown');
target.store.dispatch({ type: GA.SET_ERROR, id: wire.gid, error: error?.code || 'frame_rejected' });
}
};
if (isAdmin) {
node.session = new GroupSession({
groupId: gid, name: groupName, isAdmin: true, subtle,
send: node.send, emit: store.emitterFor(gid),
});
}
nodes.set(name, node);
return node;
};
const admin = make('admin', true);
const joiner = make('joiner', false);
links.set('A-B', ['admin', 'joiner']);
await admin.session.init();
admin.store.dispatch({
type: GA.CREATE_GROUP,
entry: createGroupEntry({
id: gid, name: groupName, selfFp: admin.session.selfFp,
adminFp: admin.session.selfFp, isAdmin: true,
members: admin.session._memberSnapshot(),
}),
});
await admin.session.invite([{ sessionId: 'A-B', name: 'Peer' }]);
return { gid, admin, joiner };
}
// ---------------------------------------------------------------------------
// the code reaches the store, on BOTH sides
// ---------------------------------------------------------------------------
{
const { gid, admin, joiner } = await formPair('Field team');
for (const node of [admin, joiner]) {
const group = node.store.get().groups[gid];
assert.ok(group, `${node.name} must have the group in its store`);
assert.deepEqual(node.errors, [], `${node.name} saw no rejected frames`);
assert.equal(group.phase, GROUP_PHASE.AWAITING_SAS,
`${node.name}: the store must reach the safety-code step`);
// The two things the modal reads to decide whether to show the digits and
// enable the confirm button. Either one missing is the reported bug.
assert.match(group.sasCode, /^\d{7}$/, `${node.name}: the code must be IN THE STORE, not just in the session`);
assert.equal(group.sasConfirmed, false, `${node.name}: shown, not yet confirmed`);
assert.equal(group.members.length, 2, `${node.name}: both members are in the store`);
}
// Both stores hold the same digits — the whole point of the ceremony.
assert.equal(
admin.store.get().groups[gid].sasCode,
joiner.store.get().groups[gid].sasCode,
'both sides must render the same code',
);
// The modal's own gate: with a code present, confirming is allowed and lands.
for (const node of [admin, joiner]) {
node.session.confirmSas();
node.store.dispatch({ type: GA.CONFIRM_SAS, id: gid });
const group = node.store.get().groups[gid];
assert.equal(group.phase, GROUP_PHASE.READY, `${node.name}: confirmed group is ready`);
assert.equal(group.sasConfirmed, true);
}
}
// ---------------------------------------------------------------------------
// a group name in a non-Latin script
// ---------------------------------------------------------------------------
{
// The bug: the create dialog capped input at MAX_NAME_BYTES *characters*, so
// this 36-character name (68 bytes) passed the dialog and then threw inside
// the admin's roster signing. Formation died with nothing on screen.
const cyrillic = 'Наша секретная группа для обсуждений';
assert.ok(bytes(cyrillic) > cyrillic.length, 'the test name really is multi-byte');
assert.ok(bytes(cyrillic) > 64, 'and it really did exceed the old limit');
assert.doesNotThrow(() => assertName(cyrillic), 'the protocol must accept a normal Cyrillic name');
const { gid, admin, joiner } = await formPair(cyrillic);
for (const node of [admin, joiner]) {
const group = node.store.get().groups[gid];
assert.deepEqual(node.errors, [], `${node.name}: a Cyrillic name must not break formation`);
assert.equal(group.phase, GROUP_PHASE.AWAITING_SAS, `${node.name}: reached the code`);
assert.match(group.sasCode, /^\d{7}$/);
}
// The limit still exists — it is just counted in the same unit everywhere.
const tooLong = 'я'.repeat(GROUP_LIMITS.MAX_NAME_BYTES);
assert.ok(bytes(tooLong) > GROUP_LIMITS.MAX_NAME_BYTES);
assert.throws(() => assertName(tooLong), /too long/);
}
// ---------------------------------------------------------------------------
// a failure is visible in the store, not disguised as progress
// ---------------------------------------------------------------------------
{
const { gid, admin } = await formPair('Broken');
admin.store.dispatch({ type: GA.SET_ERROR, id: gid, error: 'ceremony_timed_out' });
const group = admin.store.get().groups[gid];
assert.equal(group.phase, GROUP_PHASE.FAILED);
assert.equal(group.error, 'ceremony_timed_out');
// FAILED is distinguishable from the working phases, which is what the modal
// needs in order to stop claiming it is still exchanging nonces.
assert.notEqual(group.phase, GROUP_PHASE.REVEALING);
assert.notEqual(group.phase, GROUP_PHASE.COMMITTING);
// And a failed group can never be confirmed into readiness.
admin.store.dispatch({ type: GA.CONFIRM_SAS, id: gid });
assert.notEqual(admin.store.get().groups[gid].phase, GROUP_PHASE.READY,
'a failed group must not be confirmable');
}
console.log('group-app-integration.test.mjs: all assertions passed');