import { describe, test, expect, beforeEach } from 'bun:test'; import { Hono } from 'hono'; import { createInboxServer, MemoryInboxStore, computeMsgId, type InboxStore, } from '../src/index.js'; import { signPayload } from '@shade/server'; import { SubtleCryptoProvider } from '@shade/crypto-web'; import { generateIdentityKeyPair, toBase64 } from '@shade/core'; 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; } describe('Shade Inbox Server', () => { let store: InboxStore; let app: Hono; beforeEach(() => { store = new MemoryInboxStore(); app = createInboxServer({ crypto, store, disableRateLimit: true }); }); function req(method: string, path: string, body?: any) { const init: RequestInit = { method, headers: { 'Content-Type': 'application/json' } }; if (body !== undefined) init.body = JSON.stringify(body); return app.request(path, init); } async function registerBob(address = 'bob') { const bob = await makeIdentity(); const body = await signPayload(crypto, bob.signingPrivateKey, { address, signingKey: toBase64(bob.signingPublicKey), }); const res = await req('POST', '/v1/inbox/register', body); expect(res.status).toBe(200); return bob; } async function putMsg(args: { sender: Awaited>; recipient: string; ciphertext: Uint8Array; ttlSeconds?: number; }) { const msgId = await computeMsgId(args.ciphertext); const body: Record = { senderSigningKey: toBase64(args.sender.signingPublicKey), msgId, ciphertext: toBase64(args.ciphertext), }; if (args.ttlSeconds !== undefined) body.ttlSeconds = args.ttlSeconds; const signed = await signPayload(crypto, args.sender.signingPrivateKey, body); const res = await req('POST', `/v1/inbox/${args.recipient}`, signed); return { res, msgId }; } // ─── Health ───────────────────────────────────────────────── test('health endpoint responds', async () => { const res = await req('GET', '/health'); expect(res.status).toBe(200); const json = await res.json(); expect(json.service).toBe('shade-inbox-server'); }); // ─── Registration (TOFU) ──────────────────────────────────── describe('POST /v1/inbox/register', () => { test('accepts valid registration', async () => { await registerBob(); }); test('idempotent re-register with same key', async () => { const bob = await registerBob('bob'); const body = await signPayload(crypto, bob.signingPrivateKey, { address: 'bob', signingKey: toBase64(bob.signingPublicKey), }); const res = await req('POST', '/v1/inbox/register', body); expect(res.status).toBe(200); }); test('rejects different key claiming same address', async () => { await registerBob('bob'); const eve = await makeIdentity(); const body = await signPayload(crypto, eve.signingPrivateKey, { address: 'bob', signingKey: toBase64(eve.signingPublicKey), }); const res = await req('POST', '/v1/inbox/register', body); expect(res.status).toBe(401); }); test('rejects unsigned body', async () => { const bob = await makeIdentity(); const res = await req('POST', '/v1/inbox/register', { address: 'bob', signingKey: toBase64(bob.signingPublicKey), }); expect(res.status).toBe(400); }); test('rejects bad signature', async () => { const bob = await makeIdentity(); const res = await req('POST', '/v1/inbox/register', { address: 'bob', signingKey: toBase64(bob.signingPublicKey), signedAt: Date.now(), signature: toBase64(randBytes(64)), }); expect(res.status).toBe(401); }); }); // ─── PUT blob ─────────────────────────────────────────────── describe('POST /v1/inbox/:address (PUT blob)', () => { test('stores a signed blob from sender', async () => { await registerBob(); const alice = await makeIdentity(); const ct = randBytes(128); const { res, msgId } = await putMsg({ sender: alice, recipient: 'bob', ciphertext: ct }); expect(res.status).toBe(200); const json = await res.json(); expect(json.msgId).toBe(msgId); expect(json.idempotent).toBe(false); }); test('idempotent on duplicate ciphertext', async () => { await registerBob(); const alice = await makeIdentity(); const ct = randBytes(64); const first = await putMsg({ sender: alice, recipient: 'bob', ciphertext: ct }); expect(first.res.status).toBe(200); const second = await putMsg({ sender: alice, recipient: 'bob', ciphertext: ct }); expect(second.res.status).toBe(200); const j2 = await second.res.json(); expect(j2.idempotent).toBe(true); expect(j2.msgId).toBe(first.msgId); }); test('rejects mismatched msgId', async () => { await registerBob(); const alice = await makeIdentity(); const ct = randBytes(64); const wrongId = '0'.repeat(64); const body = await signPayload(crypto, alice.signingPrivateKey, { senderSigningKey: toBase64(alice.signingPublicKey), msgId: wrongId, ciphertext: toBase64(ct), }); const res = await req('POST', '/v1/inbox/bob', body); expect(res.status).toBe(400); }); test('rejects PUT to unregistered address', async () => { const alice = await makeIdentity(); const ct = randBytes(64); const { res } = await putMsg({ sender: alice, recipient: 'nobody', ciphertext: ct }); expect(res.status).toBe(404); }); test('rejects bad sender signature', async () => { await registerBob(); const alice = await makeIdentity(); const eve = await makeIdentity(); const ct = randBytes(64); const msgId = await computeMsgId(ct); // Sign with Eve, claim Alice's key. const body = await signPayload(crypto, eve.signingPrivateKey, { senderSigningKey: toBase64(alice.signingPublicKey), msgId, ciphertext: toBase64(ct), }); const res = await req('POST', '/v1/inbox/bob', body); expect(res.status).toBe(401); }); test('rejects ciphertext > maxBlobBytes', async () => { const small = createInboxServer({ crypto, store: new MemoryInboxStore(), disableRateLimit: true, quota: { maxBlobBytes: 256 }, }); // Register bob in this fresh app. const bob = await makeIdentity(); const reg = await signPayload(crypto, bob.signingPrivateKey, { address: 'bob', signingKey: toBase64(bob.signingPublicKey), }); await small.request('/v1/inbox/register', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(reg), }); const alice = await makeIdentity(); const ct = randBytes(257); const msgId = await computeMsgId(ct); const body = await signPayload(crypto, alice.signingPrivateKey, { senderSigningKey: toBase64(alice.signingPublicKey), msgId, ciphertext: toBase64(ct), }); const res = await small.request('/v1/inbox/bob', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }); expect(res.status).toBe(400); }); test('rejects stale signature (replay window)', async () => { await registerBob(); const alice = await makeIdentity(); const ct = randBytes(64); const msgId = await computeMsgId(ct); // Hand-craft: sign normally, then mutate signedAt to 10 minutes ago. const signed = await signPayload(crypto, alice.signingPrivateKey, { senderSigningKey: toBase64(alice.signingPublicKey), msgId, ciphertext: toBase64(ct), }); (signed as any).signedAt = Date.now() - 10 * 60 * 1000; const res = await req('POST', '/v1/inbox/bob', signed); // signedAt mutated → signature invalid → 401, OR replay → 409. expect([401, 409]).toContain(res.status); }); test('enforces per-address quota', async () => { const small = createInboxServer({ crypto, store: new MemoryInboxStore(), disableRateLimit: true, quota: { maxBlobsPerAddress: 2 }, }); const bob = await makeIdentity(); const reg = await signPayload(crypto, bob.signingPrivateKey, { address: 'bob', signingKey: toBase64(bob.signingPublicKey), }); await small.request('/v1/inbox/register', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(reg), }); const alice = await makeIdentity(); for (let i = 0; i < 2; i++) { const ct = randBytes(32 + i); const msgId = await computeMsgId(ct); const body = await signPayload(crypto, alice.signingPrivateKey, { senderSigningKey: toBase64(alice.signingPublicKey), msgId, ciphertext: toBase64(ct), }); const r = await small.request('/v1/inbox/bob', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }); expect(r.status).toBe(200); } // Third should be quota-rejected. const ct = randBytes(99); const msgId = await computeMsgId(ct); const body = await signPayload(crypto, alice.signingPrivateKey, { senderSigningKey: toBase64(alice.signingPublicKey), msgId, ciphertext: toBase64(ct), }); const r = await small.request('/v1/inbox/bob', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }); expect(r.status).toBe(400); }); }); // ─── FETCH ────────────────────────────────────────────────── describe('POST /v1/inbox/:address/fetch', () => { test('returns blobs after registration', async () => { const bob = await registerBob(); const alice = await makeIdentity(); const ct1 = randBytes(64); const ct2 = randBytes(80); await putMsg({ sender: alice, recipient: 'bob', ciphertext: ct1 }); await putMsg({ sender: alice, recipient: 'bob', ciphertext: ct2 }); const fetchBody = await signPayload(crypto, bob.signingPrivateKey, { address: 'bob', sinceCursor: 0 }); const res = await req('POST', '/v1/inbox/bob/fetch', fetchBody); expect(res.status).toBe(200); const json = await res.json(); expect(json.blobs.length).toBe(2); expect(typeof json.cursor).toBe('number'); }); test('cursor pagination skips already-seen blobs', async () => { const bob = await registerBob(); const alice = await makeIdentity(); await putMsg({ sender: alice, recipient: 'bob', ciphertext: randBytes(20) }); const firstFetch = await signPayload(crypto, bob.signingPrivateKey, { address: 'bob', sinceCursor: 0 }); const r1 = await req('POST', '/v1/inbox/bob/fetch', firstFetch); const j1 = await r1.json(); const cursor = j1.cursor; expect(j1.blobs.length).toBe(1); // Add a second blob. await putMsg({ sender: alice, recipient: 'bob', ciphertext: randBytes(30) }); const secondFetch = await signPayload(crypto, bob.signingPrivateKey, { address: 'bob', sinceCursor: cursor }); const r2 = await req('POST', '/v1/inbox/bob/fetch', secondFetch); const j2 = await r2.json(); expect(j2.blobs.length).toBe(1); }); test('rejects fetch from a different signing key', async () => { await registerBob(); const eve = await makeIdentity(); const fetchBody = await signPayload(crypto, eve.signingPrivateKey, { address: 'bob', sinceCursor: 0 }); const res = await req('POST', '/v1/inbox/bob/fetch', fetchBody); expect(res.status).toBe(401); }); test('rejects fetch on unregistered address', async () => { const eve = await makeIdentity(); const fetchBody = await signPayload(crypto, eve.signingPrivateKey, { address: 'nobody', sinceCursor: 0 }); const res = await req('POST', '/v1/inbox/nobody/fetch', fetchBody); expect(res.status).toBe(404); }); }); // ─── DELETE / ack ─────────────────────────────────────────── describe('DELETE /v1/inbox/:address/:msgId', () => { test('removes a blob after ack', async () => { const bob = await registerBob(); const alice = await makeIdentity(); const ct = randBytes(64); const { msgId } = await putMsg({ sender: alice, recipient: 'bob', ciphertext: ct }); const ackBody = await signPayload(crypto, bob.signingPrivateKey, { address: 'bob', msgId }); const res = await req('DELETE', `/v1/inbox/bob/${msgId}`, ackBody); expect(res.status).toBe(200); const j = await res.json(); expect(j.ok).toBe(true); // Subsequent fetch should return zero. const fetchBody = await signPayload(crypto, bob.signingPrivateKey, { address: 'bob', sinceCursor: 0 }); const r2 = await req('POST', '/v1/inbox/bob/fetch', fetchBody); const j2 = await r2.json(); expect(j2.blobs.length).toBe(0); }); test('rejects ack from a different signing key', async () => { const bob = await registerBob(); const alice = await makeIdentity(); const eve = await makeIdentity(); const ct = randBytes(64); const { msgId } = await putMsg({ sender: alice, recipient: 'bob', ciphertext: ct }); const ackBody = await signPayload(crypto, eve.signingPrivateKey, { address: 'bob', msgId }); const res = await req('DELETE', `/v1/inbox/bob/${msgId}`, ackBody); expect(res.status).toBe(401); // and the blob must still be there const fetchBody = await signPayload(crypto, bob.signingPrivateKey, { address: 'bob', sinceCursor: 0 }); const r2 = await req('POST', '/v1/inbox/bob/fetch', fetchBody); const j2 = await r2.json(); expect(j2.blobs.length).toBe(1); }); }); });