Files
Shade/packages/shade-streams/tests/file-metadata.test.ts
Sterister e6fdf31b49
Some checks failed
Test / test (push) Has been cancelled
Cross-platform vectors / TypeScript vectors (bun) (push) Has been cancelled
Cross-platform vectors / Kotlin vectors (gradle) (push) Has been cancelled
Docker build and publish / docker (push) Has been cancelled
Publish / publish (push) Has been cancelled
release(v4.0.0): Shade GA — V3.x consolidation + audit prep
V3.1 → V3.12 consolidated and tagged for the first GA release. Wire
format unchanged from 0.4.x — 4.0 peers interoperate with 0.4.x peers
byte-for-byte. The version bump is semantic: audit-cycle complete,
opt-in surface fully exposed, threat model refreshed for every new
surface.

Highlights:
- All 24 @shade/* packages bumped to 4.0.0 in lockstep.
- CHANGELOG 4.0.0 section is the canonical manifest of what landed.
- THREAT-MODEL extended (§10 fingerprint gates, §11 WebRTC P2P, §12
  Web-Worker boundary) + residual-risks table refreshed.
- OpenAPI now covers all 27 routes: prekey, transfer, KT, inbox,
  bridge, observer, /metrics, /healthz, /ready.
- MIGRATION 0.3.x → 4.0 documented + smoke-tested against
  shade migrate-storage on a real SQLite DB.
- docs/audit/REVIEW-BUNDLE.md + SCOPE.md ready for external reviewer.
- scripts/soak.ts harness for the GA-stable 2-week soak window.
- All V*.md plans archived under docs/archive/ with Status: Done.
- Voice/Video carved out into V5.0; 4.0 audit focuses on the frozen
  non-realtime stack.

Tests: TS 1000/1000 + Kotlin 11/11 cross-platform vectors green.
Docker: gt.zyon.no/stian/shade-prekey:4.0.0 builds and reports
  version 4.0.0 on /health.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 18:35:35 +02:00

281 lines
9.0 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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();
});
});