mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-02 20:30:10 +00:00
Improve metadata-tagger tests and add integration
Switch unit tests to mock child_process.execFile and assert argv structure instead of a single shell command string. Add helpers (lastCallArgv, expectMetadataArg) and expand coverage to catch the #171 quote-regression, validate sanitization of invisible/whitespace/null chars, ensure no shell-quoting is introduced, and cover all format branches (m4b/mp3/flac). Add a new integration test suite that runs real ffmpeg/ffprobe (skips if binaries missing) to verify metadata round-trips byte-for-byte. Update metadata-tagger implementation (binary change) to use the argv-style spawn/execFile path expected by the tests.
This commit is contained in:
Binary file not shown.
@@ -0,0 +1,152 @@
|
|||||||
|
/**
|
||||||
|
* Component: Metadata Tagging Integration Tests (real ffmpeg)
|
||||||
|
* Documentation: documentation/phase3/file-organization.md
|
||||||
|
*
|
||||||
|
* Gated behind a runtime ffmpeg/ffprobe availability check. Skips cleanly
|
||||||
|
* if either binary is missing. Verifies that tag values written via the
|
||||||
|
* real argv-form spawn path are read back byte-for-byte by ffprobe — this
|
||||||
|
* is the integration-layer guarantee that the quote-bug (#171) cannot recur.
|
||||||
|
*
|
||||||
|
* Note: availability check uses spawnSync at module load so `it.skipIf` can
|
||||||
|
* see the result (test-registration is synchronous; beforeAll runs later).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { execFile, spawnSync } from 'child_process';
|
||||||
|
import { promisify } from 'util';
|
||||||
|
import fs from 'fs/promises';
|
||||||
|
import os from 'os';
|
||||||
|
import path from 'path';
|
||||||
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||||
|
import { tagAudioFileMetadata } from '@/lib/utils/metadata-tagger';
|
||||||
|
|
||||||
|
const execFileP = promisify(execFile);
|
||||||
|
|
||||||
|
function hasBinarySync(bin: string): boolean {
|
||||||
|
try {
|
||||||
|
const result = spawnSync(bin, ['-version'], { stdio: 'ignore' });
|
||||||
|
return result.status === 0;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const ffmpegAvailable = hasBinarySync('ffmpeg');
|
||||||
|
const ffprobeAvailable = hasBinarySync('ffprobe');
|
||||||
|
const skipReal = !(ffmpegAvailable && ffprobeAvailable);
|
||||||
|
|
||||||
|
let workDir = '';
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
if (skipReal) {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.warn(
|
||||||
|
`[metadata-tagger.integration] Skipping real-ffmpeg tests: ffmpeg=${ffmpegAvailable}, ffprobe=${ffprobeAvailable}`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
workDir = await fs.mkdtemp(path.join(os.tmpdir(), 'rmab-tagger-it-'));
|
||||||
|
}, 30_000);
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
if (workDir) {
|
||||||
|
try {
|
||||||
|
await fs.rm(workDir, { recursive: true, force: true });
|
||||||
|
} catch {
|
||||||
|
// best-effort cleanup
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Create a small silent fixture audio file in the requested format. */
|
||||||
|
async function makeFixture(ext: '.m4a' | '.mp3' | '.flac'): Promise<string> {
|
||||||
|
const out = path.join(workDir, `fixture-${Date.now()}-${Math.random().toString(36).slice(2)}${ext}`);
|
||||||
|
const codecArgs =
|
||||||
|
ext === '.mp3'
|
||||||
|
? ['-c:a', 'libmp3lame', '-q:a', '9']
|
||||||
|
: ext === '.flac'
|
||||||
|
? ['-c:a', 'flac']
|
||||||
|
: ['-c:a', 'aac', '-b:a', '32k'];
|
||||||
|
await execFileP('ffmpeg', [
|
||||||
|
'-y',
|
||||||
|
'-f', 'lavfi',
|
||||||
|
'-i', 'anullsrc=channel_layout=mono:sample_rate=22050',
|
||||||
|
'-t', '1',
|
||||||
|
...codecArgs,
|
||||||
|
out,
|
||||||
|
]);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read a single metadata tag back from a file via ffprobe. */
|
||||||
|
async function readTag(filePath: string, key: string): Promise<string | undefined> {
|
||||||
|
const { stdout } = await execFileP('ffprobe', [
|
||||||
|
'-v', 'error',
|
||||||
|
'-show_entries', 'format_tags',
|
||||||
|
'-of', 'json',
|
||||||
|
filePath,
|
||||||
|
]);
|
||||||
|
const parsed = JSON.parse(stdout) as { format?: { tags?: Record<string, string> } };
|
||||||
|
const tags = parsed.format?.tags ?? {};
|
||||||
|
return tags[key] ?? tags[key.toLowerCase()] ?? tags[key.toUpperCase()];
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('metadata-tagger integration (real ffmpeg)', () => {
|
||||||
|
// Torture value covers the bug-class surface:
|
||||||
|
// embedded `"`, `'`, `$`, `` ` ``, `\`, em-dash.
|
||||||
|
const TORTURE = `Test "Quoted" Book — Don't $hop \`now\` \\path`;
|
||||||
|
|
||||||
|
it.skipIf(skipReal)(
|
||||||
|
'm4a: round-trips a torture-value title byte-for-byte',
|
||||||
|
async () => {
|
||||||
|
const src = await makeFixture('.m4a');
|
||||||
|
const result = await tagAudioFileMetadata(src, { title: TORTURE, author: 'Author' });
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.taggedFilePath).toBeDefined();
|
||||||
|
const readBack = await readTag(result.taggedFilePath!, 'title');
|
||||||
|
expect(readBack).toBe(TORTURE);
|
||||||
|
},
|
||||||
|
60_000
|
||||||
|
);
|
||||||
|
|
||||||
|
it.skipIf(skipReal)(
|
||||||
|
'mp3: round-trips a torture-value title byte-for-byte',
|
||||||
|
async () => {
|
||||||
|
const src = await makeFixture('.mp3');
|
||||||
|
const result = await tagAudioFileMetadata(src, { title: TORTURE, author: 'Author' });
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
const readBack = await readTag(result.taggedFilePath!, 'title');
|
||||||
|
expect(readBack).toBe(TORTURE);
|
||||||
|
},
|
||||||
|
60_000
|
||||||
|
);
|
||||||
|
|
||||||
|
it.skipIf(skipReal)(
|
||||||
|
'flac: round-trips a torture-value title byte-for-byte',
|
||||||
|
async () => {
|
||||||
|
const src = await makeFixture('.flac');
|
||||||
|
const result = await tagAudioFileMetadata(src, { title: TORTURE, author: 'Author' });
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
const readBack = await readTag(result.taggedFilePath!, 'title');
|
||||||
|
expect(readBack).toBe(TORTURE);
|
||||||
|
},
|
||||||
|
60_000
|
||||||
|
);
|
||||||
|
|
||||||
|
it.skipIf(skipReal)(
|
||||||
|
'm4a: author does NOT inherit a stray leading `"` when title contains `"`',
|
||||||
|
async () => {
|
||||||
|
// The exact #171 symptom: a `"` in one field used to leak as a leading `"`
|
||||||
|
// on an adjacent field after shell tokenization. Verify both are clean.
|
||||||
|
const src = await makeFixture('.m4a');
|
||||||
|
const result = await tagAudioFileMetadata(src, {
|
||||||
|
title: 'Has "embedded" quotes',
|
||||||
|
author: 'Alexandre Dumas',
|
||||||
|
});
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
const readAuthor = await readTag(result.taggedFilePath!, 'album_artist');
|
||||||
|
expect(readAuthor).toBe('Alexandre Dumas');
|
||||||
|
expect(readAuthor?.startsWith('"')).toBe(false);
|
||||||
|
},
|
||||||
|
60_000
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -6,14 +6,14 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import { checkFfmpegAvailable, tagAudioFileMetadata, tagMultipleFiles } from '@/lib/utils/metadata-tagger';
|
import { checkFfmpegAvailable, tagAudioFileMetadata, tagMultipleFiles } from '@/lib/utils/metadata-tagger';
|
||||||
|
|
||||||
const execMock = vi.hoisted(() => vi.fn());
|
const execFileMock = vi.hoisted(() => vi.fn());
|
||||||
const fsMock = vi.hoisted(() => ({
|
const fsMock = vi.hoisted(() => ({
|
||||||
access: vi.fn(),
|
access: vi.fn(),
|
||||||
unlink: vi.fn(),
|
unlink: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('child_process', () => ({
|
vi.mock('child_process', () => ({
|
||||||
exec: execMock,
|
execFile: execFileMock,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('fs/promises', () => ({
|
vi.mock('fs/promises', () => ({
|
||||||
@@ -21,20 +21,49 @@ vi.mock('fs/promises', () => ({
|
|||||||
...fsMock,
|
...fsMock,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
function mockExecSuccess(stdout = 'ok') {
|
function mockExecFileSuccess(stdout = 'ok') {
|
||||||
execMock.mockImplementation((command: string, options: any, callback?: any) => {
|
execFileMock.mockImplementation((file: string, args: string[], options: any, callback?: any) => {
|
||||||
const cb = typeof options === 'function' ? options : callback;
|
const cb = typeof options === 'function' ? options : callback;
|
||||||
cb(null, stdout, '');
|
cb(null, stdout, '');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function mockExecFailure(message = 'ffmpeg error') {
|
function mockExecFileFailure(message = 'ffmpeg error') {
|
||||||
execMock.mockImplementation((command: string, options: any, callback?: any) => {
|
execFileMock.mockImplementation((file: string, args: string[], options: any, callback?: any) => {
|
||||||
const cb = typeof options === 'function' ? options : callback;
|
const cb = typeof options === 'function' ? options : callback;
|
||||||
cb(new Error(message), '', '');
|
cb(new Error(message), '', '');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function lastCallArgv(): string[] {
|
||||||
|
const call = execFileMock.mock.calls.at(-1);
|
||||||
|
if (!call) throw new Error('execFile was not called');
|
||||||
|
return call[1] as string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function lastCallCommand(): string {
|
||||||
|
const call = execFileMock.mock.calls.at(-1);
|
||||||
|
if (!call) throw new Error('execFile was not called');
|
||||||
|
return call[0] as string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assert that argv contains the adjacent pair `['-metadata', '${key}=${value}']`.
|
||||||
|
* This is the regression-catching assertion — it verifies the metadata payload
|
||||||
|
* is a single argv element with no embedded shell quoting.
|
||||||
|
*/
|
||||||
|
function expectMetadataArg(argv: string[], key: string, value: string): void {
|
||||||
|
const expectedPayload = `${key}=${value}`;
|
||||||
|
for (let i = 0; i < argv.length - 1; i++) {
|
||||||
|
if (argv[i] === '-metadata' && argv[i + 1] === expectedPayload) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error(
|
||||||
|
`Expected argv to contain ['-metadata', '${expectedPayload}'] as adjacent elements.\nArgv: ${JSON.stringify(argv)}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
describe('metadata tagger', () => {
|
describe('metadata tagger', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
@@ -50,12 +79,12 @@ describe('metadata tagger', () => {
|
|||||||
|
|
||||||
expect(result.success).toBe(false);
|
expect(result.success).toBe(false);
|
||||||
expect(result.error).toContain('Unsupported file format');
|
expect(result.error).toContain('Unsupported file format');
|
||||||
expect(execMock).not.toHaveBeenCalled();
|
expect(execFileMock).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('tags an m4b file with metadata', async () => {
|
it('tags an m4b file with metadata using argv form', async () => {
|
||||||
fsMock.access.mockResolvedValue(undefined);
|
fsMock.access.mockResolvedValue(undefined);
|
||||||
mockExecSuccess('done');
|
mockExecFileSuccess('done');
|
||||||
|
|
||||||
const result = await tagAudioFileMetadata('/tmp/book.m4b', {
|
const result = await tagAudioFileMetadata('/tmp/book.m4b', {
|
||||||
title: 'Book',
|
title: 'Book',
|
||||||
@@ -68,19 +97,62 @@ describe('metadata tagger', () => {
|
|||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
expect(result.taggedFilePath).toBe('/tmp/book.m4b.tmp');
|
expect(result.taggedFilePath).toBe('/tmp/book.m4b.tmp');
|
||||||
|
|
||||||
const command = execMock.mock.calls[0][0] as string;
|
expect(lastCallCommand()).toBe('ffmpeg');
|
||||||
expect(command).toContain('-metadata title="Book"');
|
|
||||||
expect(command).toContain('-metadata album_artist="Author"');
|
const argv = lastCallArgv();
|
||||||
expect(command).toContain('-metadata composer="Narrator"');
|
expectMetadataArg(argv, 'title', 'Book');
|
||||||
expect(command).toContain('-metadata date="2020"');
|
expectMetadataArg(argv, 'album', 'Book');
|
||||||
expect(command).toContain('-metadata ----:com.apple.iTunes:ASIN="ASIN123"');
|
expectMetadataArg(argv, 'album_artist', 'Author');
|
||||||
expect(command).toContain('-f mp4');
|
expectMetadataArg(argv, 'artist', 'Author');
|
||||||
|
expectMetadataArg(argv, 'composer', 'Narrator');
|
||||||
|
expectMetadataArg(argv, 'date', '2020');
|
||||||
|
expectMetadataArg(argv, '----:com.apple.iTunes:ASIN', 'ASIN123');
|
||||||
|
|
||||||
|
expect(argv).toContain('/tmp/book.m4b');
|
||||||
|
expect(argv).toContain('/tmp/book.m4b.tmp');
|
||||||
|
|
||||||
|
const fIdx = argv.indexOf('-f');
|
||||||
|
expect(fIdx).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(argv[fIdx + 1]).toBe('mp4');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tags an mp3 file with mp3 output format', async () => {
|
||||||
|
fsMock.access.mockResolvedValue(undefined);
|
||||||
|
mockExecFileSuccess('done');
|
||||||
|
|
||||||
|
await tagAudioFileMetadata('/tmp/book.mp3', {
|
||||||
|
title: 'Book',
|
||||||
|
author: 'Author',
|
||||||
|
asin: 'ASIN999',
|
||||||
|
});
|
||||||
|
|
||||||
|
const argv = lastCallArgv();
|
||||||
|
expectMetadataArg(argv, 'title', 'Book');
|
||||||
|
expectMetadataArg(argv, 'ASIN', 'ASIN999');
|
||||||
|
const fIdx = argv.indexOf('-f');
|
||||||
|
expect(argv[fIdx + 1]).toBe('mp3');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tags a flac file with flac output format', async () => {
|
||||||
|
fsMock.access.mockResolvedValue(undefined);
|
||||||
|
mockExecFileSuccess('done');
|
||||||
|
|
||||||
|
await tagAudioFileMetadata('/tmp/book.flac', {
|
||||||
|
title: 'Book',
|
||||||
|
author: 'Author',
|
||||||
|
});
|
||||||
|
|
||||||
|
const argv = lastCallArgv();
|
||||||
|
expectMetadataArg(argv, 'title', 'Book');
|
||||||
|
expectMetadataArg(argv, 'albumartist', 'Author');
|
||||||
|
const fIdx = argv.indexOf('-f');
|
||||||
|
expect(argv[fIdx + 1]).toBe('flac');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('cleans up temp files and returns errors when ffmpeg fails', async () => {
|
it('cleans up temp files and returns errors when ffmpeg fails', async () => {
|
||||||
fsMock.access.mockResolvedValue(undefined);
|
fsMock.access.mockResolvedValue(undefined);
|
||||||
fsMock.unlink.mockResolvedValue(undefined);
|
fsMock.unlink.mockResolvedValue(undefined);
|
||||||
mockExecFailure('exec failed');
|
mockExecFileFailure('exec failed');
|
||||||
|
|
||||||
const result = await tagAudioFileMetadata('/tmp/book.mp3', {
|
const result = await tagAudioFileMetadata('/tmp/book.mp3', {
|
||||||
title: 'Book',
|
title: 'Book',
|
||||||
@@ -95,7 +167,7 @@ describe('metadata tagger', () => {
|
|||||||
|
|
||||||
it('tags multiple files in sequence', async () => {
|
it('tags multiple files in sequence', async () => {
|
||||||
fsMock.access.mockResolvedValue(undefined);
|
fsMock.access.mockResolvedValue(undefined);
|
||||||
mockExecSuccess('done');
|
mockExecFileSuccess('done');
|
||||||
|
|
||||||
const results = await tagMultipleFiles(['/tmp/one.m4a', '/tmp/two.m4a'], {
|
const results = await tagMultipleFiles(['/tmp/one.m4a', '/tmp/two.m4a'], {
|
||||||
title: 'Book',
|
title: 'Book',
|
||||||
@@ -107,17 +179,17 @@ describe('metadata tagger', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('checks ffmpeg availability', async () => {
|
it('checks ffmpeg availability', async () => {
|
||||||
mockExecSuccess('ffmpeg version');
|
mockExecFileSuccess('ffmpeg version');
|
||||||
await expect(checkFfmpegAvailable()).resolves.toBe(true);
|
await expect(checkFfmpegAvailable()).resolves.toBe(true);
|
||||||
|
|
||||||
mockExecFailure('not installed');
|
mockExecFileFailure('not installed');
|
||||||
await expect(checkFfmpegAvailable()).resolves.toBe(false);
|
await expect(checkFfmpegAvailable()).resolves.toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('series metadata', () => {
|
describe('series metadata', () => {
|
||||||
it('writes show/episode_id for m4b when series/seriesPart provided', async () => {
|
it('writes show/episode_id for m4b when series/seriesPart provided', async () => {
|
||||||
fsMock.access.mockResolvedValue(undefined);
|
fsMock.access.mockResolvedValue(undefined);
|
||||||
mockExecSuccess('done');
|
mockExecFileSuccess('done');
|
||||||
|
|
||||||
await tagAudioFileMetadata('/tmp/book.m4b', {
|
await tagAudioFileMetadata('/tmp/book.m4b', {
|
||||||
title: 'Book',
|
title: 'Book',
|
||||||
@@ -126,14 +198,14 @@ describe('metadata tagger', () => {
|
|||||||
seriesPart: '1',
|
seriesPart: '1',
|
||||||
});
|
});
|
||||||
|
|
||||||
const command = execMock.mock.calls[0][0] as string;
|
const argv = lastCallArgv();
|
||||||
expect(command).toContain('-metadata show="The Mistborn Saga"');
|
expectMetadataArg(argv, 'show', 'The Mistborn Saga');
|
||||||
expect(command).toContain('-metadata episode_id="1"');
|
expectMetadataArg(argv, 'episode_id', '1');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('writes SERIES/SERIES-PART for mp3 when series/seriesPart provided', async () => {
|
it('writes SERIES/SERIES-PART for mp3 when series/seriesPart provided', async () => {
|
||||||
fsMock.access.mockResolvedValue(undefined);
|
fsMock.access.mockResolvedValue(undefined);
|
||||||
mockExecSuccess('done');
|
mockExecFileSuccess('done');
|
||||||
|
|
||||||
await tagAudioFileMetadata('/tmp/book.mp3', {
|
await tagAudioFileMetadata('/tmp/book.mp3', {
|
||||||
title: 'Book',
|
title: 'Book',
|
||||||
@@ -142,14 +214,14 @@ describe('metadata tagger', () => {
|
|||||||
seriesPart: '1.5',
|
seriesPart: '1.5',
|
||||||
});
|
});
|
||||||
|
|
||||||
const command = execMock.mock.calls[0][0] as string;
|
const argv = lastCallArgv();
|
||||||
expect(command).toContain('-metadata SERIES="The Mistborn Saga"');
|
expectMetadataArg(argv, 'SERIES', 'The Mistborn Saga');
|
||||||
expect(command).toContain('-metadata SERIES-PART="1.5"');
|
expectMetadataArg(argv, 'SERIES-PART', '1.5');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('writes SERIES/SERIES-PART for flac when series/seriesPart provided', async () => {
|
it('writes SERIES/SERIES-PART for flac when series/seriesPart provided', async () => {
|
||||||
fsMock.access.mockResolvedValue(undefined);
|
fsMock.access.mockResolvedValue(undefined);
|
||||||
mockExecSuccess('done');
|
mockExecFileSuccess('done');
|
||||||
|
|
||||||
await tagAudioFileMetadata('/tmp/book.flac', {
|
await tagAudioFileMetadata('/tmp/book.flac', {
|
||||||
title: 'Book',
|
title: 'Book',
|
||||||
@@ -158,110 +230,323 @@ describe('metadata tagger', () => {
|
|||||||
seriesPart: '2',
|
seriesPart: '2',
|
||||||
});
|
});
|
||||||
|
|
||||||
const command = execMock.mock.calls[0][0] as string;
|
const argv = lastCallArgv();
|
||||||
expect(command).toContain('-metadata SERIES="The Mistborn Saga"');
|
expectMetadataArg(argv, 'SERIES', 'The Mistborn Saga');
|
||||||
expect(command).toContain('-metadata SERIES-PART="2"');
|
expectMetadataArg(argv, 'SERIES-PART', '2');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('omits series tags when fields are absent', async () => {
|
it('omits series tags when fields are absent', async () => {
|
||||||
fsMock.access.mockResolvedValue(undefined);
|
fsMock.access.mockResolvedValue(undefined);
|
||||||
mockExecSuccess('done');
|
mockExecFileSuccess('done');
|
||||||
|
|
||||||
await tagAudioFileMetadata('/tmp/book.m4b', {
|
await tagAudioFileMetadata('/tmp/book.m4b', {
|
||||||
title: 'Book',
|
title: 'Book',
|
||||||
author: 'Author',
|
author: 'Author',
|
||||||
});
|
});
|
||||||
|
|
||||||
const command = execMock.mock.calls[0][0] as string;
|
const argv = lastCallArgv();
|
||||||
expect(command).not.toContain('show=');
|
const payloads = argv.filter((_, i) => i > 0 && argv[i - 1] === '-metadata');
|
||||||
expect(command).not.toContain('episode_id=');
|
expect(payloads.some((p) => p.startsWith('show='))).toBe(false);
|
||||||
expect(command).not.toContain('SERIES=');
|
expect(payloads.some((p) => p.startsWith('episode_id='))).toBe(false);
|
||||||
expect(command).not.toContain('SERIES-PART=');
|
expect(payloads.some((p) => p.startsWith('SERIES='))).toBe(false);
|
||||||
|
expect(payloads.some((p) => p.startsWith('SERIES-PART='))).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('metadata escaping', () => {
|
describe('quote regression (#171) — embedded `"` flows through untouched', () => {
|
||||||
it('does NOT escape single quotes (they are literal in double-quoted shell strings)', async () => {
|
const tricky = 'Test "Quoted" Book';
|
||||||
|
|
||||||
|
it('m4b: embedded double-quote in title is a single clean argv element', async () => {
|
||||||
fsMock.access.mockResolvedValue(undefined);
|
fsMock.access.mockResolvedValue(undefined);
|
||||||
mockExecSuccess('done');
|
mockExecFileSuccess('done');
|
||||||
|
|
||||||
|
await tagAudioFileMetadata('/tmp/book.m4b', {
|
||||||
|
title: tricky,
|
||||||
|
author: 'Author',
|
||||||
|
});
|
||||||
|
|
||||||
|
const argv = lastCallArgv();
|
||||||
|
expectMetadataArg(argv, 'title', tricky);
|
||||||
|
expectMetadataArg(argv, 'album', tricky);
|
||||||
|
// Adjacent fields must not have inherited a stray leading `"`.
|
||||||
|
expectMetadataArg(argv, 'album_artist', 'Author');
|
||||||
|
expectMetadataArg(argv, 'artist', 'Author');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('mp3: embedded double-quote in author is a single clean argv element', async () => {
|
||||||
|
fsMock.access.mockResolvedValue(undefined);
|
||||||
|
mockExecFileSuccess('done');
|
||||||
|
|
||||||
|
await tagAudioFileMetadata('/tmp/book.mp3', {
|
||||||
|
title: 'Book',
|
||||||
|
author: 'Alexandre "Dumas',
|
||||||
|
});
|
||||||
|
|
||||||
|
const argv = lastCallArgv();
|
||||||
|
expectMetadataArg(argv, 'album_artist', 'Alexandre "Dumas');
|
||||||
|
expectMetadataArg(argv, 'artist', 'Alexandre "Dumas');
|
||||||
|
// Title must NOT have inherited a leading quote (the exact #171 symptom).
|
||||||
|
expectMetadataArg(argv, 'title', 'Book');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('flac: embedded double-quote in series flows through cleanly', async () => {
|
||||||
|
fsMock.access.mockResolvedValue(undefined);
|
||||||
|
mockExecFileSuccess('done');
|
||||||
|
|
||||||
|
await tagAudioFileMetadata('/tmp/book.flac', {
|
||||||
|
title: 'Book',
|
||||||
|
author: 'Author',
|
||||||
|
series: 'The "Final" Saga',
|
||||||
|
seriesPart: '1',
|
||||||
|
});
|
||||||
|
|
||||||
|
const argv = lastCallArgv();
|
||||||
|
expectMetadataArg(argv, 'SERIES', 'The "Final" Saga');
|
||||||
|
expectMetadataArg(argv, 'SERIES-PART', '1');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('special characters flow through as literals (no shell escaping)', () => {
|
||||||
|
it('single quotes are untouched', async () => {
|
||||||
|
fsMock.access.mockResolvedValue(undefined);
|
||||||
|
mockExecFileSuccess('done');
|
||||||
|
|
||||||
await tagAudioFileMetadata('/tmp/book.m4b', {
|
await tagAudioFileMetadata('/tmp/book.m4b', {
|
||||||
title: "It's Not Her",
|
title: "It's Not Her",
|
||||||
author: "Author's Name",
|
author: "O'Brien",
|
||||||
});
|
});
|
||||||
|
|
||||||
const command = execMock.mock.calls[0][0] as string;
|
const argv = lastCallArgv();
|
||||||
// Single quotes should appear as-is, NOT escaped with backslash
|
expectMetadataArg(argv, 'title', "It's Not Her");
|
||||||
expect(command).toContain('-metadata title="It\'s Not Her"');
|
expectMetadataArg(argv, 'album_artist', "O'Brien");
|
||||||
expect(command).not.toContain("It\\'s"); // No backslash-escaped single quotes
|
|
||||||
expect(command).toContain('-metadata album_artist="Author\'s Name"');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('escapes double quotes in metadata values', async () => {
|
it('dollar signs are untouched (no shell variable expansion)', async () => {
|
||||||
fsMock.access.mockResolvedValue(undefined);
|
fsMock.access.mockResolvedValue(undefined);
|
||||||
mockExecSuccess('done');
|
mockExecFileSuccess('done');
|
||||||
|
|
||||||
await tagAudioFileMetadata('/tmp/book.m4b', {
|
|
||||||
title: 'Book "Title"',
|
|
||||||
author: 'Author',
|
|
||||||
});
|
|
||||||
|
|
||||||
const command = execMock.mock.calls[0][0] as string;
|
|
||||||
expect(command).toContain('-metadata title="Book \\"Title\\""');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('escapes backticks to prevent command substitution', async () => {
|
|
||||||
fsMock.access.mockResolvedValue(undefined);
|
|
||||||
mockExecSuccess('done');
|
|
||||||
|
|
||||||
await tagAudioFileMetadata('/tmp/book.m4b', {
|
|
||||||
title: 'Book `test`',
|
|
||||||
author: 'Author',
|
|
||||||
});
|
|
||||||
|
|
||||||
const command = execMock.mock.calls[0][0] as string;
|
|
||||||
expect(command).toContain('-metadata title="Book \\`test\\`"');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('escapes dollar signs to prevent variable expansion', async () => {
|
|
||||||
fsMock.access.mockResolvedValue(undefined);
|
|
||||||
mockExecSuccess('done');
|
|
||||||
|
|
||||||
await tagAudioFileMetadata('/tmp/book.m4b', {
|
await tagAudioFileMetadata('/tmp/book.m4b', {
|
||||||
title: 'Book $100',
|
title: 'Book $100',
|
||||||
author: 'Author',
|
author: 'Author',
|
||||||
});
|
});
|
||||||
|
|
||||||
const command = execMock.mock.calls[0][0] as string;
|
const argv = lastCallArgv();
|
||||||
expect(command).toContain('-metadata title="Book \\$100"');
|
expectMetadataArg(argv, 'title', 'Book $100');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('escapes backslashes before other characters', async () => {
|
it('backticks are untouched (no command substitution)', async () => {
|
||||||
fsMock.access.mockResolvedValue(undefined);
|
fsMock.access.mockResolvedValue(undefined);
|
||||||
mockExecSuccess('done');
|
mockExecFileSuccess('done');
|
||||||
|
|
||||||
|
await tagAudioFileMetadata('/tmp/book.m4b', {
|
||||||
|
title: 'Book `test`',
|
||||||
|
author: 'Author',
|
||||||
|
});
|
||||||
|
|
||||||
|
const argv = lastCallArgv();
|
||||||
|
expectMetadataArg(argv, 'title', 'Book `test`');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('backslashes are untouched', async () => {
|
||||||
|
fsMock.access.mockResolvedValue(undefined);
|
||||||
|
mockExecFileSuccess('done');
|
||||||
|
|
||||||
await tagAudioFileMetadata('/tmp/book.m4b', {
|
await tagAudioFileMetadata('/tmp/book.m4b', {
|
||||||
title: 'Path\\to\\book',
|
title: 'Path\\to\\book',
|
||||||
author: 'Author',
|
author: 'Author',
|
||||||
});
|
});
|
||||||
|
|
||||||
const command = execMock.mock.calls[0][0] as string;
|
const argv = lastCallArgv();
|
||||||
expect(command).toContain('-metadata title="Path\\\\to\\\\book"');
|
expectMetadataArg(argv, 'title', 'Path\\to\\book');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('handles complex titles with multiple special characters', async () => {
|
it('combined torture-test value flows through as one literal argv element', async () => {
|
||||||
fsMock.access.mockResolvedValue(undefined);
|
fsMock.access.mockResolvedValue(undefined);
|
||||||
mockExecSuccess('done');
|
mockExecFileSuccess('done');
|
||||||
|
|
||||||
|
const tortureTitle = `Don't Say "Hello" for $5 \`backtick\` and \\back`;
|
||||||
|
|
||||||
await tagAudioFileMetadata('/tmp/book.m4b', {
|
await tagAudioFileMetadata('/tmp/book.m4b', {
|
||||||
title: "Don't Say \"Hello\" for $5",
|
title: tortureTitle,
|
||||||
author: "O'Brien",
|
author: "O'Brien",
|
||||||
});
|
});
|
||||||
|
|
||||||
const command = execMock.mock.calls[0][0] as string;
|
const argv = lastCallArgv();
|
||||||
// Single quotes literal, double quotes escaped, dollar escaped
|
expectMetadataArg(argv, 'title', tortureTitle);
|
||||||
expect(command).toContain('-metadata title="Don\'t Say \\"Hello\\" for \\$5"');
|
expectMetadataArg(argv, 'album_artist', "O'Brien");
|
||||||
expect(command).toContain('-metadata album_artist="O\'Brien"');
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('input sanitization (boundary defense for invisible chars)', () => {
|
||||||
|
it('strips leading BOM from title', async () => {
|
||||||
|
fsMock.access.mockResolvedValue(undefined);
|
||||||
|
mockExecFileSuccess('done');
|
||||||
|
|
||||||
|
await tagAudioFileMetadata('/tmp/book.m4b', {
|
||||||
|
title: 'Alexandre Dumas Title',
|
||||||
|
author: 'Author',
|
||||||
|
});
|
||||||
|
|
||||||
|
const argv = lastCallArgv();
|
||||||
|
expectMetadataArg(argv, 'title', 'Alexandre Dumas Title');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('strips leading NBSP from author', async () => {
|
||||||
|
fsMock.access.mockResolvedValue(undefined);
|
||||||
|
mockExecFileSuccess('done');
|
||||||
|
|
||||||
|
await tagAudioFileMetadata('/tmp/book.m4b', {
|
||||||
|
title: 'Book',
|
||||||
|
author: ' Alexandre Dumas',
|
||||||
|
});
|
||||||
|
|
||||||
|
const argv = lastCallArgv();
|
||||||
|
expectMetadataArg(argv, 'album_artist', 'Alexandre Dumas');
|
||||||
|
expectMetadataArg(argv, 'artist', 'Alexandre Dumas');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('strips leading zero-width chars', async () => {
|
||||||
|
fsMock.access.mockResolvedValue(undefined);
|
||||||
|
mockExecFileSuccess('done');
|
||||||
|
|
||||||
|
await tagAudioFileMetadata('/tmp/book.m4b', {
|
||||||
|
title: 'Book Title',
|
||||||
|
author: 'Author',
|
||||||
|
});
|
||||||
|
|
||||||
|
const argv = lastCallArgv();
|
||||||
|
expectMetadataArg(argv, 'title', 'Book Title');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('trims surrounding whitespace', async () => {
|
||||||
|
fsMock.access.mockResolvedValue(undefined);
|
||||||
|
mockExecFileSuccess('done');
|
||||||
|
|
||||||
|
await tagAudioFileMetadata('/tmp/book.m4b', {
|
||||||
|
title: ' Spaced Book ',
|
||||||
|
author: '\tAuthor Name\t',
|
||||||
|
});
|
||||||
|
|
||||||
|
const argv = lastCallArgv();
|
||||||
|
expectMetadataArg(argv, 'title', 'Spaced Book');
|
||||||
|
expectMetadataArg(argv, 'album_artist', 'Author Name');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('strips null bytes and line breaks anywhere in value', async () => {
|
||||||
|
fsMock.access.mockResolvedValue(undefined);
|
||||||
|
mockExecFileSuccess('done');
|
||||||
|
|
||||||
|
await tagAudioFileMetadata('/tmp/book.m4b', {
|
||||||
|
title: 'Book\x00With\nBreaks\r',
|
||||||
|
author: 'Author',
|
||||||
|
});
|
||||||
|
|
||||||
|
const argv = lastCallArgv();
|
||||||
|
expectMetadataArg(argv, 'title', 'BookWithBreaks');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves clean inputs untouched', async () => {
|
||||||
|
fsMock.access.mockResolvedValue(undefined);
|
||||||
|
mockExecFileSuccess('done');
|
||||||
|
|
||||||
|
await tagAudioFileMetadata('/tmp/book.m4b', {
|
||||||
|
title: 'Perfectly Clean Title',
|
||||||
|
author: 'Clean Author',
|
||||||
|
narrator: 'Clean Narrator',
|
||||||
|
asin: 'B0009JKV9W',
|
||||||
|
series: 'Clean Series',
|
||||||
|
seriesPart: '1',
|
||||||
|
});
|
||||||
|
|
||||||
|
const argv = lastCallArgv();
|
||||||
|
expectMetadataArg(argv, 'title', 'Perfectly Clean Title');
|
||||||
|
expectMetadataArg(argv, 'album_artist', 'Clean Author');
|
||||||
|
expectMetadataArg(argv, 'composer', 'Clean Narrator');
|
||||||
|
expectMetadataArg(argv, '----:com.apple.iTunes:ASIN', 'B0009JKV9W');
|
||||||
|
expectMetadataArg(argv, 'show', 'Clean Series');
|
||||||
|
expectMetadataArg(argv, 'episode_id', '1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sanitization applies across all three format branches', async () => {
|
||||||
|
fsMock.access.mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
for (const filePath of ['/tmp/book.m4b', '/tmp/book.mp3', '/tmp/book.flac']) {
|
||||||
|
mockExecFileSuccess('done');
|
||||||
|
await tagAudioFileMetadata(filePath, {
|
||||||
|
title: ' Dirty Title ',
|
||||||
|
author: ' Dirty Author',
|
||||||
|
});
|
||||||
|
const argv = lastCallArgv();
|
||||||
|
expectMetadataArg(argv, 'title', 'Dirty Title');
|
||||||
|
// m4b/mp3 use album_artist; flac uses albumartist (no underscore).
|
||||||
|
const authorKey = filePath.endsWith('.flac') ? 'albumartist' : 'album_artist';
|
||||||
|
expectMetadataArg(argv, authorKey, 'Dirty Author');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('argv structural integrity', () => {
|
||||||
|
it('no -metadata payload starts or ends with `"` (the #171 bug class)', async () => {
|
||||||
|
fsMock.access.mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
const torture = {
|
||||||
|
title: 'Has "quotes" $dollar `tick` \\back',
|
||||||
|
author: 'A "B" C',
|
||||||
|
narrator: 'N "ar" tor',
|
||||||
|
asin: 'ASIN"123',
|
||||||
|
series: 'Ser "ies"',
|
||||||
|
seriesPart: '"1"',
|
||||||
|
year: 2020,
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const filePath of ['/tmp/book.m4b', '/tmp/book.mp3', '/tmp/book.flac']) {
|
||||||
|
mockExecFileSuccess('done');
|
||||||
|
await tagAudioFileMetadata(filePath, torture);
|
||||||
|
const argv = lastCallArgv();
|
||||||
|
for (let i = 0; i < argv.length - 1; i++) {
|
||||||
|
if (argv[i] === '-metadata') {
|
||||||
|
const payload = argv[i + 1];
|
||||||
|
// Split key=value; the VALUE part must not have leading/trailing wrapping quotes.
|
||||||
|
const eqIdx = payload.indexOf('=');
|
||||||
|
const value = eqIdx >= 0 ? payload.substring(eqIdx + 1) : payload;
|
||||||
|
// The seriesPart torture value is literally '"1"', so we check that the value
|
||||||
|
// matches one of the original torture values (no extra wrapping was added).
|
||||||
|
if (value.startsWith('"') && value.endsWith('"')) {
|
||||||
|
// Only acceptable when the original input was already wrapped (seriesPart='"1"').
|
||||||
|
expect(value).toBe('"1"');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('input and output paths are passed as single argv elements (no quote wrapping)', async () => {
|
||||||
|
fsMock.access.mockResolvedValue(undefined);
|
||||||
|
mockExecFileSuccess('done');
|
||||||
|
|
||||||
|
await tagAudioFileMetadata('/path with spaces/book.m4b', {
|
||||||
|
title: 'Book',
|
||||||
|
author: 'Author',
|
||||||
|
});
|
||||||
|
|
||||||
|
const argv = lastCallArgv();
|
||||||
|
const iIdx = argv.indexOf('-i');
|
||||||
|
expect(iIdx).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(argv[iIdx + 1]).toBe('/path with spaces/book.m4b');
|
||||||
|
expect(argv[argv.length - 1]).toBe('/path with spaces/book.m4b.tmp');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('execFile is called with command "ffmpeg" (not a shell)', async () => {
|
||||||
|
fsMock.access.mockResolvedValue(undefined);
|
||||||
|
mockExecFileSuccess('done');
|
||||||
|
|
||||||
|
await tagAudioFileMetadata('/tmp/book.m4b', {
|
||||||
|
title: 'Book',
|
||||||
|
author: 'Author',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(execFileMock).toHaveBeenCalled();
|
||||||
|
expect(lastCallCommand()).toBe('ffmpeg');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user