import { describe, expect, test } from 'bun:test'; import { OutboundQueue } from '../src/index.js'; /** * Regression coverage for the long-poll waiter `since` cursor. * * The bug being guarded against: when `enqueue` woke a pending * `drain` waiter, it used a `since=0` snapshot and replayed every * event that had ever been queued — including the ones the waiter * had already processed in a previous poll. Downstream the queue * fed `Shade.acceptTransferEnvelope`, so the duplicate replay * dispatched the same envelope into `manager.decrypt` twice. The * second decrypt consumed an already-used skipped key, fell into * the stale-counter branch of `ratchetDecrypt`, and corrupted the * Double Ratchet receive chain — surfacing as * `DecryptionError: wrong key or tampered data` on every * subsequent message. */ describe('OutboundQueue — waiter since cursor', () => { test('mid-poll enqueue must not replay events the waiter already saw', async () => { const queue = new OutboundQueue({ idleEvictionMs: 0 }); const peer = 'alice'; const e1 = queue.enqueue(peer, { kind: 'envelope', bytes: new Uint8Array([1]) }); const e2 = queue.enqueue(peer, { kind: 'envelope', bytes: new Uint8Array([2]) }); // First poll drains both events (no blocking — they're already there). const first = await queue.drain(peer, 0, 0); expect(first.map((e) => e.id)).toEqual([e1.id, e2.id]); // Now the waiter polls past the last seen id. It blocks because // there are no events newer than `since`. Concurrently a fresh // event gets enqueued — that's the path the bug fired on. const blockMs = 5_000; const polling = queue.drain(peer, e2.id, blockMs); // Yield so `drain` actually parks on the waiter list before we // race the enqueue against it. await Promise.resolve(); const e3 = queue.enqueue(peer, { kind: 'envelope', bytes: new Uint8Array([3]) }); const woken = await polling; // Pre-fix: would resolve with [e1, e2, e3] (a `since=0` snapshot // drained verbatim). Post-fix: only the events newer than the // waiter's recorded `since` come through. expect(woken.map((e) => e.id)).toEqual([e3.id]); }); test('parked waiter at the head still gets the new event when others have polled past it', async () => { const queue = new OutboundQueue({ idleEvictionMs: 0 }); const peer = 'alice'; const e1 = queue.enqueue(peer, { kind: 'envelope', bytes: new Uint8Array([1]) }); // A waiter that parks past the head — there are no events newer // than e1.id, so it has to block. const polling = queue.drain(peer, e1.id, 5_000); await Promise.resolve(); const e2 = queue.enqueue(peer, { kind: 'envelope', bytes: new Uint8Array([2]) }); const woken = await polling; expect(woken.map((e) => e.id)).toEqual([e2.id]); }); });