Compare commits

...

9 Commits

Author SHA1 Message Date
Marcel
2ae830a3c8 test(e2e): add minimal Geschichten writer + reader Playwright spec
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 4m2s
CI / OCR Service Tests (pull_request) Successful in 41s
CI / Backend Unit Tests (pull_request) Failing after 3m6s
CI / Unit & Component Tests (push) Failing after 3m35s
CI / OCR Service Tests (push) Successful in 36s
CI / Backend Unit Tests (push) Failing after 3m15s
Three e2e tests against the real stack:
- admin can navigate to /geschichten, create a draft, publish, and see the
  story appear on the index
- a reader (or admin) can click a story card and reach the detail page
  with an <article> landmark visible
- AxeBuilder scan of /geschichten reports no serious or critical WCAG
  violations

Partial fix for Sara's review B1 on PR #382. The deeper 5-spec a11y suite
and visual-regression coverage are deferred to a follow-up issue.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-02 18:53:09 +02:00
Marcel
c23fad7dc8 test(geschichten): cover GeschichteEditor title guard, status mode, pre-fill, payload
10 browser-based component tests:
- title-empty disables both DRAFT save buttons
- inline title-required error appears after blur
- DRAFT mode renders "Entwurf speichern" + "Veröffentlichen"
- PUBLISHED mode renders "Speichern" + "Zurück zu Entwurf"
- initialPersons / initialDocuments props render as chips on first paint
- title input is populated from a geschichte prop
- "Entwurf speichern" passes trimmed title + status=DRAFT to onSubmit
- "Veröffentlichen" passes status=PUBLISHED
- personIds / documentIds from initial props flow through onSubmit

Closes Felix's review B1 on PR #382.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-02 18:51:40 +02:00
Marcel
11c0d49907 test(geschichten): cover GeschichtenCard render, threshold, write-action gate
Browser-based component spec asserting:
- empty geschichten → no <section> rendered
- >= 1 story → heading + story link visible
- canWrite=false → no "+ Geschichte schreiben" link
- canWrite=true → link with /geschichten/new?personId pre-fill
- 0–2 stories → no footer link
- 3+ stories → "Alle Geschichten zu {name}" footer link to /geschichten?personId
- excerpt is plain text (no <strong>, no <script>)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-02 18:49:26 +02:00
Marcel
da249369ee test(geschichten): cover DocumentMultiSelect search, chip add/remove
Browser-based component spec mirroring PersonTypeahead.svelte.spec.ts:
renders empty input, surfaces pre-selected chips with formatted date,
emits hidden documentIds inputs for each chip, debounces the search
against /api/documents/search, adds a chip on click, hides already-
selected docs from new dropdown results, and removes a chip on × click.

Closes Felix's review B2 on PR #382.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-02 18:47:54 +02:00
Marcel
74b13abf53 fix(geschichten): widen story body and lift section-header contrast
Story-detail body now uses an explicit Tailwind block-element selector
ruleset instead of the `prose` plugin, so the body fills the full max-w-3xl
parent width — previously `prose` clamped to ~65ch inside an already narrow
page.

GeschichtenCard heading and the "+ Geschichte schreiben" link now use
text-ink-2 (#4b5563 = 7.6:1 on white, AAA-passable) instead of text-ink-3
or text-ink/60. Same fix on the "+ Geschichte anhängen" link in the
Document drawer column and on the Personen / Dokumente section headers
on the story detail page.

Closes Leonie's review B1, B2 and S4 on PR #382.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-02 18:46:31 +02:00
Marcel
ad535e314b refactor(extract-text): rename stripHtml → extractText and document non-sanitiser status
Adds a module docstring at the top of extractText.ts spelling out that this
is text extraction, not XSS sanitisation, and that callers must rely on
safeHtml() (DOMPurify) for security. Adds a Vitest test block with classic
XSS-shaped payloads (<script>, <svg/onload>, <iframe srcdoc>, javascript:
href) asserting that no markup is re-emitted, even though the module is
explicitly not a sanitiser.

Updates the two callers (/geschichten index, GeschichtenCard) to import
from the new path. The collapse-whitespace pass also makes the regex
fallback's output saner for excerpt rendering.

Closes Nora's review B1 on PR #382.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-02 18:44:40 +02:00
Marcel
18e5d18cc7 feat(geschichte): V59 grants BLOG_WRITE to existing WRITE_ALL groups
Without this, the Geschichten feature ships dark on prod day-one — no group
holds BLOG_WRITE, so the editor controls never render even for admins. The
mapping "anyone who can write documents can also author family stories" is
the safest default and admins can revoke afterwards via the new checkbox UI.

Closes Tobias's review S5 on PR #382.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-02 18:42:46 +02:00
Marcel
35ec7e799f feat(admin): add BLOG_WRITE to group permission checkbox UI
Both /admin/groups/new and /admin/groups/[id] now expose BLOG_WRITE in the
standard-permissions card so admins can grant Geschichten authoring through
the UI instead of running raw SQL. Adds Paraglide labels in de/en/es.

Closes Markus's review B1 on PR #382.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-02 18:41:09 +02:00
Marcel
77ac9a01b5 chore(deps): drop frontend/yarn.lock — repo uses npm everywhere
Both lockfiles were updated on every npm install, creating a drift surface
for nothing. CI, Docker and dev all use npm, so yarn.lock has no consumer.
Add it to .gitignore so future yarn-curious developers don't accidentally
re-introduce it.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-02 18:39:32 +02:00
20 changed files with 624 additions and 2864 deletions

4
.gitignore vendored
View File

@@ -13,3 +13,7 @@ scripts/large-data.sql
.vitest-attachments
**/test-results/
.worktrees/
.superpowers/
# Repo uses npm; yarn.lock is ignored to avoid double-lockfile drift.
frontend/yarn.lock

View File

@@ -0,0 +1,16 @@
-- Grant BLOG_WRITE to every existing group that already holds WRITE_ALL.
-- Without this, the Geschichten feature ships dark to production: no group
-- has BLOG_WRITE, so the editor controls are invisible and "+ Neue Geschichte"
-- is never rendered. The natural mapping is "groups that can already write
-- documents and tags can also author family stories." Admins can revoke or
-- re-assign via the group editor afterwards.
INSERT INTO group_permissions (group_id, permission)
SELECT DISTINCT gp.group_id, 'BLOG_WRITE'
FROM group_permissions gp
WHERE gp.permission = 'WRITE_ALL'
AND NOT EXISTS (
SELECT 1 FROM group_permissions existing
WHERE existing.group_id = gp.group_id
AND existing.permission = 'BLOG_WRITE'
);

View File

@@ -0,0 +1,78 @@
import AxeBuilder from '@axe-core/playwright';
import { expect, test } from '@playwright/test';
/**
* Minimal Geschichten coverage. The deeper a11y / visual-regression suite is
* tracked separately; this file proves the core writer + reader journey works
* end-to-end against the real stack.
*
* Pre-requisite: V59 has granted BLOG_WRITE to the Administrators group, so
* the seeded admin user can author. The auth.setup project handles login.
*/
const stamp = () => new Date().toISOString().replace(/[^0-9]/g, '');
test.describe('Geschichten — writer + reader journey', () => {
test('admin can create a draft, publish it, and see it on the index', async ({ page }) => {
const title = `E2E story ${stamp()}`;
// Land on the index — empty state or pre-existing demo data is fine
await page.goto('/geschichten');
await page.waitForSelector('[data-hydrated]');
await expect(page.getByRole('heading', { name: 'Geschichten', level: 1 })).toBeVisible();
// Click "Neue Geschichte" — visible because admin has BLOG_WRITE
await page.getByRole('link', { name: 'Neue Geschichte' }).click();
await page.waitForURL('/geschichten/new');
// Fill in title — the body editor is Tiptap and harder to script reliably
await page.getByPlaceholder('Titel der Geschichte').fill(title);
// Save as draft and verify we land on the detail page
await page.getByRole('button', { name: 'Entwurf speichern' }).click();
await page.waitForURL(/\/geschichten\/[^/]+$/);
// Capture the new id from the URL
const detailUrl = page.url();
const id = detailUrl.split('/').pop();
expect(id).toBeTruthy();
// Publish from the edit page
await page.getByRole('link', { name: 'Bearbeiten' }).click();
await page.waitForURL(/\/edit$/);
await page.getByRole('button', { name: 'Veröffentlichen' }).click();
await page.waitForURL(detailUrl);
// Index now shows the published story
await page.goto('/geschichten');
await expect(page.getByRole('link', { name: title })).toBeVisible();
});
test('reader is taken to a story detail when clicking a card', async ({ page }) => {
await page.goto('/geschichten');
await page.waitForSelector('[data-hydrated]');
// Use the first story link in the list (demo data exists; if not, the
// previous test seeded one). The link wraps the whole card.
const firstStory = page.locator('a[href^="/geschichten/"]').filter({ hasText: /.+/ }).first();
await expect(firstStory).toBeVisible();
await firstStory.click();
await page.waitForURL(/\/geschichten\/[^/]+$/);
await expect(page.locator('article')).toBeVisible();
});
test('AxeBuilder finds no critical violations on the index', async ({ page }) => {
await page.goto('/geschichten');
await page.waitForSelector('[data-hydrated]');
const results = await new AxeBuilder({ page }).withTags(['wcag2a', 'wcag2aa']).analyze();
// Filter to non-deferred severity. We don't gate the whole PR on a clean
// AxeBuilder run yet — Sara's review tracks the broader a11y backlog —
// but any "serious" or "critical" finding from this scan would block merge.
const blocking = results.violations.filter(
(v) => v.impact === 'serious' || v.impact === 'critical'
);
expect(blocking, JSON.stringify(blocking, null, 2)).toEqual([]);
});
});

View File

@@ -228,6 +228,7 @@
"admin_perm_read_all": "Nur lesen",
"admin_perm_annotate_all": "Lesen & Annotieren",
"admin_perm_write_all": "Lesen & Schreiben",
"admin_perm_blog_write": "Geschichten schreiben",
"admin_perm_admin": "Vollzugriff (Admin)",
"admin_perm_admin_user": "Benutzer verwalten",
"admin_perm_admin_tag": "Schlagworte verwalten",

View File

@@ -228,6 +228,7 @@
"admin_perm_read_all": "Read only",
"admin_perm_annotate_all": "Read & Annotate",
"admin_perm_write_all": "Read & Write",
"admin_perm_blog_write": "Write stories",
"admin_perm_admin": "Full access (Admin)",
"admin_perm_admin_user": "Manage users",
"admin_perm_admin_tag": "Manage tags",

View File

@@ -228,6 +228,7 @@
"admin_perm_read_all": "Solo lectura",
"admin_perm_annotate_all": "Leer y anotar",
"admin_perm_write_all": "Leer y escribir",
"admin_perm_blog_write": "Escribir historias",
"admin_perm_admin": "Acceso completo (Admin)",
"admin_perm_admin_user": "Gestionar usuarios",
"admin_perm_admin_tag": "Gestionar etiquetas",

View File

@@ -199,7 +199,7 @@ function getFullName(person: Person): string {
{#if canBlogWrite && documentId}
<a
href="/geschichten/new?documentId={documentId}"
class="font-sans text-xs font-medium text-ink/60 hover:text-ink"
class="font-sans text-xs font-medium text-ink-2 hover:text-ink"
>
{m.geschichten_card_attach_action()}
</a>

View File

@@ -0,0 +1,126 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page, userEvent } from 'vitest/browser';
import DocumentMultiSelect from './DocumentMultiSelect.svelte';
const waitForDebounce = () => new Promise((r) => setTimeout(r, 350));
const docFactory = (id: string, title: string, date = '1880-01-01') => ({
id,
title,
documentDate: date,
originalFilename: `${title}.pdf`,
status: 'UPLOADED',
metadataComplete: false,
scriptType: 'UNKNOWN' as const,
createdAt: '2024-01-01T00:00:00',
updatedAt: '2024-01-01T00:00:00'
});
function mockSearchResponse(items: ReturnType<typeof docFactory>[]) {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
json: vi.fn().mockResolvedValue({ items: items.map((document) => ({ document })) })
})
);
}
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});
describe('DocumentMultiSelect — rendering', () => {
it('renders an empty chip-input by default', async () => {
render(DocumentMultiSelect);
await expect.element(page.getByPlaceholder('Dokument suchen…')).toBeInTheDocument();
});
it('renders pre-selected documents as chips with their date', async () => {
render(DocumentMultiSelect, {
selectedDocuments: [docFactory('d1', 'Brief vom 1. Mai', '1882-05-01')]
});
await expect.element(page.getByText(/Brief vom 1\. Mai/)).toBeInTheDocument();
await expect.element(page.getByText(/01\.05\.1882/)).toBeInTheDocument();
});
it('emits a hidden documentIds input for each pre-selected document', async () => {
render(DocumentMultiSelect, {
selectedDocuments: [docFactory('d1', 'A'), docFactory('d2', 'B')]
});
const inputs = document.querySelectorAll<HTMLInputElement>(
'input[type="hidden"][name="documentIds"]'
);
expect(inputs).toHaveLength(2);
expect([inputs[0].value, inputs[1].value].sort()).toEqual(['d1', 'd2']);
});
});
describe('DocumentMultiSelect — search and select', () => {
it('queries /api/documents/search after debounce and shows results', async () => {
mockSearchResponse([docFactory('d1', 'Brief von Eugenie')]);
render(DocumentMultiSelect);
await userEvent.fill(page.getByPlaceholder('Dokument suchen…'), 'Eug');
await waitForDebounce();
expect(globalThis.fetch).toHaveBeenCalledWith(
expect.stringMatching(/^\/api\/documents\/search\?q=Eug/)
);
await expect.element(page.getByText(/Brief von Eugenie/)).toBeInTheDocument();
});
it('adds a chip when a search result is clicked', async () => {
mockSearchResponse([docFactory('d1', 'Brief von Eugenie')]);
render(DocumentMultiSelect);
await userEvent.fill(page.getByPlaceholder('Dokument suchen…'), 'Eug');
await waitForDebounce();
await userEvent.click(page.getByText(/Brief von Eugenie/));
// After selection the search field clears and the chip is rendered
const hidden = document.querySelector<HTMLInputElement>(
'input[type="hidden"][name="documentIds"]'
);
expect(hidden?.value).toBe('d1');
});
it('hides already-selected documents from new search results', async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: vi.fn().mockResolvedValue({
items: [
{ document: docFactory('d1', 'Already attached') },
{ document: docFactory('d2', 'Not attached') }
]
})
});
vi.stubGlobal('fetch', fetchMock);
render(DocumentMultiSelect, {
selectedDocuments: [docFactory('d1', 'Already attached')]
});
await userEvent.fill(page.getByPlaceholder('Dokument suchen…'), 'attached');
await waitForDebounce();
// "Not attached" appears in the dropdown; "Already attached" only as the chip.
const matches = await page.getByText(/Already attached/).all();
expect(matches.length).toBe(1); // chip only, not in dropdown
await expect.element(page.getByText(/Not attached/)).toBeInTheDocument();
});
});
describe('DocumentMultiSelect — remove', () => {
it('removes a chip when its × button is clicked', async () => {
render(DocumentMultiSelect, {
selectedDocuments: [docFactory('d1', 'Brief A')]
});
await userEvent.click(page.getByLabelText('Entfernen'));
expect(
document.querySelector<HTMLInputElement>('input[type="hidden"][name="documentIds"]')
).toBeNull();
});
});

View File

@@ -0,0 +1,150 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page, userEvent } from 'vitest/browser';
import GeschichteEditor from './GeschichteEditor.svelte';
const personFactory = (id: string, displayName: string) => ({
id,
firstName: displayName.split(' ')[0],
lastName: displayName.split(' ').slice(1).join(' ') || displayName,
displayName,
personType: 'PERSON' as const
});
const docFactory = (id: string, title: string, date = '1882-01-01') => ({
id,
title,
documentDate: date,
originalFilename: `${title}.pdf`,
status: 'UPLOADED' as const,
metadataComplete: false,
scriptType: 'UNKNOWN' as const,
createdAt: '2024-01-01T00:00:00',
updatedAt: '2024-01-01T00:00:00'
});
const draftFactory = (overrides: Record<string, unknown> = {}) => ({
id: 'g1',
title: 'Existing draft',
body: '<p>Hello world</p>',
status: 'DRAFT' as const,
persons: [],
documents: [],
createdAt: '2024-01-01T00:00:00',
updatedAt: '2024-01-01T00:00:00',
...overrides
});
afterEach(() => cleanup());
describe('GeschichteEditor — title-required guard', () => {
it('disables both DRAFT save buttons when the title is empty', async () => {
const onSubmit = vi.fn().mockResolvedValue(undefined);
render(GeschichteEditor, { onSubmit });
const draft = await page.getByRole('button', { name: 'Entwurf speichern' }).element();
const publish = await page.getByRole('button', { name: 'Veröffentlichen' }).element();
expect(draft).toHaveProperty('disabled', true);
expect(publish).toHaveProperty('disabled', true);
});
it('shows the inline error after the title field is blurred while empty', async () => {
const onSubmit = vi.fn();
render(GeschichteEditor, { onSubmit });
await userEvent.click(page.getByPlaceholder('Titel der Geschichte'));
await userEvent.tab(); // blur
await expect.element(page.getByText('Bitte gib einen Titel ein.')).toBeInTheDocument();
});
});
describe('GeschichteEditor — save bar adapts to status', () => {
it('renders DRAFT mode buttons when no geschichte prop is supplied', async () => {
render(GeschichteEditor, { onSubmit: vi.fn() });
await expect
.element(page.getByRole('button', { name: 'Entwurf speichern' }))
.toBeInTheDocument();
await expect.element(page.getByRole('button', { name: 'Veröffentlichen' })).toBeInTheDocument();
});
it('renders PUBLISHED mode buttons when geschichte.status is PUBLISHED', async () => {
render(GeschichteEditor, {
geschichte: draftFactory({ status: 'PUBLISHED', publishedAt: '2024-04-01T12:00:00' }),
onSubmit: vi.fn()
});
await expect.element(page.getByRole('button', { name: 'Speichern' })).toBeInTheDocument();
await expect
.element(page.getByRole('button', { name: 'Zurück zu Entwurf' }))
.toBeInTheDocument();
});
});
describe('GeschichteEditor — pre-fill', () => {
it('renders initial persons as chips', async () => {
render(GeschichteEditor, {
initialPersons: [personFactory('p1', 'Franz Raddatz')],
onSubmit: vi.fn()
});
await expect.element(page.getByText('Franz Raddatz')).toBeInTheDocument();
});
it('renders initial documents as chips', async () => {
render(GeschichteEditor, {
initialDocuments: [docFactory('d1', 'Brief von Eugenie')],
onSubmit: vi.fn()
});
await expect.element(page.getByText(/Brief von Eugenie/)).toBeInTheDocument();
});
it('populates the title input from a geschichte prop', async () => {
render(GeschichteEditor, {
geschichte: draftFactory({ title: 'My existing story' }),
onSubmit: vi.fn()
});
const input = await page.getByPlaceholder('Titel der Geschichte').element();
expect((input as HTMLInputElement).value).toBe('My existing story');
});
});
describe('GeschichteEditor — onSubmit payload', () => {
it('passes the trimmed title and DRAFT status when "Entwurf speichern" is clicked', async () => {
const onSubmit = vi.fn().mockResolvedValue(undefined);
render(GeschichteEditor, { onSubmit });
await userEvent.fill(page.getByPlaceholder('Titel der Geschichte'), ' My title ');
await userEvent.click(page.getByRole('button', { name: 'Entwurf speichern' }));
expect(onSubmit).toHaveBeenCalledTimes(1);
const payload = onSubmit.mock.calls[0][0];
expect(payload.title).toBe('My title');
expect(payload.status).toBe('DRAFT');
});
it('passes status=PUBLISHED when "Veröffentlichen" is clicked', async () => {
const onSubmit = vi.fn().mockResolvedValue(undefined);
render(GeschichteEditor, { onSubmit });
await userEvent.fill(page.getByPlaceholder('Titel der Geschichte'), 'Story');
await userEvent.click(page.getByRole('button', { name: 'Veröffentlichen' }));
expect(onSubmit).toHaveBeenCalledTimes(1);
expect(onSubmit.mock.calls[0][0].status).toBe('PUBLISHED');
});
it('passes the personIds and documentIds from initial props through onSubmit', async () => {
const onSubmit = vi.fn().mockResolvedValue(undefined);
render(GeschichteEditor, {
initialPersons: [personFactory('p1', 'Franz Raddatz')],
initialDocuments: [docFactory('d1', 'Brief A')],
onSubmit
});
await userEvent.fill(page.getByPlaceholder('Titel der Geschichte'), 'Story');
await userEvent.click(page.getByRole('button', { name: 'Entwurf speichern' }));
expect(onSubmit).toHaveBeenCalledTimes(1);
const payload = onSubmit.mock.calls[0][0];
expect(payload.personIds).toEqual(['p1']);
expect(payload.documentIds).toEqual(['d1']);
});
});

View File

@@ -1,7 +1,7 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages.js';
import type { components } from '$lib/generated/api';
import { plainExcerpt } from '$lib/utils/stripHtml';
import { plainExcerpt } from '$lib/utils/extractText';
import { formatDate } from '$lib/utils/date';
type Geschichte = components['schemas']['Geschichte'];
@@ -39,14 +39,14 @@ function authorName(g: Geschichte): string {
<header class="mb-5 flex items-center justify-between">
<h2
id="geschichten-card-heading"
class="font-sans text-xs font-bold tracking-widest text-ink-3 uppercase"
class="font-sans text-xs font-bold tracking-widest text-ink-2 uppercase"
>
{m.geschichten_card_heading()}
</h2>
{#if canWrite}
<a
href="/geschichten/new?personId={personId}"
class="inline-flex items-center font-sans text-sm font-medium text-ink/60 hover:text-ink"
class="inline-flex items-center font-sans text-sm font-medium text-ink-2 hover:text-ink"
>
{m.geschichten_card_write_action()}
</a>

View File

@@ -0,0 +1,120 @@
import { afterEach, describe, expect, it } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import GeschichtenCard from './GeschichtenCard.svelte';
const makeStory = (id: string, title: string, body: string | null = '<p>Body</p>') => ({
id,
title,
body,
status: 'PUBLISHED' as const,
publishedAt: '2024-04-01T12:00:00',
createdAt: '2024-03-01T12:00:00',
updatedAt: '2024-04-01T12:00:00',
persons: [],
documents: [],
author: {
id: 'u1',
email: 'marcel@example.com',
firstName: 'Marcel',
lastName: 'Raddatz',
enabled: true,
notifyOnReply: false,
notifyOnMention: false,
groups: [],
createdAt: '2024-01-01T00:00:00',
color: '#000'
}
});
afterEach(() => cleanup());
describe('GeschichtenCard', () => {
it('renders nothing when geschichten is empty', async () => {
render(GeschichtenCard, {
geschichten: [],
personId: 'p1',
personName: 'Franz',
canWrite: true
});
// No heading, no list — the entire <section> should not exist
expect(
document.querySelector('section[aria-labelledby="geschichten-card-heading"]')
).toBeNull();
});
it('renders the section heading and stories when geschichten is non-empty', async () => {
render(GeschichtenCard, {
geschichten: [makeStory('g1', 'Erinnerung an Franz')],
personId: 'p1',
personName: 'Franz',
canWrite: false
});
await expect.element(page.getByText('Geschichten')).toBeInTheDocument();
await expect
.element(page.getByRole('link', { name: 'Erinnerung an Franz' }))
.toBeInTheDocument();
});
it('hides the "+ Geschichte schreiben" link when canWrite is false', async () => {
render(GeschichtenCard, {
geschichten: [makeStory('g1', 'A story')],
personId: 'p1',
personName: 'Franz',
canWrite: false
});
const writeLinks = await page.getByText(/Geschichte schreiben/).all();
expect(writeLinks).toHaveLength(0);
});
it('shows the write-action link only when canWrite is true', async () => {
render(GeschichtenCard, {
geschichten: [makeStory('g1', 'A story')],
personId: 'p1',
personName: 'Franz',
canWrite: true
});
const link = await page.getByRole('link', { name: /Geschichte schreiben/ }).element();
expect(link.getAttribute('href')).toBe('/geschichten/new?personId=p1');
});
it('hides the "Alle Geschichten zu …" footer link below the 3-story threshold', async () => {
render(GeschichtenCard, {
geschichten: [makeStory('g1', 'A'), makeStory('g2', 'B')],
personId: 'p1',
personName: 'Franz',
canWrite: false
});
const overflow = await page.getByText(/Alle Geschichten zu/).all();
expect(overflow).toHaveLength(0);
});
it('shows the footer link at the 3-story threshold (>= 3)', async () => {
render(GeschichtenCard, {
geschichten: [makeStory('g1', 'A'), makeStory('g2', 'B'), makeStory('g3', 'C')],
personId: 'p1',
personName: 'Franz',
canWrite: false
});
const link = await page.getByRole('link', { name: /Alle Geschichten zu Franz/ }).element();
expect(link.getAttribute('href')).toBe('/geschichten?personId=p1');
});
it('renders a plain-text excerpt without HTML markup', async () => {
render(GeschichtenCard, {
geschichten: [
makeStory(
'g1',
'Mit HTML',
'<p>Plain <strong>bold</strong> story</p><script>alert(1)</script>'
)
],
personId: 'p1',
personName: 'Franz',
canWrite: false
});
// Body excerpt appears once as plain text — no <strong> rendered, no script
await expect.element(page.getByText(/Plain bold story/)).toBeInTheDocument();
expect(document.body.innerHTML).not.toContain('<script>');
});
});

View File

@@ -0,0 +1,67 @@
import { describe, expect, it } from 'vitest';
import { extractText, plainExcerpt } from './extractText';
describe('extractText', () => {
it('returns empty string for null/undefined/empty', () => {
expect(extractText(null)).toBe('');
expect(extractText(undefined)).toBe('');
expect(extractText('')).toBe('');
});
it('strips tags and preserves visible text', () => {
expect(extractText('<p>Hello <strong>world</strong></p>')).toBe('Hello world');
});
it('collapses whitespace within and between blocks', () => {
expect(extractText('<p>One</p><p>Two</p>')).toBe('OneTwo');
expect(extractText('<p>foo bar</p>')).toBe('foo bar');
});
// XSS-shaped inputs: extractText must NOT execute, render, or expose the
// payload as HTML. It is only required to return *some* string. The fact
// that it exists is documented as a non-sanitiser; these tests prevent
// silent regressions where the function might somehow leak a tag.
describe('XSS-shaped input — never re-emits markup, even though this is not a sanitiser', () => {
it('drops <script> and surfaces only its text content', () => {
const out = extractText('<p>ok</p><script>alert(1)</script>');
expect(out).not.toContain('<script>');
expect(out).not.toContain('</script>');
});
it('drops <svg/onload> markup', () => {
const out = extractText('<svg/onload=alert(1)>');
expect(out).not.toContain('<svg');
expect(out).not.toContain('onload');
});
it('drops <iframe srcdoc=…> markup', () => {
const out = extractText('<iframe srcdoc="<script>alert(1)</script>">');
expect(out).not.toContain('<iframe');
expect(out).not.toContain('srcdoc');
});
it('drops <a href="javascript:…"> tag (text content may remain)', () => {
const out = extractText('<a href="javascript:alert(1)">click</a>');
expect(out).not.toContain('<a ');
expect(out).not.toContain('javascript:');
});
});
});
describe('plainExcerpt', () => {
it('returns full text when under the limit', () => {
expect(plainExcerpt('<p>short</p>', 80)).toBe('short');
});
it('truncates at the boundary with an ellipsis', () => {
const html = '<p>' + 'a'.repeat(100) + '</p>';
const out = plainExcerpt(html, 20);
expect(out.length).toBeLessThanOrEqual(21);
expect(out.endsWith('…')).toBe(true);
});
it('breaks at a word boundary when possible', () => {
const out = plainExcerpt('<p>The quick brown fox jumps over</p>', 18);
expect(out).toBe('The quick brown…');
});
});

View File

@@ -0,0 +1,38 @@
/**
* **Not a sanitizer.** This module extracts visible text from a (presumed
* already-sanitised) HTML string for excerpt rendering. It is safe ONLY
* because the Geschichte body is sanitised against the OWASP allow-list
* on the server before persistence, and via DOMPurify on render.
*
* Do not use these helpers to defend against XSS — `safeHtml()` in
* `./sanitize.ts` is the only sanitiser. Calling `extractText()` on
* untrusted input that has not been sanitised does not protect against
* `javascript:` URLs, event-handler attributes, or `<svg/onload>` payloads.
*/
/**
* Strip tags and return plain text. Uses DOMParser in the browser; on the
* server it falls back to a regex that drops angle-bracket sequences.
* The fallback is **not** a sanitiser — see module docstring.
*/
export function extractText(html: string | null | undefined): string {
if (!html) return '';
if (typeof DOMParser === 'function') {
const doc = new DOMParser().parseFromString(html, 'text/html');
return (doc.body.textContent ?? '').replace(/\s+/g, ' ').trim();
}
return html
.replace(/<[^>]*>/g, '')
.replace(/\s+/g, ' ')
.trim();
}
/**
* Strip tags then truncate to `max` chars on a word boundary, appending an
* ellipsis when truncated. Used for editorial story excerpts.
*/
export function plainExcerpt(html: string | null | undefined, max = 80): string {
const text = extractText(html);
if (text.length <= max) return text;
return text.slice(0, max).replace(/\s+\S*$/, '') + '…';
}

View File

@@ -1,36 +0,0 @@
import { describe, expect, it } from 'vitest';
import { plainExcerpt, stripHtml } from './stripHtml';
describe('stripHtml', () => {
it('returns empty string for null/undefined/empty', () => {
expect(stripHtml(null)).toBe('');
expect(stripHtml(undefined)).toBe('');
expect(stripHtml('')).toBe('');
});
it('strips tags and preserves visible text', () => {
expect(stripHtml('<p>Hello <strong>world</strong></p>')).toBe('Hello world');
});
it('strips nested HTML', () => {
expect(stripHtml('<div><p>A</p><p>B</p></div>')).toBe('AB');
});
});
describe('plainExcerpt', () => {
it('returns full text when under the limit', () => {
expect(plainExcerpt('<p>short</p>', 80)).toBe('short');
});
it('truncates at the boundary with an ellipsis', () => {
const html = '<p>' + 'a'.repeat(100) + '</p>';
const out = plainExcerpt(html, 20);
expect(out.length).toBeLessThanOrEqual(21); // 20 chars + ellipsis
expect(out.endsWith('…')).toBe(true);
});
it('breaks at a word boundary when possible', () => {
const out = plainExcerpt('<p>The quick brown fox jumps over</p>', 18);
expect(out).toBe('The quick brown…');
});
});

View File

@@ -1,23 +0,0 @@
/**
* Strip HTML tags from a string and return the plain text.
* Uses DOMParser in the browser, falls back to a regex strip on the server
* (where DOMParser is not available without isomorphic-dompurify's JSDOM).
*/
export function stripHtml(html: string | null | undefined): string {
if (!html) return '';
if (typeof DOMParser === 'function') {
const doc = new DOMParser().parseFromString(html, 'text/html');
return (doc.body.textContent ?? '').trim();
}
return html.replace(/<[^>]*>/g, '').trim();
}
/**
* Strip HTML and truncate to a maximum length, appending an ellipsis when
* the source exceeds it. Used for editorial story excerpts.
*/
export function plainExcerpt(html: string | null | undefined, max = 80): string {
const text = stripHtml(html);
if (text.length <= max) return text;
return text.slice(0, max).replace(/\s+\S*$/, '') + '…';
}

View File

@@ -27,7 +27,8 @@ $effect(() => {
const STANDARD_PERMISSIONS = $derived([
{ value: 'READ_ALL', label: m.admin_perm_read_all() },
{ value: 'ANNOTATE_ALL', label: m.admin_perm_annotate_all() },
{ value: 'WRITE_ALL', label: m.admin_perm_write_all() }
{ value: 'WRITE_ALL', label: m.admin_perm_write_all() },
{ value: 'BLOG_WRITE', label: m.admin_perm_blog_write() }
]);
const ADMIN_PERMISSIONS = $derived([

View File

@@ -6,7 +6,8 @@ import { m } from '$lib/paraglide/messages.js';
const availableStandard = $derived([
{ value: 'READ_ALL', label: m.admin_perm_read_all() },
{ value: 'ANNOTATE_ALL', label: m.admin_perm_annotate_all() },
{ value: 'WRITE_ALL', label: m.admin_perm_write_all() }
{ value: 'WRITE_ALL', label: m.admin_perm_write_all() },
{ value: 'BLOG_WRITE', label: m.admin_perm_blog_write() }
]);
const availableAdmin = $derived([
{ value: 'ADMIN', label: m.admin_perm_admin() },

View File

@@ -1,7 +1,7 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { m } from '$lib/paraglide/messages.js';
import { plainExcerpt } from '$lib/utils/stripHtml';
import { plainExcerpt } from '$lib/utils/extractText';
import { formatDate } from '$lib/utils/date';
import PersonTypeahead from '$lib/components/PersonTypeahead.svelte';
import type { PageData } from './$types';

View File

@@ -58,8 +58,17 @@ async function handleDelete() {
</p>
</header>
<div class="prose font-serif text-lg leading-relaxed text-ink">
<!-- Sanitised via safeHtml() (DOMPurify) on render — matches backend OWASP allow-list -->
<!--
Body styles are explicit (no `prose`) so the text uses the full max-w-3xl
parent width — Tailwind Typography's default `max-w-prose` clamps to ~65ch
and produces a much narrower column inside an already narrow page, which
Leonie flagged as unreadable for the senior-author persona.
Sanitised via safeHtml() (DOMPurify) on render — matches backend OWASP allow-list.
-->
<div
class="font-serif text-lg leading-relaxed text-ink [&_h2]:mt-8 [&_h2]:mb-3 [&_h2]:text-2xl [&_h2]:font-bold [&_h3]:mt-6 [&_h3]:mb-2 [&_h3]:text-xl [&_h3]:font-bold [&_li]:mb-1 [&_ol]:mb-4 [&_ol]:list-decimal [&_ol]:pl-6 [&_p]:mb-4 [&_ul]:mb-4 [&_ul]:list-disc [&_ul]:pl-6"
>
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
{@html sanitized}
</div>
@@ -68,7 +77,7 @@ async function handleDelete() {
<!-- Personen -->
{#if g.persons && g.persons.length > 0}
<section class="mt-10 border-t border-line pt-6">
<h2 class="mb-3 font-sans text-xs font-bold tracking-widest text-ink-3 uppercase">
<h2 class="mb-3 font-sans text-xs font-bold tracking-widest text-ink-2 uppercase">
{m.geschichten_persons_section()}
</h2>
<ul class="flex flex-wrap gap-2">
@@ -89,7 +98,7 @@ async function handleDelete() {
<!-- Dokumente -->
{#if g.documents && g.documents.length > 0}
<section class="mt-8 border-t border-line pt-6">
<h2 class="mb-3 font-sans text-xs font-bold tracking-widest text-ink-3 uppercase">
<h2 class="mb-3 font-sans text-xs font-bold tracking-widest text-ink-2 uppercase">
{m.geschichten_documents_section()}
</h2>
<ul class="flex flex-col gap-2">

File diff suppressed because it is too large Load Diff