Files
av-tools/tests/unit/export-output.test.ts

177 lines
5.4 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import {
createExportReport,
deriveOutputFileName,
exportReportBlob,
isBrowserPreviewMimeType,
isSafeArchiveLeafName,
mimeTypeFromOutputName,
normalizeOutputMimeType,
preferredExtensionForMimeType,
redactPrivatePaths,
safeExportFileName,
serializeExportReport,
uniqueExportFileNames,
type ExportResult,
} from '../../src/export';
describe('safe export output names', () => {
it('removes directory traversal, device names, and unsafe punctuation', () => {
expect(safeExportFileName('../../CON.mp4')).toBe('_CON.mp4');
expect(safeExportFileName('..\\..\\holiday 🎬.MP4')).toBe('holiday.mp4');
expect(
safeExportFileName('/private/video.mov', { extension: '.webm' })
).toBe('video.webm');
expect(isSafeArchiveLeafName('../unsafe.mp4')).toBe(false);
expect(isSafeArchiveLeafName('safe-output.mp4')).toBe(true);
});
it('derives numbered names and rejects invalid part positions', () => {
expect(
deriveOutputFileName({
sourceName: 'My recording.mov',
operation: 'split',
extension: 'mp4',
partIndex: 1,
partCount: 12,
})
).toBe('My-recording-split-002.mp4');
expect(() =>
deriveOutputFileName({
sourceName: 'source.mp4',
operation: 'split',
partIndex: 2,
partCount: 2,
})
).toThrow(/part index/iu);
});
it('deduplicates names for case-insensitive filesystems', () => {
expect(
uniqueExportFileNames([
'Clip.mp4',
'clip.mp4',
'../../CLIP.mp4',
'other.mp4',
])
).toEqual(['Clip.mp4', 'clip-2.mp4', 'CLIP-3.mp4', 'other.mp4']);
});
});
describe('output MIME helpers', () => {
it('normalizes base types and falls back to known extensions', () => {
expect(normalizeOutputMimeType('Video/MP4; codecs=avc1')).toBe('video/mp4');
expect(normalizeOutputMimeType('not a mime', 'frame.png')).toBe(
'image/png'
);
expect(normalizeOutputMimeType(undefined, 'unknown.xyz')).toBe(
'application/octet-stream'
);
expect(mimeTypeFromOutputName('captions.vtt')).toBe('text/vtt');
expect(preferredExtensionForMimeType('image/jpeg')).toBe('.jpg');
expect(isBrowserPreviewMimeType('audio/mpeg')).toBe(true);
expect(isBrowserPreviewMimeType('application/json')).toBe(false);
});
});
describe('privacy-safe JSON export reports', () => {
const result = {
id: 'result',
planId: 'plan',
outputId: 'media',
fileName: '../../converted.mp4',
blob: new Blob(['private bytes']),
mimeType: 'video/mp4',
role: 'media',
size: 13,
createdAt: '2026-01-01T00:00:00.000Z',
} satisfies ExportResult;
it('includes plan diagnostics and output metadata without source data', async () => {
const privateRuntimeResult = {
...result,
// Extra runtime fields must never be copied into the report.
sourceBlob: new Blob(['source secret']),
hostPath: '/home/zemion/private/source.mp4',
};
const report = createExportReport({
plan: {
id: 'plan-/home/zemion/private/source.mp4',
operation: 'convert',
diagnostics: [
{
code: 'codec-warning',
severity: 'warning',
message:
'Read /mnt/DATA/Videos/secret.mov via blob:https://local/id',
field: 'C:\\Users\\Person\\Videos\\secret.mov',
},
],
},
results: [privateRuntimeResult],
execution: {
status: 'completed',
engineMode: 'single-thread',
ffmpegVersion: '6.0',
elapsedSeconds: 1.25,
conversionMilliseconds: 1_000,
outputReadMilliseconds: 25,
cleanupMilliseconds: 5,
exitCode: 0,
},
generatedAt: '2026-07-24T12:00:00+02:00',
});
expect(report.generatedAt).toBe('2026-07-24T10:00:00.000Z');
expect(report.plan.id).toContain('[local path]');
expect(report.outputs[0]).toEqual({
id: 'result',
outputId: 'media',
fileName: 'converted.mp4',
mimeType: 'video/mp4',
role: 'media',
sizeBytes: 13,
});
expect(report.diagnostics[0]?.message).toBe(
'Read [local path] via [object URL]'
);
expect(report.diagnostics[0]?.field).toBe('[local path]');
expect(report.privacy).toEqual({
includesSourceMedia: false,
includesCommandArguments: false,
privatePathsRedacted: true,
});
expect(report.execution).toMatchObject({
conversionMilliseconds: 1_000,
outputReadMilliseconds: 25,
cleanupMilliseconds: 5,
});
const serialized = serializeExportReport(report);
expect(serialized.endsWith('\n')).toBe(true);
expect(serialized).not.toContain('sourceBlob');
expect(serialized).not.toContain('hostPath');
expect(serialized).not.toContain('/home/zemion');
const blob = exportReportBlob(report);
expect(blob.type).toBe('application/json');
expect(await blob.text()).toBe(serialized);
});
it('redacts common local path and URL forms', () => {
expect(
redactPrivatePaths(
'file:///home/me/a.mp4 C:\\fakepath\\b.mp4 /Users/me/c.mp4'
)
).toBe('[local path] [local path] [local path]');
});
it('rejects invalid numeric report metadata', () => {
expect(() =>
createExportReport({
plan: { id: 'plan', operation: 'convert', diagnostics: [] },
results: [{ ...result, size: Number.NaN }],
})
).toThrow(/output size/iu);
});
});