save-as-ebook/tests/check-epub-negative.js
2026-08-28 17:40:42 +03:00

288 lines
12 KiB
JavaScript

// Proves that each important check in check-epub.js rejects a deliberately
// broken archive. A non-zero exit alone is not enough: every case must print
// the particular FAIL line it was designed to exercise.
//
// node check-epub-negative.js [out/fixture.epub]
const childProcess = require('child_process');
const fs = require('fs');
const os = require('os');
const path = require('path');
const vm = require('vm');
const fixture = process.argv[2] || path.join(__dirname, 'out', 'fixture.epub');
const checker = path.join(__dirname, 'check-epub.js');
const builder = path.join(__dirname, 'build-epub.js');
const mimetype = 'application/epub+zip';
const sandbox = {console, setTimeout, clearTimeout, setInterval, clearInterval,
setImmediate, Promise, Blob, Uint8Array, ArrayBuffer, Date, Math, JSON};
vm.createContext(sandbox);
vm.runInContext(fs.readFileSync(path.join(__dirname, '..', 'web-extension', 'libs', 'jszip.js'), 'utf8'),
sandbox, {filename: 'jszip.js'});
const JSZip = sandbox.JSZip;
let failures = 0;
function checkerResult(file) {
return childProcess.spawnSync(process.execPath, [checker, file], {
encoding: 'utf8',
maxBuffer: 1024 * 1024
});
}
function pass(name) {
console.log('PASS ' + name);
}
function fail(name, detail) {
failures++;
console.log('FAIL ' + name + ' -- ' + detail);
}
function replace(text, before, after) {
if (text.indexOf(before) < 0) {
throw new Error('fixture no longer contains ' + JSON.stringify(before));
}
return text.replace(before, after);
}
async function rewrite(zip, name, change) {
const original = await zip.file(name).async('string');
zip.file(name, change(original));
}
// The builder names the chapter files, deriving each one from the chapter's
// position in the book rather than from anything the fixture states, so the
// mutations below ask the package which file a chapter landed in instead of
// spelling the name out and going stale the next time the rule changes.
async function chapterHref(zip, index) {
const opf = await zip.file('OEBPS/content.opf').async('string');
const match = opf.match(new RegExp('<item id="ebook' + index + '"[^>]*href="([^"]+)"'));
if (!match) {
throw new Error('the fixture has no chapter ' + index);
}
return match[1].replace(/^pages\//, '');
}
async function rewriteChapter(zip, index, change) {
return rewrite(zip, 'OEBPS/pages/' + await chapterHref(zip, index), change);
}
async function mutatedArchive(raw, mutate) {
const zip = await JSZip.loadAsync(raw);
await mutate(zip);
// Loading and regenerating a zip loses the per-entry compression setting.
// Set this explicitly so mutations unrelated to OCF stay isolated.
zip.file('mimetype', mimetype, {compression: 'STORE'});
return zip.generateAsync({type: 'uint8array', compression: 'DEFLATE'});
}
async function compressedMimetype(raw) {
const zip = await JSZip.loadAsync(raw);
zip.file('mimetype', mimetype, {compression: 'DEFLATE'});
return zip.generateAsync({type: 'uint8array', compression: 'DEFLATE'});
}
async function misplacedMimetype(raw) {
const source = await JSZip.loadAsync(raw);
const zip = new JSZip();
const names = Object.keys(source.files).filter((name) => name !== 'mimetype');
for (const name of names) {
const entry = source.files[name];
if (!entry.dir) {
zip.file(name, await entry.async('uint8array'), {binary: true});
}
}
zip.file('mimetype', mimetype, {compression: 'STORE'});
return zip.generateAsync({type: 'uint8array', compression: 'DEFLATE'});
}
async function runCase(tempDir, raw, test) {
const file = path.join(tempDir, test.slug + '.epub');
let archive;
try {
archive = test.archive ? await test.archive(raw) : await mutatedArchive(raw, test.mutate);
} catch (error) {
fail(test.name, 'could not build corrupt fixture: ' + error.message);
return;
}
fs.writeFileSync(file, archive);
const result = checkerResult(file);
const output = (result.stdout || '') + (result.stderr || '');
const expected = 'FAIL ' + test.expected;
if (result.status === 1 && output.indexOf(expected) > -1) {
pass(test.name);
} else {
fail(test.name, 'wanted exit 1 and ' + JSON.stringify(expected) +
', got exit ' + result.status + '\n' + output.trim());
}
}
(async () => {
if (!fs.existsSync(fixture)) {
const built = childProcess.spawnSync(process.execPath, [builder, fixture], {
encoding: 'utf8',
maxBuffer: 1024 * 1024
});
if (built.status !== 0) {
console.error((built.stdout || '') + (built.stderr || ''));
process.exit(1);
}
}
const valid = checkerResult(fixture);
if (valid.status !== 0) {
console.error('The negative-test baseline is not valid:\n' +
(valid.stdout || '') + (valid.stderr || ''));
process.exit(1);
}
pass('uncorrupted baseline passes before negative checks');
const raw = fs.readFileSync(fixture);
// the names the three fixture chapters were built under, for the mutations
// that reach into one chapter to break a link to another
const fixtureZip = await JSZip.loadAsync(raw);
const first = await chapterHref(fixtureZip, 0);
const second = await chapterHref(fixtureZip, 1);
const third = await chapterHref(fixtureZip, 2);
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'save-as-ebook-check-'));
const tests = [
{
name: 'compressed mimetype is rejected',
slug: 'compressed-mimetype',
expected: 'mimetype is the first entry and uncompressed',
archive: compressedMimetype
},
{
name: 'misplaced mimetype is rejected',
slug: 'misplaced-mimetype',
expected: 'mimetype is the first entry and uncompressed',
archive: misplacedMimetype
},
{
name: 'a manifest entry whose file is missing is rejected',
slug: 'missing-manifest-file',
expected: 'every manifest href exists in the archive',
mutate: (zip) => zip.remove('OEBPS/ebook.css')
},
{
name: 'duplicate manifest IDs are rejected',
slug: 'duplicate-manifest-id',
expected: 'manifest ids are unique',
mutate: (zip) => rewrite(zip, 'OEBPS/content.opf', (opf) =>
replace(opf, 'id="style1"', 'id="style0"'))
},
{
name: 'two manifest items naming one file are rejected',
slug: 'shared-manifest-href',
expected: 'no two manifest items name the same file',
mutate: (zip) => rewrite(zip, 'OEBPS/content.opf', (opf) =>
replace(opf, 'href="pages/' + second + '"', 'href="pages/' + first + '"'))
},
{
name: 'a dangling spine idref is rejected',
slug: 'dangling-spine',
expected: 'every spine itemref resolves',
mutate: (zip) => rewrite(zip, 'OEBPS/content.opf', (opf) =>
replace(opf, 'idref="ebook1"', 'idref="missing-chapter"'))
},
{
name: 'a dangling image source is rejected',
slug: 'dangling-image',
expected: 'every <img> in the content resolves to a file in the archive',
mutate: (zip) => rewriteChapter(zip, 0, (xhtml) =>
replace(xhtml, '../images/img-1.png', '../images/missing.png'))
},
{
name: 'a dangling navigation file is rejected',
slug: 'dangling-navigation',
expected: 'every navigation target resolves to a file and an id in it',
mutate: (zip) => rewrite(zip, 'OEBPS/toc.xhtml', (nav) =>
replace(nav, 'href="pages/' + first + '"', 'href="pages/missing.xhtml"'))
},
{
name: 'a dangling content fragment is rejected',
slug: 'dangling-fragment',
expected: 'every link into a chapter resolves to an id in that same chapter',
mutate: (zip) => rewriteChapter(zip, 0, (xhtml) =>
replace(xhtml, 'href="#results"', 'href="#missing-fragment"'))
},
{
name: 'a link to a chapter the book does not hold is rejected',
slug: 'dangling-chapter-link',
expected: 'every link between chapters resolves to a file in the book and an id in it',
mutate: (zip) => rewriteChapter(zip, 0, (xhtml) =>
replace(xhtml, 'href="' + second + '"', 'href="missing.xhtml"'))
},
{
name: 'a link to a chapter naming an id it does not have is rejected',
slug: 'dangling-chapter-fragment',
expected: 'every link between chapters resolves to a file in the book and an id in it',
mutate: (zip) => rewriteChapter(zip, 0, (xhtml) =>
replace(xhtml, 'href="' + third + '#summary"', 'href="' + third + '#missing"'))
},
{
name: 'a right to left book that pages left to right is rejected',
slug: 'direction-disagrees',
expected: 'page progression direction agrees with the chapters that read right to left',
mutate: (zip) => rewrite(zip, 'OEBPS/content.opf', (opf) =>
replace(opf, ' page-progression-direction="rtl"', ''))
},
{
name: 'an incorrect manifest media type is rejected',
slug: 'incorrect-media-type',
expected: 'manifest media types match their files',
mutate: (zip) => rewrite(zip, 'OEBPS/content.opf', (opf) =>
replace(opf, 'media-type="image/png"', 'media-type="image/jpeg"'))
},
{
name: 'MathML without a mathml manifest property is rejected',
slug: 'mathml-undeclared',
expected: 'properties="mathml" is declared for exactly the files that contain MathML',
mutate: (zip) => rewrite(zip, 'OEBPS/content.opf', (opf) =>
replace(opf, ' properties="mathml" media-type="application/xhtml+xml"',
' media-type="application/xhtml+xml"'))
},
{
name: 'a false mathml manifest property is rejected',
slug: 'mathml-false-property',
expected: 'properties="mathml" is declared for exactly the files that contain MathML',
mutate: (zip) => rewrite(zip, 'OEBPS/content.opf', (opf) =>
replace(opf, 'id="ebook0" href=', 'id="ebook0" properties="mathml" href='))
},
{
name: 'a false alternativeText accessibility claim is rejected',
slug: 'false-accessibility-claim',
expected: 'alternativeText is claimed only when every image is accounted for',
mutate: (zip) => rewrite(zip, 'OEBPS/content.opf', (opf) =>
replace(opf,
'<meta property="schema:accessibilityFeature">tableOfContents</meta>',
'<meta property="schema:accessibilityFeature">tableOfContents</meta>\n' +
'<meta property="schema:accessibilityFeature">alternativeText</meta>'))
},
{
name: 'mismatched OPF and NCX identifiers are rejected',
slug: 'mismatched-identifiers',
expected: 'ncx dtb:uid matches dc:identifier',
mutate: (zip) => rewrite(zip, 'OEBPS/toc.ncx', (ncx) =>
ncx.replace(/content="urn:uuid:[^"]+"/, 'content="urn:uuid:00000000-0000-4000-8000-000000000000"'))
}
];
try {
for (const test of tests) {
await runCase(tempDir, raw, test);
}
} finally {
fs.rmSync(tempDir, {recursive: true, force: true});
}
console.log(failures === 0 ? '\nnegative EPUB checks OK' :
'\n' + failures + ' negative EPUB check(s) failed');
process.exit(failures === 0 ? 0 : 1);
})().catch((error) => {
console.error('FAILED to run negative EPUB checks:', error);
process.exit(1);
});