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: '' })).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([ ']>&a;', ']>&a;', '', ])('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/); }); });