import { describe, test, expect, beforeEach } from 'bun:test'; import { Hono } from 'hono'; import { createBlobRoutes, MemoryBlobStore, type BlobStore, } from '../src/index.js'; import { signPayload } from '@shade/server'; import { SubtleCryptoProvider, ed25519PublicKeyFromSeed } from '@shade/crypto-web'; import { toBase64, fromBase64 } from '@shade/core'; const crypto = new SubtleCryptoProvider(); function randBytes(n: number): Uint8Array { const buf = new Uint8Array(n); globalThis.crypto.getRandomValues(buf); return buf; } function hex(bytes: Uint8Array): string { let s = ''; for (const b of bytes) s += b.toString(16).padStart(2, '0'); return s; } describe('Shade Blob Routes (V4.9)', () => { let store: BlobStore; let app: Hono; beforeEach(() => { store = new MemoryBlobStore(); app = createBlobRoutes(store, crypto, { disableRateLimit: true }); }); async function makeOwner() { const seed = randBytes(32); const pubkey = ed25519PublicKeyFromSeed(seed); return { seed, pubkey }; } function makeSlotId(): string { return hex(randBytes(32)); } async function signedPut(args: { slotId: string; blob: Uint8Array; seed: Uint8Array; pubkey: Uint8Array; ifMatch?: string; }) { const payload: Record = { ownerPubkey: toBase64(args.pubkey), blob: toBase64(args.blob), slotId: args.slotId, }; if (args.ifMatch !== undefined) payload.ifMatch = args.ifMatch; const signed = await signPayload(crypto, args.seed, payload); const { slotId: _omit, ...wire } = signed as Record; return app.request(`/v1/blob/${args.slotId}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(wire), }); } async function signedDelete(args: { slotId: string; seed: Uint8Array; }) { const signed = await signPayload(crypto, args.seed, { slotId: args.slotId, }); const { slotId: _omit, ...wire } = signed as Record; return app.request(`/v1/blob/${args.slotId}`, { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(wire), }); } // ─── GET ───────────────────────────────────────────────────── test('GET on missing slot returns 404', async () => { const slotId = makeSlotId(); const res = await app.request(`/v1/blob/${slotId}`); expect(res.status).toBe(404); }); test('GET requires lowercase 64-hex slotId', async () => { const res = await app.request('/v1/blob/notahex'); expect(res.status).toBe(400); const res2 = await app.request(`/v1/blob/${'A'.repeat(64)}`); expect(res2.status).toBe(400); }); // ─── PUT (TOFU) ────────────────────────────────────────────── test('first PUT creates slot and returns etag', async () => { const slotId = makeSlotId(); const owner = await makeOwner(); const blob = randBytes(128); const res = await signedPut({ slotId, blob, ...owner }); expect(res.status).toBe(200); const json = (await res.json()) as { created: boolean; etag: string }; expect(json.created).toBe(true); expect(typeof json.etag).toBe('string'); const got = await app.request(`/v1/blob/${slotId}`); expect(got.status).toBe(200); const back = (await got.json()) as { blob: string; etag: string }; expect(fromBase64(back.blob)).toEqual(blob); expect(back.etag).toBe(json.etag); }); test('PUT without ifMatch on populated slot returns 409', async () => { const slotId = makeSlotId(); const owner = await makeOwner(); await signedPut({ slotId, blob: randBytes(64), ...owner }); const res = await signedPut({ slotId, blob: randBytes(64), ...owner }); expect(res.status).toBe(409); const json = (await res.json()) as { code: string }; expect(json.code).toBe('SHADE_CONFLICT'); }); test('PUT with stale ifMatch returns 412', async () => { const slotId = makeSlotId(); const owner = await makeOwner(); const r1 = await signedPut({ slotId, blob: randBytes(64), ...owner }); const j1 = (await r1.json()) as { etag: string }; // Use an etag we know does not match. const stale = String(Number(j1.etag) - 999); const res = await signedPut({ slotId, blob: randBytes(64), ...owner, ifMatch: stale, }); expect(res.status).toBe(412); const json = (await res.json()) as { code: string }; expect(json.code).toBe('SHADE_PRECONDITION_FAILED'); }); test('PUT with matching ifMatch updates and bumps etag', async () => { const slotId = makeSlotId(); const owner = await makeOwner(); const r1 = await signedPut({ slotId, blob: randBytes(64), ...owner }); const j1 = (await r1.json()) as { etag: string }; const r2 = await signedPut({ slotId, blob: randBytes(64), ...owner, ifMatch: j1.etag, }); expect(r2.status).toBe(200); const j2 = (await r2.json()) as { created: boolean; etag: string }; expect(j2.created).toBe(false); expect(Number(j2.etag)).toBeGreaterThan(Number(j1.etag)); }); test('PUT with ifMatch="*" unconditionally overwrites existing slot', async () => { const slotId = makeSlotId(); const owner = await makeOwner(); await signedPut({ slotId, blob: randBytes(64), ...owner }); const res = await signedPut({ slotId, blob: randBytes(64), ...owner, ifMatch: '*', }); expect(res.status).toBe(200); }); test('PUT with ifMatch="*" on empty slot returns 412', async () => { const slotId = makeSlotId(); const owner = await makeOwner(); const res = await signedPut({ slotId, blob: randBytes(64), ...owner, ifMatch: '*', }); expect(res.status).toBe(412); }); test('PUT by a different owner key on existing slot is rejected', async () => { const slotId = makeSlotId(); const ownerA = await makeOwner(); await signedPut({ slotId, blob: randBytes(64), ...ownerA }); const ownerB = await makeOwner(); const res = await signedPut({ slotId, blob: randBytes(64), ...ownerB, ifMatch: '*', }); expect(res.status).toBe(401); }); test('PUT with bad signature is rejected', async () => { const slotId = makeSlotId(); const owner = await makeOwner(); // Sign the payload, then mutate the blob bytes — signature no // longer matches the canonicalized body. const blob = randBytes(64); const payload = { ownerPubkey: toBase64(owner.pubkey), blob: toBase64(blob), slotId, }; const signed = await signPayload(crypto, owner.seed, payload); (signed as any).blob = toBase64(randBytes(64)); const { slotId: _omit, ...wire } = signed as Record; const res = await app.request(`/v1/blob/${slotId}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(wire), }); expect(res.status).toBe(401); }); test('PUT rejects empty blob and oversized blob', async () => { const slotId = makeSlotId(); const owner = await makeOwner(); const empty = await signedPut({ slotId, blob: new Uint8Array(0), ...owner }); expect(empty.status).toBe(400); const tooBig = await signedPut({ slotId, blob: randBytes(70 * 1024), ...owner, }); expect(tooBig.status).toBe(400); }); // ─── DELETE ────────────────────────────────────────────────── test('DELETE clears slot and lets a fresh key TOFU re-claim', async () => { const slotId = makeSlotId(); const ownerA = await makeOwner(); await signedPut({ slotId, blob: randBytes(64), ...ownerA }); const del = await signedDelete({ slotId, seed: ownerA.seed }); expect(del.status).toBe(200); // Slot is gone. const gone = await app.request(`/v1/blob/${slotId}`); expect(gone.status).toBe(404); // A fresh owner can now claim it. const ownerB = await makeOwner(); const claim = await signedPut({ slotId, blob: randBytes(64), ...ownerB }); expect(claim.status).toBe(200); }); test('DELETE by a different key is rejected', async () => { const slotId = makeSlotId(); const ownerA = await makeOwner(); await signedPut({ slotId, blob: randBytes(64), ...ownerA }); const ownerB = await makeOwner(); const res = await signedDelete({ slotId, seed: ownerB.seed }); expect(res.status).toBe(401); }); test('DELETE on missing slot returns 404', async () => { const slotId = makeSlotId(); const owner = await makeOwner(); const res = await signedDelete({ slotId, seed: owner.seed }); expect(res.status).toBe(404); }); // ─── Cross-slot replay ─────────────────────────────────────── test('PUT signed for slot A is rejected against slot B', async () => { const slotA = makeSlotId(); const slotB = makeSlotId(); const owner = await makeOwner(); const blob = randBytes(64); // Sign for slotA, send to slotB (URL). const payload = { ownerPubkey: toBase64(owner.pubkey), blob: toBase64(blob), slotId: slotA, }; const signed = await signPayload(crypto, owner.seed, payload); const { slotId: _omit, ...wire } = signed as Record; const res = await app.request(`/v1/blob/${slotB}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(wire), }); expect(res.status).toBe(401); }); });