Files
Shade/packages/shade-streams/tests/file-metadata.test.ts

281 lines
9.0 KiB
TypeScript
Raw Normal View History

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();
});
});