import { describe, test, expect } from 'bun:test'; import { createInboxServer, MemoryInboxStore, computeMsgId, InboxPruneTask, } from '../src/index.js'; import { signPayload } from '@shade/server'; import { SubtleCryptoProvider } from '@shade/crypto-web'; import { generateIdentityKeyPair, toBase64, fromBase64 } 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('Inbox lifecycle', () => { test('100 messages delivered without online overlap', async () => { const store = new MemoryInboxStore(); const app = createInboxServer({ crypto, store, disableRateLimit: true }); const bob = await makeIdentity(); const alice = await makeIdentity(); // Bob registers, then goes "offline". const reg = await signPayload(crypto, bob.signingPrivateKey, { address: 'bob', signingKey: toBase64(bob.signingPublicKey), }); await app.request('/v1/inbox/register', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(reg), }); // Alice puts 100 unique blobs while Bob is offline. const sentMsgIds = new Set(); for (let i = 0; i < 100; i++) { const ct = randBytes(64 + (i % 8)); const msgId = await computeMsgId(ct); sentMsgIds.add(msgId); const body = await signPayload(crypto, alice.signingPrivateKey, { senderSigningKey: toBase64(alice.signingPublicKey), msgId, ciphertext: toBase64(ct), }); const r = await app.request(`/v1/inbox/bob`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }); expect(r.status).toBe(200); } // Bob comes online and pulls everything in pages. const seen = new Set(); let cursor = 0; let safety = 0; while (safety++ < 50) { const fetchBody = await signPayload(crypto, bob.signingPrivateKey, { address: 'bob', sinceCursor: cursor, }); const r = await app.request(`/v1/inbox/bob/fetch`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(fetchBody), }); const j: any = await r.json(); for (const b of j.blobs) seen.add(b.msgId); cursor = j.cursor; if (!j.hasMore) break; } expect(seen.size).toBe(100); for (const msgId of sentMsgIds) expect(seen.has(msgId)).toBe(true); }); test('persistence across "restart" — same store, fresh app object', async () => { const store = new MemoryInboxStore(); const bob = await makeIdentity(); const alice = await makeIdentity(); // Stage 1: register + put 5 blobs. { const app = createInboxServer({ crypto, store, disableRateLimit: true }); const reg = await signPayload(crypto, bob.signingPrivateKey, { address: 'bob', signingKey: toBase64(bob.signingPublicKey), }); await app.request('/v1/inbox/register', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(reg), }); for (let i = 0; i < 5; i++) { const ct = randBytes(48 + i); const msgId = await computeMsgId(ct); const body = await signPayload(crypto, alice.signingPrivateKey, { senderSigningKey: toBase64(alice.signingPublicKey), msgId, ciphertext: toBase64(ct), }); const r = await app.request(`/v1/inbox/bob`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }); expect(r.status).toBe(200); } } // Stage 2: simulate a restart by building a brand-new Hono app on top // of the same persistent store, then verify fetches still see the data. { const app = createInboxServer({ crypto, store, disableRateLimit: true }); const fetchBody = await signPayload(crypto, bob.signingPrivateKey, { address: 'bob', sinceCursor: 0, }); const r = await app.request('/v1/inbox/bob/fetch', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(fetchBody), }); const j: any = await r.json(); expect(j.blobs.length).toBe(5); } }); test('prune removes expired blobs but keeps live ones', async () => { const store = new MemoryInboxStore(); const app = createInboxServer({ crypto, store, disableRateLimit: true }); const bob = await makeIdentity(); const alice = await makeIdentity(); const reg = await signPayload(crypto, bob.signingPrivateKey, { address: 'bob', signingKey: toBase64(bob.signingPublicKey), }); await app.request('/v1/inbox/register', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(reg), }); // One blob with min TTL, one with default TTL (well in future). const shortCt = randBytes(64); const shortMsgId = await computeMsgId(shortCt); const shortBody = await signPayload(crypto, alice.signingPrivateKey, { senderSigningKey: toBase64(alice.signingPublicKey), msgId: shortMsgId, ciphertext: toBase64(shortCt), ttlSeconds: 60, }); await app.request('/v1/inbox/bob', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(shortBody), }); const longCt = randBytes(64); const longMsgId = await computeMsgId(longCt); const longBody = await signPayload(crypto, alice.signingPrivateKey, { senderSigningKey: toBase64(alice.signingPublicKey), msgId: longMsgId, ciphertext: toBase64(longCt), }); await app.request('/v1/inbox/bob', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(longBody), }); // Force-expire the short blob by mutating expires_at. const list: any = (store as any).blobs.get('bob'); list[0].expiresAt = Date.now() - 1000; const prune = new InboxPruneTask(store, { intervalMinutes: 60 }); const removed = await prune.runOnce(); expect(removed).toBe(1); const fetchBody = await signPayload(crypto, bob.signingPrivateKey, { address: 'bob', sinceCursor: 0, }); const r = await app.request('/v1/inbox/bob/fetch', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(fetchBody), }); const j: any = await r.json(); expect(j.blobs.length).toBe(1); expect(j.blobs[0].msgId).toBe(longMsgId); }); }); describe('Tamper resistance', () => { test('bit-flip on stored ciphertext is reported as decode/decrypt failure on the client', async () => { const store = new MemoryInboxStore(); const app = createInboxServer({ crypto, store, disableRateLimit: true }); const bob = await makeIdentity(); const alice = await makeIdentity(); const reg = await signPayload(crypto, bob.signingPrivateKey, { address: 'bob', signingKey: toBase64(bob.signingPublicKey), }); await app.request('/v1/inbox/register', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(reg), }); const ct = randBytes(64); const msgId = await computeMsgId(ct); const body = await signPayload(crypto, alice.signingPrivateKey, { senderSigningKey: toBase64(alice.signingPublicKey), msgId, ciphertext: toBase64(ct), }); const putRes = await app.request('/v1/inbox/bob', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }); expect(putRes.status).toBe(200); // Tamper directly in the store. const blobs: any = (store as any).blobs.get('bob'); blobs[0].ciphertext[5] ^= 0x01; // Fetch returns the tampered blob — server is oblivious to integrity. const fetchBody = await signPayload(crypto, bob.signingPrivateKey, { address: 'bob', sinceCursor: 0, }); const r = await app.request('/v1/inbox/bob/fetch', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(fetchBody), }); const j: any = await r.json(); expect(j.blobs.length).toBe(1); // Recipient recomputes msgId; tampered ciphertext now hashes to a // value different from the stored msgId — that's the client-side // canary the V3.6 spec requires. const tampered = fromBase64(j.blobs[0].ciphertext); const recomputed = await computeMsgId(tampered); expect(recomputed).not.toBe(msgId); }); });