322 lines
10 KiB
TypeScript
322 lines
10 KiB
TypeScript
|
|
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<NonNullable<Awaited<ReturnType<typeof createShade>>['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<string, VfsEntry>([
|
||
|
|
['/', { 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();
|
||
|
|
}
|
||
|
|
});
|
||
|
|
});
|