Files
securebit-chat/tests/sessions-reducer.test.mjs
T
lockbitchat e00c3bd413
CodeQL Analysis / Analyze CodeQL (push) Waiting to run
Deploy Application / deploy (push) Waiting to run
Mirror to Codeberg / mirror (push) Waiting to run
Mirror to PrivacyGuides / mirror (push) Waiting to run
feat(groups): group chats, and a mesh rather than a star; release v6.1.1
A group is an orchestration layer over the pairwise sessions the app already
holds. It owns no transport and no shared key: every frame leaves over a chat
that is already SAS-verified and already ratcheted, so a removed member simply
stops being sent anything. Membership is a roster the admin signs, ordered by
epoch, and the safety code is a commit-then-reveal round over every member's
fingerprint and nonce.

Delivery was the part that did not match its own description. The admin held a
link to everyone and nobody else held a link to anybody, so the relay path — the
documented fallback — was in fact the entire topology, and the admin going away
partitioned the group. Now, once the code is confirmed, each pair without a link
dials one over that relay path. The descriptors are compact enough to ride a
group frame and are signed with the sender's group identity key, so the relaying
member can drop a dial but cannot substitute one. The member with the smaller
fingerprint dials, which is the whole glare protocol.

Mesh links are released without a human comparing digits. Twenty-eight codes for
a group of eight is not a check anyone performs; the guarantee moves rather than
disappears, since the descriptor was signed by a key the signed roster names and
the group code covers. markGroupLinkVerified refuses any session whose in-band
exchange has not completed and whose peer has not proved possession of that key.

An existing 1:1 chat between two members is adopted instead of re-dialled, via a
probe bound to that session's own key fingerprint so it cannot be replayed onto
another chat to impersonate its author.

Security fix: g_hello was accepted on any session from anyone who knew the group
id, so any member could publish an identity the admin never invited and have the
admin sign and broadcast a roster containing it. It is now accepted only on a
session an invitation went out on, which also confines it to a direct link.

Mesh connections are kept out of the chat registry and muted from the document
events the header listens to, so a routing detail cannot tear down the display of
a conversation the user actually opened.
2026-08-25 16:27:08 -04:00

209 lines
10 KiB
JavaScript

// Verifies the multi-session reducer keeps sessions fully isolated: a change to one
// session never mutates another, unread only grows for non-active received traffic, and
// removing a session re-points the active pointer without disturbing siblings.
import assert from 'node:assert/strict';
const {
sessionsReducer,
createInitialState,
createSessionEntry,
SESSION_ACTIONS: A,
decorateSession,
monoInitials,
statusDot
} = await import('../src/state/sessionsStore.js');
function withTwoSessions() {
let state = createInitialState();
state = sessionsReducer(state, { type: A.CREATE_SESSION, entry: createSessionEntry({ id: 'a', peerLabel: 'work laptop' }) });
state = sessionsReducer(state, { type: A.CREATE_SESSION, entry: createSessionEntry({ id: 'b', peerLabel: 'atlas repo' }) });
return state;
}
// CREATE_SESSION activates the new session and preserves order.
{
const state = withTwoSessions();
assert.deepEqual(state.order, ['a', 'b']);
assert.equal(state.activeSessionId, 'b', 'newest session becomes active');
assert.equal(Object.keys(state.sessions).length, 2);
}
// Isolation: mutating session B leaves session A's object referentially untouched.
{
const before = withTwoSessions();
const aRef = before.sessions.a;
const after = sessionsReducer(before, { type: A.ADD_MESSAGE, id: 'b', message: { id: 1, message: 'hi', type: 'sent' } });
assert.equal(after.sessions.a, aRef, 'session A object must be the same reference after editing B');
assert.equal(after.sessions.b.messages.length, 1);
assert.equal(after.sessions.a.messages.length, 0, 'A transcript untouched');
// And the original state object was not mutated in place.
assert.equal(before.sessions.b.messages.length, 0, 'reducer is immutable');
}
// SET_STATUS / SET_FINGERPRINT / SET_SAS are scoped to one session.
{
let state = withTwoSessions();
state = sessionsReducer(state, { type: A.SET_STATUS, id: 'a', status: 'verified' });
state = sessionsReducer(state, { type: A.SET_SAS, id: 'a', sas: { isVerified: true, bothConfirmed: true } });
state = sessionsReducer(state, { type: A.SET_FINGERPRINT, id: 'a', fingerprint: 'AB:CD' });
assert.equal(state.sessions.a.status, 'verified');
assert.equal(state.sessions.a.sas.isVerified, true);
assert.equal(state.sessions.a.keyFingerprint, 'AB:CD');
assert.equal(state.sessions.b.status, 'new', 'sibling status untouched');
assert.equal(state.sessions.b.sas.isVerified, false, 'sibling SAS untouched');
assert.equal(state.sessions.b.keyFingerprint, '', 'sibling fingerprint untouched');
}
// Peer presence is cleared when the session leaves the connected state, so a reconnect
// never re-shows the peer's stale status before they re-broadcast it.
{
let state = withTwoSessions();
state = sessionsReducer(state, { type: A.SET_STATUS, id: 'a', status: 'connected' });
state = sessionsReducer(state, { type: A.SET_PEER_PRESENCE, id: 'a', presence: 'busy' });
assert.equal(state.sessions.a.peerPresence, 'busy');
// connected -> verified keeps presence (still connected).
state = sessionsReducer(state, { type: A.SET_STATUS, id: 'a', status: 'verified' });
assert.equal(state.sessions.a.peerPresence, 'busy', 'presence kept while still connected');
// verified -> peer_disconnected clears it.
state = sessionsReducer(state, { type: A.SET_STATUS, id: 'a', status: 'peer_disconnected' });
assert.equal(state.sessions.a.peerPresence, null, 'presence cleared on disconnect');
// Reconnecting does not resurrect the old presence; it stays null until re-broadcast.
state = sessionsReducer(state, { type: A.SET_STATUS, id: 'a', status: 'connected' });
assert.equal(state.sessions.a.peerPresence, null, 'no stale presence after reconnect');
state = sessionsReducer(state, { type: A.SET_PEER_PRESENCE, id: 'a', presence: 'available' });
assert.equal(state.sessions.a.peerPresence, 'available', 'fresh presence applies after reconnect');
// A session repairing its network path is still a live session: blanking the
// peer's presence would make a two-second glitch look like a disconnect.
state = sessionsReducer(state, { type: A.SET_STATUS, id: 'a', status: 'reconnecting' });
assert.equal(state.sessions.a.peerPresence, 'available', 'presence survives a path repair');
}
// A reconnecting session reads as in-progress (amber), not as dropped (red).
{
const entry = createSessionEntry({ id: 'a', peerLabel: 'phone' });
entry.status = 'reconnecting';
const d = decorateSession(entry, 'a');
assert.equal(d.headerSub, 'Reconnecting…');
const dropped = createSessionEntry({ id: 'b', peerLabel: 'phone' });
dropped.status = 'disconnected';
assert.notEqual(d.dot, decorateSession(dropped, 'b').dot, 'reconnecting must not look dropped');
}
// UPDATE_MESSAGE_STATUS and DELETE_MESSAGE only touch the named session/message.
{
let state = withTwoSessions();
state = sessionsReducer(state, { type: A.ADD_MESSAGE, id: 'a', message: { id: 1, mid: 'm1', message: 'x', type: 'sent', status: 'sending' } });
state = sessionsReducer(state, { type: A.UPDATE_MESSAGE_STATUS, id: 'a', mid: 'm1', status: 'delivered' });
assert.equal(state.sessions.a.messages[0].status, 'delivered');
state = sessionsReducer(state, { type: A.DELETE_MESSAGE, id: 'a', mid: 'm1' });
assert.equal(state.sessions.a.messages.length, 0);
assert.equal(state.sessions.b.messages.length, 0);
}
// Unread bookkeeping.
{
let state = withTwoSessions(); // active = b
state = sessionsReducer(state, { type: A.INCREMENT_UNREAD, id: 'a' });
state = sessionsReducer(state, { type: A.INCREMENT_UNREAD, id: 'a' });
assert.equal(state.sessions.a.unreadCount, 2);
assert.equal(state.sessions.b.unreadCount, 0);
state = sessionsReducer(state, { type: A.SET_ACTIVE, id: 'a' });
state = sessionsReducer(state, { type: A.CLEAR_UNREAD, id: 'a' });
assert.equal(state.sessions.a.unreadCount, 0);
assert.equal(state.activeSessionId, 'a');
}
// PATCH_SETUP merges, scoped per session.
{
let state = withTwoSessions();
state = sessionsReducer(state, { type: A.PATCH_SETUP, id: 'a', patch: { offerData: 'OFFER', showOfferStep: true } });
assert.equal(state.sessions.a.setup.offerData, 'OFFER');
assert.equal(state.sessions.a.setup.showOfferStep, true);
assert.equal(state.sessions.a.setup.answerData, '', 'untouched setup field keeps default');
assert.equal(state.sessions.b.setup.offerData, '', 'sibling setup untouched');
}
// RENAME marks the label custom.
{
let state = withTwoSessions();
state = sessionsReducer(state, { type: A.RENAME, id: 'a', label: 'Alice' });
assert.equal(state.sessions.a.peerLabel, 'Alice');
assert.equal(state.sessions.a.labelIsCustom, true);
assert.equal(state.sessions.b.labelIsCustom, false);
}
// REMOVE_SESSION re-points active to the previous sibling and leaves the rest intact.
{
let state = withTwoSessions(); // order [a,b], active b
const bRef = state.sessions.b;
state = sessionsReducer(state, { type: A.SET_ACTIVE, id: 'a' });
state = sessionsReducer(state, { type: A.REMOVE_SESSION, id: 'a' });
assert.equal(state.sessions.a, undefined, 'a removed');
assert.equal(state.sessions.b, bRef, 'sibling b object untouched');
assert.deepEqual(state.order, ['b']);
assert.equal(state.activeSessionId, 'b', 'active re-pointed to remaining session');
}
// REMOVE_SESSION on the last session leaves no active.
{
let state = createInitialState();
state = sessionsReducer(state, { type: A.CREATE_SESSION, entry: createSessionEntry({ id: 'solo' }) });
state = sessionsReducer(state, { type: A.REMOVE_SESSION, id: 'solo' });
assert.equal(state.activeSessionId, null);
assert.deepEqual(state.order, []);
}
// Decorators mirror the design helpers.
{
assert.equal(monoInitials('work laptop'), 'WL');
assert.equal(monoInitials('atlas'), 'AT');
assert.equal(statusDot('verified'), '#3ecf8e');
assert.equal(statusDot('connecting'), '#e3b341');
assert.equal(statusDot('disconnected'), '#e5727a');
const entry = createSessionEntry({ id: 'a', peerLabel: 'work laptop' });
entry.unreadCount = 3;
entry.status = 'connecting';
const d = decorateSession(entry, 'b');
assert.equal(d.mono, 'WL');
assert.equal(d.unread, '3');
assert.equal(d.active, false);
assert.equal(d.inactive, true);
}
// The rail preview shows conversation, not system notices.
{
let state = createInitialState();
state = sessionsReducer(state, { type: A.CREATE_SESSION, entry: createSessionEntry({ id: 'a', peerLabel: 'work laptop' }) });
state = sessionsReducer(state, { type: A.SET_STATUS, id: 'a', status: 'connected' });
state = sessionsReducer(state, { type: A.ADD_MESSAGE, id: 'a', message: { id: 1, message: 'see you at six', type: 'received' } });
assert.equal(decorateSession(state.sessions.a, 'a').preview, 'see you at six');
// A closing notice arriving after it must not take the preview over: the
// status line next to it already says the connection is gone, and the last
// thing the peer actually said is what belongs there.
state = sessionsReducer(state, {
type: A.ADD_MESSAGE, id: 'a',
message: { id: 2, message: '🔌 Enhanced secure connection closed. Check connection status.', type: 'system' },
});
assert.equal(
decorateSession(state.sessions.a, 'a').preview, 'see you at six',
'a system notice must not become the chat preview',
);
// With nothing but system messages the preview falls back to the status text.
let bare = createInitialState();
bare = sessionsReducer(bare, { type: A.CREATE_SESSION, entry: createSessionEntry({ id: 'b', peerLabel: 'x' }) });
bare = sessionsReducer(bare, { type: A.SET_STATUS, id: 'b', status: 'disconnected' });
bare = sessionsReducer(bare, { type: A.ADD_MESSAGE, id: 'b', message: { id: 1, message: 'Peer manually disconnected.', type: 'system' } });
assert.equal(decorateSession(bare.sessions.b, 'b').preview, 'Disconnected');
}
console.log('sessions-reducer.test.mjs: all assertions passed');