From 0b9e8c2abb16ee911f2948ccc767ea0bd0d3104d Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 8 Jun 2026 22:58:15 +0200 Subject: [PATCH] feat(lesereisen): JourneyItemCard, JourneyInterlude, JourneyReader with XSS + omit-rule specs Co-Authored-By: Claude Sonnet 4.6 --- .../lib/geschichte/JourneyInterlude.svelte | 21 +++ .../JourneyInterlude.svelte.spec.ts | 44 +++++ .../src/lib/geschichte/JourneyItemCard.svelte | 41 +++++ .../geschichte/JourneyItemCard.svelte.spec.ts | 123 +++++++++++++ .../src/lib/geschichte/JourneyReader.svelte | 89 ++++++++++ .../geschichte/JourneyReader.svelte.spec.ts | 166 ++++++++++++++++++ 6 files changed, 484 insertions(+) create mode 100644 frontend/src/lib/geschichte/JourneyInterlude.svelte create mode 100644 frontend/src/lib/geschichte/JourneyInterlude.svelte.spec.ts create mode 100644 frontend/src/lib/geschichte/JourneyItemCard.svelte create mode 100644 frontend/src/lib/geschichte/JourneyItemCard.svelte.spec.ts create mode 100644 frontend/src/lib/geschichte/JourneyReader.svelte create mode 100644 frontend/src/lib/geschichte/JourneyReader.svelte.spec.ts diff --git a/frontend/src/lib/geschichte/JourneyInterlude.svelte b/frontend/src/lib/geschichte/JourneyInterlude.svelte new file mode 100644 index 00000000..1bd8822c --- /dev/null +++ b/frontend/src/lib/geschichte/JourneyInterlude.svelte @@ -0,0 +1,21 @@ + + +
+ + +

{note}

+
diff --git a/frontend/src/lib/geschichte/JourneyInterlude.svelte.spec.ts b/frontend/src/lib/geschichte/JourneyInterlude.svelte.spec.ts new file mode 100644 index 00000000..1210e545 --- /dev/null +++ b/frontend/src/lib/geschichte/JourneyInterlude.svelte.spec.ts @@ -0,0 +1,44 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; +import { page } from 'vitest/browser'; + +const { default: JourneyInterlude } = await import('./JourneyInterlude.svelte'); + +afterEach(cleanup); + +declare global { + interface Window { + __xss_interlude?: number; + } +} + +describe('JourneyInterlude', () => { + it('renders the note text as plaintext', async () => { + render(JourneyInterlude, { props: { note: 'Eine kurze Pause auf der Reise.' } }); + + await expect.element(page.getByText('Eine kurze Pause auf der Reise.')).toBeVisible(); + }); + + it('has aria-label Kuratorennotiz', async () => { + render(JourneyInterlude, { props: { note: 'Notiz' } }); + + const el = document.querySelector('[aria-label="Kuratorennotiz"]'); + expect(el).not.toBeNull(); + }); + + it('renders the section-break glyph ❦', async () => { + render(JourneyInterlude, { props: { note: 'Notiz' } }); + + expect(document.body.textContent).toContain('❦'); + }); + + it('XSS: note is rendered as plaintext — injected payload does not execute', async () => { + // Interlude uses Svelte text interpolation ({note}), NOT {@html}. + render(JourneyInterlude, { + props: { note: '' } + }); + + expect(window.__xss_interlude).toBeUndefined(); + expect(document.body.textContent).toContain(' 0); + + + + {doc.title} + {#if formattedDate} + {formattedDate} + {/if} + + +{#if hasNote} + +

+ + {item.note} +

+{/if} diff --git a/frontend/src/lib/geschichte/JourneyItemCard.svelte.spec.ts b/frontend/src/lib/geschichte/JourneyItemCard.svelte.spec.ts new file mode 100644 index 00000000..dcdb6324 --- /dev/null +++ b/frontend/src/lib/geschichte/JourneyItemCard.svelte.spec.ts @@ -0,0 +1,123 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; +import { page } from 'vitest/browser'; +import type { components } from '$lib/generated/api'; + +const { default: JourneyItemCard } = await import('./JourneyItemCard.svelte'); + +afterEach(cleanup); + +declare global { + interface Window { + __xss_note?: number; + } +} + +type JourneyItemView = components['schemas']['JourneyItemView']; + +const baseItem = (overrides: Partial = {}): JourneyItemView => ({ + id: 'item1', + position: 0, + document: { + id: 'd1', + title: 'Brief an Helene', + documentDate: '1923-05-15', + datePrecision: 'FULL' + }, + ...overrides +}); + +describe('JourneyItemCard', () => { + it('renders the document title', async () => { + render(JourneyItemCard, { props: { item: baseItem() } }); + + await expect.element(page.getByText('Brief an Helene')).toBeVisible(); + }); + + it('renders the document date when documentDate is present', async () => { + render(JourneyItemCard, { props: { item: baseItem() } }); + + await expect.element(page.getByText(/1923/)).toBeVisible(); + }); + + it('whole card is a single element', async () => { + render(JourneyItemCard, { props: { item: baseItem() } }); + + const link = document.querySelector('a'); + expect(link).not.toBeNull(); + expect(link?.href).toContain('/documents/d1'); + }); + + it('link has dated aria-label when documentDate is present', async () => { + render(JourneyItemCard, { props: { item: baseItem() } }); + + const link = document.querySelector('a'); + expect(link?.getAttribute('aria-label')).toContain('Brief'); + expect(link?.getAttribute('aria-label')).toContain('1923'); + }); + + it('link has undated aria-label when documentDate is absent', async () => { + render(JourneyItemCard, { + props: { + item: baseItem({ + document: { id: 'd2', title: 'Ohne Datum', datePrecision: 'NONE' } + }) + } + }); + + const link = document.querySelector('a'); + expect(link?.getAttribute('aria-label')).toBe('Brief öffnen'); + }); + + it('omits date text when documentDate is absent', async () => { + render(JourneyItemCard, { + props: { + item: baseItem({ + document: { id: 'd2', title: 'Ohne Datum', datePrecision: 'NONE' } + }) + } + }); + + await expect.element(page.getByText(/1923/)).not.toBeInTheDocument(); + }); + + it('renders ✎ glyph and note text when note is present', async () => { + render(JourneyItemCard, { props: { item: baseItem({ note: 'Ein wichtiger Brief' }) } }); + + expect(document.body.textContent).toContain('✎'); + await expect.element(page.getByText('Ein wichtiger Brief')).toBeVisible(); + }); + + it('omits annotation block when note is blank or whitespace', async () => { + render(JourneyItemCard, { props: { item: baseItem({ note: ' ' }) } }); + + expect(document.body.textContent).not.toContain('✎'); + }); + + it('omits annotation block when note is absent', async () => { + render(JourneyItemCard, { props: { item: baseItem({ note: undefined }) } }); + + expect(document.body.textContent).not.toContain('✎'); + }); + + it('link meets 44px touch-target (min-h-[44px] class)', async () => { + render(JourneyItemCard, { props: { item: baseItem() } }); + + const link = document.querySelector('a'); + expect(link?.className).toContain('min-h-[44px]'); + }); + + it('XSS: note is rendered as plaintext — injected payload does not execute', async () => { + // Note uses Svelte text interpolation ({note}), NOT {@html}. + render(JourneyItemCard, { + props: { + item: baseItem({ + note: '' + }) + } + }); + + expect(window.__xss_note).toBeUndefined(); + expect(document.body.textContent).toContain(' + item.document != null || (item.note != null && item.note.trim().length > 0) + ) +); + +const confirm = getConfirmService(); + +async function handleDelete() { + const ok = await confirm.confirm({ + title: m.geschichte_delete_confirm_title(), + body: m.geschichte_delete_confirm_body(), + confirmLabel: m.btn_delete(), + cancelLabel: m.btn_cancel(), + destructive: true + }); + if (!ok) return; + const res = await csrfFetch(`/api/geschichten/${g.id}`, { method: 'DELETE' }); + if (res.ok) { + goto('/geschichten'); + } +} + + +{#if introText} + +

{introText}

+{/if} + +{#if validItems.length === 0} +

+ {m.journey_empty_state()} +

+{:else} +
    + {#each validItems as item (item.id)} +
  1. + {#if item.document != null} + + {:else} + + {/if} +
  2. + {/each} +
+{/if} + + +{#if canBlogWrite} +
+ + {m.btn_edit()} + + +
+{/if} diff --git a/frontend/src/lib/geschichte/JourneyReader.svelte.spec.ts b/frontend/src/lib/geschichte/JourneyReader.svelte.spec.ts new file mode 100644 index 00000000..d3398c63 --- /dev/null +++ b/frontend/src/lib/geschichte/JourneyReader.svelte.spec.ts @@ -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 => ({ + 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: '' + }), + canBlogWrite: false + } + }); + + expect(window.__xss_journey).toBeUndefined(); + expect(document.body.textContent).toContain('