351 lines
11 KiB
TypeScript
351 lines
11 KiB
TypeScript
|
|
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);
|
||
|
|
});
|
||
|
|
});
|