diff --git a/frontend/src/lib/geschichte/GeschichtenCard.svelte.spec.ts b/frontend/src/lib/geschichte/GeschichtenCard.svelte.spec.ts
index 90e3bdb7..2aeefc5a 100644
--- a/frontend/src/lib/geschichte/GeschichtenCard.svelte.spec.ts
+++ b/frontend/src/lib/geschichte/GeschichtenCard.svelte.spec.ts
@@ -121,6 +121,16 @@ describe('GeschichtenCard', () => {
expect(link.getAttribute('href')).toBe('/geschichten?personId=p1');
});
+ it('JOURNEY type does not bleed a REISE badge into the person-sidebar card', async () => {
+ render(GeschichtenCard, {
+ geschichten: [{ ...makeStory('g1', 'Reise Berlin'), type: 'JOURNEY' as const }],
+ personId: 'p1',
+ personName: 'Franz',
+ canWrite: false
+ });
+ expect(document.querySelector('[data-testid="journey-badge"]')).toBeNull();
+ });
+
it('renders a plain-text excerpt without HTML markup', async () => {
render(GeschichtenCard, {
geschichten: [
diff --git a/frontend/src/lib/shared/utils/extractText.spec.ts b/frontend/src/lib/shared/utils/extractText.spec.ts
index 404ac5cb..89f12985 100644
--- a/frontend/src/lib/shared/utils/extractText.spec.ts
+++ b/frontend/src/lib/shared/utils/extractText.spec.ts
@@ -48,6 +48,18 @@ describe('extractText', () => {
});
});
+// SSR regex-fallback XSS gate — must stay in the Node (.test.ts / .spec.ts) project.
+// The browser project's DOMParser would silently take the safe branch → false green.
+// This test fires the regex fallback specifically (Node has no DOMParser).
+describe('plainExcerpt — SSR regex-fallback XSS gate (Node tier)', () => {
+ it('does not emit onerror= in output when given an payload (security regression)', () => {
+ // plainExcerpt calls extractText which regex-strips tags in Node (no DOMParser).
+ // SvelteKit SSR auto-escapes the result, so onerror= in output is the first-paint risk.
+ const out = plainExcerpt('
');
+ expect(out).not.toContain('onerror=');
+ });
+});
+
describe('plainExcerpt', () => {
it('returns full text when under the limit', () => {
expect(plainExcerpt('
short
', 80)).toBe('short'); diff --git a/frontend/src/routes/geschichten/[id]/page.svelte.test.ts b/frontend/src/routes/geschichten/[id]/page.svelte.test.ts index a251ea80..10172d12 100644 --- a/frontend/src/routes/geschichten/[id]/page.svelte.test.ts +++ b/frontend/src/routes/geschichten/[id]/page.svelte.test.ts @@ -3,23 +3,26 @@ 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: GeschichtePage } = await import('./+page.svelte'); afterEach(cleanup); -const baseGeschichte = (overrides: RecordIm Jahr 1923 fuhr Helene...
', - publishedAt: '2026-04-15T10:00:00Z' as string | null, - author: { firstName: 'Anna', lastName: 'Schmidt', email: 'anna@example.com' } as { - firstName?: string; - lastName?: string; - email: string; - } | null, - persons: [] as { id: string; displayName: string }[], - items: [] as { id: string; documentId?: string; position: number; note?: string }[], + type: 'STORY', + status: 'PUBLISHED', + author: { id: 'u1', displayName: 'Anna Schmidt' }, + persons: [], + items: [], + createdAt: '2026-01-01T00:00:00Z', + updatedAt: '2026-01-01T00:00:00Z', + publishedAt: '2026-04-15T10:00:00Z', ...overrides }); @@ -55,9 +58,7 @@ describe('geschichten/[id] page', () => { context: new Map([[CONFIRM_KEY, createConfirmService()]]), props: { data: baseData({ - geschichte: baseGeschichte({ - author: { firstName: undefined, lastName: undefined, email: 'fallback@example.com' } - }) + geschichte: baseGeschichte({ author: { id: 'u2', displayName: 'fallback@example.com' } }) }) } }); @@ -65,10 +66,10 @@ describe('geschichten/[id] page', () => { await expect.element(page.getByText(/fallback@example.com/)).toBeVisible(); }); - it('renders an empty author when author is null', async () => { + it('renders an empty author when author is absent', async () => { render(GeschichtePage, { context: new Map([[CONFIRM_KEY, createConfirmService()]]), - props: { data: baseData({ geschichte: baseGeschichte({ author: null }) }) } + props: { data: baseData({ geschichte: baseGeschichte({ author: undefined }) }) } }); await expect.element(page.getByRole('heading', { level: 1 })).toBeVisible(); @@ -86,7 +87,9 @@ describe('geschichten/[id] page', () => { it('omits the publishedAt suffix when publishedAt is null', async () => { render(GeschichtePage, { context: new Map([[CONFIRM_KEY, createConfirmService()]]), - props: { data: baseData({ geschichte: baseGeschichte({ publishedAt: null }) }) } + props: { + data: baseData({ geschichte: baseGeschichte({ publishedAt: undefined }) }) + } }); await expect.element(page.getByText(/veröffentlicht am/i)).not.toBeInTheDocument(); @@ -108,8 +111,8 @@ describe('geschichten/[id] page', () => { data: baseData({ geschichte: baseGeschichte({ persons: [ - { id: 'p1', displayName: 'Helene Schmidt' }, - { id: 'p2', displayName: 'Karl Müller' } + { id: 'p1', firstName: 'Helene', lastName: 'Schmidt' }, + { id: 'p2', firstName: 'Karl', lastName: 'Müller' } ] }) }) @@ -136,7 +139,14 @@ describe('geschichten/[id] page', () => { props: { data: baseData({ geschichte: baseGeschichte({ - items: [{ id: 'item1', documentId: 'd1', position: 0, note: 'Brief aus 1923' }] + items: [ + { + id: 'item1', + position: 0, + document: { id: 'd1', title: 'Brief 1923', datePrecision: 'FULL' }, + note: 'Brief aus 1923' + } + ] }) }) } @@ -168,4 +178,31 @@ describe('geschichten/[id] page', () => { await expect.element(page.getByRole('link', { name: /bearbeiten/i })).not.toBeInTheDocument(); await expect.element(page.getByRole('button', { name: /löschen/i })).not.toBeInTheDocument(); }); + + it('STORY with items:[] renders rich-text body and no empty-state message', async () => { + render(GeschichtePage, { + context: new Map([[CONFIRM_KEY, createConfirmService()]]), + props: { data: baseData({ geschichte: baseGeschichte({ type: 'STORY', items: [] }) }) } + }); + + await expect.element(page.getByText(/Im Jahr 1923/)).toBeVisible(); + await expect.element(page.getByText(/Diese Lesereise ist noch leer/)).not.toBeInTheDocument(); + }); + + it('type:undefined + non-empty body renders StoryReader and no empty-state', async () => { + render(GeschichtePage, { + context: new Map([[CONFIRM_KEY, createConfirmService()]]), + props: { + data: baseData({ + geschichte: baseGeschichte({ + type: undefined as unknown as 'STORY' | 'JOURNEY', + items: [] + }) + }) + } + }); + + await expect.element(page.getByText(/Im Jahr 1923/)).toBeVisible(); + await expect.element(page.getByText(/Diese Lesereise ist noch leer/)).not.toBeInTheDocument(); + }); }); diff --git a/frontend/src/routes/geschichten/new/page.server.test.ts b/frontend/src/routes/geschichten/new/page.server.test.ts new file mode 100644 index 00000000..81663ba9 --- /dev/null +++ b/frontend/src/routes/geschichten/new/page.server.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest'; + +vi.mock('$env/dynamic/private', () => ({ + env: { API_INTERNAL_URL: 'http://backend:8080' } +})); + +vi.mock('$lib/shared/api.server', () => ({ + createApiClient: () => ({ + GET: vi.fn().mockResolvedValue({ response: { ok: false }, data: null }) + }) +})); + +import { load } from './+page.server'; + +function makeEvent(search: string, canBlogWrite = true) { + return { + url: new URL(`http://localhost/geschichten/new${search}`), + fetch: vi.fn(), + parent: vi.fn().mockResolvedValue({ canBlogWrite }) + } as never; +} + +describe('geschichten/new load — selectedType validation (security regression)', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('returns selectedType: STORY for ?type=STORY', async () => { + const result = await load(makeEvent('?type=STORY')); + expect(result.selectedType).toBe('STORY'); + }); + + it('returns selectedType: JOURNEY for ?type=JOURNEY', async () => { + const result = await load(makeEvent('?type=JOURNEY')); + expect(result.selectedType).toBe('JOURNEY'); + }); + + it('returns selectedType: null when ?type param is absent', async () => { + const result = await load(makeEvent('')); + expect(result.selectedType).toBeNull(); + }); + + it('returns selectedType: null for invalid ?type param (security regression)', async () => { + const result = await load(makeEvent('?type=ADMIN')); + expect(result.selectedType).toBeNull(); + }); + + it('returns selectedType: null for ?type=STORY%00JOURNEY (null-byte encoded — strict equality rejects it)', async () => { + // Strict equality rejects encoded variants; .includes/.startsWith would not. + const result = await load(makeEvent('?type=STORY%00JOURNEY')); + expect(result.selectedType).toBeNull(); + }); + + it('returns selectedType: STORY for repeated ?type=STORY&type=JOURNEY (first-value semantics — intentional)', async () => { + // url.searchParams.get() returns the first value; this is intentional and documented. + const result = await load(makeEvent('?type=STORY&type=JOURNEY')); + expect(result.selectedType).toBe('STORY'); + }); + + it('returns BOTH selectedType: STORY AND initialPersons when ?type=STORY&personId=p1 (no coupling)', async () => { + const { createApiClient } = await import('$lib/shared/api.server'); + vi.mocked(createApiClient).mockReturnValue({ + GET: vi + .fn() + .mockResolvedValue({ response: { ok: true }, data: { id: 'p1', displayName: 'Anna' } }) + } as never); + + const result = await load(makeEvent('?type=STORY&personId=p1')); + expect(result.selectedType).toBe('STORY'); + expect(result.initialPersons).toHaveLength(1); + }); + + it('redirects non-BLOG_WRITE users to /geschichten', async () => { + await expect(load(makeEvent('', false))).rejects.toMatchObject({ location: '/geschichten' }); + }); +}); diff --git a/frontend/src/routes/geschichten/new/page.svelte.test.ts b/frontend/src/routes/geschichten/new/page.svelte.test.ts index 8e26a1a2..26a0ad49 100644 --- a/frontend/src/routes/geschichten/new/page.svelte.test.ts +++ b/frontend/src/routes/geschichten/new/page.svelte.test.ts @@ -20,32 +20,34 @@ const { default: GeschichtenNewPage } = await import('./+page.svelte'); afterEach(cleanup); -const baseData = { - initialPersons: [] as { id: string; displayName: string }[] -}; +const baseData = (overrides: Record