feat(lesereisen): JourneyItemCard, JourneyInterlude, JourneyReader with XSS + omit-rule specs

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-06-08 22:58:15 +02:00
parent 8a6bc27979
commit 0b9e8c2abb
6 changed files with 484 additions and 0 deletions

View File

@@ -0,0 +1,166 @@
import { describe, it, expect, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import { createConfirmService, CONFIRM_KEY } from '$lib/shared/services/confirm.svelte.js';
import type { components } from '$lib/generated/api';
const { default: JourneyReader } = await import('./JourneyReader.svelte');
afterEach(cleanup);
declare global {
interface Window {
__xss_journey?: number;
}
}
type GeschichteView = components['schemas']['GeschichteView'];
type JourneyItemView = components['schemas']['JourneyItemView'];
const baseGeschichte = (overrides: Partial<GeschichteView> = {}): GeschichteView => ({
id: 'g1',
title: 'Lesereise Berlin',
body: null as unknown as undefined,
type: 'JOURNEY',
status: 'PUBLISHED',
persons: [],
items: [],
createdAt: '2026-01-01T00:00:00Z',
updatedAt: '2026-01-01T00:00:00Z',
...overrides
});
const docItem = (id: string, title: string, position: number, note?: string): JourneyItemView => ({
id,
position,
document: { id: `d${id}`, title, datePrecision: 'FULL', documentDate: '1923-05-15' },
note
});
const interludeItem = (id: string, note: string, position: number): JourneyItemView => ({
id,
position,
document: undefined,
note
});
const ctx = () => new Map([[CONFIRM_KEY, createConfirmService()]]);
describe('JourneyReader', () => {
it('renders intro paragraph when body is non-empty', async () => {
render(JourneyReader, {
context: ctx(),
props: {
geschichte: baseGeschichte({ body: 'Eine Reise durch die Geschichte.' }),
canBlogWrite: false
}
});
await expect.element(page.getByText('Eine Reise durch die Geschichte.')).toBeVisible();
});
it('omits intro paragraph when body is null', async () => {
render(JourneyReader, {
context: ctx(),
props: { geschichte: baseGeschichte({ body: undefined }), canBlogWrite: false }
});
// Only empty state should render
await expect.element(page.getByTestId('journey-empty-state')).toBeVisible();
});
it('omits intro paragraph when body is only whitespace', async () => {
render(JourneyReader, {
context: ctx(),
props: { geschichte: baseGeschichte({ body: ' ' }), canBlogWrite: false }
});
expect(document.body.textContent?.trim().replace(/\s+/g, ' ')).not.toContain(' ');
await expect.element(page.getByTestId('journey-empty-state')).toBeVisible();
});
it('renders empty-state message when items array is empty', async () => {
render(JourneyReader, {
context: ctx(),
props: { geschichte: baseGeschichte({ items: [] }), canBlogWrite: false }
});
await expect.element(page.getByText('Diese Lesereise ist noch leer.')).toBeVisible();
});
it('renders both intro and empty-state when body is set but items is empty', async () => {
render(JourneyReader, {
context: ctx(),
props: {
geschichte: baseGeschichte({
body: 'Eine Einleitung.',
items: []
}),
canBlogWrite: false
}
});
await expect.element(page.getByText('Eine Einleitung.')).toBeVisible();
await expect.element(page.getByText('Diese Lesereise ist noch leer.')).toBeVisible();
});
it('renders document items (JourneyItemCard)', async () => {
render(JourneyReader, {
context: ctx(),
props: {
geschichte: baseGeschichte({ items: [docItem('item1', 'Brief an Helene', 0)] }),
canBlogWrite: false
}
});
await expect.element(page.getByText('Brief an Helene')).toBeVisible();
});
it('renders interlude items (JourneyInterlude)', async () => {
render(JourneyReader, {
context: ctx(),
props: {
geschichte: baseGeschichte({ items: [interludeItem('inter1', 'Eine Pause.', 0)] }),
canBlogWrite: false
}
});
await expect.element(page.getByText('Eine Pause.')).toBeVisible();
expect(document.body.textContent).toContain('❦');
});
it('omits items where document is null AND note is blank (dangling-item rule)', async () => {
render(JourneyReader, {
context: ctx(),
props: {
geschichte: baseGeschichte({
items: [
{ id: 'dangling', position: 0, document: undefined, note: ' ' },
docItem('item2', 'Echter Brief', 1)
]
}),
canBlogWrite: false
}
});
await expect.element(page.getByText('Echter Brief')).toBeVisible();
// Empty-state must NOT render when valid items exist
await expect.element(page.getByText('Diese Lesereise ist noch leer.')).not.toBeInTheDocument();
});
it('XSS: Journey body is rendered as plaintext — injected payload does not execute', async () => {
// JourneyReader uses Svelte text interpolation, NOT {@html}.
render(JourneyReader, {
context: ctx(),
props: {
geschichte: baseGeschichte({
body: '<img src=x onerror="window.__xss_journey=1">'
}),
canBlogWrite: false
}
});
expect(window.__xss_journey).toBeUndefined();
expect(document.body.textContent).toContain('<img src=x onerror=');
});
});