feat(files): @shade/files 0.3.0 — E2EE filesystem RPC primitive
Some checks failed
Test / test (push) Has been cancelled
Some checks failed
Test / test (push) Has been cancelled
M-Files-1..6 land the full files-RPC layer + everything 0.3.0 needs to
ship. Apps keep their own UI; this layer ships the typed RPC, the
streams bridge for content I/O, and production hooks (rate limit,
retention, fingerprint gate, metrics).
@shade/files (NEW)
- Standard ops: list/stat/mkdir/delete/move/read/write/getThumbnail with
Zod-validated wire schemas + clean user-handler types.
- Custom ops: typed via TypeScript declaration merging on CustomOpsMap
+ per-op Zod schemas; client.custom('app.foo', {...}) is fully typed.
- Content I/O: inline (≤ 256 KiB plaintext) base64-in-RPC; streams
(> 256 KiB) ride @shade/transfer via userMetadata.shadeFilesWriteId
/ shadeFilesReadStreamId correlation. Server-side TransformStream
bridges accept inbound transfers immediately (engine rejects chunks
that arrive before accept) and park the readable for the matching
RPC.
- Directory ops: walk(path, opts) async-iterable depth-first walker;
uploadDirectory()/downloadDirectory() with bounded concurrency pool
(default 4, cap 16), aggregated progress, abort.
- Production hooks (callback-based, vendor-neutral): rate-limit (op +
byte), idempotency cache (LRU + TTL + in-flight de-dupe), path
policy (traversal + percent-decode hardening), fingerprint gate
(required/optional/reject), pluggable Ed25519 sig verification with
±5 min replay window, onMetric sink (standard names).
- React hooks (subpath @shade/files/react): ShadeFilesProvider,
useShadeFiles, useFileList, useFileTransfer/Upload/Download.
- Shade.files.serve(handler) + Shade.files.client(peer) high-level
entrypoint in @shade/sdk; lazy + memoized; one handler per Shade.
Wire format bump
- @shade/proto wire VERSION 0x01 → 0x02. Length prefixes changed from
u16 to u32. The previous u16 silently truncated payloads above
64 KiB — a hard correctness ceiling that blocked inline file ops
up to 256 KiB. Wire-incompatible with 0.2.x peers; new sessions
only. Cross-platform Kotlin port (android/shade-android) updated to
match; test-vectors/wire-format.json regenerated.
Concurrency safety
- ShadeSessionManager.encrypt/.decrypt now run under per-peer mutex.
Concurrent decryptions of the same peer raced ratchet state
(manifested as sporadic "Failed to decrypt — wrong key or tampered
data" under load — surfaced once concurrent uploadDirectory pumped
many writes in flight). Encrypt was already serialized via
Shade.send's encryptChains; decrypt is now serialized at the
manager layer too.
@shade/streams extension
- StreamMetadata.userMetadata?: Record<string, string> for
application-level key/value pairs that round-trip verbatim through
stream-init plaintext. Used by @shade/files for write/read
correlation; available to any consumer.
@shade/sdk extension
- Shade.files getter (lazy + memoized).
- BackgroundHooks.onPruneFiles + periodic timer (default 5 min) +
BackgroundTasks.setHook(name, fn) for runtime hook registration.
Bundles in-flight 0.2.0 work
- packages/shade-streams/, packages/shade-transfer/, related
shade-sdk streams-bridge + shade-widgets transfer hooks were
uncommitted prior to this session. Including them keeps the
workspace consistent at 0.3.0 since @shade/files depends on them.
Tests
- 74 new tests in @shade/files (572 → 646 workspace pass; 0 fail;
3× stable). Coverage spans unit (inline-threshold + concurrency),
integration (read-write inline + streams up to 1 MiB, walk +
upload/download directory, custom-op, metrics, SDK namespace
end-to-end), and security (tampered-envelope sig verification,
replay window, fingerprint gate, rate-limit + quota).
Release artifacts
- All packages bumped to 0.3.0 via scripts/bump-version.ts.
- scripts/publish-all.ts PACKAGES updated with shade-files in
topological order (after shade-transfer, before shade-sdk).
- bun run publish:dry clean (14 packed, 0 failed).
- examples/08-files-browser/ — three-process CLI demo (prekey + Bob
server + Alice CLI) covering list/stat/mkdir/delete/upload/download.
- docs/files.md — full API + design doc.
- CHANGELOG.md 0.3.0 entry.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
77
packages/shade-files/tests/integration/custom-op.test.ts
Normal file
77
packages/shade-files/tests/integration/custom-op.test.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
|
||||
import { z } from 'zod';
|
||||
import { setupFileRig, type FileTestRig } from './helpers/rig.js';
|
||||
import { CustomOpRejectedError, NotImplementedError } from '../../src/index.js';
|
||||
|
||||
// Module augmentation: register a typed custom op for the test.
|
||||
declare module '../../src/index.js' {
|
||||
interface CustomOpsMap {
|
||||
'test.echo': { args: { message: string }; response: { echoed: string } };
|
||||
'test.add': { args: { a: number; b: number }; response: { sum: number } };
|
||||
}
|
||||
}
|
||||
|
||||
describe('Custom ops — registry + Zod validation + typed I/O', () => {
|
||||
let rig: FileTestRig;
|
||||
const callLog: string[] = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
rig = await setupFileRig({
|
||||
custom: {
|
||||
'test.echo': {
|
||||
args: z.object({ message: z.string().min(1).max(64) }),
|
||||
response: z.object({ echoed: z.string() }),
|
||||
handler: async (args, ctx) => {
|
||||
callLog.push(`echo:${ctx.sender}:${args.message}`);
|
||||
return { echoed: args.message.toUpperCase() };
|
||||
},
|
||||
},
|
||||
'test.add': {
|
||||
args: z.object({ a: z.number(), b: z.number() }),
|
||||
response: z.object({ sum: z.number() }),
|
||||
handler: async (args) => ({ sum: args.a + args.b }),
|
||||
},
|
||||
'test.bad-response': {
|
||||
args: z.object({}),
|
||||
response: z.object({ x: z.number() }),
|
||||
// Returns wrong shape on purpose.
|
||||
handler: async () => ({ y: 'not-a-number' }) as unknown as { x: number },
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await rig.teardown();
|
||||
});
|
||||
|
||||
test('typed echo round-trips through registered Zod schemas', async () => {
|
||||
const result = await rig.fs.custom('test.echo', { message: 'hello' });
|
||||
expect(result.echoed).toBe('HELLO');
|
||||
expect(callLog).toContain('echo:alice:hello');
|
||||
});
|
||||
|
||||
test('typed add', async () => {
|
||||
const result = await rig.fs.custom('test.add', { a: 3, b: 4 });
|
||||
expect(result.sum).toBe(7);
|
||||
});
|
||||
|
||||
test('invalid args (Zod-rejected payload) → InvalidArgsError', async () => {
|
||||
await expect(
|
||||
// message: '' violates min(1) — TypeScript still allows it since string
|
||||
rig.fs.custom('test.echo', { message: '' }),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
test('unknown custom op name → NotImplementedError', async () => {
|
||||
await expect(
|
||||
rig.fs.custom('test.unknown' as never, {} as never),
|
||||
).rejects.toBeInstanceOf(NotImplementedError);
|
||||
});
|
||||
|
||||
test('handler returns wrong shape → CustomOpRejectedError', async () => {
|
||||
await expect(
|
||||
rig.fs.custom('test.bad-response' as never, {} as never),
|
||||
).rejects.toBeInstanceOf(CustomOpRejectedError);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,210 @@
|
||||
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
|
||||
import { sha256 } from '@noble/hashes/sha2.js';
|
||||
import {
|
||||
NotFoundError,
|
||||
downloadDirectory,
|
||||
createMemoryDirectory,
|
||||
walk,
|
||||
type DirectoryHandleLike,
|
||||
type FileEntry,
|
||||
} from '../../src/index.js';
|
||||
import { setupFileRig, type FileTestRig } from './helpers/rig.js';
|
||||
|
||||
interface StoredFile {
|
||||
bytes: Uint8Array;
|
||||
contentType?: string;
|
||||
sha256: string;
|
||||
}
|
||||
|
||||
function bytesToHex(b: Uint8Array): string {
|
||||
return Array.from(b, (x) => x.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
|
||||
async function streamToBytes(s: ReadableStream<Uint8Array>): Promise<Uint8Array> {
|
||||
const reader = s.getReader();
|
||||
const parts: Uint8Array[] = [];
|
||||
let total = 0;
|
||||
while (true) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) break;
|
||||
if (value === undefined) continue;
|
||||
parts.push(value);
|
||||
total += value.byteLength;
|
||||
}
|
||||
reader.releaseLock();
|
||||
const out = new Uint8Array(total);
|
||||
let offset = 0;
|
||||
for (const p of parts) {
|
||||
out.set(p, offset);
|
||||
offset += p.byteLength;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function streamFromBytes(bytes: Uint8Array): ReadableStream<Uint8Array> {
|
||||
return new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.enqueue(bytes);
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
describe('downloadDirectory — bulk download from remote', () => {
|
||||
let rig: FileTestRig;
|
||||
const blobs = new Map<string, StoredFile>();
|
||||
const dirs = new Set<string>();
|
||||
|
||||
beforeAll(async () => {
|
||||
blobs.clear();
|
||||
dirs.clear();
|
||||
// Build remote tree:
|
||||
// /src/
|
||||
// ├── small.txt ('hello world\n', 12 bytes)
|
||||
// ├── img.bin (50 KiB random)
|
||||
// └── nested/
|
||||
// ├── big.bin (400 KiB random)
|
||||
// └── tiny.bin (3 bytes)
|
||||
dirs.add('/');
|
||||
dirs.add('/src');
|
||||
dirs.add('/src/nested');
|
||||
const small = new TextEncoder().encode('hello world\n');
|
||||
const mid = new Uint8Array(50 * 1024); crypto.getRandomValues(mid);
|
||||
const big = new Uint8Array(400 * 1024); crypto.getRandomValues(big);
|
||||
const tiny = new Uint8Array([1, 2, 3]);
|
||||
blobs.set('/src/small.txt', { bytes: small, sha256: bytesToHex(sha256(small)), contentType: 'text/plain' });
|
||||
blobs.set('/src/img.bin', { bytes: mid, sha256: bytesToHex(sha256(mid)) });
|
||||
blobs.set('/src/nested/big.bin', { bytes: big, sha256: bytesToHex(sha256(big)) });
|
||||
blobs.set('/src/nested/tiny.bin', { bytes: tiny, sha256: bytesToHex(sha256(tiny)) });
|
||||
|
||||
rig = await setupFileRig({
|
||||
list: async (ctx) => {
|
||||
if (!dirs.has(ctx.path)) throw new NotFoundError(ctx.path);
|
||||
const entries: FileEntry[] = [];
|
||||
const dirPrefix = ctx.path === '/' ? '/' : ctx.path + '/';
|
||||
// Subdirs
|
||||
for (const d of dirs) {
|
||||
if (d === ctx.path) continue;
|
||||
if (!d.startsWith(dirPrefix)) continue;
|
||||
const rest = d.slice(dirPrefix.length);
|
||||
if (rest.includes('/')) continue;
|
||||
entries.push({ name: rest, kind: 'dir', size: 0, mtime: 0, metadata: {} });
|
||||
}
|
||||
// Files
|
||||
for (const [path, blob] of blobs) {
|
||||
if (!path.startsWith(dirPrefix)) continue;
|
||||
const rest = path.slice(dirPrefix.length);
|
||||
if (rest.includes('/')) continue;
|
||||
entries.push({
|
||||
name: rest,
|
||||
kind: 'file',
|
||||
size: blob.bytes.byteLength,
|
||||
mtime: 0,
|
||||
...(blob.contentType !== undefined ? { contentType: blob.contentType } : {}),
|
||||
metadata: {},
|
||||
});
|
||||
}
|
||||
return { entries, hasMore: false };
|
||||
},
|
||||
read: async (ctx) => {
|
||||
const blob = blobs.get(ctx.path);
|
||||
if (blob === undefined) throw new NotFoundError(ctx.path);
|
||||
if (blob.bytes.byteLength > 256 * 1024) {
|
||||
return blob.contentType !== undefined
|
||||
? {
|
||||
kind: 'streams',
|
||||
stream: streamFromBytes(blob.bytes),
|
||||
size: blob.bytes.byteLength,
|
||||
sha256: blob.sha256,
|
||||
contentType: blob.contentType,
|
||||
}
|
||||
: {
|
||||
kind: 'streams',
|
||||
stream: streamFromBytes(blob.bytes),
|
||||
size: blob.bytes.byteLength,
|
||||
sha256: blob.sha256,
|
||||
};
|
||||
}
|
||||
return blob.contentType !== undefined
|
||||
? { kind: 'inline', bytes: blob.bytes, sha256: blob.sha256, contentType: blob.contentType }
|
||||
: { kind: 'inline', bytes: blob.bytes, sha256: blob.sha256 };
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await rig.teardown();
|
||||
});
|
||||
|
||||
test('downloads entire tree, sha256 matches per file', async () => {
|
||||
const local = createMemoryDirectory('local');
|
||||
const handle = downloadDirectory(rig.fs, '/src', local);
|
||||
const result = await handle.done();
|
||||
expect(result.filesDone).toBe(4);
|
||||
expect(result.bytesDone).toBe(12 + 50 * 1024 + 400 * 1024 + 3);
|
||||
|
||||
// Verify local tree contents
|
||||
const downloadedFiles = new Map<string, Uint8Array>();
|
||||
async function dump(dir: DirectoryHandleLike, prefix: string): Promise<void> {
|
||||
for await (const [name, child] of dir.entries()) {
|
||||
const path = prefix === '' ? name : `${prefix}/${name}`;
|
||||
if (child.kind === 'directory') {
|
||||
await dump(child as DirectoryHandleLike, path);
|
||||
} else {
|
||||
const file = await (child as { getFile: () => Promise<{ arrayBuffer: () => Promise<ArrayBuffer> }> }).getFile();
|
||||
downloadedFiles.set(path, new Uint8Array(await file.arrayBuffer()));
|
||||
}
|
||||
}
|
||||
}
|
||||
await dump(local, '');
|
||||
expect(downloadedFiles.size).toBe(4);
|
||||
expect(downloadedFiles.has('small.txt')).toBe(true);
|
||||
expect(downloadedFiles.has('img.bin')).toBe(true);
|
||||
expect(downloadedFiles.has('nested/big.bin')).toBe(true);
|
||||
expect(downloadedFiles.has('nested/tiny.bin')).toBe(true);
|
||||
|
||||
expect(bytesToHex(sha256(downloadedFiles.get('nested/big.bin')!))).toBe(
|
||||
blobs.get('/src/nested/big.bin')!.sha256,
|
||||
);
|
||||
expect(bytesToHex(sha256(downloadedFiles.get('img.bin')!))).toBe(
|
||||
blobs.get('/src/img.bin')!.sha256,
|
||||
);
|
||||
});
|
||||
|
||||
test('aggregated progress events fire monotonically', async () => {
|
||||
const local = createMemoryDirectory('local');
|
||||
const handle = downloadDirectory(rig.fs, '/src', local);
|
||||
const progresses: { filesDone: number; bytesDone: number }[] = [];
|
||||
(async () => {
|
||||
for await (const ev of handle.events) {
|
||||
if (ev.type === 'progress') {
|
||||
progresses.push({ filesDone: ev.filesDone, bytesDone: ev.bytesDone });
|
||||
}
|
||||
}
|
||||
})().catch(() => undefined);
|
||||
await handle.done();
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
for (let i = 1; i < progresses.length; i++) {
|
||||
expect(progresses[i]!.filesDone).toBeGreaterThanOrEqual(progresses[i - 1]!.filesDone);
|
||||
expect(progresses[i]!.bytesDone).toBeGreaterThanOrEqual(progresses[i - 1]!.bytesDone);
|
||||
}
|
||||
});
|
||||
|
||||
test('aborts via handle.abort()', async () => {
|
||||
const local = createMemoryDirectory('local');
|
||||
const handle = downloadDirectory(rig.fs, '/src', local);
|
||||
setTimeout(() => void handle.abort('test-cancel'), 5);
|
||||
await expect(handle.done()).rejects.toThrow();
|
||||
});
|
||||
|
||||
test('walk + downloadDirectory are consistent', async () => {
|
||||
const local = createMemoryDirectory('local');
|
||||
const remoteFiles: string[] = [];
|
||||
for await (const item of walk(rig.fs, '/src')) {
|
||||
if (item.entry.kind === 'file') remoteFiles.push(item.relativePath);
|
||||
}
|
||||
const handle = downloadDirectory(rig.fs, '/src', local);
|
||||
const result = await handle.done();
|
||||
expect(result.filesDone).toBe(remoteFiles.length);
|
||||
});
|
||||
});
|
||||
142
packages/shade-files/tests/integration/helpers/rig.ts
Normal file
142
packages/shade-files/tests/integration/helpers/rig.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
import { createShade, type Shade } from '@shade/sdk';
|
||||
import {
|
||||
createPrekeyServer,
|
||||
MemoryPrekeyStore,
|
||||
PrekeyServerEvents,
|
||||
} from '@shade/server';
|
||||
import { SubtleCryptoProvider } from '@shade/crypto-web';
|
||||
import {
|
||||
PendingRpcRegistry,
|
||||
ShadeFileRpcChannel,
|
||||
attachClientRouting,
|
||||
attachFileHandler,
|
||||
createClientStreamsBridge,
|
||||
createFileClient,
|
||||
createFileHandler,
|
||||
createServerStreamsBridge,
|
||||
type ClientStreamsBridge,
|
||||
type FileClient,
|
||||
type FileHandler,
|
||||
type FileHandlerConfig,
|
||||
type ServerStreamsBridge,
|
||||
} from '../../../src/index.js';
|
||||
|
||||
const crypto = new SubtleCryptoProvider();
|
||||
|
||||
export interface FileTestRig {
|
||||
alice: Shade;
|
||||
bob: Shade;
|
||||
fs: FileClient;
|
||||
bobHandler: FileHandler;
|
||||
/** Server-side streams bridge (Bob). */
|
||||
bobStreamsBridge: ServerStreamsBridge;
|
||||
/** Client-side streams bridge (Alice). */
|
||||
aliceStreamsBridge: ClientStreamsBridge;
|
||||
/** Tear everything down (kills servers, shuts down shades). */
|
||||
teardown(): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup options.
|
||||
*
|
||||
* Defaults to wiring streams-bridges on both sides so content I/O tests
|
||||
* (`read-write-streams.test.ts`) work transparently. Pass `withStreams: false`
|
||||
* to skip — useful for the legacy `std-ops` tests that don't need them.
|
||||
*/
|
||||
export interface SetupRigOptions {
|
||||
withStreams?: boolean;
|
||||
}
|
||||
|
||||
export async function setupFileRig(
|
||||
bobConfig: Omit<FileHandlerConfig, 'streamsBridge'>,
|
||||
options: SetupRigOptions = {},
|
||||
): Promise<FileTestRig> {
|
||||
const withStreams = options.withStreams ?? true;
|
||||
// 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
|
||||
const alice = await createShade({ prekeyServer: prekeyUrl, address: 'alice' });
|
||||
const bob = await createShade({ prekeyServer: prekeyUrl, address: 'bob' });
|
||||
|
||||
// 3. BOTH sides need a transferRoute mounted because file-RPC is
|
||||
// request/response — Bob's reply must reach Alice's HTTP endpoint
|
||||
// just as Alice's request reached Bob's.
|
||||
let bobBaseUrl = '';
|
||||
let aliceBaseUrl = '';
|
||||
|
||||
alice.configureTransfers({
|
||||
resolveBaseUrl: async (peer) => {
|
||||
if (peer === 'bob') return bobBaseUrl;
|
||||
throw new Error(`alice: unknown peer ${peer}`);
|
||||
},
|
||||
});
|
||||
bob.configureTransfers({
|
||||
resolveBaseUrl: async (peer) => {
|
||||
if (peer === 'alice') return aliceBaseUrl;
|
||||
throw new Error(`bob: unknown peer ${peer}`);
|
||||
},
|
||||
});
|
||||
|
||||
const bobApp = await bob.transferRoute();
|
||||
const aliceApp = await alice.transferRoute();
|
||||
const bobServer = Bun.serve({ port: 0, fetch: bobApp.fetch });
|
||||
const aliceServer = Bun.serve({ port: 0, fetch: aliceApp.fetch });
|
||||
bobBaseUrl = `http://localhost:${bobServer.port}`;
|
||||
aliceBaseUrl = `http://localhost:${aliceServer.port}`;
|
||||
|
||||
// 4. Streams bridges (both sides) — required for content I/O > 256 KiB.
|
||||
let bobStreamsBridge: ServerStreamsBridge | undefined;
|
||||
let aliceStreamsBridge: ClientStreamsBridge | undefined;
|
||||
if (withStreams) {
|
||||
bobStreamsBridge = await createServerStreamsBridge(bob);
|
||||
aliceStreamsBridge = await createClientStreamsBridge(alice);
|
||||
}
|
||||
|
||||
// 5. Bob: file handler + channel
|
||||
const bobChannel = new ShadeFileRpcChannel(bob);
|
||||
const fullBobConfig: FileHandlerConfig = {
|
||||
...bobConfig,
|
||||
...(bobStreamsBridge !== undefined ? { streamsBridge: bobStreamsBridge } : {}),
|
||||
};
|
||||
const bobHandler = createFileHandler(bob, fullBobConfig);
|
||||
attachFileHandler(bobChannel, bobHandler);
|
||||
|
||||
// 6. Alice: client + channel + pending registry
|
||||
const aliceChannel = new ShadeFileRpcChannel(alice);
|
||||
const alicePending = new PendingRpcRegistry();
|
||||
attachClientRouting(aliceChannel, alicePending);
|
||||
const fs = createFileClient(alice, aliceChannel, alicePending, 'bob', {
|
||||
defaultTimeoutMs: 5000,
|
||||
...(aliceStreamsBridge !== undefined ? { streamsBridge: aliceStreamsBridge } : {}),
|
||||
});
|
||||
|
||||
return {
|
||||
alice,
|
||||
bob,
|
||||
fs,
|
||||
bobHandler,
|
||||
bobStreamsBridge: bobStreamsBridge as ServerStreamsBridge,
|
||||
aliceStreamsBridge: aliceStreamsBridge as ClientStreamsBridge,
|
||||
async teardown() {
|
||||
bobChannel.destroy();
|
||||
aliceChannel.destroy();
|
||||
bobHandler.destroy();
|
||||
if (bobStreamsBridge !== undefined) await bobStreamsBridge.destroy();
|
||||
if (aliceStreamsBridge !== undefined) await aliceStreamsBridge.destroy();
|
||||
await alice.shutdown();
|
||||
await bob.shutdown();
|
||||
bobServer.stop();
|
||||
aliceServer.stop();
|
||||
prekeyServer.stop();
|
||||
},
|
||||
};
|
||||
}
|
||||
72
packages/shade-files/tests/integration/metrics.test.ts
Normal file
72
packages/shade-files/tests/integration/metrics.test.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
|
||||
import { setupFileRig, type FileTestRig } from './helpers/rig.js';
|
||||
import {
|
||||
METRIC_OP_DURATION_MS,
|
||||
METRIC_OP_TOTAL,
|
||||
METRIC_RATE_LIMIT_REJECT_TOTAL,
|
||||
type MetricSink,
|
||||
type MetricTags,
|
||||
type FileEntry,
|
||||
} from '../../src/index.js';
|
||||
|
||||
interface MetricEvent {
|
||||
name: string;
|
||||
value: number;
|
||||
tags: MetricTags;
|
||||
}
|
||||
|
||||
describe('Metrics — onMetric on success', () => {
|
||||
let rig: FileTestRig;
|
||||
const events: MetricEvent[] = [];
|
||||
const sink: MetricSink = (name, value, tags) => events.push({ name, value, tags });
|
||||
|
||||
beforeAll(async () => {
|
||||
rig = await setupFileRig({
|
||||
onMetric: sink,
|
||||
list: async () => ({ entries: [], hasMore: false }),
|
||||
});
|
||||
});
|
||||
afterAll(async () => { await rig.teardown(); });
|
||||
|
||||
test('emits op_total + op_duration_ms with result=ok on success', async () => {
|
||||
events.length = 0;
|
||||
await rig.fs.list('/');
|
||||
const totals = events.filter((e) => e.name === METRIC_OP_TOTAL);
|
||||
expect(totals.length).toBeGreaterThanOrEqual(1);
|
||||
expect(totals[0]!.tags.result).toBe('ok');
|
||||
expect(totals[0]!.tags.op).toBe('list');
|
||||
const durations = events.filter((e) => e.name === METRIC_OP_DURATION_MS);
|
||||
expect(durations.length).toBeGreaterThanOrEqual(1);
|
||||
expect(durations[0]!.value).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Metrics — onMetric rate-limit reject', () => {
|
||||
let rig: FileTestRig;
|
||||
const events: MetricEvent[] = [];
|
||||
const sink: MetricSink = (name, value, tags) => events.push({ name, value, tags });
|
||||
|
||||
beforeAll(async () => {
|
||||
rig = await setupFileRig({
|
||||
onMetric: sink,
|
||||
rateLimits: { maxOpsPerMinutePerSender: 3 },
|
||||
stat: async (_ctx) => {
|
||||
const e: FileEntry = { name: 'x', kind: 'file', size: 1, mtime: 0, metadata: {} };
|
||||
return e;
|
||||
},
|
||||
});
|
||||
});
|
||||
afterAll(async () => { await rig.teardown(); });
|
||||
|
||||
test('emits rate_limit_reject_total when capacity exhausted', async () => {
|
||||
events.length = 0;
|
||||
// Cap is 3; first 3 stats succeed, 4th rejected.
|
||||
await rig.fs.stat('/x');
|
||||
await rig.fs.stat('/x');
|
||||
await rig.fs.stat('/x');
|
||||
await expect(rig.fs.stat('/x')).rejects.toThrow();
|
||||
const rejects = events.filter((e) => e.name === METRIC_RATE_LIMIT_REJECT_TOTAL);
|
||||
expect(rejects.length).toBeGreaterThanOrEqual(1);
|
||||
expect(rejects[0]!.tags.op).toBe('stat');
|
||||
});
|
||||
});
|
||||
138
packages/shade-files/tests/integration/read-write-inline.test.ts
Normal file
138
packages/shade-files/tests/integration/read-write-inline.test.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
|
||||
import { sha256 } from '@noble/hashes/sha2.js';
|
||||
import { NotFoundError, type FileEntry } from '../../src/index.js';
|
||||
import type { UserReadResult, UserWriteArgs, WriteResult } from '../../src/server/io-types.js';
|
||||
import type { OpContext } from '../../src/server/handler-context.js';
|
||||
import { setupFileRig, type FileTestRig } from './helpers/rig.js';
|
||||
|
||||
interface StoredBlob {
|
||||
bytes: Uint8Array;
|
||||
contentType?: string;
|
||||
sha256: string;
|
||||
mtime: number;
|
||||
}
|
||||
|
||||
function bytesToHex(b: Uint8Array): string {
|
||||
return Array.from(b, (x) => x.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
|
||||
describe('Content I/O — inline read/write E2E', () => {
|
||||
let rig: FileTestRig;
|
||||
const blobs = new Map<string, StoredBlob>();
|
||||
|
||||
beforeAll(async () => {
|
||||
blobs.clear();
|
||||
rig = await setupFileRig({
|
||||
read: async (ctx: OpContext<{ path: string }>): Promise<UserReadResult> => {
|
||||
const blob = blobs.get(ctx.path);
|
||||
if (blob === undefined) throw new NotFoundError(ctx.path);
|
||||
return blob.contentType !== undefined
|
||||
? { kind: 'inline', bytes: blob.bytes, sha256: blob.sha256, contentType: blob.contentType }
|
||||
: { kind: 'inline', bytes: blob.bytes, sha256: blob.sha256 };
|
||||
},
|
||||
write: async (ctx: OpContext<UserWriteArgs>): Promise<WriteResult> => {
|
||||
const args = ctx.args;
|
||||
if (args.content.kind !== 'inline') {
|
||||
throw new Error('expected inline content for this test');
|
||||
}
|
||||
if (blobs.has(args.path) && !args.overwrite) {
|
||||
throw new Error('exists');
|
||||
}
|
||||
const stored: StoredBlob = {
|
||||
bytes: args.content.bytes,
|
||||
...(args.contentType !== undefined ? { contentType: args.contentType } : {}),
|
||||
sha256: args.content.sha256,
|
||||
mtime: Date.now(),
|
||||
};
|
||||
blobs.set(args.path, stored);
|
||||
const entry: FileEntry = {
|
||||
name: args.path.split('/').filter(Boolean).pop() ?? '',
|
||||
kind: 'file',
|
||||
size: args.content.size,
|
||||
mtime: stored.mtime,
|
||||
...(args.contentType !== undefined ? { contentType: args.contentType } : {}),
|
||||
metadata: { sha256: args.content.sha256 },
|
||||
};
|
||||
return { entry };
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await rig.teardown();
|
||||
});
|
||||
|
||||
test('write 1 KiB inline → read it back, sha256 matches', async () => {
|
||||
const data = new Uint8Array(1024);
|
||||
for (let i = 0; i < data.length; i++) data[i] = (i * 7) & 0xff;
|
||||
const expectedSha = bytesToHex(sha256(data));
|
||||
|
||||
const writeResult = await rig.fs.write('/small.bin', data, { contentType: 'application/octet-stream' });
|
||||
expect(writeResult.entry.size).toBe(1024);
|
||||
expect(writeResult.entry.metadata.sha256).toBe(expectedSha);
|
||||
|
||||
const readResult = await rig.fs.read('/small.bin');
|
||||
expect(readResult.kind).toBe('inline');
|
||||
if (readResult.kind === 'inline') {
|
||||
expect(readResult.bytes.byteLength).toBe(1024);
|
||||
expect(readResult.sha256).toBe(expectedSha);
|
||||
expect(readResult.contentType).toBe('application/octet-stream');
|
||||
expect(Array.from(readResult.bytes)).toEqual(Array.from(data));
|
||||
}
|
||||
});
|
||||
|
||||
test('write 100 KiB inline → read it back, sha256 matches', async () => {
|
||||
const data = new Uint8Array(100 * 1024);
|
||||
crypto.getRandomValues(data);
|
||||
const expectedSha = bytesToHex(sha256(data));
|
||||
|
||||
await rig.fs.write('/big.bin', data);
|
||||
const readResult = await rig.fs.read('/big.bin');
|
||||
expect(readResult.kind).toBe('inline');
|
||||
if (readResult.kind === 'inline') {
|
||||
expect(readResult.bytes.byteLength).toBe(100 * 1024);
|
||||
expect(readResult.sha256).toBe(expectedSha);
|
||||
expect(Array.from(readResult.bytes)).toEqual(Array.from(data));
|
||||
}
|
||||
});
|
||||
|
||||
test('overwrite without flag → server-defined error; with flag → succeeds', async () => {
|
||||
const a = new Uint8Array([1, 2, 3]);
|
||||
const b = new Uint8Array([4, 5, 6]);
|
||||
await rig.fs.write('/dup.bin', a);
|
||||
await expect(rig.fs.write('/dup.bin', b)).rejects.toThrow();
|
||||
await rig.fs.write('/dup.bin', b, { overwrite: true });
|
||||
const out = await rig.fs.read('/dup.bin');
|
||||
expect(out.kind).toBe('inline');
|
||||
if (out.kind === 'inline') {
|
||||
expect(Array.from(out.bytes)).toEqual([4, 5, 6]);
|
||||
}
|
||||
});
|
||||
|
||||
test('write Blob input → inferred contentType, round-trips', async () => {
|
||||
const blob = new Blob([new Uint8Array([0xde, 0xad, 0xbe, 0xef])], { type: 'image/png' });
|
||||
await rig.fs.write('/blobby.png', blob);
|
||||
const out = await rig.fs.read('/blobby.png');
|
||||
expect(out.kind).toBe('inline');
|
||||
if (out.kind === 'inline') {
|
||||
expect(out.contentType).toBe('image/png');
|
||||
expect(Array.from(out.bytes)).toEqual([0xde, 0xad, 0xbe, 0xef]);
|
||||
}
|
||||
});
|
||||
|
||||
test('read non-existent → NotFoundError', async () => {
|
||||
await expect(rig.fs.read('/missing.bin')).rejects.toThrow();
|
||||
});
|
||||
|
||||
test('inline path also handles 256 KiB exactly (boundary)', async () => {
|
||||
const data = new Uint8Array(256 * 1024);
|
||||
crypto.getRandomValues(data);
|
||||
const expectedSha = bytesToHex(sha256(data));
|
||||
await rig.fs.write('/boundary.bin', data);
|
||||
const out = await rig.fs.read('/boundary.bin');
|
||||
expect(out.kind).toBe('inline');
|
||||
if (out.kind === 'inline') {
|
||||
expect(out.sha256).toBe(expectedSha);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,175 @@
|
||||
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
|
||||
import { sha256 } from '@noble/hashes/sha2.js';
|
||||
import { NotFoundError, type FileEntry } from '../../src/index.js';
|
||||
import { setupFileRig, type FileTestRig } from './helpers/rig.js';
|
||||
|
||||
interface StoredBlob {
|
||||
bytes: Uint8Array;
|
||||
contentType?: string;
|
||||
sha256: string;
|
||||
}
|
||||
|
||||
function bytesToHex(b: Uint8Array): string {
|
||||
return Array.from(b, (x) => x.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
|
||||
async function streamToBytes(s: ReadableStream<Uint8Array>): Promise<Uint8Array> {
|
||||
const reader = s.getReader();
|
||||
const parts: Uint8Array[] = [];
|
||||
let total = 0;
|
||||
while (true) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) break;
|
||||
if (value === undefined) continue;
|
||||
parts.push(value);
|
||||
total += value.byteLength;
|
||||
}
|
||||
reader.releaseLock();
|
||||
const out = new Uint8Array(total);
|
||||
let offset = 0;
|
||||
for (const p of parts) {
|
||||
out.set(p, offset);
|
||||
offset += p.byteLength;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function streamFromBytes(bytes: Uint8Array): ReadableStream<Uint8Array> {
|
||||
return new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.enqueue(bytes);
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
describe('Content I/O — streamed read/write E2E (>256 KiB)', () => {
|
||||
let rig: FileTestRig;
|
||||
const blobs = new Map<string, StoredBlob>();
|
||||
|
||||
beforeAll(async () => {
|
||||
blobs.clear();
|
||||
rig = await setupFileRig({
|
||||
read: async (ctx) => {
|
||||
const blob = blobs.get(ctx.path);
|
||||
if (blob === undefined) throw new NotFoundError(ctx.path);
|
||||
// Return as streams when blob ≥ 256 KiB.
|
||||
if (blob.bytes.byteLength > 256 * 1024) {
|
||||
return blob.contentType !== undefined
|
||||
? {
|
||||
kind: 'streams',
|
||||
stream: streamFromBytes(blob.bytes),
|
||||
size: blob.bytes.byteLength,
|
||||
sha256: blob.sha256,
|
||||
contentType: blob.contentType,
|
||||
}
|
||||
: {
|
||||
kind: 'streams',
|
||||
stream: streamFromBytes(blob.bytes),
|
||||
size: blob.bytes.byteLength,
|
||||
sha256: blob.sha256,
|
||||
};
|
||||
}
|
||||
return blob.contentType !== undefined
|
||||
? { kind: 'inline', bytes: blob.bytes, sha256: blob.sha256, contentType: blob.contentType }
|
||||
: { kind: 'inline', bytes: blob.bytes, sha256: blob.sha256 };
|
||||
},
|
||||
write: async (ctx) => {
|
||||
const args = ctx.args;
|
||||
let bytes: Uint8Array;
|
||||
let resolvedSha: string;
|
||||
if (args.content.kind === 'inline') {
|
||||
bytes = args.content.bytes;
|
||||
resolvedSha = args.content.sha256;
|
||||
} else {
|
||||
bytes = await streamToBytes(args.content.stream);
|
||||
resolvedSha = await args.content.sha256;
|
||||
if (bytes.byteLength !== args.content.size) {
|
||||
throw new Error(`stream produced ${bytes.byteLength} bytes; declared ${args.content.size}`);
|
||||
}
|
||||
}
|
||||
const stored: StoredBlob = {
|
||||
bytes,
|
||||
...(args.contentType !== undefined ? { contentType: args.contentType } : {}),
|
||||
sha256: resolvedSha,
|
||||
};
|
||||
blobs.set(args.path, stored);
|
||||
const entry: FileEntry = {
|
||||
name: args.path.split('/').filter(Boolean).pop() ?? '',
|
||||
kind: 'file',
|
||||
size: bytes.byteLength,
|
||||
mtime: Date.now(),
|
||||
...(args.contentType !== undefined ? { contentType: args.contentType } : {}),
|
||||
metadata: { sha256: resolvedSha },
|
||||
};
|
||||
return { entry };
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await rig.teardown();
|
||||
});
|
||||
|
||||
test('write 1 MiB streamed → server sees streams content + sha256 matches', async () => {
|
||||
const data = new Uint8Array(1024 * 1024);
|
||||
crypto.getRandomValues(data);
|
||||
const expectedSha = bytesToHex(sha256(data));
|
||||
|
||||
const writeResult = await rig.fs.write('/big1mb.bin', data);
|
||||
expect(writeResult.entry.size).toBe(data.byteLength);
|
||||
expect(writeResult.entry.metadata.sha256).toBe(expectedSha);
|
||||
});
|
||||
|
||||
test('read 1 MiB streamed → client gets streams output, drains correctly', async () => {
|
||||
const out = await rig.fs.read('/big1mb.bin');
|
||||
expect(out.kind).toBe('streams');
|
||||
if (out.kind === 'streams') {
|
||||
expect(out.size).toBe(1024 * 1024);
|
||||
const drained = await streamToBytes(out.stream);
|
||||
expect(drained.byteLength).toBe(1024 * 1024);
|
||||
expect(bytesToHex(sha256(drained))).toBe(out.sha256);
|
||||
await out.done();
|
||||
}
|
||||
});
|
||||
|
||||
test('boundary 256 KiB + 1 → streams write + read round-trip', async () => {
|
||||
const data = new Uint8Array(256 * 1024 + 1);
|
||||
for (let i = 0; i < data.length; i++) data[i] = (i * 31) & 0xff;
|
||||
const expectedSha = bytesToHex(sha256(data));
|
||||
|
||||
await rig.fs.write('/boundary-plus-one.bin', data);
|
||||
const out = await rig.fs.read('/boundary-plus-one.bin');
|
||||
expect(out.kind).toBe('streams');
|
||||
if (out.kind === 'streams') {
|
||||
expect(out.sha256).toBe(expectedSha);
|
||||
const drained = await streamToBytes(out.stream);
|
||||
expect(drained.byteLength).toBe(256 * 1024 + 1);
|
||||
expect(Array.from(drained.slice(0, 4))).toEqual(Array.from(data.slice(0, 4)));
|
||||
expect(Array.from(drained.slice(-4))).toEqual(Array.from(data.slice(-4)));
|
||||
await out.done();
|
||||
}
|
||||
});
|
||||
|
||||
test('write streams via { stream, size } wrapper → ok', async () => {
|
||||
const data = new Uint8Array(500 * 1024);
|
||||
crypto.getRandomValues(data);
|
||||
const expectedSha = bytesToHex(sha256(data));
|
||||
const wrapped = { stream: streamFromBytes(data), size: data.byteLength, contentType: 'application/octet-stream' };
|
||||
const result = await rig.fs.write('/wrapped.bin', wrapped);
|
||||
expect(result.entry.size).toBe(data.byteLength);
|
||||
expect(result.entry.metadata.sha256).toBe(expectedSha);
|
||||
expect(result.entry.contentType).toBe('application/octet-stream');
|
||||
});
|
||||
|
||||
test('write streams from Blob > 256 KiB → ok', async () => {
|
||||
const data = new Uint8Array(400 * 1024);
|
||||
crypto.getRandomValues(data);
|
||||
const expectedSha = bytesToHex(sha256(data));
|
||||
const blob = new Blob([data], { type: 'image/png' });
|
||||
const result = await rig.fs.write('/blobby-big.png', blob);
|
||||
expect(result.entry.size).toBe(data.byteLength);
|
||||
expect(result.entry.metadata.sha256).toBe(expectedSha);
|
||||
expect(result.entry.contentType).toBe('image/png');
|
||||
});
|
||||
});
|
||||
100
packages/shade-files/tests/integration/sdk-namespace.test.ts
Normal file
100
packages/shade-files/tests/integration/sdk-namespace.test.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
|
||||
import { createShade, type Shade } from '@shade/sdk';
|
||||
import { createPrekeyServer, MemoryPrekeyStore, PrekeyServerEvents } from '@shade/server';
|
||||
import { SubtleCryptoProvider } from '@shade/crypto-web';
|
||||
import type { FileEntry } from '../../src/index.js';
|
||||
|
||||
const crypto = new SubtleCryptoProvider();
|
||||
|
||||
/**
|
||||
* End-to-end test of `Shade.files` — the high-level SDK entrypoint.
|
||||
* Verifies that `shade.files.serve(...)` and `shade.files.client(peer)`
|
||||
* compose correctly and share a single channel + bridges per Shade.
|
||||
*/
|
||||
describe('Shade.files namespace — end-to-end via SDK getter', () => {
|
||||
let alice: Shade;
|
||||
let bob: Shade;
|
||||
let prekeyServer: { stop(): void };
|
||||
let aliceServer: { stop(): void };
|
||||
let bobServer: { stop(): void };
|
||||
let stopBobFiles: (() => Promise<void>) | null = null;
|
||||
|
||||
beforeAll(async () => {
|
||||
const prekey = createPrekeyServer({
|
||||
crypto,
|
||||
store: new MemoryPrekeyStore(),
|
||||
disableRateLimit: true,
|
||||
events: new PrekeyServerEvents(),
|
||||
});
|
||||
prekeyServer = Bun.serve({ port: 0, fetch: prekey.fetch });
|
||||
const prekeyUrl = `http://localhost:${(prekeyServer as unknown as { port: number }).port}`;
|
||||
|
||||
alice = await createShade({ prekeyServer: prekeyUrl, address: 'alice' });
|
||||
bob = await createShade({ prekeyServer: prekeyUrl, address: 'bob' });
|
||||
|
||||
let aliceUrl = '';
|
||||
let bobUrl = '';
|
||||
alice.configureTransfers({
|
||||
resolveBaseUrl: async (peer) => {
|
||||
if (peer === 'bob') return bobUrl;
|
||||
throw new Error(`unknown peer: ${peer}`);
|
||||
},
|
||||
});
|
||||
bob.configureTransfers({
|
||||
resolveBaseUrl: async (peer) => {
|
||||
if (peer === 'alice') return aliceUrl;
|
||||
throw new Error(`unknown peer: ${peer}`);
|
||||
},
|
||||
});
|
||||
const aliceApp = await alice.transferRoute();
|
||||
const bobApp = await bob.transferRoute();
|
||||
aliceServer = Bun.serve({ port: 0, fetch: aliceApp.fetch });
|
||||
bobServer = Bun.serve({ port: 0, fetch: bobApp.fetch });
|
||||
aliceUrl = `http://localhost:${(aliceServer as unknown as { port: number }).port}`;
|
||||
bobUrl = `http://localhost:${(bobServer as unknown as { port: number }).port}`;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (stopBobFiles !== null) await stopBobFiles();
|
||||
await alice.files.destroy();
|
||||
await bob.files.destroy();
|
||||
await alice.shutdown();
|
||||
await bob.shutdown();
|
||||
aliceServer.stop();
|
||||
bobServer.stop();
|
||||
prekeyServer.stop();
|
||||
});
|
||||
|
||||
test('shade.files getter is memoized', () => {
|
||||
const a = bob.files;
|
||||
const b = bob.files;
|
||||
expect(a).toBe(b);
|
||||
});
|
||||
|
||||
test('serve() + client() round-trip stat through the SDK', async () => {
|
||||
stopBobFiles = await bob.files.serve({
|
||||
stat: async (ctx) => {
|
||||
const e: FileEntry = {
|
||||
name: ctx.path.split('/').filter(Boolean).pop() ?? '',
|
||||
kind: 'file',
|
||||
size: 42,
|
||||
mtime: 1234,
|
||||
metadata: {},
|
||||
};
|
||||
return e;
|
||||
},
|
||||
});
|
||||
|
||||
const fs = await alice.files.client('bob');
|
||||
const result = await fs.stat('/answer.txt');
|
||||
expect(result.name).toBe('answer.txt');
|
||||
expect(result.size).toBe(42);
|
||||
expect(result.mtime).toBe(1234);
|
||||
});
|
||||
|
||||
test('second serve() throws (one handler per Shade)', async () => {
|
||||
await expect(
|
||||
bob.files.serve({ stat: async () => ({ name: 'x', kind: 'file', size: 0, mtime: 0, metadata: {} }) }),
|
||||
).rejects.toThrow(/handler is already registered/);
|
||||
});
|
||||
});
|
||||
199
packages/shade-files/tests/integration/std-ops.test.ts
Normal file
199
packages/shade-files/tests/integration/std-ops.test.ts
Normal file
@@ -0,0 +1,199 @@
|
||||
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
|
||||
import {
|
||||
ConflictError,
|
||||
IdempotencyConflictError,
|
||||
NotFoundError,
|
||||
PermissionDeniedError,
|
||||
type FileEntry,
|
||||
} from '../../src/index.js';
|
||||
import { setupFileRig, type FileTestRig } from './helpers/rig.js';
|
||||
|
||||
describe('Standard ops — list/stat/mkdir/delete/move E2E', () => {
|
||||
let rig: FileTestRig;
|
||||
// Simple in-memory backing store on Bob.
|
||||
const tree = new Map<string, FileEntry>();
|
||||
|
||||
beforeAll(async () => {
|
||||
tree.clear();
|
||||
tree.set('/', { name: '', kind: 'dir', size: 0, mtime: 0, metadata: {} });
|
||||
tree.set('/foo', { name: 'foo', kind: 'dir', size: 0, mtime: 100, metadata: {} });
|
||||
tree.set('/foo/bar.txt', {
|
||||
name: 'bar.txt',
|
||||
kind: 'file',
|
||||
size: 12,
|
||||
mtime: 200,
|
||||
contentType: 'text/plain',
|
||||
metadata: {},
|
||||
});
|
||||
tree.set('/foo/baz.txt', {
|
||||
name: 'baz.txt',
|
||||
kind: 'file',
|
||||
size: 5,
|
||||
mtime: 300,
|
||||
metadata: {},
|
||||
});
|
||||
|
||||
rig = await setupFileRig({
|
||||
list: async (ctx) => {
|
||||
const dir = ctx.path;
|
||||
const entries: FileEntry[] = [];
|
||||
for (const [path, entry] of tree) {
|
||||
if (path === dir) continue;
|
||||
if (!path.startsWith(dir === '/' ? '/' : dir + '/')) continue;
|
||||
const rest = path.slice(dir === '/' ? 1 : dir.length + 1);
|
||||
if (rest.includes('/')) continue;
|
||||
entries.push(entry);
|
||||
}
|
||||
return { entries, hasMore: false };
|
||||
},
|
||||
stat: async (ctx) => {
|
||||
const e = tree.get(ctx.path);
|
||||
if (e === undefined) throw new NotFoundError(`${ctx.path} not found`);
|
||||
return e;
|
||||
},
|
||||
mkdir: async (ctx) => {
|
||||
if (tree.has(ctx.path)) throw new ConflictError(`${ctx.path} exists`);
|
||||
const name = ctx.path.split('/').filter(Boolean).pop() ?? '';
|
||||
const entry: FileEntry = {
|
||||
name,
|
||||
kind: 'dir',
|
||||
size: 0,
|
||||
mtime: Date.now(),
|
||||
metadata: {},
|
||||
};
|
||||
tree.set(ctx.path, entry);
|
||||
return { entry };
|
||||
},
|
||||
delete: async (ctx) => {
|
||||
if (!tree.has(ctx.path)) throw new NotFoundError(ctx.path);
|
||||
let count = 0;
|
||||
if (ctx.args.recursive) {
|
||||
for (const path of [...tree.keys()]) {
|
||||
if (path === ctx.path || path.startsWith(ctx.path + '/')) {
|
||||
tree.delete(path);
|
||||
count++;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
tree.delete(ctx.path);
|
||||
count = 1;
|
||||
}
|
||||
return { deletedCount: count };
|
||||
},
|
||||
move: async (ctx) => {
|
||||
const src = ctx.args.src;
|
||||
const dst = ctx.args.dst;
|
||||
const e = tree.get(src);
|
||||
if (e === undefined) throw new NotFoundError(src);
|
||||
if (tree.has(dst) && !ctx.args.overwrite) {
|
||||
throw new ConflictError(`${dst} exists`);
|
||||
}
|
||||
const newName = dst.split('/').filter(Boolean).pop() ?? e.name;
|
||||
tree.delete(src);
|
||||
tree.set(dst, { ...e, name: newName });
|
||||
return { entry: tree.get(dst)! };
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await rig.teardown();
|
||||
});
|
||||
|
||||
test('list /', async () => {
|
||||
const page = await rig.fs.list('/');
|
||||
expect(page.hasMore).toBe(false);
|
||||
const names = page.entries.map((e) => e.name).sort();
|
||||
expect(names).toEqual(['foo']);
|
||||
});
|
||||
|
||||
test('list /foo', async () => {
|
||||
const page = await rig.fs.list('/foo');
|
||||
const names = page.entries.map((e) => e.name).sort();
|
||||
expect(names).toEqual(['bar.txt', 'baz.txt']);
|
||||
});
|
||||
|
||||
test('stat existing file', async () => {
|
||||
const e = await rig.fs.stat('/foo/bar.txt');
|
||||
expect(e.size).toBe(12);
|
||||
expect(e.contentType).toBe('text/plain');
|
||||
});
|
||||
|
||||
test('stat missing → NotFoundError', async () => {
|
||||
let caught: unknown = null;
|
||||
try {
|
||||
await rig.fs.stat('/no/such/file');
|
||||
} catch (err) {
|
||||
caught = err;
|
||||
}
|
||||
expect(caught instanceof NotFoundError).toBe(true);
|
||||
expect((caught as NotFoundError).payload.code).toBe('NOT_FOUND');
|
||||
});
|
||||
|
||||
test('mkdir creates a new directory', async () => {
|
||||
const result = await rig.fs.mkdir('/created');
|
||||
expect(result.entry.kind).toBe('dir');
|
||||
expect(tree.has('/created')).toBe(true);
|
||||
});
|
||||
|
||||
test('mkdir on existing path → ConflictError', async () => {
|
||||
await expect(rig.fs.mkdir('/foo')).rejects.toBeInstanceOf(ConflictError);
|
||||
});
|
||||
|
||||
test('delete file', async () => {
|
||||
tree.set('/temp.txt', { name: 'temp.txt', kind: 'file', size: 0, mtime: 0, metadata: {} });
|
||||
const r = await rig.fs.delete('/temp.txt');
|
||||
expect(r.deletedCount).toBe(1);
|
||||
expect(tree.has('/temp.txt')).toBe(false);
|
||||
});
|
||||
|
||||
test('delete missing → NotFoundError', async () => {
|
||||
await expect(rig.fs.delete('/doesnt-exist')).rejects.toBeInstanceOf(NotFoundError);
|
||||
});
|
||||
|
||||
test('move file', async () => {
|
||||
tree.set('/x.txt', { name: 'x.txt', kind: 'file', size: 0, mtime: 0, metadata: {} });
|
||||
const r = await rig.fs.move('/x.txt', '/y.txt');
|
||||
expect(r.entry.name).toBe('y.txt');
|
||||
expect(tree.has('/x.txt')).toBe(false);
|
||||
expect(tree.has('/y.txt')).toBe(true);
|
||||
});
|
||||
|
||||
test('move overwrite=false → ConflictError on collision', async () => {
|
||||
tree.set('/a.txt', { name: 'a.txt', kind: 'file', size: 0, mtime: 0, metadata: {} });
|
||||
tree.set('/b.txt', { name: 'b.txt', kind: 'file', size: 0, mtime: 0, metadata: {} });
|
||||
await expect(rig.fs.move('/a.txt', '/b.txt')).rejects.toBeInstanceOf(ConflictError);
|
||||
});
|
||||
|
||||
test('idempotent retry: same key + same args → cached response', async () => {
|
||||
tree.set('/idem.txt', { name: 'idem.txt', kind: 'file', size: 0, mtime: 0, metadata: {} });
|
||||
const key = 'IdemKey1AaBbCcDdEeFfGg'; // 22 chars
|
||||
const r1 = await rig.fs.delete('/idem.txt', { idempotencyKey: key });
|
||||
expect(r1.deletedCount).toBe(1);
|
||||
// 2nd call with same key → same result without throwing NotFound
|
||||
const r2 = await rig.fs.delete('/idem.txt', { idempotencyKey: key });
|
||||
expect(r2.deletedCount).toBe(1);
|
||||
});
|
||||
|
||||
test('idempotency conflict: same key + different args', async () => {
|
||||
tree.set('/c1.txt', { name: 'c1.txt', kind: 'file', size: 0, mtime: 0, metadata: {} });
|
||||
tree.set('/c2.txt', { name: 'c2.txt', kind: 'file', size: 0, mtime: 0, metadata: {} });
|
||||
const key = 'ConflictKeyAaBbCcDdEeF'; // 22 chars
|
||||
await rig.fs.delete('/c1.txt', { idempotencyKey: key });
|
||||
await expect(
|
||||
rig.fs.delete('/c2.txt', { idempotencyKey: key }),
|
||||
).rejects.toBeInstanceOf(IdempotencyConflictError);
|
||||
});
|
||||
|
||||
test('path validation: traversal rejected by server', async () => {
|
||||
await expect(rig.fs.list('/foo')).resolves.toBeDefined();
|
||||
// Client-side schema rejects traversal too — bypass via direct bad path:
|
||||
await expect(rig.fs.stat('/foo/../etc')).rejects.toThrow();
|
||||
});
|
||||
|
||||
// We don't test PermissionDenied here (no beforeOp gate configured),
|
||||
// but the type is referenced to ensure it's exported properly.
|
||||
test('PermissionDeniedError is exported', () => {
|
||||
expect(PermissionDeniedError).toBeDefined();
|
||||
});
|
||||
});
|
||||
123
packages/shade-files/tests/integration/thumbnail.test.ts
Normal file
123
packages/shade-files/tests/integration/thumbnail.test.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
|
||||
import { sha256 } from '@noble/hashes/sha2.js';
|
||||
import { ConflictError, NotFoundError } from '../../src/index.js';
|
||||
import { setupFileRig, type FileTestRig } from './helpers/rig.js';
|
||||
|
||||
function bytesToHex(b: Uint8Array): string {
|
||||
return Array.from(b, (x) => x.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
|
||||
// Minimal valid PNG: 8-byte signature + IHDR + zero-byte IDAT + IEND. We
|
||||
// use realistic magic bytes so the format-hardening check accepts it.
|
||||
function tinyPng(): Uint8Array {
|
||||
const sig = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
||||
const filler = new Uint8Array(20);
|
||||
const out = new Uint8Array(sig.length + filler.length);
|
||||
out.set(sig, 0);
|
||||
out.set(filler, sig.length);
|
||||
return out;
|
||||
}
|
||||
|
||||
function tinyJpeg(): Uint8Array {
|
||||
const head = new Uint8Array([0xff, 0xd8, 0xff, 0xe0]);
|
||||
const filler = new Uint8Array(12);
|
||||
const out = new Uint8Array(head.length + filler.length);
|
||||
out.set(head, 0);
|
||||
out.set(filler, head.length);
|
||||
return out;
|
||||
}
|
||||
|
||||
function tinyWebp(): Uint8Array {
|
||||
// 'RIFF' + sizeLE + 'WEBP' + 'VP8 ' (or 'VP8L' / 'VP8X')
|
||||
const out = new Uint8Array(20);
|
||||
out.set([0x52, 0x49, 0x46, 0x46], 0); // 'RIFF'
|
||||
out.set([0x0c, 0x00, 0x00, 0x00], 4); // size little-endian (12 bytes follow)
|
||||
out.set([0x57, 0x45, 0x42, 0x50], 8); // 'WEBP'
|
||||
out.set([0x56, 0x50, 0x38, 0x20], 12); // 'VP8 '
|
||||
return out;
|
||||
}
|
||||
|
||||
describe('Content I/O — getThumbnail E2E with format hardening', () => {
|
||||
let rig: FileTestRig;
|
||||
// Map: path → (size → { bytes, format })
|
||||
const thumbnails = new Map<string, Map<number, { bytes: Uint8Array; format: 'png' | 'webp' | 'jpeg' }>>();
|
||||
|
||||
beforeAll(async () => {
|
||||
thumbnails.clear();
|
||||
thumbnails.set(
|
||||
'/photo.png',
|
||||
new Map([
|
||||
[64, { bytes: tinyPng(), format: 'png' }],
|
||||
[128, { bytes: tinyPng(), format: 'png' }],
|
||||
]),
|
||||
);
|
||||
thumbnails.set(
|
||||
'/holiday.jpg',
|
||||
new Map([[256, { bytes: tinyJpeg(), format: 'jpeg' }]]),
|
||||
);
|
||||
thumbnails.set(
|
||||
'/icon.webp',
|
||||
new Map([[64, { bytes: tinyWebp(), format: 'webp' }]]),
|
||||
);
|
||||
// Mismatched format: server returns PNG bytes but claims JPEG.
|
||||
thumbnails.set('/lying.png', new Map([[64, { bytes: tinyPng(), format: 'jpeg' }]]));
|
||||
|
||||
rig = await setupFileRig({
|
||||
getThumbnail: async (ctx) => {
|
||||
const sizes = thumbnails.get(ctx.path);
|
||||
if (sizes === undefined) throw new NotFoundError(ctx.path);
|
||||
const entry = sizes.get(ctx.args.size);
|
||||
if (entry === undefined) throw new NotFoundError(`${ctx.path}@${ctx.args.size}`);
|
||||
return {
|
||||
bytes: entry.bytes,
|
||||
format: entry.format,
|
||||
width: ctx.args.size,
|
||||
height: ctx.args.size,
|
||||
};
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await rig.teardown();
|
||||
});
|
||||
|
||||
test('PNG thumbnail round-trips with computed sha256', async () => {
|
||||
const result = await rig.fs.getThumbnail('/photo.png', 64);
|
||||
expect(result.format).toBe('png');
|
||||
expect(result.width).toBe(64);
|
||||
expect(result.height).toBe(64);
|
||||
expect(result.sha256).toBe(bytesToHex(sha256(result.bytes)));
|
||||
// Verify magic bytes survived
|
||||
expect(Array.from(result.bytes.slice(0, 8))).toEqual([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
||||
});
|
||||
|
||||
test('JPEG thumbnail round-trips', async () => {
|
||||
const result = await rig.fs.getThumbnail('/holiday.jpg', 256);
|
||||
expect(result.format).toBe('jpeg');
|
||||
expect(result.width).toBe(256);
|
||||
expect(Array.from(result.bytes.slice(0, 3))).toEqual([0xff, 0xd8, 0xff]);
|
||||
});
|
||||
|
||||
test('WebP thumbnail round-trips', async () => {
|
||||
const result = await rig.fs.getThumbnail('/icon.webp', 64);
|
||||
expect(result.format).toBe('webp');
|
||||
expect(Array.from(result.bytes.slice(0, 4))).toEqual([0x52, 0x49, 0x46, 0x46]);
|
||||
expect(Array.from(result.bytes.slice(8, 12))).toEqual([0x57, 0x45, 0x42, 0x50]);
|
||||
});
|
||||
|
||||
test('PNG bytes claimed as JPEG → format-hardening rejects', async () => {
|
||||
await expect(rig.fs.getThumbnail('/lying.png', 64)).rejects.toBeInstanceOf(ConflictError);
|
||||
});
|
||||
|
||||
test('non-existent path → NotFoundError', async () => {
|
||||
await expect(rig.fs.getThumbnail('/missing.png', 64)).rejects.toBeInstanceOf(NotFoundError);
|
||||
});
|
||||
|
||||
test('different sizes return different thumbnails', async () => {
|
||||
const small = await rig.fs.getThumbnail('/photo.png', 64);
|
||||
const big = await rig.fs.getThumbnail('/photo.png', 128);
|
||||
expect(small.width).toBe(64);
|
||||
expect(big.width).toBe(128);
|
||||
});
|
||||
});
|
||||
238
packages/shade-files/tests/integration/upload-directory.test.ts
Normal file
238
packages/shade-files/tests/integration/upload-directory.test.ts
Normal file
@@ -0,0 +1,238 @@
|
||||
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
|
||||
import { sha256 } from '@noble/hashes/sha2.js';
|
||||
import {
|
||||
ConflictError,
|
||||
NotFoundError,
|
||||
uploadDirectory,
|
||||
createMemoryDirectory,
|
||||
type FileEntry,
|
||||
type BulkTransferEvent,
|
||||
} from '../../src/index.js';
|
||||
import { setupFileRig, type FileTestRig } from './helpers/rig.js';
|
||||
|
||||
interface StoredFile {
|
||||
bytes: Uint8Array;
|
||||
contentType?: string;
|
||||
sha256: string;
|
||||
}
|
||||
|
||||
function bytesToHex(b: Uint8Array): string {
|
||||
return Array.from(b, (x) => x.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
|
||||
async function streamToBytes(s: ReadableStream<Uint8Array>): Promise<Uint8Array> {
|
||||
const reader = s.getReader();
|
||||
const parts: Uint8Array[] = [];
|
||||
let total = 0;
|
||||
while (true) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) break;
|
||||
if (value === undefined) continue;
|
||||
parts.push(value);
|
||||
total += value.byteLength;
|
||||
}
|
||||
reader.releaseLock();
|
||||
const out = new Uint8Array(total);
|
||||
let offset = 0;
|
||||
for (const p of parts) {
|
||||
out.set(p, offset);
|
||||
offset += p.byteLength;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
describe('uploadDirectory — bulk upload to remote', () => {
|
||||
let rig: FileTestRig;
|
||||
const blobs = new Map<string, StoredFile>();
|
||||
const dirs = new Set<string>(['/']);
|
||||
|
||||
beforeAll(async () => {
|
||||
blobs.clear();
|
||||
dirs.clear();
|
||||
dirs.add('/');
|
||||
|
||||
rig = await setupFileRig({
|
||||
mkdir: async (ctx) => {
|
||||
const path = ctx.path;
|
||||
if (dirs.has(path)) {
|
||||
if (!ctx.args.recursive) throw new ConflictError('exists');
|
||||
// Idempotent for recursive
|
||||
}
|
||||
// Recursive: add ancestors
|
||||
if (ctx.args.recursive) {
|
||||
const segments = path.split('/').filter(Boolean);
|
||||
let acc = '';
|
||||
for (const seg of segments) {
|
||||
acc += '/' + seg;
|
||||
dirs.add(acc);
|
||||
}
|
||||
} else {
|
||||
dirs.add(path);
|
||||
}
|
||||
return {
|
||||
entry: {
|
||||
name: path.split('/').filter(Boolean).pop() ?? '',
|
||||
kind: 'dir',
|
||||
size: 0,
|
||||
mtime: Date.now(),
|
||||
metadata: {},
|
||||
},
|
||||
};
|
||||
},
|
||||
write: async (ctx) => {
|
||||
const args = ctx.args;
|
||||
let bytes: Uint8Array;
|
||||
let storedSha: string;
|
||||
if (args.content.kind === 'inline') {
|
||||
bytes = args.content.bytes;
|
||||
storedSha = args.content.sha256;
|
||||
} else {
|
||||
bytes = await streamToBytes(args.content.stream);
|
||||
storedSha = await args.content.sha256;
|
||||
}
|
||||
if (blobs.has(args.path) && !args.overwrite) {
|
||||
throw new ConflictError(`${args.path} exists`);
|
||||
}
|
||||
blobs.set(args.path, {
|
||||
bytes,
|
||||
...(args.contentType !== undefined ? { contentType: args.contentType } : {}),
|
||||
sha256: storedSha,
|
||||
});
|
||||
const entry: FileEntry = {
|
||||
name: args.path.split('/').filter(Boolean).pop() ?? '',
|
||||
kind: 'file',
|
||||
size: bytes.byteLength,
|
||||
mtime: Date.now(),
|
||||
...(args.contentType !== undefined ? { contentType: args.contentType } : {}),
|
||||
metadata: { sha256: storedSha },
|
||||
};
|
||||
return { entry };
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await rig.teardown();
|
||||
});
|
||||
|
||||
test('uploads a small tree with mixed inline + streams', async () => {
|
||||
const local = createMemoryDirectory('local');
|
||||
const sub = local.addDir('sub');
|
||||
const small = new Uint8Array(100); for (let i = 0; i < small.length; i++) small[i] = i & 0xff;
|
||||
const big = new Uint8Array(400 * 1024); crypto.getRandomValues(big);
|
||||
local.addFile('hello.txt', new TextEncoder().encode('hello world'), 'text/plain');
|
||||
local.addFile('small.bin', small);
|
||||
sub.addFile('big.bin', big, 'application/octet-stream');
|
||||
|
||||
const handle = uploadDirectory(rig.fs, local, '/upload-target');
|
||||
|
||||
const events: BulkTransferEvent[] = [];
|
||||
(async () => {
|
||||
for await (const ev of handle.events) events.push(ev);
|
||||
})().catch(() => undefined);
|
||||
|
||||
const result = await handle.done();
|
||||
expect(result.filesDone).toBe(3);
|
||||
expect(result.bytesDone).toBe(11 + 100 + 400 * 1024);
|
||||
|
||||
// Verify remote tree
|
||||
expect(blobs.has('/upload-target/hello.txt')).toBe(true);
|
||||
expect(blobs.has('/upload-target/small.bin')).toBe(true);
|
||||
expect(blobs.has('/upload-target/sub/big.bin')).toBe(true);
|
||||
expect(dirs.has('/upload-target/sub')).toBe(true);
|
||||
|
||||
// Sha256 paritet for streamed file
|
||||
expect(blobs.get('/upload-target/sub/big.bin')!.sha256).toBe(bytesToHex(sha256(big)));
|
||||
expect(blobs.get('/upload-target/hello.txt')!.contentType).toBe('text/plain');
|
||||
|
||||
// Wait a tick for events to flush
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
const planEvent = events.find((e) => e.type === 'plan');
|
||||
expect(planEvent).toBeDefined();
|
||||
if (planEvent && planEvent.type === 'plan') {
|
||||
expect(planEvent.totalFiles).toBe(3);
|
||||
expect(planEvent.totalBytes).toBe(11 + 100 + 400 * 1024);
|
||||
}
|
||||
const completes = events.filter((e) => e.type === 'complete');
|
||||
expect(completes.length).toBe(1);
|
||||
});
|
||||
|
||||
test('aggregated progress is monotonically non-decreasing', async () => {
|
||||
const local = createMemoryDirectory('local');
|
||||
for (let i = 0; i < 10; i++) {
|
||||
local.addFile(`f${i}.bin`, new Uint8Array(50));
|
||||
}
|
||||
const handle = uploadDirectory(rig.fs, local, '/progress');
|
||||
const progresses: { filesDone: number; bytesDone: number }[] = [];
|
||||
(async () => {
|
||||
for await (const ev of handle.events) {
|
||||
if (ev.type === 'progress') {
|
||||
progresses.push({ filesDone: ev.filesDone, bytesDone: ev.bytesDone });
|
||||
}
|
||||
}
|
||||
})().catch(() => undefined);
|
||||
await handle.done();
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
for (let i = 1; i < progresses.length; i++) {
|
||||
expect(progresses[i]!.filesDone).toBeGreaterThanOrEqual(progresses[i - 1]!.filesDone);
|
||||
expect(progresses[i]!.bytesDone).toBeGreaterThanOrEqual(progresses[i - 1]!.bytesDone);
|
||||
}
|
||||
expect(progresses[progresses.length - 1]!.filesDone).toBe(10);
|
||||
});
|
||||
|
||||
test('fail-fast: first error aborts the bulk', async () => {
|
||||
const local = createMemoryDirectory('local');
|
||||
for (let i = 0; i < 5; i++) {
|
||||
local.addFile(`x${i}.bin`, new Uint8Array(10));
|
||||
}
|
||||
// Pre-create a conflicting file at /conflict/x0.bin so the first write fails.
|
||||
blobs.set('/conflict/x0.bin', { bytes: new Uint8Array(0), sha256: 'x' });
|
||||
|
||||
const handle = uploadDirectory(rig.fs, local, '/conflict', { concurrency: 1 });
|
||||
await expect(handle.done()).rejects.toThrow();
|
||||
});
|
||||
|
||||
test('continueOnError: completes despite per-file errors', async () => {
|
||||
const local = createMemoryDirectory('local');
|
||||
for (let i = 0; i < 5; i++) {
|
||||
local.addFile(`y${i}.bin`, new Uint8Array(10));
|
||||
}
|
||||
blobs.set('/cont/y2.bin', { bytes: new Uint8Array(0), sha256: 'x' });
|
||||
|
||||
const handle = uploadDirectory(rig.fs, local, '/cont', {
|
||||
concurrency: 1,
|
||||
continueOnError: true,
|
||||
});
|
||||
const errors: string[] = [];
|
||||
(async () => {
|
||||
for await (const ev of handle.events) {
|
||||
if (ev.type === 'file-error') errors.push(ev.path);
|
||||
}
|
||||
})().catch(() => undefined);
|
||||
const result = await handle.done();
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
expect(errors).toEqual(['y2.bin']);
|
||||
expect(result.filesDone).toBe(4);
|
||||
});
|
||||
|
||||
test('concurrency cap respected', async () => {
|
||||
const local = createMemoryDirectory('local');
|
||||
for (let i = 0; i < 30; i++) {
|
||||
local.addFile(`z${i}.bin`, new Uint8Array(10));
|
||||
}
|
||||
// Concurrency above MAX (16) should be clamped.
|
||||
const handle = uploadDirectory(rig.fs, local, '/cap', { concurrency: 100 });
|
||||
const result = await handle.done();
|
||||
expect(result.filesDone).toBe(30);
|
||||
});
|
||||
|
||||
test('aborts mid-flight via handle.abort()', async () => {
|
||||
const local = createMemoryDirectory('local');
|
||||
for (let i = 0; i < 50; i++) {
|
||||
local.addFile(`q${i}.bin`, new Uint8Array(50 * 1024)); // 50 KiB each
|
||||
}
|
||||
const handle = uploadDirectory(rig.fs, local, '/abort');
|
||||
setTimeout(() => void handle.abort('test-cancel'), 20);
|
||||
await expect(handle.done()).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
126
packages/shade-files/tests/integration/walk.test.ts
Normal file
126
packages/shade-files/tests/integration/walk.test.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
|
||||
import { walk, type FileEntry, NotFoundError } from '../../src/index.js';
|
||||
import { setupFileRig, type FileTestRig } from './helpers/rig.js';
|
||||
|
||||
describe('walk — async-iterable depth-first directory traversal', () => {
|
||||
let rig: FileTestRig;
|
||||
// In-memory tree:
|
||||
// /
|
||||
// ├── a/
|
||||
// │ ├── 1.txt
|
||||
// │ └── b/
|
||||
// │ └── 2.txt
|
||||
// ├── c/
|
||||
// │ └── 3.txt
|
||||
// └── 4.txt
|
||||
const tree = new Map<string, FileEntry>();
|
||||
|
||||
function setEntry(path: string, kind: 'file' | 'dir'): void {
|
||||
const name = path === '/' ? '' : path.split('/').filter(Boolean).pop() ?? '';
|
||||
tree.set(path, { name, kind, size: kind === 'file' ? 10 : 0, mtime: 0, metadata: {} });
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
tree.clear();
|
||||
setEntry('/', 'dir');
|
||||
setEntry('/a', 'dir');
|
||||
setEntry('/a/1.txt', 'file');
|
||||
setEntry('/a/b', 'dir');
|
||||
setEntry('/a/b/2.txt', 'file');
|
||||
setEntry('/c', 'dir');
|
||||
setEntry('/c/3.txt', 'file');
|
||||
setEntry('/4.txt', 'file');
|
||||
|
||||
rig = await setupFileRig({
|
||||
list: async (ctx) => {
|
||||
if (!tree.has(ctx.path)) throw new NotFoundError(ctx.path);
|
||||
const entries: FileEntry[] = [];
|
||||
for (const [path, entry] of tree) {
|
||||
if (path === ctx.path) continue;
|
||||
if (!path.startsWith(ctx.path === '/' ? '/' : ctx.path + '/')) continue;
|
||||
const rest = path.slice(ctx.path === '/' ? 1 : ctx.path.length + 1);
|
||||
if (rest.includes('/')) continue;
|
||||
entries.push(entry);
|
||||
}
|
||||
return { entries, hasMore: false };
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await rig.teardown();
|
||||
});
|
||||
|
||||
test('walks the entire tree depth-first', async () => {
|
||||
const items: { path: string; depth: number }[] = [];
|
||||
for await (const item of walk(rig.fs, '/')) {
|
||||
items.push({ path: item.relativePath, depth: item.depth });
|
||||
}
|
||||
// Depth-first order: visit a, then descend into a/* (1.txt + a/b → a/b/2.txt), then c, c/3.txt, then 4.txt
|
||||
expect(items.map((i) => i.path)).toEqual([
|
||||
'a',
|
||||
'a/1.txt',
|
||||
'a/b',
|
||||
'a/b/2.txt',
|
||||
'c',
|
||||
'c/3.txt',
|
||||
'4.txt',
|
||||
]);
|
||||
// Depth values
|
||||
expect(items.find((i) => i.path === 'a')?.depth).toBe(1);
|
||||
expect(items.find((i) => i.path === 'a/1.txt')?.depth).toBe(2);
|
||||
expect(items.find((i) => i.path === 'a/b/2.txt')?.depth).toBe(3);
|
||||
});
|
||||
|
||||
test('respects maxDepth', async () => {
|
||||
const items: string[] = [];
|
||||
for await (const item of walk(rig.fs, '/', { maxDepth: 1 })) {
|
||||
items.push(item.relativePath);
|
||||
}
|
||||
// Only direct children — no descent into /a or /c.
|
||||
expect(items.sort()).toEqual(['4.txt', 'a', 'c']);
|
||||
});
|
||||
|
||||
test('breaks cleanly when consumer stops iterating', async () => {
|
||||
const items: string[] = [];
|
||||
for await (const item of walk(rig.fs, '/')) {
|
||||
items.push(item.relativePath);
|
||||
if (items.length === 2) break;
|
||||
}
|
||||
expect(items).toEqual(['a', 'a/1.txt']);
|
||||
});
|
||||
|
||||
test('filter callback skips entries (and excludes their subtree)', async () => {
|
||||
const items: string[] = [];
|
||||
for await (const item of walk(rig.fs, '/', {
|
||||
filter: (entry, _rel) => entry.name !== 'a',
|
||||
})) {
|
||||
items.push(item.relativePath);
|
||||
}
|
||||
expect(items.sort()).toEqual(['4.txt', 'c', 'c/3.txt']);
|
||||
});
|
||||
|
||||
test('aborts via signal mid-walk', async () => {
|
||||
const ctrl = new AbortController();
|
||||
const items: string[] = [];
|
||||
setTimeout(() => ctrl.abort(), 5);
|
||||
let threw = false;
|
||||
try {
|
||||
for await (const item of walk(rig.fs, '/', { signal: ctrl.signal })) {
|
||||
items.push(item.relativePath);
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
}
|
||||
} catch {
|
||||
threw = true;
|
||||
}
|
||||
expect(threw).toBe(true);
|
||||
});
|
||||
|
||||
test('walking non-existent path → throws on first list', async () => {
|
||||
await expect(async () => {
|
||||
for await (const _item of walk(rig.fs, '/nope')) {
|
||||
/* unreachable */
|
||||
}
|
||||
}).toThrow();
|
||||
});
|
||||
});
|
||||
86
packages/shade-files/tests/security/fingerprint-gate.test.ts
Normal file
86
packages/shade-files/tests/security/fingerprint-gate.test.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
|
||||
import { setupFileRig, type FileTestRig } from '../integration/helpers/rig.js';
|
||||
import { FingerprintRequiredError, NotFoundError, type FileEntry } from '../../src/index.js';
|
||||
|
||||
describe('Fingerprint gate', () => {
|
||||
let rig: FileTestRig;
|
||||
const verifiedSet = new Set<string>();
|
||||
|
||||
beforeAll(async () => {
|
||||
verifiedSet.clear();
|
||||
rig = await setupFileRig({
|
||||
requireFingerprintVerifiedFor: (ctx) => {
|
||||
// Mutations require verification; reads are optional.
|
||||
const op = ctx.op;
|
||||
if (op === 'mkdir' || op === 'delete' || op === 'move' || op === 'write') return 'required';
|
||||
if (op === 'list') return 'optional';
|
||||
return 'optional';
|
||||
},
|
||||
isFingerprintVerified: (sender) => verifiedSet.has(sender),
|
||||
stat: async (ctx) => {
|
||||
const e: FileEntry = {
|
||||
name: ctx.path.split('/').filter(Boolean).pop() ?? '',
|
||||
kind: 'file', size: 1, mtime: 0, metadata: {},
|
||||
};
|
||||
return e;
|
||||
},
|
||||
mkdir: async (ctx) => {
|
||||
const e: FileEntry = {
|
||||
name: ctx.path.split('/').filter(Boolean).pop() ?? '',
|
||||
kind: 'dir', size: 0, mtime: 0, metadata: {},
|
||||
};
|
||||
return { entry: e };
|
||||
},
|
||||
delete: async () => ({ deletedCount: 1 }),
|
||||
list: async () => ({ entries: [], hasMore: false }),
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await rig.teardown();
|
||||
});
|
||||
|
||||
test('mutation without verification → FingerprintRequiredError', async () => {
|
||||
verifiedSet.delete('alice');
|
||||
await expect(rig.fs.mkdir('/locked')).rejects.toBeInstanceOf(FingerprintRequiredError);
|
||||
await expect(rig.fs.delete('/locked')).rejects.toBeInstanceOf(FingerprintRequiredError);
|
||||
});
|
||||
|
||||
test('mutation after marking peer verified → succeeds', async () => {
|
||||
verifiedSet.add('alice');
|
||||
const result = await rig.fs.mkdir('/verified-dir');
|
||||
expect(result.entry.name).toBe('verified-dir');
|
||||
});
|
||||
|
||||
test('optional ops (stat, list) work without verification', async () => {
|
||||
verifiedSet.delete('alice');
|
||||
const stat = await rig.fs.stat('/anything');
|
||||
expect(stat.name).toBe('anything');
|
||||
const list = await rig.fs.list('/anything');
|
||||
expect(list.entries).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Fingerprint gate with reject policy', () => {
|
||||
let rig: FileTestRig;
|
||||
beforeAll(async () => {
|
||||
rig = await setupFileRig({
|
||||
requireFingerprintVerifiedFor: () => 'reject',
|
||||
stat: async (ctx) => {
|
||||
const e: FileEntry = {
|
||||
name: 'x',
|
||||
kind: 'file',
|
||||
size: 1,
|
||||
mtime: 0,
|
||||
metadata: {},
|
||||
};
|
||||
return e;
|
||||
},
|
||||
});
|
||||
});
|
||||
afterAll(async () => { await rig.teardown(); });
|
||||
|
||||
test('all ops rejected outright', async () => {
|
||||
await expect(rig.fs.stat('/x')).rejects.toBeInstanceOf(FingerprintRequiredError);
|
||||
});
|
||||
});
|
||||
55
packages/shade-files/tests/security/quota.test.ts
Normal file
55
packages/shade-files/tests/security/quota.test.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
|
||||
import { setupFileRig, type FileTestRig } from '../integration/helpers/rig.js';
|
||||
import { FsRateLimitError, QuotaExceededError, type FileEntry } from '../../src/index.js';
|
||||
|
||||
describe('Op rate limit', () => {
|
||||
let rig: FileTestRig;
|
||||
let listCount = 0;
|
||||
beforeAll(async () => {
|
||||
listCount = 0;
|
||||
rig = await setupFileRig({
|
||||
rateLimits: { maxOpsPerMinutePerSender: 5 },
|
||||
list: async () => {
|
||||
listCount++;
|
||||
return { entries: [], hasMore: false };
|
||||
},
|
||||
});
|
||||
});
|
||||
afterAll(async () => { await rig.teardown(); });
|
||||
|
||||
test('op rate-limit kicks in after capacity', async () => {
|
||||
listCount = 0;
|
||||
for (let i = 0; i < 5; i++) await rig.fs.list('/');
|
||||
expect(listCount).toBe(5);
|
||||
await expect(rig.fs.list('/')).rejects.toBeInstanceOf(FsRateLimitError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Byte quota', () => {
|
||||
let rig: FileTestRig;
|
||||
beforeAll(async () => {
|
||||
rig = await setupFileRig({
|
||||
rateLimits: {
|
||||
// Plenty of ops, but tight byte cap for the quota test.
|
||||
maxOpsPerMinutePerSender: 100,
|
||||
maxBytesPerHourPerSender: 1024,
|
||||
},
|
||||
write: async (ctx) => {
|
||||
const e: FileEntry = {
|
||||
name: ctx.args.path.split('/').filter(Boolean).pop() ?? '',
|
||||
kind: 'file',
|
||||
size: ctx.args.content.kind === 'inline' ? ctx.args.content.bytes.byteLength : ctx.args.content.size,
|
||||
mtime: 0,
|
||||
metadata: {},
|
||||
};
|
||||
return { entry: e };
|
||||
},
|
||||
});
|
||||
});
|
||||
afterAll(async () => { await rig.teardown(); });
|
||||
|
||||
test('write 2 KiB inline → exceeds 1 KiB/hour cap', async () => {
|
||||
const big = new Uint8Array(2048);
|
||||
await expect(rig.fs.write('/big.bin', big)).rejects.toBeInstanceOf(QuotaExceededError);
|
||||
});
|
||||
});
|
||||
119
packages/shade-files/tests/security/replay.test.ts
Normal file
119
packages/shade-files/tests/security/replay.test.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
|
||||
import { setupFileRig, type FileTestRig } from '../integration/helpers/rig.js';
|
||||
import { ConflictError, NotFoundError, type FileEntry } from '../../src/index.js';
|
||||
|
||||
/**
|
||||
* Replay-window: the dispatcher rejects requests where `signedAt` is more
|
||||
* than ±5 min from the server clock. Plus: idempotent retries on the same
|
||||
* mutation key produce a single side-effect even with stale signedAt.
|
||||
*/
|
||||
|
||||
describe('Replay window + idempotent retry', () => {
|
||||
let rig: FileTestRig;
|
||||
let writeCount = 0;
|
||||
const blobs = new Map<string, Uint8Array>();
|
||||
|
||||
beforeAll(async () => {
|
||||
rig = await setupFileRig({
|
||||
mkdir: async (ctx) => {
|
||||
if (blobs.has(ctx.path)) throw new ConflictError('exists');
|
||||
blobs.set(ctx.path, new Uint8Array(0));
|
||||
const e: FileEntry = {
|
||||
name: ctx.path.split('/').filter(Boolean).pop() ?? '',
|
||||
kind: 'dir', size: 0, mtime: Date.now(), metadata: {},
|
||||
};
|
||||
return { entry: e };
|
||||
},
|
||||
write: async (ctx) => {
|
||||
writeCount++;
|
||||
if (ctx.args.content.kind !== 'inline') throw new Error('inline expected');
|
||||
blobs.set(ctx.args.path, ctx.args.content.bytes);
|
||||
const e: FileEntry = {
|
||||
name: ctx.args.path.split('/').filter(Boolean).pop() ?? '',
|
||||
kind: 'file',
|
||||
size: ctx.args.content.bytes.byteLength,
|
||||
mtime: Date.now(),
|
||||
metadata: { sha256: ctx.args.content.sha256 },
|
||||
};
|
||||
return { entry: e };
|
||||
},
|
||||
stat: async (ctx) => {
|
||||
if (!blobs.has(ctx.path)) throw new NotFoundError(ctx.path);
|
||||
return {
|
||||
name: ctx.path.split('/').filter(Boolean).pop() ?? '',
|
||||
kind: 'file', size: 0, mtime: 0, metadata: {},
|
||||
};
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await rig.teardown();
|
||||
});
|
||||
|
||||
test('idempotent retry: same key + same args → single side-effect', async () => {
|
||||
writeCount = 0;
|
||||
// Idempotency keys are exactly 22 chars, base64url alphabet.
|
||||
const key = 'replay_key_1234567890A';
|
||||
const data = new Uint8Array([1, 2, 3]);
|
||||
const r1 = await rig.fs.write('/replay.bin', data, { idempotencyKey: key });
|
||||
const r2 = await rig.fs.write('/replay.bin', data, { idempotencyKey: key });
|
||||
const r3 = await rig.fs.write('/replay.bin', data, { idempotencyKey: key });
|
||||
expect(writeCount).toBe(1);
|
||||
expect(r1.entry.size).toBe(3);
|
||||
expect(r2.entry.size).toBe(3);
|
||||
expect(r3.entry.size).toBe(3);
|
||||
});
|
||||
|
||||
test('out-of-window signedAt → InvalidSignatureError (skew rejection)', async () => {
|
||||
// Build a custom client that LIES about signedAt. We hand-craft an
|
||||
// RpcRequest envelope and ship it via the underlying channel.
|
||||
const {
|
||||
ShadeFileRpcChannel,
|
||||
PendingRpcRegistry,
|
||||
attachClientRouting,
|
||||
KIND_STAT_V1,
|
||||
generateRequestId,
|
||||
} = await import('../../src/index.js');
|
||||
const aliceChannel = new ShadeFileRpcChannel(rig.alice);
|
||||
const alicePending = new PendingRpcRegistry();
|
||||
attachClientRouting(aliceChannel, alicePending);
|
||||
|
||||
const requestId = generateRequestId();
|
||||
const stalePromise = alicePending.register<unknown>(requestId, { timeoutMs: 3000 });
|
||||
await aliceChannel.send('bob', {
|
||||
kind: KIND_STAT_V1,
|
||||
id: requestId,
|
||||
args: { path: '/replay.bin' },
|
||||
sig: 'unsigned',
|
||||
signedAt: Date.now() - 10 * 60 * 1000, // 10 min in the past — outside ±5 min window
|
||||
});
|
||||
await expect(stalePromise).rejects.toThrow(/replay window|signature/i);
|
||||
aliceChannel.destroy();
|
||||
});
|
||||
|
||||
test('signedAt far in the future → also rejected', async () => {
|
||||
const {
|
||||
ShadeFileRpcChannel,
|
||||
PendingRpcRegistry,
|
||||
attachClientRouting,
|
||||
KIND_STAT_V1,
|
||||
generateRequestId,
|
||||
} = await import('../../src/index.js');
|
||||
const aliceChannel = new ShadeFileRpcChannel(rig.alice);
|
||||
const alicePending = new PendingRpcRegistry();
|
||||
attachClientRouting(aliceChannel, alicePending);
|
||||
|
||||
const requestId = generateRequestId();
|
||||
const promise = alicePending.register<unknown>(requestId, { timeoutMs: 3000 });
|
||||
await aliceChannel.send('bob', {
|
||||
kind: KIND_STAT_V1,
|
||||
id: requestId,
|
||||
args: { path: '/replay.bin' },
|
||||
sig: 'unsigned',
|
||||
signedAt: Date.now() + 10 * 60 * 1000,
|
||||
});
|
||||
await expect(promise).rejects.toThrow(/replay window|signature/i);
|
||||
aliceChannel.destroy();
|
||||
});
|
||||
});
|
||||
115
packages/shade-files/tests/security/tampered-envelope.test.ts
Normal file
115
packages/shade-files/tests/security/tampered-envelope.test.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
|
||||
import { ed25519 } from '@noble/curves/ed25519.js';
|
||||
import { setupFileRig, type FileTestRig } from '../integration/helpers/rig.js';
|
||||
import {
|
||||
bytesToBase64,
|
||||
canonicalRpcBytes,
|
||||
hashArgs,
|
||||
InvalidSignatureError,
|
||||
type FileEntry,
|
||||
NotFoundError,
|
||||
} from '../../src/index.js';
|
||||
|
||||
/**
|
||||
* The dispatcher's `verifySender` callback gets the canonical bytes the
|
||||
* client claims they signed. By plugging a real Ed25519 verify in tests,
|
||||
* we can demonstrate that:
|
||||
* - A valid sig over the canonical bytes is accepted.
|
||||
* - Tampering ANY bound field (kind, args, signedAt, sender) breaks
|
||||
* verification → InvalidSignatureError.
|
||||
*/
|
||||
|
||||
describe('Tampered envelope — Ed25519 sig verification', () => {
|
||||
let rig: FileTestRig;
|
||||
// Generate a stable Ed25519 keypair for Alice. Bob will pin it.
|
||||
const alicePriv = ed25519.utils.randomSecretKey();
|
||||
const alicePub = ed25519.getPublicKey(alicePriv);
|
||||
|
||||
beforeAll(async () => {
|
||||
rig = await setupFileRig({
|
||||
verifySender: (sender, canonical, sigBase64) => {
|
||||
// We only know Alice's key for this test. Bob's pub key would be
|
||||
// looked up similarly in a real app.
|
||||
if (sender !== 'alice') return false;
|
||||
// Decode base64 sig
|
||||
try {
|
||||
const bin = atob(sigBase64);
|
||||
const sigBytes = new Uint8Array(bin.length);
|
||||
for (let i = 0; i < bin.length; i++) sigBytes[i] = bin.charCodeAt(i);
|
||||
return ed25519.verify(sigBytes, canonical, alicePub);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
stat: async (ctx) => {
|
||||
if (ctx.path !== '/exists.txt') throw new NotFoundError(ctx.path);
|
||||
const e: FileEntry = { name: 'exists.txt', kind: 'file', size: 1, mtime: 0, metadata: {} };
|
||||
return e;
|
||||
},
|
||||
});
|
||||
// Re-create the client with a real signRequest hook.
|
||||
// (Rig's default fs has signRequest=undefined; we replace it.)
|
||||
const { createFileClient, ShadeFileRpcChannel, PendingRpcRegistry, attachClientRouting } = await import('../../src/index.js');
|
||||
const aliceChannel = new ShadeFileRpcChannel(rig.alice);
|
||||
const alicePending = new PendingRpcRegistry();
|
||||
attachClientRouting(aliceChannel, alicePending);
|
||||
rig.fs = createFileClient(rig.alice, aliceChannel, alicePending, 'bob', {
|
||||
defaultTimeoutMs: 5000,
|
||||
signRequest: async (canonical) => {
|
||||
const sig = ed25519.sign(canonical, alicePriv);
|
||||
return bytesToBase64(sig);
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await rig.teardown();
|
||||
});
|
||||
|
||||
test('valid signature → request succeeds', async () => {
|
||||
const result = await rig.fs.stat('/exists.txt');
|
||||
expect(result.name).toBe('exists.txt');
|
||||
});
|
||||
|
||||
test('tampered args → InvalidSignatureError', async () => {
|
||||
// Craft a request manually: sign over '/a' but ship '/b'.
|
||||
const { createFileClient, ShadeFileRpcChannel, PendingRpcRegistry, attachClientRouting } = await import('../../src/index.js');
|
||||
const aliceChannel = new ShadeFileRpcChannel(rig.alice);
|
||||
const alicePending = new PendingRpcRegistry();
|
||||
attachClientRouting(aliceChannel, alicePending);
|
||||
const tamperedFs = createFileClient(rig.alice, aliceChannel, alicePending, 'bob', {
|
||||
defaultTimeoutMs: 3000,
|
||||
signRequest: async (_canonical) => {
|
||||
// Sign over a DIFFERENT canonical (different argsHash), so the
|
||||
// server's recomputation won't match.
|
||||
const fake = canonicalRpcBytes({
|
||||
address: 'alice',
|
||||
signedAt: 0,
|
||||
kind: 'shade.fs.list/v1',
|
||||
id: 'AAAAAAAAAAAAAAAAAAAAAA',
|
||||
argsHash: hashArgs({ tampered: true }),
|
||||
});
|
||||
const sig = ed25519.sign(fake, alicePriv);
|
||||
return bytesToBase64(sig);
|
||||
},
|
||||
});
|
||||
await expect(tamperedFs.stat('/exists.txt')).rejects.toBeInstanceOf(InvalidSignatureError);
|
||||
});
|
||||
|
||||
test('valid signature from unknown signer → InvalidSignatureError', async () => {
|
||||
const { createFileClient, ShadeFileRpcChannel, PendingRpcRegistry, attachClientRouting } = await import('../../src/index.js');
|
||||
const aliceChannel = new ShadeFileRpcChannel(rig.alice);
|
||||
const alicePending = new PendingRpcRegistry();
|
||||
attachClientRouting(aliceChannel, alicePending);
|
||||
|
||||
const otherPriv = ed25519.utils.randomSecretKey();
|
||||
const wrongFs = createFileClient(rig.alice, aliceChannel, alicePending, 'bob', {
|
||||
defaultTimeoutMs: 3000,
|
||||
signRequest: async (canonical) => {
|
||||
const sig = ed25519.sign(canonical, otherPriv);
|
||||
return bytesToBase64(sig);
|
||||
},
|
||||
});
|
||||
await expect(wrongFs.stat('/exists.txt')).rejects.toBeInstanceOf(InvalidSignatureError);
|
||||
});
|
||||
});
|
||||
135
packages/shade-files/tests/unit/canonical.test.ts
Normal file
135
packages/shade-files/tests/unit/canonical.test.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import {
|
||||
canonicalRpcBytes,
|
||||
canonicalJsonStringify,
|
||||
hashArgs,
|
||||
bytesToHex,
|
||||
bytesToBase64,
|
||||
base64ToBytes,
|
||||
} from '../../src/index.js';
|
||||
|
||||
describe('canonicalJsonStringify', () => {
|
||||
test('sorts object keys', () => {
|
||||
expect(canonicalJsonStringify({ b: 1, a: 2 })).toBe('{"a":2,"b":1}');
|
||||
});
|
||||
|
||||
test('skips undefined values', () => {
|
||||
expect(canonicalJsonStringify({ a: 1, b: undefined, c: 2 })).toBe('{"a":1,"c":2}');
|
||||
});
|
||||
|
||||
test('preserves array order', () => {
|
||||
expect(canonicalJsonStringify([3, 1, 2])).toBe('[3,1,2]');
|
||||
});
|
||||
|
||||
test('recursive sorting', () => {
|
||||
const a = canonicalJsonStringify({ outer: { y: 1, x: 2 } });
|
||||
const b = canonicalJsonStringify({ outer: { x: 2, y: 1 } });
|
||||
expect(a).toBe(b);
|
||||
expect(a).toBe('{"outer":{"x":2,"y":1}}');
|
||||
});
|
||||
|
||||
test('handles primitives', () => {
|
||||
expect(canonicalJsonStringify(null)).toBe('null');
|
||||
expect(canonicalJsonStringify(true)).toBe('true');
|
||||
expect(canonicalJsonStringify(42)).toBe('42');
|
||||
expect(canonicalJsonStringify('hi')).toBe('"hi"');
|
||||
});
|
||||
|
||||
test('different argument orders hash identically', () => {
|
||||
const h1 = hashArgs({ b: 1, a: 2 });
|
||||
const h2 = hashArgs({ a: 2, b: 1 });
|
||||
expect(bytesToHex(h1)).toBe(bytesToHex(h2));
|
||||
});
|
||||
|
||||
test('different values hash differently', () => {
|
||||
expect(bytesToHex(hashArgs({ a: 1 }))).not.toBe(bytesToHex(hashArgs({ a: 2 })));
|
||||
});
|
||||
});
|
||||
|
||||
describe('canonicalRpcBytes', () => {
|
||||
test('deterministic for same input (known vector)', () => {
|
||||
const args = {
|
||||
address: 'alice',
|
||||
signedAt: 1730000000000,
|
||||
kind: 'shade.fs.list/v1',
|
||||
id: 'AbCdEfGhIjKlMnOpQrStUv',
|
||||
argsHash: new Uint8Array(32).fill(0xab),
|
||||
};
|
||||
const a = canonicalRpcBytes(args);
|
||||
const b = canonicalRpcBytes(args);
|
||||
expect(a).toEqual(b);
|
||||
});
|
||||
|
||||
test('changes when address differs', () => {
|
||||
const base = {
|
||||
signedAt: 1,
|
||||
kind: 'shade.fs.list/v1',
|
||||
id: 'AbCdEfGhIjKlMnOpQrStUv',
|
||||
argsHash: new Uint8Array(32),
|
||||
};
|
||||
const a = canonicalRpcBytes({ ...base, address: 'alice' });
|
||||
const b = canonicalRpcBytes({ ...base, address: 'bob' });
|
||||
expect(a).not.toEqual(b);
|
||||
});
|
||||
|
||||
test('changes when signedAt differs', () => {
|
||||
const base = {
|
||||
address: 'alice',
|
||||
kind: 'shade.fs.list/v1',
|
||||
id: 'AbCdEfGhIjKlMnOpQrStUv',
|
||||
argsHash: new Uint8Array(32),
|
||||
};
|
||||
expect(canonicalRpcBytes({ ...base, signedAt: 1 })).not.toEqual(
|
||||
canonicalRpcBytes({ ...base, signedAt: 2 }),
|
||||
);
|
||||
});
|
||||
|
||||
test('changes when kind differs', () => {
|
||||
const base = {
|
||||
address: 'alice',
|
||||
signedAt: 1,
|
||||
id: 'AbCdEfGhIjKlMnOpQrStUv',
|
||||
argsHash: new Uint8Array(32),
|
||||
};
|
||||
expect(canonicalRpcBytes({ ...base, kind: 'shade.fs.list/v1' })).not.toEqual(
|
||||
canonicalRpcBytes({ ...base, kind: 'shade.fs.stat/v1' }),
|
||||
);
|
||||
});
|
||||
|
||||
test('changes when argsHash differs', () => {
|
||||
const base = {
|
||||
address: 'alice',
|
||||
signedAt: 1,
|
||||
kind: 'shade.fs.list/v1',
|
||||
id: 'AbCdEfGhIjKlMnOpQrStUv',
|
||||
};
|
||||
expect(
|
||||
canonicalRpcBytes({ ...base, argsHash: new Uint8Array(32) }),
|
||||
).not.toEqual(
|
||||
canonicalRpcBytes({ ...base, argsHash: new Uint8Array(32).fill(1) }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hashArgs', () => {
|
||||
test('produces 32-byte digest', () => {
|
||||
expect(hashArgs({ a: 1 }).length).toBe(32);
|
||||
});
|
||||
|
||||
test('null input is fine', () => {
|
||||
expect(hashArgs(null).length).toBe(32);
|
||||
});
|
||||
});
|
||||
|
||||
describe('bytesToHex / base64', () => {
|
||||
test('hex roundtrip', () => {
|
||||
const b = new Uint8Array([0x01, 0xab, 0xff, 0x00]);
|
||||
expect(bytesToHex(b)).toBe('01abff00');
|
||||
});
|
||||
|
||||
test('base64 roundtrip', () => {
|
||||
const b = new Uint8Array([1, 2, 3, 4]);
|
||||
const enc = bytesToBase64(b);
|
||||
expect(base64ToBytes(enc)).toEqual(b);
|
||||
});
|
||||
});
|
||||
90
packages/shade-files/tests/unit/concurrency.test.ts
Normal file
90
packages/shade-files/tests/unit/concurrency.test.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import { runWithConcurrency } from '../../src/client/concurrency.js';
|
||||
|
||||
async function* range(n: number): AsyncIterable<number> {
|
||||
for (let i = 0; i < n; i++) yield i;
|
||||
}
|
||||
|
||||
function delay(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
describe('runWithConcurrency', () => {
|
||||
test('runs all items', async () => {
|
||||
const seen: number[] = [];
|
||||
await runWithConcurrency(range(10), async (i) => {
|
||||
seen.push(i);
|
||||
}, { concurrency: 4 });
|
||||
expect(seen.sort((a, b) => a - b)).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8, 9]);
|
||||
});
|
||||
|
||||
test('respects concurrency cap (never exceeds N inflight)', async () => {
|
||||
let inflight = 0;
|
||||
let peak = 0;
|
||||
await runWithConcurrency(range(50), async () => {
|
||||
inflight++;
|
||||
peak = Math.max(peak, inflight);
|
||||
await delay(5);
|
||||
inflight--;
|
||||
}, { concurrency: 4 });
|
||||
expect(peak).toBeLessThanOrEqual(4);
|
||||
expect(peak).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
test('throws on first error by default (fail-fast)', async () => {
|
||||
let processed = 0;
|
||||
await expect(
|
||||
runWithConcurrency(range(20), async (i) => {
|
||||
await delay(1);
|
||||
if (i === 3) throw new Error('boom');
|
||||
processed++;
|
||||
}, { concurrency: 2 }),
|
||||
).rejects.toThrow('boom');
|
||||
// We don't process all 20; bounded by fail-fast.
|
||||
expect(processed).toBeLessThan(20);
|
||||
});
|
||||
|
||||
test('continueOnError reports each + drains', async () => {
|
||||
const errors: number[] = [];
|
||||
let processed = 0;
|
||||
await runWithConcurrency(range(10), async (i) => {
|
||||
await delay(1);
|
||||
if (i % 3 === 0) throw new Error(`bad-${i}`);
|
||||
processed++;
|
||||
}, {
|
||||
concurrency: 3,
|
||||
continueOnError: true,
|
||||
onError: (item) => errors.push(item),
|
||||
});
|
||||
expect(processed).toBe(6); // i = 1,2,4,5,7,8
|
||||
expect(errors.sort((a, b) => a - b)).toEqual([0, 3, 6, 9]);
|
||||
});
|
||||
|
||||
test('aborts via signal', async () => {
|
||||
const ctrl = new AbortController();
|
||||
let processed = 0;
|
||||
setTimeout(() => ctrl.abort(), 20);
|
||||
await expect(
|
||||
runWithConcurrency(range(100), async () => {
|
||||
await delay(5);
|
||||
processed++;
|
||||
}, { concurrency: 4, signal: ctrl.signal }),
|
||||
).rejects.toThrow();
|
||||
expect(processed).toBeLessThan(100);
|
||||
});
|
||||
|
||||
test('concurrency=1 is sequential', async () => {
|
||||
const order: number[] = [];
|
||||
await runWithConcurrency(range(5), async (i) => {
|
||||
order.push(i);
|
||||
await delay(1);
|
||||
}, { concurrency: 1 });
|
||||
expect(order).toEqual([0, 1, 2, 3, 4]);
|
||||
});
|
||||
|
||||
test('throws on concurrency < 1', () => {
|
||||
expect(() =>
|
||||
runWithConcurrency(range(0), async () => undefined, { concurrency: 0 }),
|
||||
).toThrow('concurrency must be ≥ 1');
|
||||
});
|
||||
});
|
||||
64
packages/shade-files/tests/unit/correlate.test.ts
Normal file
64
packages/shade-files/tests/unit/correlate.test.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import * as fc from 'fast-check';
|
||||
import {
|
||||
generateRequestId,
|
||||
generateIdempotencyKey,
|
||||
base64UrlEncode,
|
||||
base64UrlDecode,
|
||||
RequestIdSchema,
|
||||
} from '../../src/index.js';
|
||||
|
||||
describe('generateRequestId', () => {
|
||||
test('produces 22-char base64url string', () => {
|
||||
const id = generateRequestId();
|
||||
expect(id.length).toBe(22);
|
||||
expect(RequestIdSchema.safeParse(id).success).toBe(true);
|
||||
});
|
||||
|
||||
test('1e5 generated IDs are all unique', () => {
|
||||
const seen = new Set<string>();
|
||||
for (let i = 0; i < 100_000; i++) {
|
||||
const id = generateRequestId();
|
||||
expect(seen.has(id)).toBe(false);
|
||||
seen.add(id);
|
||||
}
|
||||
expect(seen.size).toBe(100_000);
|
||||
});
|
||||
|
||||
test('generateIdempotencyKey returns the same shape', () => {
|
||||
expect(generateIdempotencyKey().length).toBe(22);
|
||||
});
|
||||
});
|
||||
|
||||
describe('base64url encode/decode', () => {
|
||||
test('roundtrip arbitrary bytes (property-based)', () => {
|
||||
fc.assert(
|
||||
fc.property(fc.uint8Array({ minLength: 0, maxLength: 64 }), (bytes) => {
|
||||
const decoded = base64UrlDecode(base64UrlEncode(bytes));
|
||||
expect(decoded).toEqual(bytes);
|
||||
}),
|
||||
{ numRuns: 500 },
|
||||
);
|
||||
});
|
||||
|
||||
test('produces URL-safe alphabet only', () => {
|
||||
fc.assert(
|
||||
fc.property(fc.uint8Array({ minLength: 1, maxLength: 64 }), (bytes) => {
|
||||
const enc = base64UrlEncode(bytes);
|
||||
expect(enc).not.toMatch(/[+/=]/);
|
||||
}),
|
||||
{ numRuns: 200 },
|
||||
);
|
||||
});
|
||||
|
||||
test('handles empty input', () => {
|
||||
expect(base64UrlEncode(new Uint8Array(0))).toBe('');
|
||||
expect(base64UrlDecode('')).toEqual(new Uint8Array(0));
|
||||
});
|
||||
|
||||
test('decodes inputs without padding correctly', () => {
|
||||
expect(base64UrlDecode('YQ')).toEqual(new Uint8Array([0x61]));
|
||||
expect(base64UrlDecode('YWI')).toEqual(new Uint8Array([0x61, 0x62]));
|
||||
expect(base64UrlDecode('YWJj')).toEqual(new Uint8Array([0x61, 0x62, 0x63]));
|
||||
});
|
||||
});
|
||||
149
packages/shade-files/tests/unit/envelope-codec.test.ts
Normal file
149
packages/shade-files/tests/unit/envelope-codec.test.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import {
|
||||
encodeEnvelope,
|
||||
looksLikeFileEnvelope,
|
||||
tryParseEnvelope,
|
||||
classify,
|
||||
KIND_LIST_V1,
|
||||
KIND_ERROR_V1,
|
||||
KIND_CANCEL_V1,
|
||||
responseKindOf,
|
||||
} from '../../src/index.js';
|
||||
import type { RpcRequest, RpcResponse, RpcError, RpcCancel } from '../../src/index.js';
|
||||
|
||||
const ID = 'AbCdEfGhIjKlMnOpQrStUv';
|
||||
|
||||
describe('looksLikeFileEnvelope', () => {
|
||||
test('matches plaintext containing shade.fs', () => {
|
||||
expect(looksLikeFileEnvelope('{"kind":"shade.fs.list/v1"}')).toBe(true);
|
||||
});
|
||||
test('rejects unrelated', () => {
|
||||
expect(looksLikeFileEnvelope('hello world')).toBe(false);
|
||||
expect(looksLikeFileEnvelope('{"kind":"shade.stream-init/v1"}')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('tryParseEnvelope', () => {
|
||||
test('returns null on non-JSON', () => {
|
||||
expect(tryParseEnvelope('not json {{')).toBeNull();
|
||||
});
|
||||
|
||||
test('returns null on JSON that does not match any envelope', () => {
|
||||
expect(tryParseEnvelope('{"kind":"unknown"}')).toBeNull();
|
||||
expect(tryParseEnvelope('{"foo":"bar"}')).toBeNull();
|
||||
});
|
||||
|
||||
test('classifies request', () => {
|
||||
const req: RpcRequest = {
|
||||
kind: KIND_LIST_V1,
|
||||
id: ID,
|
||||
args: { path: '/' },
|
||||
sig: 'abc',
|
||||
signedAt: 1,
|
||||
};
|
||||
const c = tryParseEnvelope(encodeEnvelope(req));
|
||||
expect(c?.kind).toBe('request');
|
||||
});
|
||||
|
||||
test('classifies response', () => {
|
||||
const resp: RpcResponse = {
|
||||
kind: responseKindOf(KIND_LIST_V1),
|
||||
id: ID,
|
||||
result: { entries: [], hasMore: false },
|
||||
};
|
||||
const c = tryParseEnvelope(encodeEnvelope(resp));
|
||||
expect(c?.kind).toBe('response');
|
||||
});
|
||||
|
||||
test('classifies error', () => {
|
||||
const err: RpcError = {
|
||||
kind: KIND_ERROR_V1,
|
||||
id: ID,
|
||||
error: { code: 'NOT_FOUND', message: 'missing' },
|
||||
};
|
||||
const c = tryParseEnvelope(encodeEnvelope(err));
|
||||
expect(c?.kind).toBe('error');
|
||||
});
|
||||
|
||||
test('classifies cancel', () => {
|
||||
const cancel: RpcCancel = {
|
||||
kind: KIND_CANCEL_V1,
|
||||
id: ID,
|
||||
reason: 'user-cancel',
|
||||
};
|
||||
const c = tryParseEnvelope(encodeEnvelope(cancel));
|
||||
expect(c?.kind).toBe('cancel');
|
||||
});
|
||||
|
||||
test('rejects request with missing fields', () => {
|
||||
expect(tryParseEnvelope(JSON.stringify({ kind: KIND_LIST_V1 }))).toBeNull();
|
||||
expect(
|
||||
tryParseEnvelope(
|
||||
JSON.stringify({ kind: KIND_LIST_V1, id: ID, args: {}, sig: 'x' }),
|
||||
),
|
||||
).toBeNull(); // missing signedAt
|
||||
});
|
||||
|
||||
test('rejects response shape with non-.response suffix and no signature', () => {
|
||||
// Missing both `.response` suffix (so not a response) AND missing
|
||||
// `sig`/`signedAt` (so not a valid request) → no schema matches.
|
||||
expect(
|
||||
tryParseEnvelope(
|
||||
JSON.stringify({ kind: 'shade.fs.list/v1', id: ID, result: {} }),
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
test('rejects malformed id length', () => {
|
||||
const req = {
|
||||
kind: KIND_LIST_V1,
|
||||
id: 'short',
|
||||
args: {},
|
||||
sig: 'x',
|
||||
signedAt: 1,
|
||||
};
|
||||
expect(tryParseEnvelope(JSON.stringify(req))).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('classify on already-validated envelopes', () => {
|
||||
test('correct discriminator on each branch', () => {
|
||||
const req: RpcRequest = {
|
||||
kind: KIND_LIST_V1, id: ID, args: {}, sig: 'x', signedAt: 1,
|
||||
};
|
||||
expect(classify(req).kind).toBe('request');
|
||||
|
||||
const resp: RpcResponse = {
|
||||
kind: responseKindOf(KIND_LIST_V1), id: ID, result: {},
|
||||
};
|
||||
expect(classify(resp).kind).toBe('response');
|
||||
|
||||
const err: RpcError = {
|
||||
kind: KIND_ERROR_V1, id: ID, error: { code: 'NOT_FOUND', message: '' },
|
||||
};
|
||||
expect(classify(err).kind).toBe('error');
|
||||
|
||||
const cancel: RpcCancel = { kind: KIND_CANCEL_V1, id: ID };
|
||||
expect(classify(cancel).kind).toBe('cancel');
|
||||
});
|
||||
});
|
||||
|
||||
describe('encodeEnvelope', () => {
|
||||
test('roundtrips request envelope', () => {
|
||||
const req: RpcRequest = {
|
||||
kind: KIND_LIST_V1,
|
||||
id: ID,
|
||||
args: { path: '/foo' },
|
||||
idempotencyKey: 'IdemKeyAaBbCcDdEeFfGgH',
|
||||
attempt: 1,
|
||||
sig: 'sig',
|
||||
signedAt: 1730000000000,
|
||||
};
|
||||
const c = tryParseEnvelope(encodeEnvelope(req));
|
||||
expect(c?.kind).toBe('request');
|
||||
if (c?.kind === 'request') {
|
||||
expect(c.envelope.idempotencyKey).toBe('IdemKeyAaBbCcDdEeFfGgH');
|
||||
expect(c.envelope.attempt).toBe(1);
|
||||
}
|
||||
});
|
||||
});
|
||||
108
packages/shade-files/tests/unit/idempotency-cache.test.ts
Normal file
108
packages/shade-files/tests/unit/idempotency-cache.test.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import { IdempotencyCache, IdempotencyConflictError } from '../../src/index.js';
|
||||
|
||||
describe('IdempotencyCache.begin', () => {
|
||||
test('first call returns fresh', () => {
|
||||
const cache = new IdempotencyCache();
|
||||
const result = cache.begin('alice', 'key1', { path: '/foo' });
|
||||
expect(result.status).toBe('fresh');
|
||||
});
|
||||
|
||||
test('replay returns cached response', () => {
|
||||
const cache = new IdempotencyCache();
|
||||
const a = cache.begin('alice', 'key1', { path: '/foo' });
|
||||
if (a.status !== 'fresh') throw new Error('expected fresh');
|
||||
a.commit({ ok: true });
|
||||
|
||||
const b = cache.begin('alice', 'key1', { path: '/foo' });
|
||||
expect(b.status).toBe('replay');
|
||||
if (b.status === 'replay') {
|
||||
expect(b.response).toEqual({ ok: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('argsHash mismatch throws IdempotencyConflictError', () => {
|
||||
const cache = new IdempotencyCache();
|
||||
const a = cache.begin('alice', 'key1', { path: '/foo' });
|
||||
if (a.status !== 'fresh') throw new Error('expected fresh');
|
||||
a.commit({ ok: true });
|
||||
|
||||
expect(() =>
|
||||
cache.begin('alice', 'key1', { path: '/different' }),
|
||||
).toThrow(IdempotencyConflictError);
|
||||
});
|
||||
|
||||
test('inflight retry returns wait-promise', async () => {
|
||||
const cache = new IdempotencyCache();
|
||||
const a = cache.begin('alice', 'key1', { x: 1 });
|
||||
if (a.status !== 'fresh') throw new Error('expected fresh');
|
||||
|
||||
const b = cache.begin('alice', 'key1', { x: 1 });
|
||||
expect(b.status).toBe('wait');
|
||||
|
||||
if (b.status === 'wait') {
|
||||
a.commit({ result: 42 });
|
||||
const v = await b.promise;
|
||||
expect(v).toEqual({ result: 42 });
|
||||
}
|
||||
});
|
||||
|
||||
test('different senders are isolated', () => {
|
||||
const cache = new IdempotencyCache();
|
||||
const a = cache.begin('alice', 'k', { x: 1 });
|
||||
if (a.status !== 'fresh') throw new Error('expected fresh');
|
||||
a.commit({ side: 'alice' });
|
||||
|
||||
const b = cache.begin('bob', 'k', { x: 1 });
|
||||
expect(b.status).toBe('fresh');
|
||||
});
|
||||
|
||||
test('abandon removes the entry so retries proceed fresh', () => {
|
||||
const cache = new IdempotencyCache();
|
||||
const a = cache.begin('alice', 'key', { p: 1 });
|
||||
if (a.status !== 'fresh') throw new Error('expected fresh');
|
||||
a.abandon();
|
||||
|
||||
const b = cache.begin('alice', 'key', { p: 1 });
|
||||
expect(b.status).toBe('fresh');
|
||||
});
|
||||
});
|
||||
|
||||
describe('IdempotencyCache TTL + LRU', () => {
|
||||
test('expired entries are evicted on next access', async () => {
|
||||
const cache = new IdempotencyCache({ ttlMs: 5 });
|
||||
const a = cache.begin('alice', 'k', { x: 1 });
|
||||
if (a.status !== 'fresh') throw new Error('expected fresh');
|
||||
a.commit('done');
|
||||
|
||||
await new Promise((r) => setTimeout(r, 15));
|
||||
|
||||
const b = cache.begin('alice', 'k', { x: 1 });
|
||||
expect(b.status).toBe('fresh'); // expired → fresh again
|
||||
});
|
||||
|
||||
test('LRU caps per-sender entries', () => {
|
||||
const cache = new IdempotencyCache({ maxEntriesPerSender: 3 });
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const r = cache.begin('alice', `key${i}`, { i });
|
||||
if (r.status === 'fresh') r.commit(i);
|
||||
}
|
||||
expect(cache.size()).toBe(3); // first two evicted
|
||||
});
|
||||
});
|
||||
|
||||
describe('IdempotencyCache.prune', () => {
|
||||
test('removes only TTL-expired entries', async () => {
|
||||
// Two senders so begin() on one doesn't auto-evict the other's expired entry.
|
||||
const cache = new IdempotencyCache({ ttlMs: 5 });
|
||||
const a = cache.begin('alice', 'old', { x: 1 });
|
||||
if (a.status === 'fresh') a.commit(1);
|
||||
await new Promise((r) => setTimeout(r, 15));
|
||||
const b = cache.begin('bob', 'new', { x: 2 });
|
||||
if (b.status === 'fresh') b.commit(2);
|
||||
|
||||
const removed = cache.prune();
|
||||
expect(removed).toBe(1); // alice/old expired; bob/new fresh
|
||||
expect(cache.size()).toBe(1);
|
||||
});
|
||||
});
|
||||
161
packages/shade-files/tests/unit/inline-threshold.test.ts
Normal file
161
packages/shade-files/tests/unit/inline-threshold.test.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { decideInline, INLINE_THRESHOLD } from '../../src/client/inline-threshold.js';
|
||||
|
||||
const KIB = 1024;
|
||||
|
||||
function streamOf(...chunks: Uint8Array[]): ReadableStream<Uint8Array> {
|
||||
let i = 0;
|
||||
return new ReadableStream<Uint8Array>({
|
||||
pull(controller) {
|
||||
if (i < chunks.length) {
|
||||
controller.enqueue(chunks[i]!);
|
||||
i++;
|
||||
} else {
|
||||
controller.close();
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function drainStream(s: ReadableStream<Uint8Array>): Promise<Uint8Array> {
|
||||
const reader = s.getReader();
|
||||
const parts: Uint8Array[] = [];
|
||||
let total = 0;
|
||||
while (true) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) break;
|
||||
if (value === undefined) continue;
|
||||
parts.push(value);
|
||||
total += value.byteLength;
|
||||
}
|
||||
reader.releaseLock();
|
||||
const out = new Uint8Array(total);
|
||||
let offset = 0;
|
||||
for (const p of parts) {
|
||||
out.set(p, offset);
|
||||
offset += p.byteLength;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
describe('decideInline (Uint8Array)', () => {
|
||||
test('1 KiB → inline', async () => {
|
||||
const bytes = new Uint8Array(KIB).fill(0xab);
|
||||
const decision = await decideInline(bytes);
|
||||
expect(decision.kind).toBe('inline');
|
||||
if (decision.kind === 'inline') {
|
||||
expect(decision.bytes.byteLength).toBe(KIB);
|
||||
}
|
||||
});
|
||||
|
||||
test('exactly 256 KiB → inline (boundary)', async () => {
|
||||
const bytes = new Uint8Array(INLINE_THRESHOLD).fill(0xcd);
|
||||
const decision = await decideInline(bytes);
|
||||
expect(decision.kind).toBe('inline');
|
||||
});
|
||||
|
||||
test('256 KiB + 1 → streams (boundary +1)', async () => {
|
||||
const bytes = new Uint8Array(INLINE_THRESHOLD + 1).fill(0xef);
|
||||
const decision = await decideInline(bytes);
|
||||
expect(decision.kind).toBe('streams');
|
||||
if (decision.kind === 'streams') {
|
||||
expect(decision.size).toBe(INLINE_THRESHOLD + 1);
|
||||
const drained = await drainStream(decision.stream);
|
||||
expect(drained.byteLength).toBe(INLINE_THRESHOLD + 1);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('decideInline (Blob)', () => {
|
||||
test('small Blob → inline + propagates contentType', async () => {
|
||||
const blob = new Blob([new Uint8Array(100).fill(7)], { type: 'application/octet-stream' });
|
||||
const decision = await decideInline(blob);
|
||||
expect(decision.kind).toBe('inline');
|
||||
if (decision.kind === 'inline') {
|
||||
expect(decision.contentType).toBe('application/octet-stream');
|
||||
expect(decision.bytes.byteLength).toBe(100);
|
||||
}
|
||||
});
|
||||
|
||||
test('large Blob → streams + propagates size + contentType', async () => {
|
||||
const big = new Uint8Array(INLINE_THRESHOLD + KIB).fill(1);
|
||||
const blob = new Blob([big], { type: 'image/png' });
|
||||
const decision = await decideInline(blob);
|
||||
expect(decision.kind).toBe('streams');
|
||||
if (decision.kind === 'streams') {
|
||||
expect(decision.size).toBe(big.byteLength);
|
||||
expect(decision.contentType).toBe('image/png');
|
||||
}
|
||||
});
|
||||
|
||||
test('empty Blob.type → no contentType', async () => {
|
||||
const blob = new Blob([new Uint8Array(10)]);
|
||||
const decision = await decideInline(blob);
|
||||
expect(decision.kind).toBe('inline');
|
||||
if (decision.kind === 'inline') {
|
||||
expect(decision.contentType).toBeUndefined();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('decideInline (ReadableStream — bare)', () => {
|
||||
test('EOF before threshold → inline', async () => {
|
||||
const stream = streamOf(new Uint8Array(100), new Uint8Array(200));
|
||||
const decision = await decideInline(stream);
|
||||
expect(decision.kind).toBe('inline');
|
||||
if (decision.kind === 'inline') {
|
||||
expect(decision.bytes.byteLength).toBe(300);
|
||||
}
|
||||
});
|
||||
|
||||
test('crosses threshold mid-chunk → streams + remainder available', async () => {
|
||||
// 256 KiB + 1 byte across two chunks
|
||||
const a = new Uint8Array(200 * KIB).fill(0x10);
|
||||
const b = new Uint8Array(100 * KIB).fill(0x20);
|
||||
const stream = streamOf(a, b);
|
||||
const decision = await decideInline(stream);
|
||||
expect(decision.kind).toBe('streams');
|
||||
if (decision.kind === 'streams') {
|
||||
const drained = await drainStream(decision.stream);
|
||||
expect(drained.byteLength).toBe(a.byteLength + b.byteLength);
|
||||
expect(drained[0]).toBe(0x10);
|
||||
expect(drained[drained.length - 1]).toBe(0x20);
|
||||
}
|
||||
});
|
||||
|
||||
test('empty stream → inline (zero bytes)', async () => {
|
||||
const stream = streamOf();
|
||||
const decision = await decideInline(stream);
|
||||
expect(decision.kind).toBe('inline');
|
||||
if (decision.kind === 'inline') {
|
||||
expect(decision.bytes.byteLength).toBe(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('decideInline ({ stream, size })', () => {
|
||||
test('declared size ≤ threshold → inline', async () => {
|
||||
const bytes = new Uint8Array(KIB).fill(9);
|
||||
const decision = await decideInline({ stream: streamOf(bytes), size: KIB, contentType: 'text/plain' });
|
||||
expect(decision.kind).toBe('inline');
|
||||
if (decision.kind === 'inline') {
|
||||
expect(decision.bytes.byteLength).toBe(KIB);
|
||||
expect(decision.contentType).toBe('text/plain');
|
||||
}
|
||||
});
|
||||
|
||||
test('declared size > threshold → streams', async () => {
|
||||
const big = new Uint8Array(500 * KIB).fill(2);
|
||||
const decision = await decideInline({ stream: streamOf(big), size: big.byteLength });
|
||||
expect(decision.kind).toBe('streams');
|
||||
if (decision.kind === 'streams') {
|
||||
expect(decision.size).toBe(big.byteLength);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('decideInline (errors)', () => {
|
||||
test('unsupported input throws TypeError', async () => {
|
||||
await expect(decideInline(42 as unknown as Uint8Array)).rejects.toThrow(TypeError);
|
||||
});
|
||||
});
|
||||
128
packages/shade-files/tests/unit/path-policy.test.ts
Normal file
128
packages/shade-files/tests/unit/path-policy.test.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import * as fc from 'fast-check';
|
||||
import { validatePath } from '../../src/index.js';
|
||||
|
||||
describe('validatePath — happy path', () => {
|
||||
test('accepts simple absolute paths', () => {
|
||||
expect(validatePath('/foo')).toEqual({ ok: true, normalized: '/foo' });
|
||||
expect(validatePath('/foo/bar/baz.txt')).toEqual({
|
||||
ok: true,
|
||||
normalized: '/foo/bar/baz.txt',
|
||||
});
|
||||
expect(validatePath('/')).toEqual({ ok: true, normalized: '/' });
|
||||
});
|
||||
|
||||
test('normalizes redundant slashes and dots', () => {
|
||||
expect(validatePath('//foo//bar/./baz/').normalized).toBe('/foo/bar/baz');
|
||||
expect(validatePath('/./foo').normalized).toBe('/foo');
|
||||
});
|
||||
|
||||
test('UTF-8 paths are accepted', () => {
|
||||
expect(validatePath('/Документы/файл.txt').normalized).toBe('/Документы/файл.txt');
|
||||
expect(validatePath('/絵文字 😀/foo').normalized).toBe('/絵文字 😀/foo');
|
||||
});
|
||||
});
|
||||
|
||||
describe('validatePath — security', () => {
|
||||
test('rejects raw `..` segments', () => {
|
||||
expect(validatePath('/../etc/passwd').ok).toBe(false);
|
||||
expect(validatePath('/foo/../etc').ok).toBe(false);
|
||||
expect(validatePath('/..').ok).toBe(false);
|
||||
});
|
||||
|
||||
test('rejects percent-encoded `..`', () => {
|
||||
expect(validatePath('/%2e%2e/etc').ok).toBe(false);
|
||||
expect(validatePath('/foo/%2E%2E/etc').ok).toBe(false);
|
||||
});
|
||||
|
||||
test('rejects forbidden control bytes', () => {
|
||||
expect(validatePath('/foo\x00bar').ok).toBe(false);
|
||||
expect(validatePath('/foo\r\nbar').ok).toBe(false);
|
||||
expect(validatePath('/foo\x7f').ok).toBe(false);
|
||||
expect(validatePath('/foo\x01').ok).toBe(false);
|
||||
});
|
||||
|
||||
test('rejects backslashes (Windows-style)', () => {
|
||||
expect(validatePath('/foo\\bar').ok).toBe(false);
|
||||
});
|
||||
|
||||
test('rejects relative paths', () => {
|
||||
expect(validatePath('foo').ok).toBe(false);
|
||||
expect(validatePath('./foo').ok).toBe(false);
|
||||
expect(validatePath('').ok).toBe(false);
|
||||
});
|
||||
|
||||
test('rejects over-length paths', () => {
|
||||
expect(validatePath('/' + 'a'.repeat(4096)).ok).toBe(false);
|
||||
expect(validatePath('/foobar', { maxLength: 5 }).ok).toBe(false);
|
||||
expect(validatePath('/abc', { maxLength: 5 }).ok).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validatePath — rootScope', () => {
|
||||
test('accepts paths inside scope', () => {
|
||||
expect(
|
||||
validatePath('/srv/data/foo', { rootScope: '/srv/data' }).ok,
|
||||
).toBe(true);
|
||||
expect(validatePath('/srv/data', { rootScope: '/srv/data' }).ok).toBe(true);
|
||||
});
|
||||
|
||||
test('rejects paths outside scope', () => {
|
||||
expect(validatePath('/etc/passwd', { rootScope: '/srv/data' }).ok).toBe(false);
|
||||
expect(validatePath('/srv/dataX', { rootScope: '/srv/data' }).ok).toBe(false);
|
||||
// Boundary check: /srv/database is NOT inside /srv/data
|
||||
expect(validatePath('/srv/database/x', { rootScope: '/srv/data' }).ok).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validatePath — extra hook', () => {
|
||||
test('extra reject takes precedence', () => {
|
||||
const result = validatePath('/secret/foo', {
|
||||
extra: (p) => (p.includes('secret') ? 'reject' : 'allow'),
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
});
|
||||
|
||||
test('extra allow is the default', () => {
|
||||
expect(
|
||||
validatePath('/foo', {
|
||||
extra: () => 'allow',
|
||||
}).ok,
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validatePath — property-based', () => {
|
||||
test('any string with a forbidden control byte is rejected', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.string({ minLength: 1, maxLength: 100 }),
|
||||
fc.constantFrom('\x00', '\x07', '\x0a', '\x0d', '\x7f', '\\'),
|
||||
(prefix, bad) => {
|
||||
const p = `/${prefix}${bad}`;
|
||||
expect(validatePath(p).ok).toBe(false);
|
||||
},
|
||||
),
|
||||
{ numRuns: 200 },
|
||||
);
|
||||
});
|
||||
|
||||
test('any path inside rootScope normalizes within rootScope', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.array(fc.string({ minLength: 1, maxLength: 20 }).filter(
|
||||
(s) => /^[A-Za-z0-9_-]+$/.test(s),
|
||||
), { minLength: 1, maxLength: 5 }),
|
||||
(segments) => {
|
||||
const root = '/srv';
|
||||
const path = `${root}/${segments.join('/')}`;
|
||||
const r = validatePath(path, { rootScope: root });
|
||||
if (r.ok) {
|
||||
expect(r.normalized.startsWith(root)).toBe(true);
|
||||
}
|
||||
},
|
||||
),
|
||||
{ numRuns: 200 },
|
||||
);
|
||||
});
|
||||
});
|
||||
91
packages/shade-files/tests/unit/rate-limiter.test.ts
Normal file
91
packages/shade-files/tests/unit/rate-limiter.test.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import {
|
||||
FsRateLimitError,
|
||||
QuotaExceededError,
|
||||
RateLimiter,
|
||||
} from '../../src/index.js';
|
||||
|
||||
describe('RateLimiter — op bucket', () => {
|
||||
test('allows up to capacity then rejects', () => {
|
||||
const rl = new RateLimiter({ maxOpsPerMinutePerSender: 5, opCost: { default: 1 } });
|
||||
for (let i = 0; i < 5; i++) rl.acquire('alice', 'list');
|
||||
expect(() => rl.acquire('alice', 'list')).toThrow(FsRateLimitError);
|
||||
});
|
||||
|
||||
test('rejection includes retryAfterMs', () => {
|
||||
const rl = new RateLimiter({ maxOpsPerMinutePerSender: 1, opCost: { default: 1 } });
|
||||
rl.acquire('alice', 'list');
|
||||
try {
|
||||
rl.acquire('alice', 'list');
|
||||
throw new Error('expected throw');
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(FsRateLimitError);
|
||||
const payload = (err as FsRateLimitError).payload;
|
||||
expect(payload.retryAfterMs).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
test('different op costs respected', () => {
|
||||
const rl = new RateLimiter({
|
||||
maxOpsPerMinutePerSender: 10,
|
||||
opCost: { write: 5, default: 1 },
|
||||
});
|
||||
rl.acquire('alice', 'write'); // 10 - 5 = 5 left
|
||||
rl.acquire('alice', 'write'); // 5 - 5 = 0 left
|
||||
expect(() => rl.acquire('alice', 'write')).toThrow(FsRateLimitError);
|
||||
});
|
||||
|
||||
test('per-sender isolation', () => {
|
||||
const rl = new RateLimiter({ maxOpsPerMinutePerSender: 1 });
|
||||
rl.acquire('alice', 'list');
|
||||
rl.acquire('bob', 'list'); // bob's bucket independent
|
||||
expect(() => rl.acquire('alice', 'list')).toThrow(FsRateLimitError);
|
||||
expect(() => rl.acquire('bob', 'list')).toThrow(FsRateLimitError);
|
||||
});
|
||||
|
||||
test('release returns tokens', () => {
|
||||
const rl = new RateLimiter({ maxOpsPerMinutePerSender: 1 });
|
||||
rl.acquire('alice', 'list');
|
||||
rl.release('alice', 'list');
|
||||
rl.acquire('alice', 'list'); // should succeed again
|
||||
});
|
||||
});
|
||||
|
||||
describe('RateLimiter — byte bucket', () => {
|
||||
test('quota exceeded triggers QuotaExceededError', () => {
|
||||
const rl = new RateLimiter({ maxBytesPerHourPerSender: 1000 });
|
||||
rl.acquire('alice', 'write', 600);
|
||||
rl.acquire('alice', 'write', 400);
|
||||
expect(() => rl.acquire('alice', 'write', 1)).toThrow(QuotaExceededError);
|
||||
});
|
||||
|
||||
test('reconcile returns over-reserved bytes', () => {
|
||||
const rl = new RateLimiter({ maxBytesPerHourPerSender: 1000 });
|
||||
rl.acquire('alice', 'write', 800);
|
||||
rl.reconcile('alice', 800, 200); // we only used 200 of the 800 reserved
|
||||
rl.acquire('alice', 'write', 600); // capacity now 400 + 600 = 1000, fits 600
|
||||
});
|
||||
|
||||
test('release returns reserved bytes', () => {
|
||||
const rl = new RateLimiter({ maxBytesPerHourPerSender: 100 });
|
||||
rl.acquire('alice', 'read', 80);
|
||||
rl.release('alice', 'read', 80);
|
||||
rl.acquire('alice', 'read', 80);
|
||||
});
|
||||
});
|
||||
|
||||
describe('RateLimiter — refill', () => {
|
||||
test('tokens refill over time', async () => {
|
||||
const rl = new RateLimiter({
|
||||
// 6000/min = 100/sec = 0.1/ms; in 50ms we refill 5
|
||||
maxOpsPerMinutePerSender: 6000,
|
||||
opCost: { default: 1 },
|
||||
});
|
||||
// Drain by 5
|
||||
for (let i = 0; i < 5; i++) rl.acquire('alice', 'list');
|
||||
const before = rl.snapshot('alice')!.ops;
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
const after = rl.snapshot('alice')!.ops;
|
||||
expect(after).toBeGreaterThan(before);
|
||||
});
|
||||
});
|
||||
350
packages/shade-files/tests/unit/schemas.test.ts
Normal file
350
packages/shade-files/tests/unit/schemas.test.ts
Normal file
@@ -0,0 +1,350 @@
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import {
|
||||
PathSchema,
|
||||
RequestIdSchema,
|
||||
CursorSchema,
|
||||
Sha256HexSchema,
|
||||
FileEntrySchema,
|
||||
FileKindSchema,
|
||||
ListPageSchema,
|
||||
ListArgsSchema,
|
||||
StatArgsSchema,
|
||||
MkdirArgsSchema,
|
||||
DeleteArgsSchema,
|
||||
MoveArgsSchema,
|
||||
ReadArgsSchema,
|
||||
ReadResultSchema,
|
||||
WriteArgsSchema,
|
||||
GetThumbnailArgsSchema,
|
||||
GetThumbnailResultSchema,
|
||||
CustomArgsSchema,
|
||||
RpcRequestSchema,
|
||||
RpcResponseSchema,
|
||||
RpcErrorSchema,
|
||||
RpcCancelSchema,
|
||||
FileErrorCodeSchema,
|
||||
FileErrorPayloadSchema,
|
||||
} from '../../src/index.js';
|
||||
|
||||
describe('PathSchema', () => {
|
||||
test('accepts absolute paths', () => {
|
||||
expect(PathSchema.parse('/foo')).toBe('/foo');
|
||||
expect(PathSchema.parse('/foo/bar/baz.txt')).toBe('/foo/bar/baz.txt');
|
||||
expect(PathSchema.parse('/')).toBe('/');
|
||||
});
|
||||
test('rejects relative paths', () => {
|
||||
expect(() => PathSchema.parse('foo')).toThrow();
|
||||
expect(() => PathSchema.parse('./foo')).toThrow();
|
||||
});
|
||||
test('rejects NUL/CR/LF/DEL/backslash', () => {
|
||||
expect(() => PathSchema.parse('/foo\x00bar')).toThrow();
|
||||
expect(() => PathSchema.parse('/foo\r\n')).toThrow();
|
||||
expect(() => PathSchema.parse('/foo\x7f')).toThrow();
|
||||
expect(() => PathSchema.parse('/foo\\bar')).toThrow();
|
||||
});
|
||||
test('rejects empty + over-length', () => {
|
||||
expect(() => PathSchema.parse('')).toThrow();
|
||||
expect(() => PathSchema.parse('/' + 'a'.repeat(4096))).toThrow();
|
||||
});
|
||||
test('accepts UTF-8 in filenames', () => {
|
||||
expect(PathSchema.parse('/Документы/файл.txt')).toBe('/Документы/файл.txt');
|
||||
expect(PathSchema.parse('/πρόβλημα/αρχείο')).toBe('/πρόβλημα/αρχείο');
|
||||
});
|
||||
});
|
||||
|
||||
describe('RequestIdSchema', () => {
|
||||
test('accepts 22-char base64url', () => {
|
||||
expect(RequestIdSchema.parse('AbCdEfGhIjKlMnOpQrStUv')).toBe('AbCdEfGhIjKlMnOpQrStUv');
|
||||
});
|
||||
test('rejects wrong length', () => {
|
||||
expect(() => RequestIdSchema.parse('AbCd')).toThrow();
|
||||
expect(() => RequestIdSchema.parse('AbCdEfGhIjKlMnOpQrStUvWxYz')).toThrow();
|
||||
});
|
||||
test('rejects non-base64url chars', () => {
|
||||
expect(() => RequestIdSchema.parse('AbCd/EfGh+IjKlMnOpQrST')).toThrow();
|
||||
expect(() => RequestIdSchema.parse('AbCd=EfGhIjKlMnOpQrSTUV')).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('CursorSchema', () => {
|
||||
test('accepts up to 2048 chars', () => {
|
||||
expect(CursorSchema.parse('a')).toBe('a');
|
||||
expect(CursorSchema.parse('x'.repeat(2048)).length).toBe(2048);
|
||||
});
|
||||
test('rejects empty + over-length', () => {
|
||||
expect(() => CursorSchema.parse('')).toThrow();
|
||||
expect(() => CursorSchema.parse('x'.repeat(2049))).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Sha256HexSchema', () => {
|
||||
test('accepts 64 hex chars', () => {
|
||||
const h = '0'.repeat(64);
|
||||
expect(Sha256HexSchema.parse(h)).toBe(h);
|
||||
});
|
||||
test('rejects wrong length / non-hex', () => {
|
||||
expect(() => Sha256HexSchema.parse('0'.repeat(63))).toThrow();
|
||||
expect(() => Sha256HexSchema.parse('0'.repeat(65))).toThrow();
|
||||
expect(() => Sha256HexSchema.parse('g'.repeat(64))).toThrow();
|
||||
expect(() => Sha256HexSchema.parse('A'.repeat(64))).toThrow(); // uppercase rejected
|
||||
});
|
||||
});
|
||||
|
||||
describe('FileKind / FileEntry', () => {
|
||||
test('FileKind accepts file/dir', () => {
|
||||
expect(FileKindSchema.parse('file')).toBe('file');
|
||||
expect(FileKindSchema.parse('dir')).toBe('dir');
|
||||
expect(() => FileKindSchema.parse('symlink')).toThrow();
|
||||
});
|
||||
test('FileEntry roundtrip', () => {
|
||||
const e = {
|
||||
name: 'foo.txt',
|
||||
kind: 'file' as const,
|
||||
size: 1024,
|
||||
mtime: 1730000000000,
|
||||
contentType: 'text/plain',
|
||||
};
|
||||
const parsed = FileEntrySchema.parse(e);
|
||||
expect(parsed.name).toBe('foo.txt');
|
||||
expect(parsed.metadata).toEqual({}); // default
|
||||
});
|
||||
test('FileEntry rejects path separators in name', () => {
|
||||
expect(() =>
|
||||
FileEntrySchema.parse({ name: 'foo/bar', kind: 'file', size: 0, mtime: 0 }),
|
||||
).toThrow();
|
||||
expect(() =>
|
||||
FileEntrySchema.parse({ name: 'foo\\bar', kind: 'file', size: 0, mtime: 0 }),
|
||||
).toThrow();
|
||||
});
|
||||
test('FileEntry rejects negative size', () => {
|
||||
expect(() =>
|
||||
FileEntrySchema.parse({ name: 'a', kind: 'file', size: -1, mtime: 0 }),
|
||||
).toThrow();
|
||||
});
|
||||
test('FileEntry passes through metadata', () => {
|
||||
const parsed = FileEntrySchema.parse({
|
||||
name: 'mod.jar',
|
||||
kind: 'file',
|
||||
size: 100,
|
||||
mtime: 0,
|
||||
metadata: { modrinthId: 'abc', version: '1.0' },
|
||||
});
|
||||
expect(parsed.metadata).toEqual({ modrinthId: 'abc', version: '1.0' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('ListPage', () => {
|
||||
test('hasMore + nextCursor when more pages', () => {
|
||||
const p = ListPageSchema.parse({
|
||||
entries: [],
|
||||
hasMore: true,
|
||||
nextCursor: 'abc',
|
||||
});
|
||||
expect(p.hasMore).toBe(true);
|
||||
expect(p.nextCursor).toBe('abc');
|
||||
});
|
||||
test('no nextCursor when hasMore false', () => {
|
||||
const p = ListPageSchema.parse({ entries: [], hasMore: false });
|
||||
expect(p.nextCursor).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('ListArgs', () => {
|
||||
test('defaults pageSize to 100', () => {
|
||||
const a = ListArgsSchema.parse({ path: '/foo' });
|
||||
expect(a.pageSize).toBe(100);
|
||||
});
|
||||
test('rejects pageSize > 1000', () => {
|
||||
expect(() => ListArgsSchema.parse({ path: '/foo', pageSize: 1001 })).toThrow();
|
||||
});
|
||||
test('rejects pageSize < 1', () => {
|
||||
expect(() => ListArgsSchema.parse({ path: '/foo', pageSize: 0 })).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('StatArgs', () => {
|
||||
test('requires path', () => {
|
||||
expect(() => StatArgsSchema.parse({})).toThrow();
|
||||
expect(StatArgsSchema.parse({ path: '/foo' }).path).toBe('/foo');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Mkdir/Delete/Move args', () => {
|
||||
test('Mkdir defaults recursive=false', () => {
|
||||
expect(MkdirArgsSchema.parse({ path: '/a' }).recursive).toBe(false);
|
||||
});
|
||||
test('Delete defaults recursive=false', () => {
|
||||
expect(DeleteArgsSchema.parse({ path: '/a' }).recursive).toBe(false);
|
||||
});
|
||||
test('Move defaults overwrite=false', () => {
|
||||
expect(
|
||||
MoveArgsSchema.parse({ src: '/a', dst: '/b' }).overwrite,
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ReadArgs / ReadResult', () => {
|
||||
test('Read accepts optional range', () => {
|
||||
const a = ReadArgsSchema.parse({ path: '/a', range: { start: 0, end: 100 } });
|
||||
expect(a.range).toEqual({ start: 0, end: 100 });
|
||||
});
|
||||
test('ReadResult inline', () => {
|
||||
const r = ReadResultSchema.parse({
|
||||
kind: 'inline',
|
||||
bytesB64: 'YQ==',
|
||||
size: 1,
|
||||
sha256: 'a'.repeat(64),
|
||||
});
|
||||
expect(r.kind).toBe('inline');
|
||||
});
|
||||
test('ReadResult streams', () => {
|
||||
const r = ReadResultSchema.parse({
|
||||
kind: 'streams',
|
||||
streamId: 'sid-123',
|
||||
size: 1024,
|
||||
sha256: 'b'.repeat(64),
|
||||
});
|
||||
expect(r.kind).toBe('streams');
|
||||
});
|
||||
test('ReadResult rejects unknown kind', () => {
|
||||
expect(() =>
|
||||
ReadResultSchema.parse({ kind: 'magic', bytesB64: 'YQ==' }),
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('WriteArgs', () => {
|
||||
test('inline shape', () => {
|
||||
const w = WriteArgsSchema.parse({
|
||||
kind: 'inline',
|
||||
path: '/foo',
|
||||
bytesB64: 'YQ==',
|
||||
});
|
||||
expect(w.kind).toBe('inline');
|
||||
if (w.kind === 'inline') expect(w.overwrite).toBe(false);
|
||||
});
|
||||
test('streams shape', () => {
|
||||
const w = WriteArgsSchema.parse({
|
||||
kind: 'streams',
|
||||
path: '/foo',
|
||||
size: 1000,
|
||||
writeId: 'AbCdEfGhIjKlMnOpQrStUv',
|
||||
});
|
||||
if (w.kind === 'streams') expect(w.size).toBe(1000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GetThumbnailArgs / Result', () => {
|
||||
test('size must be enum 64/128/256/512', () => {
|
||||
expect(() => GetThumbnailArgsSchema.parse({ path: '/a', size: 100 })).toThrow();
|
||||
expect(GetThumbnailArgsSchema.parse({ path: '/a', size: 64 }).size).toBe(64);
|
||||
});
|
||||
test('format defaults to png', () => {
|
||||
const a = GetThumbnailArgsSchema.parse({ path: '/a', size: 128 });
|
||||
expect(a.format).toBe('png');
|
||||
});
|
||||
test('Result requires width/height/sha256', () => {
|
||||
const r = GetThumbnailResultSchema.parse({
|
||||
bytesB64: 'YQ==',
|
||||
format: 'png',
|
||||
width: 128,
|
||||
height: 128,
|
||||
sha256: 'c'.repeat(64),
|
||||
});
|
||||
expect(r.width).toBe(128);
|
||||
});
|
||||
});
|
||||
|
||||
describe('CustomArgs', () => {
|
||||
test('accepts dotted op names', () => {
|
||||
expect(
|
||||
CustomArgsSchema.parse({ name: 'dispatch.deploy-mod', payload: {} }).name,
|
||||
).toBe('dispatch.deploy-mod');
|
||||
});
|
||||
test('rejects names with spaces or slashes', () => {
|
||||
expect(() => CustomArgsSchema.parse({ name: 'foo bar', payload: {} })).toThrow();
|
||||
expect(() => CustomArgsSchema.parse({ name: 'foo/bar', payload: {} })).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('RPC envelopes', () => {
|
||||
test('RpcRequest valid', () => {
|
||||
const env = RpcRequestSchema.parse({
|
||||
kind: 'shade.fs.list/v1',
|
||||
id: 'AbCdEfGhIjKlMnOpQrStUv',
|
||||
args: { path: '/' },
|
||||
sig: 'sig',
|
||||
signedAt: 1730000000000,
|
||||
});
|
||||
expect(env.kind).toBe('shade.fs.list/v1');
|
||||
});
|
||||
test('RpcRequest rejects malformed kind', () => {
|
||||
expect(() =>
|
||||
RpcRequestSchema.parse({
|
||||
kind: 'shade.fs.list',
|
||||
id: 'AbCdEfGhIjKlMnOpQrStUv',
|
||||
args: {},
|
||||
sig: 's',
|
||||
signedAt: 1,
|
||||
}),
|
||||
).toThrow();
|
||||
});
|
||||
test('RpcResponse expects .response suffix', () => {
|
||||
const env = RpcResponseSchema.parse({
|
||||
kind: 'shade.fs.list/v1.response',
|
||||
id: 'AbCdEfGhIjKlMnOpQrStUv',
|
||||
result: { entries: [], hasMore: false },
|
||||
});
|
||||
expect(env.id.length).toBe(22);
|
||||
});
|
||||
test('RpcError fixed kind', () => {
|
||||
const env = RpcErrorSchema.parse({
|
||||
kind: 'shade.fs.error/v1',
|
||||
id: 'AbCdEfGhIjKlMnOpQrStUv',
|
||||
error: { code: 'NOT_FOUND', message: 'gone' },
|
||||
});
|
||||
expect(env.error.code).toBe('NOT_FOUND');
|
||||
});
|
||||
test('RpcCancel fixed kind', () => {
|
||||
const env = RpcCancelSchema.parse({
|
||||
kind: 'shade.fs.cancel/v1',
|
||||
id: 'AbCdEfGhIjKlMnOpQrStUv',
|
||||
reason: 'user-cancel',
|
||||
});
|
||||
expect(env.reason).toBe('user-cancel');
|
||||
});
|
||||
});
|
||||
|
||||
describe('FileError envelope', () => {
|
||||
test('all known codes parse', () => {
|
||||
const codes: ReadonlyArray<unknown> = [
|
||||
'NOT_FOUND',
|
||||
'PERMISSION_DENIED',
|
||||
'CONFLICT',
|
||||
'QUOTA_EXCEEDED',
|
||||
'RATE_LIMIT',
|
||||
'PATH_VALIDATION',
|
||||
'FINGERPRINT_REQUIRED',
|
||||
'OPERATION_TIMEOUT',
|
||||
'IDEMPOTENCY_CONFLICT',
|
||||
'CANCELLED',
|
||||
'INTERNAL',
|
||||
'NOT_IMPLEMENTED',
|
||||
'CUSTOM_OP_REJECTED',
|
||||
'INVALID_SIGNATURE',
|
||||
'INVALID_ARGS',
|
||||
];
|
||||
for (const c of codes) expect(FileErrorCodeSchema.parse(c)).toBe(c);
|
||||
});
|
||||
test('rejects unknown code', () => {
|
||||
expect(() => FileErrorCodeSchema.parse('NOT_REAL')).toThrow();
|
||||
});
|
||||
test('FileErrorPayload includes optional fields', () => {
|
||||
const p = FileErrorPayloadSchema.parse({
|
||||
code: 'RATE_LIMIT',
|
||||
message: 'too fast',
|
||||
retryAfterMs: 1000,
|
||||
});
|
||||
expect(p.retryAfterMs).toBe(1000);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user