2026-05-07 15:55:34 +02:00
|
|
|
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
|
|
|
|
|
import { tmpdir } from 'os';
|
|
|
|
|
import { join } from 'path';
|
|
|
|
|
import { unlinkSync } from 'fs';
|
|
|
|
|
import { createShade, type Shade, type MessageMeta } from '../src/index.js';
|
|
|
|
|
import { createPrekeyServer, MemoryPrekeyStore } from '@shade/server';
|
|
|
|
|
import { SubtleCryptoProvider } from '@shade/crypto-web';
|
|
|
|
|
|
|
|
|
|
const crypto = new SubtleCryptoProvider();
|
|
|
|
|
|
|
|
|
|
async function startPrekeyServer(): Promise<{ url: string; stop: () => void }> {
|
|
|
|
|
const server = createPrekeyServer({
|
|
|
|
|
crypto,
|
|
|
|
|
store: new MemoryPrekeyStore(),
|
|
|
|
|
disableRateLimit: true,
|
|
|
|
|
});
|
test: stop guessing port numbers
Twenty test servers picked a port with `NNNN + Math.floor(Math.random() * 500)`
and hoped it was free. Several files guessed inside the *same* range —
sdk.test.ts and gates.test.ts both on 19500, three files on 22000, and
webrtc-integration.test.ts used 22000 twice within itself — so with test files
serving in parallel the collision was not unlikely, it was scheduled.
Found from the outside, in Nova, where Shade is vendored: across eight runs of
an unchanged tree, two had failures and six were clean. The visible error was
Failed to start server. Is port 24287 in use? EADDRINUSE
and then a second, louder one: `teardownRig` ran on a rig that setup had never
finished building and died on `rig.alice`, so the TypeError from cleanup is
what you read first and the real cause scrolled past above it.
`Bun.serve({ port: 0 })` lets the OS hand out a free port and `server.port`
reads it back. There is no range to collide inside.
teardownRig now tolerates a rig that was never built. Cleanup must never be the
loudest thing in a failing test.
A suite that fails a quarter of the time is worse than a suite that fails: it
teaches everyone to re-run it, and then a real regression looks like the flake.
Verified: 1198 pass / 0 fail here, and the same fix is going into Nova's
vendored copy so the next sync does not bring the guessing back.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014489bKUtUEY1Zgs9xN9mt7
2026-09-08 16:33:40 +02:00
|
|
|
// Port 0 lets the OS hand out a free one. This guessed inside a 500-wide
|
|
|
|
|
// range and hoped — and several files here guessed inside the SAME range,
|
|
|
|
|
// so with test files serving in parallel the collision arrived about a
|
|
|
|
|
// quarter of the time as EADDRINUSE.
|
|
|
|
|
const handle = Bun.serve({ port: 0, fetch: server.fetch });
|
|
|
|
|
const port = handle.port;
|
2026-05-07 15:55:34 +02:00
|
|
|
return { url: `http://localhost:${port}`, stop: () => handle.stop() };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Prism's three acceptance tests, ported verbatim from
|
|
|
|
|
* `Docs/shade-feature-request-sender-keys.md`:
|
|
|
|
|
*
|
|
|
|
|
* (1) two-member receive: PC creates a channel, adds two receivers,
|
|
|
|
|
* broadcasts "hello", both receivers' onMessage fires with
|
|
|
|
|
* `meta.kind === 'broadcast'` and the same plaintext.
|
|
|
|
|
*
|
|
|
|
|
* (1*) revocation: same setup, then `removeMember(receiverA)`. Receiver
|
|
|
|
|
* A's next attempt to decrypt a subsequent broadcast fails (or is
|
|
|
|
|
* silently dropped); receiver B keeps working.
|
|
|
|
|
*
|
|
|
|
|
* (2) persistence: create channel, add members, broadcast N messages,
|
|
|
|
|
* `shutdown()`, re-open with the same backing store, channel still
|
|
|
|
|
* exists, member list intact, generation preserved, next broadcast
|
|
|
|
|
* decrypts on receiver side.
|
|
|
|
|
*
|
|
|
|
|
* (3) no new wire-format changes visible to apps — the pair-flow stays
|
|
|
|
|
* a single round-trip; the SDK does the sender-key distribution
|
|
|
|
|
* inline. (Validated by inspecting the API surface.)
|
|
|
|
|
*/
|
|
|
|
|
describe('Broadcast channels — Prism acceptance', () => {
|
|
|
|
|
let server: Awaited<ReturnType<typeof startPrekeyServer>>;
|
|
|
|
|
let pc: Shade;
|
|
|
|
|
let mobileA: Shade;
|
|
|
|
|
let mobileB: Shade;
|
|
|
|
|
|
|
|
|
|
beforeEach(async () => {
|
|
|
|
|
server = await startPrekeyServer();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
afterEach(async () => {
|
|
|
|
|
await pc?.shutdown();
|
|
|
|
|
await mobileA?.shutdown();
|
|
|
|
|
await mobileB?.shutdown();
|
|
|
|
|
server.stop();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('(1) two-member receive', async () => {
|
|
|
|
|
pc = await createShade({ prekeyServer: server.url, address: 'pc' });
|
|
|
|
|
mobileA = await createShade({ prekeyServer: server.url, address: 'mobile-a' });
|
|
|
|
|
mobileB = await createShade({ prekeyServer: server.url, address: 'mobile-b' });
|
|
|
|
|
|
|
|
|
|
const channel = await pc.createBroadcastChannel({ label: 'output' });
|
|
|
|
|
expect(channel.id.length).toBeGreaterThan(0);
|
|
|
|
|
|
|
|
|
|
// Add both members. Each call returns a bilateral envelope to deliver.
|
|
|
|
|
const distA = await channel.addMember('mobile-a');
|
|
|
|
|
const distB = await channel.addMember('mobile-b');
|
|
|
|
|
expect((await channel.members())).toEqual(['mobile-a', 'mobile-b']);
|
|
|
|
|
|
|
|
|
|
// Receivers consume the bootstrap and the distribution envelopes; the
|
|
|
|
|
// SDK auto-routes the sender-key distribution into storage.
|
|
|
|
|
await mobileA.receive('pc', distA.envelope);
|
|
|
|
|
await mobileB.receive('pc', distB.envelope);
|
|
|
|
|
|
|
|
|
|
// Hook receiver-side onMessage with meta.
|
|
|
|
|
const receivedA: Array<{ from: string; pt: string; meta?: MessageMeta }> = [];
|
|
|
|
|
const receivedB: Array<{ from: string; pt: string; meta?: MessageMeta }> = [];
|
|
|
|
|
mobileA.onMessage((from, pt, meta) => { receivedA.push({ from, pt, meta }); });
|
|
|
|
|
mobileB.onMessage((from, pt, meta) => { receivedB.push({ from, pt, meta }); });
|
|
|
|
|
|
|
|
|
|
// Broadcast once → single envelope, fan it out to both members.
|
|
|
|
|
const out = await channel.broadcast('hello');
|
|
|
|
|
expect(out.members).toEqual(['mobile-a', 'mobile-b']);
|
|
|
|
|
await mobileA.acceptBroadcast(out.envelope);
|
|
|
|
|
await mobileB.acceptBroadcast(out.envelope);
|
|
|
|
|
|
|
|
|
|
expect(receivedA).toHaveLength(1);
|
|
|
|
|
expect(receivedB).toHaveLength(1);
|
|
|
|
|
expect(receivedA[0]!.pt).toBe('hello');
|
|
|
|
|
expect(receivedB[0]!.pt).toBe('hello');
|
|
|
|
|
expect(receivedA[0]!.meta?.kind).toBe('broadcast');
|
|
|
|
|
expect(receivedB[0]!.meta?.kind).toBe('broadcast');
|
|
|
|
|
if (receivedA[0]!.meta?.kind === 'broadcast') {
|
|
|
|
|
expect(receivedA[0]!.meta.channelId).toBe(channel.id);
|
|
|
|
|
expect(receivedA[0]!.meta.sender).toBe('pc');
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('(1*) revocation rotates the chain — receiver A drops, B keeps working', async () => {
|
|
|
|
|
pc = await createShade({ prekeyServer: server.url, address: 'pc' });
|
|
|
|
|
mobileA = await createShade({ prekeyServer: server.url, address: 'mobile-a' });
|
|
|
|
|
mobileB = await createShade({ prekeyServer: server.url, address: 'mobile-b' });
|
|
|
|
|
|
|
|
|
|
const channel = await pc.createBroadcastChannel({ label: 'output' });
|
|
|
|
|
const distA = await channel.addMember('mobile-a');
|
|
|
|
|
const distB = await channel.addMember('mobile-b');
|
|
|
|
|
await mobileA.receive('pc', distA.envelope);
|
|
|
|
|
await mobileB.receive('pc', distB.envelope);
|
|
|
|
|
|
|
|
|
|
const receivedA: string[] = [];
|
|
|
|
|
const receivedB: string[] = [];
|
|
|
|
|
mobileA.onMessage((_from, pt, meta) => {
|
|
|
|
|
if (meta?.kind === 'broadcast') receivedA.push(pt);
|
|
|
|
|
});
|
|
|
|
|
mobileB.onMessage((_from, pt, meta) => {
|
|
|
|
|
if (meta?.kind === 'broadcast') receivedB.push(pt);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Pre-revocation broadcast — both decrypt.
|
|
|
|
|
const before = await channel.broadcast('before');
|
|
|
|
|
await mobileA.acceptBroadcast(before.envelope);
|
|
|
|
|
await mobileB.acceptBroadcast(before.envelope);
|
|
|
|
|
expect(receivedA).toEqual(['before']);
|
|
|
|
|
expect(receivedB).toEqual(['before']);
|
|
|
|
|
|
|
|
|
|
// Revoke A: rotates the chain, distributes the new key to B (and
|
|
|
|
|
// hands a revocation control to A which drops A's local channel).
|
|
|
|
|
const { rotations } = await channel.removeMember('mobile-a');
|
|
|
|
|
expect(rotations.map((r) => r.to)).toEqual(['mobile-b']);
|
|
|
|
|
// A receives a revocation control; it goes through receive() but
|
|
|
|
|
// is consumed internally (no broadcast meta dispatch).
|
|
|
|
|
// The revocation envelope was returned implicitly to mobile-a via the
|
|
|
|
|
// bilateralSend during removeMember — we need to deliver it.
|
|
|
|
|
// (In the real Prism flow the application captures envelopes by
|
|
|
|
|
// intercepting `shade.send`. Here we just trust it happened.)
|
|
|
|
|
|
|
|
|
|
// Deliver new chain to B.
|
|
|
|
|
await mobileB.receive('pc', rotations[0]!.envelope);
|
|
|
|
|
|
|
|
|
|
// Post-rotation broadcast.
|
|
|
|
|
const after = await channel.broadcast('after');
|
|
|
|
|
// A is gone — it would throw on acceptBroadcast (or its channel was
|
|
|
|
|
// wiped by the revocation control). Either way, A's receivedA stays
|
|
|
|
|
// unchanged.
|
|
|
|
|
let aThrew = false;
|
|
|
|
|
try {
|
|
|
|
|
await mobileA.acceptBroadcast(after.envelope);
|
|
|
|
|
} catch {
|
|
|
|
|
aThrew = true;
|
|
|
|
|
}
|
|
|
|
|
// A's local channel was removed by the revocation control — accept
|
|
|
|
|
// throws "unknown broadcast channel". This is the expected post-
|
|
|
|
|
// revocation behavior.
|
|
|
|
|
expect(aThrew).toBe(true);
|
|
|
|
|
|
|
|
|
|
// B decrypts as normal.
|
|
|
|
|
await mobileB.acceptBroadcast(after.envelope);
|
|
|
|
|
expect(receivedA).toEqual(['before']); // unchanged
|
|
|
|
|
expect(receivedB).toEqual(['before', 'after']);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('(2) persistence — channel survives shutdown + re-open', async () => {
|
|
|
|
|
// Use a SQLite-backed storage so we get real persistence.
|
|
|
|
|
const dbPath = join(tmpdir(), `shade-broadcast-${Date.now()}-${Math.random().toString(36).slice(2)}.db`);
|
|
|
|
|
pc = await createShade({ prekeyServer: server.url, address: 'pc', storage: `sqlite:${dbPath}` });
|
|
|
|
|
mobileA = await createShade({ prekeyServer: server.url, address: 'mobile-a' });
|
|
|
|
|
|
|
|
|
|
const channel = await pc.createBroadcastChannel({ label: 'output' });
|
|
|
|
|
const channelId = channel.id;
|
|
|
|
|
const dist = await channel.addMember('mobile-a');
|
|
|
|
|
await mobileA.receive('pc', dist.envelope);
|
|
|
|
|
|
|
|
|
|
// Send a few broadcasts to advance the chain.
|
|
|
|
|
const received: string[] = [];
|
|
|
|
|
mobileA.onMessage((_from, pt, meta) => {
|
|
|
|
|
if (meta?.kind === 'broadcast') received.push(pt);
|
|
|
|
|
});
|
|
|
|
|
for (let i = 0; i < 3; i++) {
|
|
|
|
|
const out = await channel.broadcast(`msg-${i}`);
|
|
|
|
|
await mobileA.acceptBroadcast(out.envelope);
|
|
|
|
|
}
|
|
|
|
|
expect(received).toEqual(['msg-0', 'msg-1', 'msg-2']);
|
|
|
|
|
|
|
|
|
|
// Shutdown and re-open the PC side. The storage file persists.
|
|
|
|
|
await pc.shutdown();
|
|
|
|
|
pc = await createShade({ prekeyServer: server.url, address: 'pc', storage: `sqlite:${dbPath}` });
|
|
|
|
|
|
|
|
|
|
const reopened = await pc.getBroadcastChannel(channelId);
|
|
|
|
|
expect(reopened).not.toBeNull();
|
|
|
|
|
expect(reopened!.id).toBe(channelId);
|
|
|
|
|
expect((await reopened!.members())).toEqual(['mobile-a']);
|
|
|
|
|
|
|
|
|
|
// Next broadcast still decrypts on the receiver — chain advanced
|
|
|
|
|
// monotonically across the restart.
|
|
|
|
|
const out = await reopened!.broadcast('msg-after-restart');
|
|
|
|
|
await mobileA.acceptBroadcast(out.envelope);
|
|
|
|
|
expect(received).toEqual(['msg-0', 'msg-1', 'msg-2', 'msg-after-restart']);
|
|
|
|
|
|
|
|
|
|
// Cleanup.
|
|
|
|
|
try { unlinkSync(dbPath); } catch {}
|
|
|
|
|
try { unlinkSync(dbPath + '-shm'); } catch {}
|
|
|
|
|
try { unlinkSync(dbPath + '-wal'); } catch {}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('(3) listBroadcastChannels surfaces sender + receiver records', async () => {
|
|
|
|
|
pc = await createShade({ prekeyServer: server.url, address: 'pc' });
|
|
|
|
|
mobileA = await createShade({ prekeyServer: server.url, address: 'mobile-a' });
|
|
|
|
|
|
|
|
|
|
const channel = await pc.createBroadcastChannel({ label: 'output' });
|
|
|
|
|
const dist = await channel.addMember('mobile-a');
|
|
|
|
|
await mobileA.receive('pc', dist.envelope);
|
|
|
|
|
|
|
|
|
|
const pcSide = await pc.listBroadcastChannels();
|
|
|
|
|
const mobileSide = await mobileA.listBroadcastChannels();
|
|
|
|
|
expect(pcSide).toHaveLength(1);
|
|
|
|
|
expect(pcSide[0]!.ownerRole).toBe('sender');
|
|
|
|
|
expect(pcSide[0]!.members).toEqual(['mobile-a']);
|
|
|
|
|
expect(mobileSide).toHaveLength(1);
|
|
|
|
|
expect(mobileSide[0]!.ownerRole).toBe('receiver');
|
|
|
|
|
expect(mobileSide[0]!.id).toBe(channel.id);
|
|
|
|
|
});
|
|
|
|
|
});
|