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:
21
frontend/src/lib/geschichte/JourneyInterlude.svelte
Normal file
21
frontend/src/lib/geschichte/JourneyInterlude.svelte
Normal file
@@ -0,0 +1,21 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
note: string;
|
||||
}
|
||||
|
||||
let { note }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
aria-label="Kuratorennotiz"
|
||||
class="my-2 border-l-4 border-journey-border bg-journey-tint px-4 py-3"
|
||||
>
|
||||
<p
|
||||
class="text-center font-sans text-xs tracking-widest text-journey uppercase"
|
||||
aria-hidden="true"
|
||||
>
|
||||
❦
|
||||
</p>
|
||||
<!-- plaintext — do NOT use {@html} here -->
|
||||
<p class="font-serif text-base leading-relaxed text-ink-2 italic">{note}</p>
|
||||
</div>
|
||||
44
frontend/src/lib/geschichte/JourneyInterlude.svelte.spec.ts
Normal file
44
frontend/src/lib/geschichte/JourneyInterlude.svelte.spec.ts
Normal file
@@ -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: '<img src=x onerror="window.__xss_interlude=1">' }
|
||||
});
|
||||
|
||||
expect(window.__xss_interlude).toBeUndefined();
|
||||
expect(document.body.textContent).toContain('<img src=x onerror=');
|
||||
});
|
||||
});
|
||||
41
frontend/src/lib/geschichte/JourneyItemCard.svelte
Normal file
41
frontend/src/lib/geschichte/JourneyItemCard.svelte
Normal file
@@ -0,0 +1,41 @@
|
||||
<script lang="ts">
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import { formatDate } from '$lib/shared/utils/date';
|
||||
import type { components } from '$lib/generated/api';
|
||||
|
||||
type JourneyItemView = components['schemas']['JourneyItemView'];
|
||||
|
||||
interface Props {
|
||||
item: JourneyItemView;
|
||||
}
|
||||
|
||||
let { item }: Props = $props();
|
||||
|
||||
const doc = $derived(item.document!);
|
||||
const formattedDate = $derived(doc.documentDate ? formatDate(doc.documentDate, 'short') : null);
|
||||
const ariaLabel = $derived(
|
||||
formattedDate
|
||||
? m.journey_item_open_aria({ date: formattedDate })
|
||||
: m.journey_item_open_aria_undated()
|
||||
);
|
||||
const hasNote = $derived(item.note != null && item.note.trim().length > 0);
|
||||
</script>
|
||||
|
||||
<a
|
||||
href="/documents/{doc.id}"
|
||||
aria-label={ariaLabel}
|
||||
class="flex min-h-[44px] flex-col gap-1 rounded border border-line bg-surface px-4 py-3 font-serif text-base text-ink hover:bg-muted focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
>
|
||||
<span class="font-bold">{doc.title}</span>
|
||||
{#if formattedDate}
|
||||
<span class="font-sans text-sm text-ink-3">{formattedDate}</span>
|
||||
{/if}
|
||||
</a>
|
||||
|
||||
{#if hasNote}
|
||||
<!-- plaintext — do NOT use {@html} here -->
|
||||
<p class="mt-1 flex items-baseline gap-1 font-sans text-sm text-ink-3">
|
||||
<span aria-hidden="true">✎</span>
|
||||
{item.note}
|
||||
</p>
|
||||
{/if}
|
||||
123
frontend/src/lib/geschichte/JourneyItemCard.svelte.spec.ts
Normal file
123
frontend/src/lib/geschichte/JourneyItemCard.svelte.spec.ts
Normal file
@@ -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> = {}): 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 <a> 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: '<img src=x onerror="window.__xss_note=1">'
|
||||
})
|
||||
}
|
||||
});
|
||||
|
||||
expect(window.__xss_note).toBeUndefined();
|
||||
expect(document.body.textContent).toContain('<img src=x onerror=');
|
||||
});
|
||||
});
|
||||
89
frontend/src/lib/geschichte/JourneyReader.svelte
Normal file
89
frontend/src/lib/geschichte/JourneyReader.svelte
Normal file
@@ -0,0 +1,89 @@
|
||||
<script lang="ts">
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import { goto } from '$app/navigation';
|
||||
import { getConfirmService } from '$lib/shared/services/confirm.svelte';
|
||||
import { csrfFetch } from '$lib/shared/cookies';
|
||||
import JourneyItemCard from './JourneyItemCard.svelte';
|
||||
import JourneyInterlude from './JourneyInterlude.svelte';
|
||||
import type { components } from '$lib/generated/api';
|
||||
|
||||
type GeschichteView = components['schemas']['GeschichteView'];
|
||||
type JourneyItemView = components['schemas']['JourneyItemView'];
|
||||
|
||||
interface Props {
|
||||
geschichte: GeschichteView;
|
||||
canBlogWrite: boolean;
|
||||
}
|
||||
|
||||
let { geschichte: g, canBlogWrite }: Props = $props();
|
||||
|
||||
// Render intro only when body is a non-empty, non-whitespace string.
|
||||
const introText = $derived(g.body?.trim() ? g.body : null);
|
||||
|
||||
// Omit items that have neither a document nor a non-blank note (dangling deleted-document guard).
|
||||
const validItems = $derived(
|
||||
g.items.filter(
|
||||
(item: JourneyItemView) =>
|
||||
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');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if introText}
|
||||
<!-- plaintext — do NOT use {@html} here -->
|
||||
<p class="mb-8 font-serif text-base leading-relaxed text-ink-2 italic">{introText}</p>
|
||||
{/if}
|
||||
|
||||
{#if validItems.length === 0}
|
||||
<p class="font-sans text-sm text-ink-3" data-testid="journey-empty-state">
|
||||
{m.journey_empty_state()}
|
||||
</p>
|
||||
{:else}
|
||||
<ol class="flex list-none flex-col gap-4">
|
||||
{#each validItems as item (item.id)}
|
||||
<li>
|
||||
{#if item.document != null}
|
||||
<JourneyItemCard item={item} />
|
||||
{:else}
|
||||
<JourneyInterlude note={item.note!} />
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ol>
|
||||
{/if}
|
||||
|
||||
<!-- Author actions -->
|
||||
{#if canBlogWrite}
|
||||
<div class="mt-10 flex items-center gap-3 border-t border-line pt-6">
|
||||
<a
|
||||
href="/geschichten/{g.id}/edit"
|
||||
class="inline-flex h-11 items-center rounded border border-line bg-surface px-4 font-sans text-sm font-medium text-ink hover:bg-muted focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
>
|
||||
{m.btn_edit()}
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
onclick={handleDelete}
|
||||
class="inline-flex h-11 items-center rounded font-sans text-sm font-medium text-danger hover:underline focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
>
|
||||
{m.btn_delete()}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
166
frontend/src/lib/geschichte/JourneyReader.svelte.spec.ts
Normal file
166
frontend/src/lib/geschichte/JourneyReader.svelte.spec.ts
Normal 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=');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user