203 lines
9.3 KiB
JavaScript
203 lines
9.3 KiB
JavaScript
// 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');
|