Files
Shade/packages/shade-transport-bridge/tests/bridge.test.ts
Sterister e6fdf31b49
Some checks failed
Test / test (push) Has been cancelled
Cross-platform vectors / TypeScript vectors (bun) (push) Has been cancelled
Cross-platform vectors / Kotlin vectors (gradle) (push) Has been cancelled
Docker build and publish / docker (push) Has been cancelled
Publish / publish (push) Has been cancelled
release(v4.0.0): Shade GA — V3.x consolidation + audit prep
V3.1 → V3.12 consolidated and tagged for the first GA release. Wire
format unchanged from 0.4.x — 4.0 peers interoperate with 0.4.x peers
byte-for-byte. The version bump is semantic: audit-cycle complete,
opt-in surface fully exposed, threat model refreshed for every new
surface.

Highlights:
- All 24 @shade/* packages bumped to 4.0.0 in lockstep.
- CHANGELOG 4.0.0 section is the canonical manifest of what landed.
- THREAT-MODEL extended (§10 fingerprint gates, §11 WebRTC P2P, §12
  Web-Worker boundary) + residual-risks table refreshed.
- OpenAPI now covers all 27 routes: prekey, transfer, KT, inbox,
  bridge, observer, /metrics, /healthz, /ready.
- MIGRATION 0.3.x → 4.0 documented + smoke-tested against
  shade migrate-storage on a real SQLite DB.
- docs/audit/REVIEW-BUNDLE.md + SCOPE.md ready for external reviewer.
- scripts/soak.ts harness for the GA-stable 2-week soak window.
- All V*.md plans archived under docs/archive/ with Status: Done.
- Voice/Video carved out into V5.0; 4.0 audit focuses on the frozen
  non-realtime stack.

Tests: TS 1000/1000 + Kotlin 11/11 cross-platform vectors green.
Docker: gt.zyon.no/stian/shade-prekey:4.0.0 builds and reports
  version 4.0.0 on /health.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 18:35:35 +02:00

513 lines
15 KiB
TypeScript

/**
* Bridge integration tests — exercises real Bun.serve + InboxServer +
* createBridgeRoutes against actual SSE / long-poll / WS clients.
*
* The acceptance criteria from V3.7 we cover here:
* 1. "Send 100 small messages" passes on all three transports.
* 2. WS-blocked client falls through to SSE without message loss.
* 3. Long-poll uses no more than one outstanding request per client.
*/
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
import { Hono } from 'hono';
import { SubtleCryptoProvider } from '@shade/crypto-web';
import {
createInboxRoutes,
createBridgeRoutes,
InboxServerEvents,
MemoryInboxStore,
computeMsgId,
type InboxStore,
} from '@shade/inbox-server';
import { signPayload } from '@shade/server';
import { generateIdentityKeyPair, toBase64 } from '@shade/core';
import {
SseBridge,
LongPollBridge,
WsBridge,
FallbackBridgeTransport,
signBridgeQuery,
type IncomingMessage,
} from '../src/index.js';
const crypto = new SubtleCryptoProvider();
interface Harness {
server: ReturnType<typeof Bun.serve>;
baseUrl: string;
store: InboxStore;
events: InboxServerEvents;
bob: Awaited<ReturnType<typeof generateIdentityKeyPair>>;
alice: Awaited<ReturnType<typeof generateIdentityKeyPair>>;
}
async function bootstrap(opts: { mountWs?: boolean } = {}): Promise<Harness> {
const store = new MemoryInboxStore();
const events = new InboxServerEvents();
const inboxApp = createInboxRoutes(store, crypto, { events, disableRateLimit: true });
const bridge = createBridgeRoutes({
store,
crypto,
events,
longPollTimeoutMs: 1_000,
longPollMaxTimeoutMs: 2_000,
heartbeatIntervalMs: 200,
fallbackPollIntervalMs: 50,
});
const app = new Hono();
app.route('/', inboxApp);
app.route('/', bridge.app);
const port = 19000 + Math.floor(Math.random() * 1000);
const server = Bun.serve({
port,
fetch: (req, srv) => app.fetch(req, srv),
websocket: opts.mountWs === false ? undefined : (bridge.websocket as any),
});
// Register Bob.
const bob = await generateIdentityKeyPair(crypto);
const alice = await generateIdentityKeyPair(crypto);
const regBody = await signPayload(crypto, bob.signingPrivateKey, {
address: 'bob',
signingKey: toBase64(bob.signingPublicKey),
});
const regRes = await fetch(`http://localhost:${port}/v1/inbox/register`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(regBody),
});
expect(regRes.status).toBe(200);
return {
server,
baseUrl: `http://localhost:${port}`,
store,
events,
bob,
alice,
};
}
async function putBlob(harness: Harness, ciphertext: Uint8Array): Promise<string> {
const msgId = await computeMsgId(ciphertext);
const body = await signPayload(crypto, harness.alice.signingPrivateKey, {
senderSigningKey: toBase64(harness.alice.signingPublicKey),
msgId,
ciphertext: toBase64(ciphertext),
});
const res = await fetch(`${harness.baseUrl}/v1/inbox/bob`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(body),
});
expect(res.status).toBe(200);
return msgId;
}
function rand(n: number): Uint8Array {
const b = new Uint8Array(n);
globalThis.crypto.getRandomValues(b);
return b;
}
function bobAuth(harness: Harness) {
return {
crypto,
signingPrivateKey: harness.bob.signingPrivateKey,
address: 'bob',
};
}
async function waitFor(predicate: () => boolean, timeoutMs = 4000): Promise<void> {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
if (predicate()) return;
await new Promise((r) => setTimeout(r, 25));
}
throw new Error(`waitFor timeout: predicate still false after ${timeoutMs}ms`);
}
describe('signBridgeQuery', () => {
test('produces deterministic shape', async () => {
const id = await generateIdentityKeyPair(crypto);
const qs = await signBridgeQuery({
crypto,
signingPrivateKey: id.signingPrivateKey,
address: 'foo',
kind: 'stream',
since: 42,
});
expect(qs.get('address')).toBe('foo');
expect(qs.get('kind')).toBe('stream');
expect(qs.get('since')).toBe('42');
expect(qs.get('signedAt')).toMatch(/^\d+$/);
expect(qs.get('signature')).toBeTruthy();
});
});
describe('SseBridge — send 100 small messages', () => {
let h: Harness;
beforeAll(async () => {
h = await bootstrap();
});
afterAll(() => {
h.server.stop(true);
});
test('all messages reach the client over SSE', async () => {
const received: IncomingMessage[] = [];
const bridge = new SseBridge({
baseUrl: h.baseUrl,
auth: bobAuth(h),
initialBackoffMs: 100,
maxBackoffMs: 500,
});
await bridge.connect({
onMessage: (m) => {
received.push(m);
},
});
const sent: string[] = [];
for (let i = 0; i < 100; i++) {
const ct = rand(32 + (i % 8));
sent.push(await putBlob(h, ct));
}
await waitFor(() => received.length === 100, 5_000);
expect(received.length).toBe(100);
const ids = new Set(received.map((m) => m.msgId));
expect(ids.size).toBe(100);
for (const id of sent) expect(ids.has(id)).toBe(true);
await bridge.disconnect();
});
});
describe('LongPollBridge — send 100 small messages', () => {
let h: Harness;
beforeAll(async () => {
h = await bootstrap();
});
afterAll(() => {
h.server.stop(true);
});
test('all messages reach the client over long-poll', async () => {
const received: IncomingMessage[] = [];
const bridge = new LongPollBridge({
baseUrl: h.baseUrl,
auth: bobAuth(h),
pollTimeoutMs: 500,
requestTimeoutMs: 1_500,
errorBackoffMs: 50,
});
await bridge.connect({
onMessage: (m) => {
received.push(m);
},
});
const sent: string[] = [];
for (let i = 0; i < 100; i++) {
const ct = rand(48);
sent.push(await putBlob(h, ct));
}
await waitFor(() => received.length === 100, 8_000);
expect(received.length).toBe(100);
const ids = new Set(received.map((m) => m.msgId));
for (const id of sent) expect(ids.has(id)).toBe(true);
await bridge.disconnect();
});
test('one outstanding request at a time', async () => {
let inflight = 0;
let peak = 0;
const wrapped: typeof fetch = async (input, init) => {
inflight++;
peak = Math.max(peak, inflight);
try {
return await fetch(input as any, init as any);
} finally {
inflight--;
}
};
const bridge = new LongPollBridge({
baseUrl: h.baseUrl,
auth: bobAuth(h),
pollTimeoutMs: 200,
requestTimeoutMs: 500,
errorBackoffMs: 50,
fetch: wrapped,
});
const received: IncomingMessage[] = [];
await bridge.connect({ onMessage: (m) => void received.push(m) });
// Let the loop run a few times; nothing else should be pumping the
// bridge concurrently.
await new Promise((r) => setTimeout(r, 1_500));
expect(peak).toBeLessThanOrEqual(1);
await bridge.disconnect();
});
});
describe('WsBridge — send 100 small messages', () => {
let h: Harness;
beforeAll(async () => {
h = await bootstrap();
});
afterAll(() => {
h.server.stop(true);
});
test('all messages reach the client over WS', async () => {
const received: IncomingMessage[] = [];
const bridge = new WsBridge({
baseUrl: h.baseUrl,
auth: bobAuth(h),
connectTimeoutMs: 2_000,
disableAutoReconnect: true,
});
await bridge.connect({
onMessage: (m) => {
received.push(m);
},
});
const sent: string[] = [];
for (let i = 0; i < 100; i++) {
const ct = rand(40);
sent.push(await putBlob(h, ct));
}
await waitFor(() => received.length === 100, 5_000);
expect(received.length).toBe(100);
const ids = new Set(received.map((m) => m.msgId));
for (const id of sent) expect(ids.has(id)).toBe(true);
await bridge.disconnect();
});
});
describe('FallbackBridgeTransport', () => {
let h: Harness;
beforeAll(async () => {
h = await bootstrap();
});
afterAll(() => {
h.server.stop(true);
});
test('falls through WS → SSE when WS endpoint is blocked', async () => {
const received: IncomingMessage[] = [];
const ws = new WsBridge({
baseUrl: 'http://127.0.0.1:1', // unreachable on purpose
auth: bobAuth(h),
connectTimeoutMs: 500,
disableAutoReconnect: true,
});
const sse = new SseBridge({
baseUrl: h.baseUrl,
auth: bobAuth(h),
initialBackoffMs: 100,
maxBackoffMs: 500,
});
const lp = new LongPollBridge({
baseUrl: h.baseUrl,
auth: bobAuth(h),
pollTimeoutMs: 500,
requestTimeoutMs: 1_500,
errorBackoffMs: 50,
});
const fallback = new FallbackBridgeTransport([ws, sse, lp]);
await fallback.connect({
onMessage: (m) => {
received.push(m);
},
});
expect(fallback.activeKind).toBe('sse');
expect(fallback.attempts).toEqual(['ws', 'sse']);
const sent: string[] = [];
for (let i = 0; i < 10; i++) {
const ct = rand(32);
sent.push(await putBlob(h, ct));
}
await waitFor(() => received.length === 10, 5_000);
expect(received.length).toBe(10);
const ids = new Set(received.map((m) => m.msgId));
for (const id of sent) expect(ids.has(id)).toBe(true);
await fallback.disconnect();
});
test('falls through SSE → long-poll when SSE endpoint returns 502', async () => {
// Build a server where SSE explicitly returns 502; long-poll passes
// through. We build a small Hono shim that wraps the bridge app.
const port = h.server.port;
const altPort = port + 1;
const store = h.store; // share data with the main harness
const events = h.events;
const bridge = createBridgeRoutes({
store,
crypto,
events,
longPollTimeoutMs: 500,
longPollMaxTimeoutMs: 1_000,
heartbeatIntervalMs: 200,
fallbackPollIntervalMs: 50,
});
const inboxApp = createInboxRoutes(store, crypto, { events, disableRateLimit: true });
const wrapped = new Hono();
wrapped.get('/v1/bridge/stream', (c) => c.text('bad gateway', 502));
wrapped.route('/', bridge.app);
wrapped.route('/', inboxApp);
const altServer = Bun.serve({
port: altPort,
fetch: (req, srv) => wrapped.fetch(req, srv),
websocket: bridge.websocket as any,
});
try {
const altUrl = `http://localhost:${altPort}`;
const received: IncomingMessage[] = [];
const sse = new SseBridge({
baseUrl: altUrl,
auth: bobAuth(h),
initialBackoffMs: 100,
maxBackoffMs: 200,
disableAutoReconnect: true,
});
const lp = new LongPollBridge({
baseUrl: altUrl,
auth: bobAuth(h),
pollTimeoutMs: 200,
requestTimeoutMs: 600,
errorBackoffMs: 50,
});
const fallback = new FallbackBridgeTransport([sse, lp]);
await fallback.connect({
onMessage: (m) => {
received.push(m);
},
});
expect(fallback.activeKind).toBe('long-poll');
// Push a message via the original PUT path.
const ct = rand(32);
await putBlob(h, ct);
await waitFor(() => received.length >= 1, 4_000);
expect(received.length).toBeGreaterThanOrEqual(1);
await fallback.disconnect();
} finally {
altServer.stop(true);
}
});
test('throws when every bridge fails', async () => {
const ws = new WsBridge({
baseUrl: 'http://127.0.0.1:1',
auth: bobAuth(h),
connectTimeoutMs: 200,
disableAutoReconnect: true,
});
const sse = new SseBridge({
baseUrl: 'http://127.0.0.1:1',
auth: bobAuth(h),
initialBackoffMs: 50,
maxBackoffMs: 100,
disableAutoReconnect: true,
});
const lp = new LongPollBridge({
baseUrl: 'http://127.0.0.1:1',
auth: bobAuth(h),
pollTimeoutMs: 100,
requestTimeoutMs: 300,
errorBackoffMs: 50,
});
const fallback = new FallbackBridgeTransport([ws, sse, lp]);
await expect(
fallback.connect({ onMessage: () => undefined }),
).rejects.toThrow();
await fallback.disconnect();
});
});
describe('Bridge auth — rejects bad signatures', () => {
let h: Harness;
beforeAll(async () => {
h = await bootstrap();
});
afterAll(() => {
h.server.stop(true);
});
test('SSE rejects when signature is wrong', async () => {
const eve = await generateIdentityKeyPair(crypto);
const sse = new SseBridge({
baseUrl: h.baseUrl,
auth: { crypto, signingPrivateKey: eve.signingPrivateKey, address: 'bob' },
initialBackoffMs: 50,
maxBackoffMs: 100,
disableAutoReconnect: true,
});
await expect(sse.connect({ onMessage: () => undefined })).rejects.toThrow();
await sse.disconnect();
});
test('long-poll rejects when address is unregistered', async () => {
const newId = await generateIdentityKeyPair(crypto);
const lp = new LongPollBridge({
baseUrl: h.baseUrl,
auth: { crypto, signingPrivateKey: newId.signingPrivateKey, address: 'unregistered' },
pollTimeoutMs: 200,
requestTimeoutMs: 500,
errorBackoffMs: 50,
});
await expect(lp.connect({ onMessage: () => undefined })).rejects.toThrow();
await lp.disconnect();
});
});
describe('Bridge cursor resume', () => {
let h: Harness;
beforeAll(async () => {
h = await bootstrap();
});
afterAll(() => {
h.server.stop(true);
});
test('SSE picks up after disconnect using Last-Event-ID cursor', async () => {
const first: IncomingMessage[] = [];
const sseA = new SseBridge({
baseUrl: h.baseUrl,
auth: bobAuth(h),
initialBackoffMs: 100,
maxBackoffMs: 200,
disableAutoReconnect: true,
});
await sseA.connect({ onMessage: (m) => first.push(m) });
await putBlob(h, rand(20));
await putBlob(h, rand(24));
await waitFor(() => first.length === 2, 3_000);
const cursor = sseA.getCursor();
await sseA.disconnect();
// Push more while disconnected.
await putBlob(h, rand(28));
await putBlob(h, rand(32));
const second: IncomingMessage[] = [];
const sseB = new SseBridge({
baseUrl: h.baseUrl,
auth: bobAuth(h),
startCursor: cursor,
initialBackoffMs: 100,
maxBackoffMs: 200,
disableAutoReconnect: true,
});
await sseB.connect({ onMessage: (m) => second.push(m) });
await waitFor(() => second.length === 2, 3_000);
expect(second.length).toBe(2);
// No overlap with the first batch.
const firstIds = new Set(first.map((m) => m.msgId));
for (const m of second) expect(firstIds.has(m.msgId!)).toBe(false);
await sseB.disconnect();
});
});