Files
securebit-chat/tests/group-sender.test.mjs
T

187 lines
7.8 KiB
JavaScript
Raw Normal View History

// Group frames against the transport's real rate limit.
//
// This is the test that was missing when group formation kept dying. The manager
// allows a burst of ten sends per second, but one group frame spends TWO of those
// slots — sendMessage checks the shared limiter and then sendSecureMessage checks
// it again — so the six frames formation sends back to back asked for twelve.
// The overflow was rejected as a plain Error with no code, which reached the user
// as a meaningless "frame_rejected", while the peer that never got the dropped
// frame waited until "ceremony_timed_out".
//
// The stand-in manager below reproduces that accounting exactly. Time is
// injected, so the pacing is asserted rather than waited for.
import assert from 'node:assert/strict';
const { createGroupSender, GROUP_SEND_GAP_MS } = await import('../src/group/groupSender.js');
/** A virtual clock: sleeps advance it instead of blocking. */
function makeClock() {
let t = 0;
return {
now: () => t,
sleep: async (ms) => { t += Math.max(0, ms); },
advance: (ms) => { t += ms; },
};
}
/**
* A manager with the real limiter's shape: ten slots per rolling second, and two
* slots consumed per outbound message.
*/
function makeManager(clock, { burst = 10, slotsPerSend = 2, connected = true } = {}) {
let windowStart = clock.now();
let used = 0;
const sent = [];
return {
sent,
isConnected: () => connected,
async sendMessage(payload) {
if (clock.now() - windowStart >= 1000) { windowStart = clock.now(); used = 0; }
if (used + slotsPerSend > burst) {
// Verbatim from EnhancedSecureWebRTCManager: a plain Error, no code.
throw new Error('Rate limit exceeded for message sending');
}
used += slotsPerSend;
sent.push({ at: clock.now(), payload });
return true;
},
};
}
// ---------------------------------------------------------------------------
// the six frames of group formation all arrive
// ---------------------------------------------------------------------------
{
const clock = makeClock();
const manager = makeManager(clock);
const send = createGroupSender({
getManager: () => manager, now: clock.now, sleep: clock.sleep,
});
// Exactly what forming a group puts on one session.
const frames = ['g_invite', 'g_member', 'g_member', 'g_roster', 'g_commit', 'g_reveal']
.map((type, i) => ({ type, gid: 'a'.repeat(32), seq: i }));
await Promise.all(frames.map((f) => send('s1', f)));
assert.equal(manager.sent.length, 6, 'every formation frame must reach the transport');
// Order is protocol-critical: a commitment has to arrive before the reveal
// that opens it. Concurrent sends must not reorder.
assert.deepEqual(
manager.sent.map((s) => JSON.parse(s.payload).type),
['g_invite', 'g_member', 'g_member', 'g_roster', 'g_commit', 'g_reveal'],
'frames keep the order they were queued in',
);
// And they are spaced, which is what keeps them inside the burst budget.
for (let i = 1; i < manager.sent.length; i++) {
const gap = manager.sent[i].at - manager.sent[i - 1].at;
assert.ok(gap >= GROUP_SEND_GAP_MS,
`frame ${i} came ${gap}ms after the last, under the ${GROUP_SEND_GAP_MS}ms budget`);
}
}
// ---------------------------------------------------------------------------
// without pacing the same run fails — the bug this exists to prevent
// ---------------------------------------------------------------------------
{
const clock = makeClock();
const manager = makeManager(clock);
const send = createGroupSender({
getManager: () => manager, now: clock.now, sleep: clock.sleep,
gapMs: 0, attempts: 1, // pacing and retries disabled
});
const results = await Promise.allSettled(
Array.from({ length: 6 }, (_, i) => send('s1', { type: 'g_commit', seq: i })),
);
const rejected = results.filter((r) => r.status === 'rejected');
assert.ok(rejected.length > 0, 'unpaced, the burst limit really does reject frames');
assert.match(rejected[0].reason.message, /Rate limit exceeded/);
// And the rejection carries no `code`, which is why it surfaced as the
// generic frame_rejected rather than anything a user could act on.
assert.equal(rejected[0].reason.code, undefined);
}
// ---------------------------------------------------------------------------
// a rate-limited frame is retried, not dropped
// ---------------------------------------------------------------------------
{
const clock = makeClock();
let calls = 0;
const manager = {
isConnected: () => true,
async sendMessage() {
calls++;
if (calls <= 2) throw new Error('Rate limit exceeded for secure message sending');
return true;
},
};
const send = createGroupSender({ getManager: () => manager, now: clock.now, sleep: clock.sleep });
await send('s1', { type: 'g_commit' });
assert.equal(calls, 3, 'the frame is retried until it lands — losing one strands every member');
}
// ---------------------------------------------------------------------------
// failures that will not improve are surfaced, not retried
// ---------------------------------------------------------------------------
{
const clock = makeClock();
let calls = 0;
const manager = {
isConnected: () => true,
async sendMessage() { calls++; throw new Error('Data channel not ready'); },
};
const send = createGroupSender({ getManager: () => manager, now: clock.now, sleep: clock.sleep });
await assert.rejects(() => send('s1', { type: 'g_commit' }), /Data channel not ready/);
assert.equal(calls, 1, 'a closed channel is reported at once rather than retried');
// One failure must not wedge the session: later frames still go out.
const ok = { isConnected: () => true, sent: 0, async sendMessage() { this.sent++; return true; } };
const send2 = createGroupSender({
getManager: (id) => (id === 'dead' ? manager : ok), now: clock.now, sleep: clock.sleep,
});
await assert.rejects(() => send2('dead', { type: 'g_commit' }));
await send2('dead', { type: 'g_reveal' }).catch(() => {});
await send2('live', { type: 'g_reveal' });
assert.equal(ok.sent, 1, 'a rejected frame does not block the queue behind it');
}
// ---------------------------------------------------------------------------
// a link that is gone is refused before anything is queued
// ---------------------------------------------------------------------------
{
const clock = makeClock();
const send = createGroupSender({ getManager: () => null, now: clock.now, sleep: clock.sleep });
await assert.rejects(() => send('s1', { type: 'g_commit' }), /no such link/);
const offline = createGroupSender({
getManager: () => ({ isConnected: () => false, sendMessage: async () => true }),
now: clock.now, sleep: clock.sleep,
});
await assert.rejects(() => offline('s1', { type: 'g_commit' }), /link is down/);
}
// ---------------------------------------------------------------------------
// separate sessions do not queue behind each other
// ---------------------------------------------------------------------------
{
const clock = makeClock();
const a = makeManager(clock);
const b = makeManager(clock);
const send = createGroupSender({
getManager: (id) => (id === 'a' ? a : b), now: clock.now, sleep: clock.sleep,
});
await Promise.all([send('a', { type: 'g_commit' }), send('b', { type: 'g_commit' })]);
assert.equal(a.sent.length, 1);
assert.equal(b.sent.length, 1);
assert.equal(a.sent[0].at, b.sent[0].at, 'each session paces independently');
}
console.log('group-sender.test.mjs: all assertions passed');