Files
onenote-tools/src/onenote/export/export.test.ts

704 lines
21 KiB
TypeScript

import { strFromU8, unzipSync } from 'fflate';
import { describe, expect, it } from 'vitest';
import type { ParserDiagnostic } from '../model/diagnostics.js';
import type {
OneNoteContentBlock,
OneNoteTextBlock,
OneNoteTextStyle,
} from '../one/content-model.js';
import {
createOneNoteExportPlan,
createOneNoteExportSnapshot,
createOneNoteStaticNotebookZip,
OneNoteExportError,
SafeExportNameAllocator,
sanitizeExportFilename,
serializeOneNoteMarkdown,
serializeOneNotePlainText,
serializeOneNoteSemanticHtml,
serializeOneNoteStructuredJson,
type OneNoteExportSnapshot,
} from './index.js';
import { utf8ByteLength } from './plan.js';
const PNG = Uint8Array.from([
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49,
0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x06,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
]);
const JPEG = Uint8Array.from([
0xff, 0xd8, 0xff, 0xc0, 0x00, 0x0b, 0x08, 0x00, 0x01, 0x00, 0x01, 0x01, 0x01,
0x11, 0x00, 0xff, 0xd9,
]);
const SOURCE_MARKUP = '<img src=x onerror=alert(1)><script>bad()</script>';
describe('OneNote export serializers', () => {
it('retains provenance and rich content without emitting active source markup', () => {
const snapshot = fixtureSnapshot();
const structured = JSON.parse(serializeOneNoteStructuredJson(snapshot)) as {
app: { version: string };
parser: { version: string };
supportLevel: string;
source: { name: string };
diagnostics: ParserDiagnostic[];
warnings: Array<{ code: string }>;
unsupportedContent: Array<{ sourceJcid: number }>;
resources: Array<Record<string, unknown>>;
};
expect(structured).toMatchObject({
app: { version: '1.2.3' },
parser: { version: '9.8.7' },
supportLevel: 'substantial',
source: { name: '../../<script>source.onepkg' },
});
expect(structured.diagnostics.map(({ code }) => code)).toEqual([
'package-warning',
'section-warning',
'page-warning',
]);
expect(
structured.resources.every((resource) => !('bytes' in resource))
).toBe(true);
expect(structured.unsupportedContent).toEqual([
expect.objectContaining({ sourceJcid: 0x1234 }),
]);
expect(structured.warnings.map(({ code }) => code)).toEqual(
expect.arrayContaining([
'UNSUPPORTED_IMAGE_RESOURCE',
'ACTIVE_ATTACHMENT_EXTENSION_NEUTRALIZED',
'BLOCK_RESOURCE_UNAVAILABLE',
])
);
const plain = serializeOneNotePlainText(snapshot);
expect(plain).toContain('Application: OneNote Tools 1.2.3');
expect(plain).toContain('Parser: TypeScript parser 9.8.7');
expect(plain).toContain(SOURCE_MARKUP);
expect(plain).toContain('Fallback page text');
expect(plain).toContain('Ink drawing: 1 stroke, 2 points');
const markdown = serializeOneNoteMarkdown(snapshot);
expect(markdown).not.toContain('<script>');
expect(markdown).not.toContain('<img src=x');
expect(markdown).not.toContain('javascript:');
expect(markdown).toContain(
String.raw`&lt;script&gt;bad\(\)&lt;/script&gt;`
);
expect(markdown).toContain('Fallback page text');
expect(markdown).toContain('| Column 1 | Column 2 |');
const html = serializeOneNoteSemanticHtml(snapshot);
const document = new DOMParser().parseFromString(html, 'text/html');
expect(
document.querySelectorAll('script,object,iframe,embed')
).toHaveLength(0);
expect(
document.querySelectorAll(
'[onload],[onerror],[onclick],foreignObject,use'
)
).toHaveLength(0);
expect(document.querySelectorAll('a[href^="javascript:"]')).toHaveLength(0);
expect(document.body.textContent).toContain(SOURCE_MARKUP);
expect(document.body.textContent).toContain('Fallback page text');
expect(document.querySelectorAll('table td')).toHaveLength(2);
expect(document.querySelector('.text strong')?.textContent).toContain(
SOURCE_MARKUP
);
expect(document.querySelectorAll('img')).toHaveLength(0);
expect(document.querySelectorAll('svg.ink')).toHaveLength(1);
expect(
document.querySelector('polyline')?.getAttribute('stroke-opacity')
).toBe('0.498');
expect(
document
.querySelector('meta[http-equiv="Content-Security-Policy"]')
?.getAttribute('content')
).toContain("default-src 'none'");
});
it('maps the full ink transparency byte range to SVG opacity', () => {
const snapshot = fixtureSnapshot();
const ink = snapshot.sections[0]!.pages[0]!.blocks.find(
(block) => block.kind === 'ink'
);
if (!ink || ink.kind !== 'ink') throw new Error('Ink fixture is missing');
ink.strokes[0]!.transparency = 0;
ink.strokes.push({
...ink.strokes[0]!,
points: ink.strokes[0]!.points.map((point) => ({ ...point })),
transparency: 255,
});
const document = new DOMParser().parseFromString(
serializeOneNoteSemanticHtml(snapshot),
'text/html'
);
expect(
[...document.querySelectorAll('polyline')].map((stroke) =>
stroke.getAttribute('stroke-opacity')
)
).toEqual(['1', '0']);
});
it('creates a deterministic, path-safe static notebook ZIP', () => {
const snapshot = fixtureSnapshot();
const first = createOneNoteStaticNotebookZip(snapshot);
const second = createOneNoteStaticNotebookZip(snapshot);
expect(first.filename).toBe('CON_script_.zip');
expect(first.mediaType).toBe('application/zip');
expect(first.bytes).toEqual(second.bytes);
expect(first.manifest.counts).toMatchObject({
sections: 1,
pages: 2,
resources: 3,
unsupportedContent: 1,
});
const files = unzipSync(first.bytes);
expect(Object.keys(files).sort()).toEqual([
'assets/_CON/CON_script_.html.bin',
'assets/_CON/photo.jpg',
'assets/_CON/photo.png',
'index.html',
'manifest.json',
'notebook.json',
'notebook.md',
'notebook.txt',
'warnings.json',
]);
for (const path of Object.keys(files)) {
expect(path.startsWith('/')).toBe(false);
expect(path).not.toContain('\\');
expect(path.split('/')).not.toContain('..');
}
expect(files['assets/_CON/photo.png']).toEqual(PNG);
expect(files['assets/_CON/photo.jpg']).toEqual(JPEG);
expect(strFromU8(files['assets/_CON/CON_script_.html.bin']!)).toBe(
'<script>alert(2)</script>'
);
const html = strFromU8(files['index.html']!);
const document = new DOMParser().parseFromString(html, 'text/html');
expect(document.querySelector('img')?.getAttribute('src')).toBe(
'assets/_CON/photo.png'
);
expect(document.querySelector('a[download]')?.getAttribute('href')).toBe(
'assets/_CON/CON_script_.html.bin'
);
expect(
document.querySelectorAll('script,object,iframe,embed')
).toHaveLength(0);
const manifest = JSON.parse(strFromU8(files['manifest.json']!)) as {
app: { version: string };
parser: { version: string };
supportLevel: string;
source: { name: string };
resources: Array<{ path: string; mediaType: string }>;
};
expect(manifest).toMatchObject({
app: { version: '1.2.3' },
parser: { version: '9.8.7' },
supportLevel: 'substantial',
source: { name: '../../<script>source.onepkg' },
});
expect(manifest.resources).toEqual(
expect.arrayContaining([
expect.objectContaining({
path: 'assets/_CON/photo.png',
mediaType: 'image/png',
}),
expect.objectContaining({
path: 'assets/_CON/photo.jpg',
mediaType: 'image/jpeg',
}),
expect.objectContaining({
path: 'assets/_CON/CON_script_.html.bin',
mediaType: 'application/octet-stream',
}),
])
);
const warnings = JSON.parse(strFromU8(files['warnings.json']!)) as {
diagnostics: ParserDiagnostic[];
exportWarnings: Array<{ code: string }>;
unsupportedContent: Array<{ sourceJcid: number }>;
};
expect(warnings.diagnostics).toHaveLength(3);
expect(warnings.exportWarnings.map(({ code }) => code)).toEqual(
expect.arrayContaining([
'UNSUPPORTED_IMAGE_RESOURCE',
'ACTIVE_ATTACHMENT_EXTENSION_NEUTRALIZED',
'BLOCK_RESOURCE_UNAVAILABLE',
])
);
expect(warnings.unsupportedContent).toEqual([
expect.objectContaining({ sourceJcid: 0x1234 }),
]);
});
it('bounds traversal and rejects cyclic content', () => {
const snapshot = fixtureSnapshot();
expect(() =>
serializeOneNotePlainText(snapshot, { limits: { maxPages: 1 } })
).toThrowError(
expect.objectContaining<Partial<OneNoteExportError>>({
code: 'limit-exceeded',
})
);
const cyclic: Extract<OneNoteContentBlock, { kind: 'outline' }> = {
kind: 'outline',
id: 'cycle',
indents: [],
layout: {},
children: [],
};
cyclic.children.push(cyclic);
snapshot.sections[0]!.pages[0]!.blocks = [cyclic];
expect(() => serializeOneNoteStructuredJson(snapshot)).toThrowError(
expect.objectContaining<Partial<OneNoteExportError>>({
code: 'invalid-input',
})
);
});
});
describe('OneNote export aggregate budgets', () => {
it('counts repeated rich-text representations against one text budget', () => {
const baseline = minimalSnapshot([textBlock('text', '')]);
const baselineCharacters =
createOneNoteExportPlan(baseline).textCharacterCount;
const snapshot = minimalSnapshot([textBlock('text', '0123456789')]);
expect(() =>
serializeOneNoteStructuredJson(snapshot, {
limits: { maxTextCharacters: baselineCharacters + 29 },
})
).toThrowError(
expect.objectContaining<Partial<OneNoteExportError>>({
code: 'limit-exceeded',
message: expect.stringContaining('text character'),
})
);
});
it('counts ink points across blocks and strokes', () => {
const ink = (id: string): OneNoteContentBlock => ({
kind: 'ink',
id,
strokes: [
{
points: [
{ x: 0, y: 0 },
{ x: 1, y: 1 },
],
},
],
children: [],
layout: {},
});
const snapshot = minimalSnapshot([ink('ink-1'), ink('ink-2')]);
expect(() =>
serializeOneNoteSemanticHtml(snapshot, {
limits: { maxInkPoints: 3 },
})
).toThrowError(
expect.objectContaining<Partial<OneNoteExportError>>({
code: 'limit-exceeded',
message: expect.stringContaining('ink point'),
})
);
});
it('preflights standalone output and measures exact UTF-8 bytes', () => {
const encodedSample = 'ASCII · 😀 · \ud800';
expect(utf8ByteLength(encodedSample)).toBe(
new TextEncoder().encode(encodedSample).byteLength
);
expect(() =>
serializeOneNoteMarkdown(minimalSnapshot(), {
limits: { maxOutputBytes: 8 * 1024 },
})
).toThrowError(
expect.objectContaining<Partial<OneNoteExportError>>({
code: 'limit-exceeded',
message: expect.stringContaining('Projected standalone'),
})
);
});
it('includes resource members in the static ZIP output budget', () => {
const resourceBytes = new Uint8Array(64);
const snapshot = minimalSnapshot(
[],
[
{
sectionId: 's',
id: 'attachment',
kind: 'attachment',
filename: 'attachment.bin',
extension: 'bin',
mediaType: 'application/octet-stream',
browserRenderable: false,
bytes: resourceBytes,
},
]
);
const resourceLimits = {
maxResourceBytes: 128,
maxTotalResourceBytes: 128,
};
const plan = createOneNoteExportPlan(snapshot, {
limits: { ...resourceLimits, maxOutputBytes: 1_000_000 },
});
const justTooSmall =
plan.projectedOutputBytes * 6 + resourceBytes.byteLength - 1;
expect(() =>
createOneNoteStaticNotebookZip(snapshot, {
limits: { ...resourceLimits, maxOutputBytes: justTooSmall },
})
).toThrowError(
expect.objectContaining<Partial<OneNoteExportError>>({
code: 'limit-exceeded',
message: expect.stringContaining('Projected static notebook'),
})
);
});
it('never auto-embeds parser-disabled or oversized-pixel images', () => {
const imageResource = (
id: string,
bytes: Uint8Array,
browserRenderable = true
): OneNoteExportSnapshot['resources'][number] => ({
sectionId: 's',
id,
kind: 'image',
filename: `${id}.png`,
extension: 'png',
mediaType: 'image/png',
browserRenderable,
bytes,
});
const snapshot = minimalSnapshot(
[],
[
imageResource('parser-disabled', PNG, false),
imageResource('dimension-bomb', pngWithDimensions(16_385, 1)),
imageResource('pixel-bomb', pngWithDimensions(8_193, 8_193)),
]
);
const plan = createOneNoteExportPlan(snapshot);
expect(plan.resources).toHaveLength(0);
expect(plan.warnings).toEqual(
expect.arrayContaining([
expect.objectContaining({
code: 'IMAGE_RESOURCE_NOT_RENDERABLE',
resourceId: 'parser-disabled',
}),
expect.objectContaining({
code: 'UNSAFE_IMAGE_RESOURCE',
resourceId: 'dimension-bomb',
message: expect.stringContaining('dimension-limit'),
}),
expect.objectContaining({
code: 'UNSAFE_IMAGE_RESOURCE',
resourceId: 'pixel-bomb',
message: expect.stringContaining('pixel-limit'),
}),
])
);
});
});
describe('OneNote export filenames', () => {
it('removes traversal, reserved names, controls and directional overrides', () => {
expect(sanitizeExportFilename('../../CON')).toBe('_CON');
expect(sanitizeExportFilename('..\\AUX.txt')).toBe('_AUX.txt');
expect(sanitizeExportFilename('safe\u202ename.txt')).toBe('safename.txt');
expect(sanitizeExportFilename('', '../../NUL')).toBe('_NUL');
expect(sanitizeExportFilename('x'.repeat(200))).toHaveLength(120);
expect(
sanitizeExportFilename(`name.${'x'.repeat(200)}`).length
).toBeLessThanOrEqual(120);
});
it('allocates case-insensitive unique names', () => {
const allocator = new SafeExportNameAllocator();
expect(allocator.allocate('Report.txt')).toBe('Report.txt');
expect(allocator.allocate('report.TXT')).toBe('report-2.txt');
});
it('keeps collision suffixes within the filename cap', () => {
const allocator = new SafeExportNameAllocator();
const filename = `${'x'.repeat(116)}.txt`;
expect(filename).toHaveLength(120);
expect(allocator.allocate(filename)).toBe(filename);
const duplicate = allocator.allocate(filename);
expect(duplicate).toBe(`${'x'.repeat(114)}-2.txt`);
expect([...duplicate]).toHaveLength(120);
});
it('allocates many duplicate names without rescanning prior suffixes', () => {
const allocator = new SafeExportNameAllocator();
const names = Array.from({ length: 10_000 }, () =>
allocator.allocate('Report.txt')
);
expect(names[0]).toBe('Report.txt');
expect(names.at(-1)).toBe('Report-10000.txt');
expect(new Set(names)).toHaveLength(names.length);
});
});
function fixtureSnapshot(): OneNoteExportSnapshot {
const richText = textBlock('text-1', SOURCE_MARKUP, {
bold: true,
italic: true,
});
richText.runs[0]!.href = 'javascript:alert(1)';
const blocks: OneNoteContentBlock[] = [
richText,
{
kind: 'table',
id: 'table-1',
declaredColumnCount: 2,
rows: [
{
id: 'row-1',
cells: [
{ id: 'cell-1', blocks: [textBlock('cell-text', 'A | B')] },
{ id: 'cell-2', blocks: [textBlock('cell-text-2', 'C')] },
],
},
],
columnWidths: [100, 100],
lockedColumns: [false, false],
bordersVisible: true,
layout: {},
},
{
kind: 'image',
id: 'image-block',
resourceId: 'image-1',
filename: 'source.svg',
altText: '<svg onload=alert(3)>Preview</svg>',
href: 'javascript:alert(4)',
isBackground: false,
layout: {},
},
{
kind: 'image',
id: 'bad-image-block',
resourceId: 'bad-image',
filename: 'bad.svg',
isBackground: false,
layout: {},
},
{
kind: 'image',
id: 'jpeg-image-block',
resourceId: 'image-2',
filename: 'photo.jpeg',
isBackground: false,
layout: {},
},
{
kind: 'attachment',
id: 'attachment-block',
resourceId: 'attachment-1',
filename: '../../CON<script>.html',
layout: {},
},
{
kind: 'ink',
id: 'ink-1',
strokes: [
{
points: [
{ x: 0, y: 0 },
{ x: 100, y: 50 },
],
width: 8,
transparency: 128,
color: 0x332211,
},
],
children: [],
layout: {},
},
{
kind: 'unsupported',
id: 'unsupported-1',
sourceJcid: 0x1234,
description: '<script>Unknown object</script>',
},
];
return createOneNoteExportSnapshot({
sourceName: '../../<script>source.onepkg',
sourceFormat: 'onepkg',
notebookName: '../CON<script>.onepkg',
appName: 'OneNote Tools',
appVersion: '1.2.3',
parser: {
name: 'TypeScript parser',
version: '9.8.7',
implementation: 'typescript',
supportedVariants: ['desktop-store', 'fsshttpb-package-store'],
referenceRevision: 'MS-ONE 16.0',
},
supportLevel: 'substantial',
supportNotes: ['Unsupported objects remain explicit.'],
sections: [
{
id: 'section-1',
name: '../CON',
sourcePath: '../unsafe/Section.one',
pages: [
{
id: 'page-1',
title: '<script>Page</script>',
text: SOURCE_MARKUP,
textPreview: SOURCE_MARKUP,
blocks,
diagnostics: [diagnostic('page-warning')],
},
{
id: 'page-2',
title: 'Fallback',
text: 'Fallback page text <script>must stay text</script>',
textPreview: 'Fallback page text',
blocks: [],
diagnostics: [],
},
],
diagnostics: [diagnostic('section-warning')],
},
],
resources: [
{
sectionId: 'section-1',
id: 'image-1',
kind: 'image',
filename: 'photo.svg',
extension: 'svg',
mediaType: 'image/svg+xml',
browserRenderable: true,
size: PNG.byteLength,
bytes: PNG,
},
{
sectionId: 'section-1',
id: 'bad-image',
kind: 'image',
filename: 'bad.svg',
extension: 'svg',
mediaType: 'image/svg+xml',
browserRenderable: true,
bytes: new TextEncoder().encode('<svg><script>bad()</script></svg>'),
},
{
sectionId: 'section-1',
id: 'image-2',
kind: 'image',
filename: 'photo.png',
extension: 'png',
mediaType: 'image/png',
browserRenderable: true,
bytes: JPEG,
},
{
sectionId: 'section-1',
id: 'attachment-1',
kind: 'attachment',
filename: '../../CON<script>.html',
extension: 'html',
mediaType: 'text/html',
browserRenderable: false,
bytes: new TextEncoder().encode('<script>alert(2)</script>'),
},
],
diagnostics: [diagnostic('package-warning')],
});
}
function minimalSnapshot(
blocks: OneNoteContentBlock[] = [],
resources: OneNoteExportSnapshot['resources'] = []
): OneNoteExportSnapshot {
return createOneNoteExportSnapshot({
sourceName: 's.one',
sourceFormat: 'one',
notebookName: 'n',
appName: 'a',
appVersion: '1',
parser: {
name: 'p',
version: '1',
implementation: 't',
supportedVariants: [],
},
sections: [
{
id: 's',
name: 's',
pages: [
{
id: 'p',
title: 'p',
text: '',
textPreview: '',
blocks,
diagnostics: [],
},
],
diagnostics: [],
},
],
resources,
});
}
function pngWithDimensions(width: number, height: number): Uint8Array {
const bytes = PNG.slice();
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
view.setUint32(16, width);
view.setUint32(20, height);
return bytes;
}
function textBlock(
id: string,
text: string,
style: OneNoteTextStyle = {}
): OneNoteTextBlock {
return {
kind: 'text',
id,
sourceText: text,
text,
runs: [{ start: 0, end: text.length, text, style }],
paragraphStyle: {},
layout: {},
};
}
function diagnostic(code: string): ParserDiagnostic {
return {
severity: 'warning',
code,
message: `${code} <script>message</script>`,
recoverable: true,
};
}