177 lines
5.4 KiB
TypeScript
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);
|
|
});
|
|
});
|