import { describe, test, expect } from 'bun:test'; import { ValidationError } from '@shade/core'; import { declaresThumbnail, encodeStreamControl, isAllowedThumbnailMime, mainStreamIdForThumbnail, parseStreamControl, THUMBNAIL_MAX_BYTES, THUMBNAIL_MIME_ALLOWLIST, thumbnailStreamIdFor, validateFileMetadata, } from '../src/index.js'; import type { StreamFileMetadata, StreamInitMessage } from '../src/index.js'; describe('V3.9 fileMetadata — schema', () => { test('THUMBNAIL_MAX_BYTES is 64 KiB', () => { expect(THUMBNAIL_MAX_BYTES).toBe(64 * 1024); }); test('THUMBNAIL_MIME_ALLOWLIST is jpeg/webp/png only', () => { expect([...THUMBNAIL_MIME_ALLOWLIST]).toEqual(['image/jpeg', 'image/webp', 'image/png']); }); test('isAllowedThumbnailMime accepts allowlist + rejects others', () => { for (const m of THUMBNAIL_MIME_ALLOWLIST) expect(isAllowedThumbnailMime(m)).toBe(true); expect(isAllowedThumbnailMime('image/svg+xml')).toBe(false); expect(isAllowedThumbnailMime('image/avif')).toBe(false); expect(isAllowedThumbnailMime('text/html')).toBe(false); expect(isAllowedThumbnailMime('')).toBe(false); }); test('thumbnailStreamIdFor + mainStreamIdForThumbnail roundtrip', () => { const main = 'AAAAAAAAAAAAAAAAAAAAAA'; const thumb = thumbnailStreamIdFor(main); expect(thumb).toBe(`${main}.thumb`); expect(mainStreamIdForThumbnail(thumb)).toBe(main); expect(mainStreamIdForThumbnail(main)).toBeNull(); }); test('declaresThumbnail requires hash + mime + bytes + streamId', () => { expect(declaresThumbnail(undefined)).toBe(false); expect(declaresThumbnail({})).toBe(false); expect(declaresThumbnail({ thumbnailHash: 'x' })).toBe(false); expect( declaresThumbnail({ thumbnailHash: 'x', thumbnailMime: 'image/webp', thumbnailBytes: 100, }), ).toBe(false); expect( declaresThumbnail({ thumbnailHash: 'x', thumbnailMime: 'image/webp', thumbnailBytes: 100, thumbnailStreamId: 'BBBB', }), ).toBe(true); }); }); describe('V3.9 validateFileMetadata', () => { test('accepts empty object', () => { expect(() => validateFileMetadata({})).not.toThrow(); }); test('accepts a fully-populated metadata', () => { const meta: StreamFileMetadata = { filename: 'report.pdf', mimeType: 'application/pdf', thumbnailStreamId: 'BB', thumbnailHash: 'aGFzaA==', thumbnailMime: 'image/webp', thumbnailBytes: 12345, }; expect(() => validateFileMetadata(meta)).not.toThrow(); }); test('rejects oversized thumbnailBytes', () => { expect(() => validateFileMetadata({ thumbnailMime: 'image/webp', thumbnailBytes: THUMBNAIL_MAX_BYTES + 1, }), ).toThrow(ValidationError); }); test('rejects negative or non-integer thumbnailBytes', () => { expect(() => validateFileMetadata({ thumbnailMime: 'image/webp', thumbnailBytes: -1 }), ).toThrow(ValidationError); expect(() => validateFileMetadata({ thumbnailMime: 'image/webp', thumbnailBytes: 1.5 }), ).toThrow(ValidationError); }); test('rejects thumbnailMime outside allowlist', () => { expect(() => validateFileMetadata({ thumbnailMime: 'image/svg+xml' as never, thumbnailBytes: 1024, }), ).toThrow(ValidationError); }); test('rejects thumbnailMime declared without thumbnailBytes', () => { expect(() => validateFileMetadata({ thumbnailMime: 'image/webp' }), ).toThrow(ValidationError); }); test('rejects filename with control characters', () => { expect(() => validateFileMetadata({ filename: 'foo\nbar' })).toThrow(ValidationError); expect(() => validateFileMetadata({ filename: 'foo\rbar' })).toThrow(ValidationError); expect(() => validateFileMetadata({ filename: 'foo' })).toThrow(ValidationError); }); test('rejects oversized filename', () => { expect(() => validateFileMetadata({ filename: 'a'.repeat(1025) }), ).toThrow(ValidationError); }); test('rejects malformed mimeType', () => { expect(() => validateFileMetadata({ mimeType: 'no-slash' })).toThrow(ValidationError); expect(() => validateFileMetadata({ mimeType: 'two/slashes/here' })).toThrow(ValidationError); expect(() => validateFileMetadata({ mimeType: '' })).toThrow(ValidationError); }); test('accepts well-formed mimeType', () => { expect(() => validateFileMetadata({ mimeType: 'application/pdf' })).not.toThrow(); expect(() => validateFileMetadata({ mimeType: 'image/x-foo+bar' })).not.toThrow(); }); }); describe('V3.9 stream-init envelope encode/parse with fileMetadata', () => { test('roundtrip preserves fileMetadata', () => { const msg: StreamInitMessage = { kind: 'shade.stream-init/v1', streamId: 'AAAAAAAAAAAAAAAAAAAAAA', streamSecret: 'BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBA', metadata: { name: 'world.zip', sizeBytes: 1024, contentType: 'application/zip', chunkSize: 256, totalChunks: 4, sentAt: 1730000000000, fileMetadata: { filename: 'world.zip', mimeType: 'application/zip', thumbnailStreamId: 'thumb-id-1234567', thumbnailHash: 'aGFzaA==', thumbnailMime: 'image/webp', thumbnailBytes: 4096, }, }, lanes: [ { laneId: 0, partition: { kind: 'range', startByte: 0, endByte: 1024, startChunk: 0 }, }, ], }; const json = encodeStreamControl(msg); expect(parseStreamControl(json)).toEqual(msg); }); test('parse rejects fileMetadata with bad thumbnailMime', () => { const bad = JSON.stringify({ kind: 'shade.stream-init/v1', streamId: 'AAAAAAAAAAAAAAAAAAAAAA', streamSecret: 'BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBA', metadata: { chunkSize: 256, sentAt: 1730000000000, fileMetadata: { thumbnailMime: 'image/svg+xml', thumbnailBytes: 1024, }, }, lanes: [ { laneId: 0, partition: { kind: 'range', startByte: 0, endByte: 1024, startChunk: 0 }, }, ], }); expect(() => parseStreamControl(bad)).toThrow(ValidationError); }); test('parse rejects fileMetadata with oversized thumbnailBytes', () => { const bad = JSON.stringify({ kind: 'shade.stream-init/v1', streamId: 'AAAAAAAAAAAAAAAAAAAAAA', streamSecret: 'BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBA', metadata: { chunkSize: 256, sentAt: 1730000000000, fileMetadata: { thumbnailMime: 'image/jpeg', thumbnailBytes: THUMBNAIL_MAX_BYTES * 10, }, }, lanes: [ { laneId: 0, partition: { kind: 'range', startByte: 0, endByte: 1024, startChunk: 0 }, }, ], }); expect(() => parseStreamControl(bad)).toThrow(ValidationError); }); test('encode rejects fileMetadata that would not parse — symmetric guard', () => { const bad: StreamInitMessage = { kind: 'shade.stream-init/v1', streamId: 'AAAAAAAAAAAAAAAAAAAAAA', streamSecret: 'BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBA', metadata: { chunkSize: 256, sentAt: 1730000000000, fileMetadata: { filename: 'evil\nname', }, }, lanes: [ { laneId: 0, partition: { kind: 'range', startByte: 0, endByte: 1024, startChunk: 0 }, }, ], }; expect(() => encodeStreamControl(bad)).toThrow(ValidationError); }); }); describe('V3.9 backwards-compat — older receivers ignore unknown fields', () => { test('parse leaves fileMetadata untouched on roundtrip even when receiver does not consult it', () => { const msg: StreamInitMessage = { kind: 'shade.stream-init/v1', streamId: 'AAAAAAAAAAAAAAAAAAAAAA', streamSecret: 'BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBA', metadata: { chunkSize: 256, sentAt: 1730000000000, fileMetadata: { filename: 'x.bin' }, }, lanes: [ { laneId: 0, partition: { kind: 'range', startByte: 0, endByte: 256, startChunk: 0 }, }, ], }; // A legacy reader picking only the fields it knows about still works. const decoded = parseStreamControl(encodeStreamControl(msg)); expect(decoded.kind).toBe('shade.stream-init/v1'); if (decoded.kind === 'shade.stream-init/v1') { expect(decoded.metadata.chunkSize).toBe(256); } }); test('a stream-init without fileMetadata is still accepted (legacy sender)', () => { const msg: StreamInitMessage = { kind: 'shade.stream-init/v1', streamId: 'AAAAAAAAAAAAAAAAAAAAAA', streamSecret: 'BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBA', metadata: { chunkSize: 256, sentAt: 1730000000000 }, lanes: [ { laneId: 0, partition: { kind: 'range', startByte: 0, endByte: 256, startChunk: 0 }, }, ], }; expect(() => parseStreamControl(encodeStreamControl(msg))).not.toThrow(); }); });