import { describe, it, expect } from 'vitest' import { isSafeFilenameTemplate, isSafeOutputDir, isValidHistoryEntry, isValidErrorLogEntry, isTemplateLike, isValidSource, isValidMediaItem } from '../src/main/validation' import type { HistoryEntry, ErrorLogEntry, Source, MediaItem } from '@shared/ipc' // --- S4: filename template path-traversal ----------------------------------- describe('isSafeFilenameTemplate', () => { it('accepts the default template', () => { expect(isSafeFilenameTemplate('%(title)s.%(ext)s')).toBe(true) }) it('accepts sub-folder templates with yt-dlp fields', () => { expect(isSafeFilenameTemplate('%(uploader)s/%(title)s.%(ext)s')).toBe(true) expect(isSafeFilenameTemplate('%(uploader)s\\%(title)s.%(ext)s')).toBe(true) }) it('rejects forward-slash traversal', () => { expect(isSafeFilenameTemplate('%(title)s/../../evil.%(ext)s')).toBe(false) }) it('rejects backslash traversal', () => { expect(isSafeFilenameTemplate('%(title)s\\..\\..\\win32.exe')).toBe(false) }) it('rejects a bare .. segment', () => { expect(isSafeFilenameTemplate('..')).toBe(false) }) it('rejects absolute paths', () => { expect(isSafeFilenameTemplate('C:\\Windows\\system32\\%(title)s')).toBe(false) expect(isSafeFilenameTemplate('/etc/%(title)s')).toBe(false) }) it('allows .. only when embedded in a longer segment (not a real traversal)', () => { // '..foo' / 'foo..bar' are filenames, not parent-dir references. expect(isSafeFilenameTemplate('my..video.%(ext)s')).toBe(true) expect(isSafeFilenameTemplate('.%(title)s.%(ext)s')).toBe(true) // leading dot = hidden file }) // --- T1: Windows-specific bypasses -------------------------------------- it('rejects a drive-relative prefix that path.isAbsolute misses', () => { // 'C:foo' is NOT absolute per Node, but still escapes the output dir. expect(isSafeFilenameTemplate('C:foo\\%(title)s.%(ext)s')).toBe(false) expect(isSafeFilenameTemplate('c:%(title)s')).toBe(false) }) it('rejects a .. segment dressed up with trailing/leading dots or spaces', () => { // Windows trims trailing dots/spaces, so these all resolve to '..'. expect(isSafeFilenameTemplate('%(title)s/.. /x')).toBe(false) expect(isSafeFilenameTemplate('%(title)s/.. ./x')).toBe(false) expect(isSafeFilenameTemplate('%(title)s/ ../x')).toBe(false) expect(isSafeFilenameTemplate('%(title)s\\.. \\x')).toBe(false) expect(isSafeFilenameTemplate('...')).toBe(false) }) }) // --- S4: output directory ---------------------------------------------------- describe('isSafeOutputDir', () => { it('accepts an empty string (resolves to OS Downloads)', () => { expect(isSafeOutputDir('')).toBe(true) }) it('accepts an absolute Windows path', () => { expect(isSafeOutputDir('C:\\Users\\me\\Downloads')).toBe(true) }) it('rejects a relative path', () => { expect(isSafeOutputDir('Downloads')).toBe(false) expect(isSafeOutputDir('..\\elsewhere')).toBe(false) }) }) // --- S5: persisted JSON entry validation ------------------------------------- const validHistory: HistoryEntry = { id: 'a', title: 'A video', url: 'https://example.com/v', kind: 'video', quality: '1080p', completedAt: 1700000000000 } describe('isValidHistoryEntry', () => { it('accepts a well-formed entry', () => { expect(isValidHistoryEntry(validHistory)).toBe(true) }) it('accepts optional string fields', () => { expect( isValidHistoryEntry({ ...validHistory, filePath: 'C:\\x.mp4', channel: 'ch', sizeLabel: '10 MB', thumbnail: 'https://x/y.jpg' }) ).toBe(true) }) it('rejects a wrong kind', () => { expect(isValidHistoryEntry({ ...validHistory, kind: 'image' })).toBe(false) }) it('rejects a missing required field', () => { const { url: _url, ...noUrl } = validHistory expect(isValidHistoryEntry(noUrl)).toBe(false) }) it('rejects a non-string filePath (e.g. injected number/object)', () => { expect(isValidHistoryEntry({ ...validHistory, filePath: 42 })).toBe(false) }) it('rejects non-objects', () => { expect(isValidHistoryEntry(null)).toBe(false) expect(isValidHistoryEntry('nope')).toBe(false) expect(isValidHistoryEntry(undefined)).toBe(false) }) }) const validError: ErrorLogEntry = { id: 'e', url: 'https://example.com/v', kind: 'audio', error: 'boom', occurredAt: 1700000000000 } describe('isValidErrorLogEntry', () => { it('accepts a well-formed entry', () => { expect(isValidErrorLogEntry(validError)).toBe(true) expect(isValidErrorLogEntry({ ...validError, title: 'optional' })).toBe(true) }) it('rejects a missing error message', () => { const { error: _error, ...noError } = validError expect(isValidErrorLogEntry(noError)).toBe(false) }) it('rejects a non-numeric occurredAt', () => { expect(isValidErrorLogEntry({ ...validError, occurredAt: 'yesterday' })).toBe(false) }) }) describe('isTemplateLike', () => { it('accepts an object with a string or numeric id', () => { expect(isTemplateLike({ id: 'x', name: 'n', args: '--foo' })).toBe(true) expect(isTemplateLike({ id: 1 })).toBe(true) }) it('rejects entries without an id', () => { expect(isTemplateLike({ name: 'n', args: '--foo' })).toBe(false) expect(isTemplateLike(null)).toBe(false) expect(isTemplateLike('str')).toBe(false) }) }) // --- T7: source row validation (feedUrl drives a network fetch) -------------- const validSource: Source = { id: 's1', url: 'https://www.youtube.com/@x', kind: 'channel', title: 'X', addedAt: 1700000000000, itemCount: 5 } describe('isValidSource', () => { it('accepts a well-formed source and its optional fields', () => { expect(isValidSource(validSource)).toBe(true) expect( isValidSource({ ...validSource, channel: 'X', watched: true, feedUrl: 'https://www.youtube.com/feeds/videos.xml?channel_id=UC', lastIndexedAt: 1700000000001 }) ).toBe(true) }) it('rejects a non-string feedUrl (audit T7)', () => { expect(isValidSource({ ...validSource, feedUrl: 123 })).toBe(false) }) it('rejects a non-boolean watched (audit T7)', () => { expect(isValidSource({ ...validSource, watched: 'yes' })).toBe(false) }) it('rejects a wrong kind or a missing required field', () => { expect(isValidSource({ ...validSource, kind: 'video' })).toBe(false) const { id: _id, ...noId } = validSource expect(isValidSource(noId)).toBe(false) }) it('rejects non-objects', () => { expect(isValidSource(null)).toBe(false) expect(isValidSource('nope')).toBe(false) }) }) // --- L35: isValidMediaItem — the persisted media-items.json row validator ---- describe('isValidMediaItem', () => { const valid: MediaItem = { id: 'src1:vid1', sourceId: 'src1', videoId: 'vid1', title: 'A video', url: 'https://youtube.com/watch?v=vid1', playlistTitle: 'Uploads', playlistIndex: 1, downloaded: false } it('accepts a minimal valid item', () => { expect(isValidMediaItem(valid)).toBe(true) }) it('accepts the optional fields when well-typed', () => { expect( isValidMediaItem({ ...valid, durationLabel: '3:21', downloaded: true, downloadedAt: 1_700_000_000_000, filePath: 'C:/Videos/a.mp4' }) ).toBe(true) }) it('rejects a missing or wrong-typed required field', () => { const { videoId: _v, ...noVideoId } = valid expect(isValidMediaItem(noVideoId)).toBe(false) expect(isValidMediaItem({ ...valid, playlistIndex: '1' })).toBe(false) expect(isValidMediaItem({ ...valid, downloaded: 'no' })).toBe(false) }) it('rejects wrong-typed optional fields', () => { expect(isValidMediaItem({ ...valid, durationLabel: 42 })).toBe(false) expect(isValidMediaItem({ ...valid, downloadedAt: 'soon' })).toBe(false) expect(isValidMediaItem({ ...valid, filePath: 99 })).toBe(false) }) it('rejects non-objects', () => { expect(isValidMediaItem(null)).toBe(false) expect(isValidMediaItem('nope')).toBe(false) }) })