import { describe, test, expect } from 'bun:test'; import { Inbox, InboxClient, computeMsgId, MemoryOutgoingQueueStore } from '../src/index.js'; import { createInboxServer, MemoryInboxStore, } from '@shade/inbox-server'; import { SubtleCryptoProvider } from '@shade/crypto-web'; import { generateIdentityKeyPair } from '@shade/core'; import type { Hono } from 'hono'; const crypto = new SubtleCryptoProvider(); async function makeIdentity() { return generateIdentityKeyPair(crypto); } function randBytes(n: number): Uint8Array { const buf = new Uint8Array(n); globalThis.crypto.getRandomValues(buf); return buf; } /** * Wrap a Hono app as a fetch implementation. Strips the protocol/host so * `app.request(path, init)` works. */ function honoFetch(app: Hono): typeof fetch { return (async (input: RequestInfo | URL, init?: RequestInit) => { const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url; const path = url.startsWith('http://localhost') ? url.slice('http://localhost'.length) : url; return app.request(path, init); }) as typeof fetch; } describe('InboxClient', () => { test('register + put + fetch + ack roundtrip', async () => { const store = new MemoryInboxStore(); const app = createInboxServer({ crypto, store, disableRateLimit: true }); const bob = await makeIdentity(); const alice = await makeIdentity(); const bobClient = new InboxClient({ baseUrl: 'http://localhost', crypto, signingPrivateKey: bob.signingPrivateKey, fetch: honoFetch(app), }); const aliceClient = new InboxClient({ baseUrl: 'http://localhost', crypto, signingPrivateKey: alice.signingPrivateKey, fetch: honoFetch(app), }); await bobClient.register({ address: 'bob', signingKey: bob.signingPublicKey }); const ct = randBytes(64); const msgId = await computeMsgId(ct); const result = await aliceClient.put({ recipientAddress: 'bob', senderSigningKey: alice.signingPublicKey, envelope: ct, }); expect(result.msgId).toBe(msgId); expect(result.idempotent).toBe(false); const second = await aliceClient.put({ recipientAddress: 'bob', senderSigningKey: alice.signingPublicKey, envelope: ct, }); expect(second.idempotent).toBe(true); const fetched = await bobClient.fetch({ address: 'bob' }); expect(fetched.blobs.length).toBe(1); expect(fetched.blobs[0]!.msgId).toBe(msgId); expect(fetched.blobs[0]!.ciphertext).toEqual(ct); const acked = await bobClient.ack({ address: 'bob', msgId }); expect(acked).toBe(true); const second2 = await bobClient.fetch({ address: 'bob' }); expect(second2.blobs.length).toBe(0); }); }); describe('Inbox orchestrator', () => { test('queue → flush → server-side blob shows up', async () => { const store = new MemoryInboxStore(); const app = createInboxServer({ crypto, store, disableRateLimit: true }); const bob = await makeIdentity(); const alice = await makeIdentity(); const aliceInbox = new Inbox({ baseUrl: 'http://localhost', ownAddress: 'alice', crypto, signingPrivateKey: alice.signingPrivateKey, signingPublicKey: alice.signingPublicKey, pollIntervalMs: 0, fetch: honoFetch(app), }); const bobInbox = new Inbox({ baseUrl: 'http://localhost', ownAddress: 'bob', crypto, signingPrivateKey: bob.signingPrivateKey, signingPublicKey: bob.signingPublicKey, pollIntervalMs: 0, fetch: honoFetch(app), }); // Bob registers so he can receive. await bobInbox.register(); // Alice queues a message. const ct = randBytes(64); const msgId = await aliceInbox.send({ recipientAddress: 'bob', envelope: ct }); expect(await aliceInbox.pendingCount()).toBe(1); // Alice ticks: flushes + (no incoming because no handler). await aliceInbox.tick(); expect(await aliceInbox.pendingCount()).toBe(0); // Bob ticks: should see the blob via incoming handler. let received: { msgId: string; bytes: number } | null = null; bobInbox.onIncoming(async (raw) => { received = { msgId: raw.msgId, bytes: raw.ciphertext.length }; return 'alice'; }); const result = await bobInbox.tick(); expect(result.received).toBe(1); expect(received).not.toBeNull(); expect(received!.msgId).toBe(msgId); expect(received!.bytes).toBe(ct.length); // No re-delivery on second tick (cursor advanced + ack performed). const r2 = await bobInbox.tick(); expect(r2.received).toBe(0); }); test('onMessageQueued hook fires for each enqueue', async () => { const store = new MemoryInboxStore(); const app = createInboxServer({ crypto, store, disableRateLimit: true }); const alice = await makeIdentity(); const inbox = new Inbox({ baseUrl: 'http://localhost', ownAddress: 'alice', crypto, signingPrivateKey: alice.signingPrivateKey, signingPublicKey: alice.signingPublicKey, pollIntervalMs: 0, fetch: honoFetch(app), }); const seen: Array<{ to: string; msgId: string }> = []; inbox.onMessageQueued((to, msgId) => { seen.push({ to, msgId }); }); await inbox.send({ recipientAddress: 'bob', envelope: randBytes(10) }); await inbox.send({ recipientAddress: 'carol', envelope: randBytes(20) }); // Wait for the (sync) hook to flush. await new Promise((r) => setTimeout(r, 5)); expect(seen.length).toBe(2); expect(seen[0]!.to).toBe('bob'); expect(seen[1]!.to).toBe('carol'); }); test('flush retries on transient server failure', async () => { const store = new MemoryInboxStore(); const app = createInboxServer({ crypto, store, disableRateLimit: true }); const alice = await makeIdentity(); const bob = await makeIdentity(); // Register bob via direct API. const bobClient = new InboxClient({ baseUrl: 'http://localhost', crypto, signingPrivateKey: bob.signingPrivateKey, fetch: honoFetch(app), }); await bobClient.register({ address: 'bob', signingKey: bob.signingPublicKey }); // Wrap fetch so first PUT fails, subsequent succeed. let failsLeft = 1; const flakyFetch: typeof fetch = (async (input, init) => { const m = (init as RequestInit | undefined)?.method ?? 'GET'; const u = typeof input === 'string' ? input : input instanceof URL ? input.toString() : (input as Request).url; if (m === 'POST' && u.includes('/v1/inbox/bob') && !u.includes('/fetch') && failsLeft > 0) { failsLeft--; throw new Error('transient network'); } return honoFetch(app)(input, init); }) as typeof fetch; const aliceInbox = new Inbox({ baseUrl: 'http://localhost', ownAddress: 'alice', crypto, signingPrivateKey: alice.signingPrivateKey, signingPublicKey: alice.signingPublicKey, pollIntervalMs: 0, fetch: flakyFetch, queueStore: new MemoryOutgoingQueueStore(), }); await aliceInbox.send({ recipientAddress: 'bob', envelope: randBytes(40) }); // First flush fails. await aliceInbox.tick(); expect(await aliceInbox.pendingCount()).toBe(1); // Second flush succeeds. await aliceInbox.tick(); expect(await aliceInbox.pendingCount()).toBe(0); }); }); describe('tamper detection', () => { test('client rejects blob whose msgId does not match recomputed hash', async () => { const store = new MemoryInboxStore(); const app = createInboxServer({ crypto, store, disableRateLimit: true }); const bob = await makeIdentity(); const alice = await makeIdentity(); // Register Bob. const bobClient = new InboxClient({ baseUrl: 'http://localhost', crypto, signingPrivateKey: bob.signingPrivateKey, fetch: honoFetch(app), }); await bobClient.register({ address: 'bob', signingKey: bob.signingPublicKey }); // Alice puts a real blob. const ct = randBytes(64); const aliceClient = new InboxClient({ baseUrl: 'http://localhost', crypto, signingPrivateKey: alice.signingPrivateKey, fetch: honoFetch(app), }); await aliceClient.put({ recipientAddress: 'bob', senderSigningKey: alice.signingPublicKey, envelope: ct, }); // Tamper: flip a byte in the in-memory store. const list: any = (store as any).blobs.get('bob'); list[0].ciphertext[0] ^= 0xff; const bobInbox = new Inbox({ baseUrl: 'http://localhost', ownAddress: 'bob', crypto, signingPrivateKey: bob.signingPrivateKey, signingPublicKey: bob.signingPublicKey, pollIntervalMs: 0, fetch: honoFetch(app), }); let decryptCalls = 0; let failures = 0; bobInbox.onIncoming(() => { decryptCalls++; return null; }); bobInbox.on((e) => { if (e.name === 'inbox.message_decrypt_failed') failures++; }); const result = await bobInbox.tick(); // Tampered blob: handler must NOT be called; decrypt-failed event fires. expect(decryptCalls).toBe(0); expect(failures).toBeGreaterThan(0); expect(result.received).toBe(0); }); });