97 lines
3.8 KiB
TypeScript
97 lines
3.8 KiB
TypeScript
import { describe, it, expect } from 'vitest';
|
|
import { parseImport, exportRecipes } from '../../src/lib/import';
|
|
import { recipeSchema } from '../../src/lib/recipe';
|
|
import { examples, pasta } from '../fixtures';
|
|
describe('Versioned, safe, lossless recipe interchange', () => {
|
|
it.each(['json', 'xml'] as const)('roundtrips all fields in %s', (format) => {
|
|
expect(parseImport(exportRecipes(examples, format), format)).toEqual({
|
|
version: 1,
|
|
recipes: examples,
|
|
});
|
|
});
|
|
it('preserves XML escaped punctuation, unicode and literal ampersands', () => {
|
|
const r = {
|
|
...pasta,
|
|
title: 'Öl & Gemüse',
|
|
tips: 'Mit "Liebe" & Geduld. 1 < 2. & bleibt Text.',
|
|
};
|
|
expect(parseImport(exportRecipes([r], 'xml'), 'xml').recipes[0]).toEqual(r);
|
|
});
|
|
it.each(['json', 'xml'] as const)(
|
|
'roundtrips external cover, gallery and step images in %s',
|
|
(format) => {
|
|
const r = recipeSchema.parse({
|
|
...pasta,
|
|
image: 'https://images.example.org/cover?width=1200&format=webp',
|
|
images: ['https://images.example.org/gallery.jpg'],
|
|
steps: pasta.steps.map((s, n) => ({
|
|
...s,
|
|
images:
|
|
n === 0 ? ['/images/kitchen.jpg', 'https://images.example.org/step.png?x=1&y=2'] : [],
|
|
})),
|
|
});
|
|
expect(parseImport(exportRecipes([r], format), format).recipes[0]).toEqual(r);
|
|
},
|
|
);
|
|
it('keeps older recipes without step images compatible', () => {
|
|
const r = { ...pasta, steps: pasta.steps.map(({ images, ...step }) => step) };
|
|
expect(
|
|
parseImport(JSON.stringify({ version: 1, recipes: [r] }), 'json').recipes[0].steps.every(
|
|
(s) => s.images.length === 0,
|
|
),
|
|
).toBe(true);
|
|
});
|
|
it.each([
|
|
'http://example.org/a.jpg',
|
|
'//example.org/a.jpg',
|
|
'javascript:alert(1)',
|
|
'data:image/png;base64,AAAA',
|
|
'https://user:password@example.org/a.jpg',
|
|
'https://',
|
|
'https://example.org/\\evil',
|
|
'https://example.org/' + 'a'.repeat(2048),
|
|
])('rejects unsupported image URL %s', (image) => {
|
|
expect(recipeSchema.safeParse({ ...pasta, image }).success).toBe(false);
|
|
expect(
|
|
recipeSchema.safeParse({ ...pasta, steps: [{ ...pasta.steps[0], images: [image] }] }).success,
|
|
).toBe(false);
|
|
});
|
|
it('limits step images', () => {
|
|
expect(
|
|
recipeSchema.safeParse({
|
|
...pasta,
|
|
steps: [{ ...pasta.steps[0], images: Array(11).fill('/images/kitchen.jpg') }],
|
|
}).success,
|
|
).toBe(false);
|
|
});
|
|
it('rejects unknown versions and missing mandatory fields', () => {
|
|
expect(() => parseImport('{"version":2,"recipes":[]}', 'json')).toThrow();
|
|
expect(() => parseImport('{"version":1,"recipes":[{"title":"Test"}]}', 'json')).toThrow();
|
|
});
|
|
it('rejects HTML payloads, invalid image paths and broken references', () => {
|
|
expect(() => recipeSchema.parse({ ...pasta, title: '<script>alert(1)</script>' })).toThrow();
|
|
expect(() => recipeSchema.parse({ ...pasta, image: '/media/../../etc/passwd' })).toThrow();
|
|
expect(() =>
|
|
recipeSchema.parse({ ...pasta, steps: [{ ...pasta.steps[0], text: '{{unknown}}' }] }),
|
|
).toThrow();
|
|
});
|
|
it('rejects duplicate ingredient, step and timer IDs', () => {
|
|
expect(() =>
|
|
recipeSchema.parse({ ...pasta, ingredients: [...pasta.ingredients, pasta.ingredients[0]] }),
|
|
).toThrow();
|
|
expect(() =>
|
|
recipeSchema.parse({ ...pasta, steps: [pasta.steps[1], pasta.steps[1]] }),
|
|
).toThrow();
|
|
});
|
|
it.each([
|
|
'<!DOCTYPE x [<!ENTITY a SYSTEM "file:///etc/passwd">]><x>&a;</x>',
|
|
'<!DOCTYPE x [<!ENTITY a "&a;&a;">]><x>&a;</x>',
|
|
'<hamham><broken></hamham>',
|
|
])('rejects unsafe or malformed XML', (raw) => {
|
|
expect(() => parseImport(raw, 'xml')).toThrow();
|
|
});
|
|
it('enforces byte limits before parsing', () => {
|
|
expect(() => parseImport('x'.repeat(2 * 1024 * 1024 + 1), 'json')).toThrow(/2 MB/);
|
|
});
|
|
});
|