import { describe, expect, test } from 'bun:test'; import { createShade } from '@shade/sdk'; import { createPrekeyServer, MemoryPrekeyStore, PrekeyServerEvents, } from '@shade/server'; import { SubtleCryptoProvider } from '@shade/crypto-web'; const crypto = new SubtleCryptoProvider(); /** * Stand up a prekey server + two Shades + Bob's file handler + RPC route * mounted on Bun.serve, then return Alice's HTTP-only `FileClient`. * * Mirrors the request-response setup a browser client would use against a * Bun-style server. */ async function setupHttpRig(opts: { bobHandler: Parameters>['files']>['serve']>[0]; }) { // 1. Prekey server. const prekeyEvents = new PrekeyServerEvents(); const prekey = createPrekeyServer({ crypto, store: new MemoryPrekeyStore(), disableRateLimit: true, events: prekeyEvents, }); const prekeyServer = Bun.serve({ port: 0, fetch: prekey.fetch }); const prekeyUrl = `http://localhost:${prekeyServer.port}`; // 2. Two Shades. Alice plays the browser client (no transferRoute); // Bob is the server. const alice = await createShade({ prekeyServer: prekeyUrl, address: 'alice' }); const bob = await createShade({ prekeyServer: prekeyUrl, address: 'bob' }); // 3. Bob: register file handler (HTTP-only — no streams) + mount // the RPC route. await bob.files.serve(opts.bobHandler, { inlineOnly: true }); const rpcRoute = bob.files.rpcRoute({ acceptFirstMessage: true }); const bobServer = Bun.serve({ port: 0, fetch: rpcRoute.fetch }); const rpcUrl = `http://localhost:${bobServer.port}/rpc`; // 5. Alice: build the HTTP-only file client. const fs = alice.files.httpClient('bob', { rpcUrl, defaultTimeoutMs: 5000 }); return { alice, bob, fs, rpcUrl, teardown: async () => { await alice.shutdown(); await bob.shutdown(); bobServer.stop(); prekeyServer.stop(); }, }; } describe('@shade/files HTTP RPC — round-trip', () => { test('list → mkdir → stat → write inline → read inline → delete via httpClient', async () => { interface VfsEntry { kind: 'file' | 'dir'; bytes?: Uint8Array; contentType?: string; } const vfs = new Map([ ['/', { kind: 'dir' }], ['/photos', { kind: 'dir' }], ]); const rig = await setupHttpRig({ bobHandler: { list: async (ctx) => { const prefix = ctx.path.endsWith('/') ? ctx.path : `${ctx.path}/`; const entries = Array.from(vfs.entries()) .filter(([p]) => p.startsWith(prefix) && p !== ctx.path && !p.slice(prefix.length).includes('/')) .map(([p, e]) => ({ name: p.slice(prefix.length) || p, kind: e.kind, size: e.bytes?.byteLength ?? 0, mtime: 0, metadata: {}, })); return { entries, hasMore: false }; }, stat: async (ctx) => { const e = vfs.get(ctx.path); if (!e) throw new (await import('../../src/index.js')).NotFoundError(`stat ${ctx.path}`); return { name: ctx.path.split('/').pop() ?? ctx.path, kind: e.kind, size: e.bytes?.byteLength ?? 0, mtime: 0, metadata: {}, ...(e.contentType !== undefined ? { contentType: e.contentType } : {}), }; }, mkdir: async (ctx) => { vfs.set(ctx.path, { kind: 'dir' }); return { entry: { name: ctx.path.split('/').pop() ?? ctx.path, kind: 'dir' as const, size: 0, mtime: 0, metadata: {} } }; }, delete: async (ctx) => { if (!vfs.has(ctx.path)) { throw new (await import('../../src/index.js')).NotFoundError(`delete ${ctx.path}`); } vfs.delete(ctx.path); return { deletedCount: 1 }; }, read: async (ctx) => { const e = vfs.get(ctx.path); if (!e || e.kind !== 'file' || !e.bytes) { throw new (await import('../../src/index.js')).NotFoundError(`read ${ctx.path}`); } // Omit sha256 — dispatcher computes it from the bytes. return { kind: 'inline' as const, bytes: e.bytes, ...(e.contentType !== undefined ? { contentType: e.contentType } : {}), }; }, write: async (ctx) => { if (ctx.args.content.kind !== 'inline') { throw new (await import('../../src/index.js')).ConflictError('streams not supported in this test handler'); } vfs.set(ctx.args.path, { kind: 'file', bytes: ctx.args.content.bytes, ...(ctx.args.contentType !== undefined ? { contentType: ctx.args.contentType } : {}), }); return { entry: { name: ctx.args.path.split('/').pop() ?? ctx.args.path, kind: 'file' as const, size: ctx.args.content.bytes.byteLength, mtime: 0, metadata: {}, ...(ctx.args.contentType !== undefined ? { contentType: ctx.args.contentType } : {}), }, }; }, }, }); try { // list const listed = await rig.fs.list('/'); expect(listed.entries.map((e) => e.name).sort()).toContain('photos'); // mkdir await rig.fs.mkdir('/docs'); const stat = await rig.fs.stat('/docs'); expect(stat.kind).toBe('dir'); // write inline const payload = new TextEncoder().encode('hello browser-friendly world'); const writeResult = await rig.fs.write('/docs/greeting.txt', payload, { contentType: 'text/plain', }); expect(writeResult.entry.size).toBe(payload.byteLength); // read inline const readResult = await rig.fs.read('/docs/greeting.txt'); expect(readResult.kind).toBe('inline'); if (readResult.kind === 'inline') { expect(new TextDecoder().decode(readResult.bytes)).toBe('hello browser-friendly world'); expect(readResult.contentType).toBe('text/plain'); } // delete const del = await rig.fs.delete('/docs/greeting.txt'); expect(del.deletedCount).toBe(1); // stat the deleted path → typed NotFoundError const { NotFoundError } = await import('../../src/index.js'); await expect(rig.fs.stat('/docs/greeting.txt')).rejects.toBeInstanceOf(NotFoundError); } finally { await rig.teardown(); } }); test('streamed write (> 256 KiB) is rejected with a clear error', async () => { const rig = await setupHttpRig({ bobHandler: { write: async () => ({ entry: { name: 'unused', kind: 'file' as const, size: 0, mtime: 0, metadata: {} }, }), }, }); try { const big = new Uint8Array(257 * 1024); const { ConflictError } = await import('../../src/index.js'); await expect(rig.fs.write('/big.bin', big)).rejects.toBeInstanceOf(ConflictError); } finally { await rig.teardown(); } }); test('rpcRoute() throws when no handler is attached', async () => { // Don't call shade.files.serve(...) — rpcRoute() should refuse. const prekeyEvents = new PrekeyServerEvents(); const prekey = createPrekeyServer({ crypto, store: new MemoryPrekeyStore(), disableRateLimit: true, events: prekeyEvents, }); const prekeyServer = Bun.serve({ port: 0, fetch: prekey.fetch }); const bob = await createShade({ prekeyServer: `http://localhost:${prekeyServer.port}`, address: 'bob', }); try { expect(() => bob.files.rpcRoute()).toThrow(/no handler attached/); } finally { await bob.shutdown(); prekeyServer.stop(); } }); test('missing X-Shade-Sender-Address header → 400', async () => { const rig = await setupHttpRig({ bobHandler: { stat: async () => ({ name: '_', kind: 'dir' as const, size: 0, mtime: 0, metadata: {}, }), }, }); try { const res = await fetch(rig.rpcUrl, { method: 'POST', body: new Uint8Array([0]), }); expect(res.status).toBe(400); const body = (await res.json()) as { error: string }; expect(body.error).toMatch(/X-Shade-Sender-Address/); } finally { await rig.teardown(); } }); test('empty body → 400', async () => { const rig = await setupHttpRig({ bobHandler: { stat: async () => ({ name: '_', kind: 'dir' as const, size: 0, mtime: 0, metadata: {}, }), }, }); try { const res = await fetch(rig.rpcUrl, { method: 'POST', headers: { 'X-Shade-Sender-Address': 'alice' }, }); expect(res.status).toBe(400); } finally { await rig.teardown(); } }); test('garbage body → 401 decrypt failure', async () => { const rig = await setupHttpRig({ bobHandler: { stat: async () => ({ name: '_', kind: 'dir' as const, size: 0, mtime: 0, metadata: {}, }), }, }); try { const res = await fetch(rig.rpcUrl, { method: 'POST', headers: { 'X-Shade-Sender-Address': 'alice' }, body: new Uint8Array([0x02, 0xff, 0xff, 0xff]), }); // 400 from envelope decode failure or 401 from decrypt failure. expect([400, 401]).toContain(res.status); } finally { await rig.teardown(); } }); test('body past maxBodyBytes → 413', async () => { const prekeyEvents = new PrekeyServerEvents(); const prekey = createPrekeyServer({ crypto, store: new MemoryPrekeyStore(), disableRateLimit: true, events: prekeyEvents, }); const prekeyServer = Bun.serve({ port: 0, fetch: prekey.fetch }); const bob = await createShade({ prekeyServer: `http://localhost:${prekeyServer.port}`, address: 'bob', }); await bob.files.serve( { stat: async () => ({ name: '_', kind: 'dir' as const, size: 0, mtime: 0, metadata: {}, }), }, { inlineOnly: true }, ); const route = bob.files.rpcRoute({ maxBodyBytes: 1024 }); const server = Bun.serve({ port: 0, fetch: route.fetch }); try { const big = new Uint8Array(2048); const res = await fetch(`http://localhost:${server.port}/rpc`, { method: 'POST', headers: { 'X-Shade-Sender-Address': 'alice' }, body: big, }); expect(res.status).toBe(413); } finally { await bob.shutdown(); server.stop(); prekeyServer.stop(); } }); });