From 59d78150b3b616b244a19a6acef6bdc5050c75a9 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sat, 13 Jun 2026 22:49:05 +0200 Subject: [PATCH] feat(timeline): add /zeitstrahl/events/new curator create route MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Server load gates on hasWriteAll with a null-user guard first (403 error page, the persons/new idiom — not a redirect); prefills ?personId/?documentId via Promise.all, swallowing 404/403 so unknown ids never leak. The save action parses the form, surfaces title+date required errors simultaneously via fail(400) preserving picker arrays, builds a TimelineEventRequest (eventDateEnd explicit null off RANGE), POSTs, maps API/409 errors via getErrorMessage without redirecting, and redirects to a UUID-validated nav target (CWE-601). Shared parse/validate/build/nav helpers live in eventFormServer.ts for reuse by the edit route. 11/11 server specs green. Refs #781 Co-Authored-By: Claude Opus 4.8 --- frontend/src/lib/timeline/eventFormServer.ts | 88 ++++++++ .../zeitstrahl/events/new/+page.server.ts | 81 +++++++ .../routes/zeitstrahl/events/new/+page.svelte | 13 ++ .../zeitstrahl/events/new/page.server.spec.ts | 206 ++++++++++++++++++ 4 files changed, 388 insertions(+) create mode 100644 frontend/src/lib/timeline/eventFormServer.ts create mode 100644 frontend/src/routes/zeitstrahl/events/new/+page.server.ts create mode 100644 frontend/src/routes/zeitstrahl/events/new/+page.svelte create mode 100644 frontend/src/routes/zeitstrahl/events/new/page.server.spec.ts diff --git a/frontend/src/lib/timeline/eventFormServer.ts b/frontend/src/lib/timeline/eventFormServer.ts new file mode 100644 index 00000000..13c8bae4 --- /dev/null +++ b/frontend/src/lib/timeline/eventFormServer.ts @@ -0,0 +1,88 @@ +import { fail } from '@sveltejs/kit'; +import { m } from '$lib/paraglide/messages.js'; +import type { components } from '$lib/generated/api'; +import type { DatePrecision } from '$lib/shared/utils/documentDate'; + +type TimelineEventRequest = components['schemas']['TimelineEventRequest']; + +// Prevents open redirect: validate before constructing /persons/{id}. See OWASP CWE-601. +const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +/** + * Resolves the context-aware post-save / post-delete redirect target. Returns + * the originating person page only when `originPersonIdRaw` is a strict UUID; + * otherwise falls back to the timeline (open-redirect guard). + */ +export function resolveNavTarget(originPersonIdRaw: string): string { + return UUID_RE.test(originPersonIdRaw) ? `/persons/${originPersonIdRaw}` : '/zeitstrahl'; +} + +export interface ParsedEventForm { + title: string; + type: 'PERSONAL' | 'HISTORICAL'; + eventDate: string; + precision: DatePrecision; + eventDateEnd: string | null; + description: string; + personIds: string[]; + documentIds: string[]; + originPersonId: string; +} + +/** Reads the curator event form fields out of submitted FormData. */ +export function parseEventForm(formData: FormData): ParsedEventForm { + const rawType = formData.get('type')?.toString(); + const type = rawType === 'HISTORICAL' ? 'HISTORICAL' : 'PERSONAL'; + const precision = (formData.get('metaDatePrecision')?.toString() as DatePrecision) || 'DAY'; + const endRaw = formData.get('eventDateEnd')?.toString().trim() ?? ''; + // Off-RANGE submits an empty string → null so a stale end-date never persists. + const eventDateEnd = precision === 'RANGE' && endRaw ? endRaw : null; + + return { + title: formData.get('title')?.toString().trim() ?? '', + type, + eventDate: formData.get('eventDate')?.toString().trim() ?? '', + precision, + eventDateEnd, + description: formData.get('description')?.toString().trim() ?? '', + personIds: formData.getAll('personIds').map((v) => v.toString()), + documentIds: formData.getAll('documentIds').map((v) => v.toString()), + originPersonId: formData.get('originPersonId')?.toString() ?? '' + }; +} + +/** + * Surfaces all failing required-field errors simultaneously (title + date) via a + * fail(400) payload that also preserves every entered value — including the + * picker arrays — so the form re-renders without losing state. Returns null when + * the form is valid. + */ +export function validateEventForm(parsed: ParsedEventForm) { + const titleError = parsed.title.length === 0 ? m.event_editor_title_required() : ''; + const dateError = parsed.eventDate.length === 0 ? m.event_editor_date_required() : ''; + if (!titleError && !dateError) return null; + return fail(400, { + titleError, + dateError, + title: parsed.title, + description: parsed.description, + type: parsed.type, + personIds: parsed.personIds, + documentIds: parsed.documentIds + }); +} + +/** Builds the TimelineEventRequest write body from parsed form fields. */ +export function toEventRequest(parsed: ParsedEventForm, version?: number): TimelineEventRequest { + return { + title: parsed.title, + type: parsed.type, + eventDate: parsed.eventDate, + precision: parsed.precision, + eventDateEnd: parsed.eventDateEnd, + ...(parsed.description ? { description: parsed.description } : {}), + ...(parsed.personIds.length ? { personIds: parsed.personIds } : {}), + ...(parsed.documentIds.length ? { documentIds: parsed.documentIds } : {}), + ...(version !== undefined ? { version } : {}) + } as TimelineEventRequest; +} diff --git a/frontend/src/routes/zeitstrahl/events/new/+page.server.ts b/frontend/src/routes/zeitstrahl/events/new/+page.server.ts new file mode 100644 index 00000000..68d68665 --- /dev/null +++ b/frontend/src/routes/zeitstrahl/events/new/+page.server.ts @@ -0,0 +1,81 @@ +import { error, fail, redirect } from '@sveltejs/kit'; +import { createApiClient, extractErrorCode } from '$lib/shared/api.server'; +import { getErrorMessage } from '$lib/shared/errors'; +import { hasWriteAll } from '$lib/shared/server/permissions'; +import { + parseEventForm, + validateEventForm, + toEventRequest, + resolveNavTarget +} from '$lib/timeline/eventFormServer'; +import type { PersonOption } from '$lib/person/personOption'; +import type { DocumentOption } from '$lib/document/documentTypeahead'; + +export async function load({ + locals, + url, + fetch +}: { + locals: App.Locals; + url: URL; + fetch: typeof globalThis.fetch; +}) { + // Null-user guard first — avoids a TypeError on locals.user.groups for an + // unauthenticated request that reaches the route. + if (!locals.user) throw error(403, 'Forbidden'); + // WRITE_ALL check mirrors Permission.WRITE_ALL — server-side gate; frontend + // canWrite flag is for hiding entry-point buttons only. + if (!hasWriteAll(locals)) throw error(403, 'Forbidden'); + + const api = createApiClient(fetch); + const personId = url.searchParams.get('personId'); + const documentId = url.searchParams.get('documentId'); + + const [personResult, documentResult] = await Promise.all([ + personId ? api.GET('/api/persons/{id}', { params: { path: { id: personId } } }) : null, + documentId ? api.GET('/api/documents/{id}', { params: { path: { id: documentId } } }) : null + ]); + + // Silently ignore 404/403 on prefill lookups to avoid leaking entity existence. + const initialPersons: PersonOption[] = + personResult && personResult.response.ok && personResult.data ? [personResult.data] : []; + const initialDocuments: DocumentOption[] = + documentResult && documentResult.response.ok && documentResult.data + ? [ + { + id: documentResult.data.id, + title: documentResult.data.title, + documentDate: documentResult.data.documentDate, + metaDatePrecision: documentResult.data.metaDatePrecision, + metaDateEnd: documentResult.data.metaDateEnd + } + ] + : []; + + return { initialPersons, initialDocuments, originPersonId: personId ?? '' }; +} + +export const actions = { + save: async ({ request, fetch }: { request: Request; fetch: typeof globalThis.fetch }) => { + const parsed = parseEventForm(await request.formData()); + + const invalid = validateEventForm(parsed); + if (invalid) return invalid; + + const api = createApiClient(fetch); + const result = await api.POST('/api/timeline/events', { body: toEventRequest(parsed) }); + + if (!result.response.ok) { + return fail(result.response.status, { + error: getErrorMessage(extractErrorCode(result.error)), + title: parsed.title, + description: parsed.description, + type: parsed.type, + personIds: parsed.personIds, + documentIds: parsed.documentIds + }); + } + + throw redirect(303, resolveNavTarget(parsed.originPersonId)); + } +}; diff --git a/frontend/src/routes/zeitstrahl/events/new/+page.svelte b/frontend/src/routes/zeitstrahl/events/new/+page.svelte new file mode 100644 index 00000000..a5967efa --- /dev/null +++ b/frontend/src/routes/zeitstrahl/events/new/+page.svelte @@ -0,0 +1,13 @@ + + + diff --git a/frontend/src/routes/zeitstrahl/events/new/page.server.spec.ts b/frontend/src/routes/zeitstrahl/events/new/page.server.spec.ts new file mode 100644 index 00000000..accb4ae2 --- /dev/null +++ b/frontend/src/routes/zeitstrahl/events/new/page.server.spec.ts @@ -0,0 +1,206 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest'; + +vi.mock('$lib/shared/api.server', () => ({ + createApiClient: vi.fn(), + extractErrorCode: (e: unknown) => (e as { code?: string } | undefined)?.code +})); + +import { createApiClient } from '$lib/shared/api.server'; +import { load, actions } from './+page.server'; + +const mockFetch = vi.fn() as unknown as typeof fetch; + +beforeEach(() => vi.clearAllMocks()); + +function localsWith(perms: string[] | null) { + if (perms === null) return { user: null }; + return { user: { groups: [{ permissions: perms }] } }; +} + +function loadEvent(perms: string[] | null, search = '') { + const url = new URL(`http://localhost/zeitstrahl/events/new${search}`); + return { + locals: localsWith(perms), + url, + fetch: mockFetch, + request: new Request(url), + route: { id: '/zeitstrahl/events/new' }, + params: {} + } as never; +} + +function saveRequest(fields: Record): Request { + const fd = new FormData(); + for (const [k, v] of Object.entries(fields)) { + if (Array.isArray(v)) v.forEach((x) => fd.append(k, x)); + else fd.set(k, v); + } + return new Request('http://localhost/zeitstrahl/events/new', { method: 'POST', body: fd }); +} + +function saveEvent(fields: Record) { + return { + request: saveRequest(fields), + fetch: mockFetch, + route: { id: '/zeitstrahl/events/new' }, + params: {} + } as never; +} + +describe('zeitstrahl/events/new load — gating (REQ-002/003)', () => { + it('throws 403 for an unauthenticated (null) user', async () => { + await expect(load(loadEvent(null))).rejects.toMatchObject({ status: 403 }); + }); + + it('throws 403 for an authenticated user without WRITE_ALL', async () => { + await expect(load(loadEvent(['READ_ALL']))).rejects.toMatchObject({ status: 403 }); + }); + + it('allows a curator with WRITE_ALL', async () => { + vi.mocked(createApiClient).mockReturnValue({ GET: vi.fn() } as never); + const result = await load(loadEvent(['WRITE_ALL'])); + expect(result.initialPersons).toEqual([]); + expect(result.initialDocuments).toEqual([]); + }); +}); + +describe('zeitstrahl/events/new load — prefill (REQ-014)', () => { + it('preselects a valid person and ignores an unknown document', async () => { + const get = vi.fn((path: string) => { + if (path === '/api/persons/{id}') + return Promise.resolve({ + response: { ok: true }, + data: { id: 'p1', displayName: 'Anna' } + }); + return Promise.resolve({ response: { ok: false }, data: null }); + }); + vi.mocked(createApiClient).mockReturnValue({ GET: get } as never); + + const result = await load(loadEvent(['WRITE_ALL'], '?personId=p1&documentId=missing')); + expect(result.initialPersons).toHaveLength(1); + expect(result.initialDocuments).toEqual([]); + expect(result.originPersonId).toBe('p1'); + }); +}); + +describe('zeitstrahl/events/new save action (REQ-004/009/010/015)', () => { + const validUuid = '11111111-1111-1111-1111-111111111111'; + + it('posts a TimelineEventRequest and redirects on success', async () => { + const post = vi + .fn() + .mockResolvedValue({ response: { ok: true, status: 200 }, data: { id: 'e-new' } }); + vi.mocked(createApiClient).mockReturnValue({ POST: post } as never); + + await expect( + actions.save(saveEvent({ title: 'Umzug', type: 'PERSONAL', eventDate: '1925-04-01' })) + ).rejects.toMatchObject({ status: 303, location: '/zeitstrahl' }); + + expect(post).toHaveBeenCalledTimes(1); + expect(post.mock.calls[0][1].body).toMatchObject({ + title: 'Umzug', + type: 'PERSONAL', + eventDate: '1925-04-01' + }); + }); + + it('sends eventDateEnd: null when precision is not RANGE', async () => { + const post = vi + .fn() + .mockResolvedValue({ response: { ok: true, status: 200 }, data: { id: 'e-new' } }); + vi.mocked(createApiClient).mockReturnValue({ POST: post } as never); + + try { + await actions.save( + saveEvent({ + title: 'Umzug', + type: 'PERSONAL', + eventDate: '1925-04-01', + metaDatePrecision: 'YEAR', + eventDateEnd: '1925-05-01' + }) + ); + } catch { + // redirect throws on success + } + expect(post.mock.calls[0][1].body.eventDateEnd).toBeNull(); + }); + + it('returns fail(400) with preserved picker arrays on blank title', async () => { + const post = vi.fn(); + vi.mocked(createApiClient).mockReturnValue({ POST: post } as never); + + const result = await actions.save( + saveEvent({ + title: ' ', + type: 'PERSONAL', + eventDate: '1925-04-01', + personIds: ['p1', 'p2'], + documentIds: ['d1'] + }) + ); + + expect(post).not.toHaveBeenCalled(); + expect(result).toMatchObject({ status: 400 }); + expect(result.data.personIds).toEqual(['p1', 'p2']); + expect(result.data.documentIds).toEqual(['d1']); + expect(result.data.titleError).toBeTruthy(); + }); + + it('surfaces both title and date errors when both blank (REQ-011)', async () => { + vi.mocked(createApiClient).mockReturnValue({ POST: vi.fn() } as never); + const result = await actions.save(saveEvent({ title: '', type: 'PERSONAL', eventDate: '' })); + expect(result.data.titleError).toBeTruthy(); + expect(result.data.dateError).toBeTruthy(); + }); + + it('redirects to /persons/{id} when originPersonId is a valid UUID', async () => { + const post = vi + .fn() + .mockResolvedValue({ response: { ok: true, status: 200 }, data: { id: 'e-new' } }); + vi.mocked(createApiClient).mockReturnValue({ POST: post } as never); + + await expect( + actions.save( + saveEvent({ + title: 'Umzug', + type: 'PERSONAL', + eventDate: '1925-04-01', + originPersonId: validUuid + }) + ) + ).rejects.toMatchObject({ status: 303, location: `/persons/${validUuid}` }); + }); + + it('defaults to /zeitstrahl when originPersonId is not a valid UUID (REQ-015)', async () => { + const post = vi + .fn() + .mockResolvedValue({ response: { ok: true, status: 200 }, data: { id: 'e-new' } }); + vi.mocked(createApiClient).mockReturnValue({ POST: post } as never); + + await expect( + actions.save( + saveEvent({ + title: 'Umzug', + type: 'PERSONAL', + eventDate: '1925-04-01', + originPersonId: '../evil' + }) + ) + ).rejects.toMatchObject({ status: 303, location: '/zeitstrahl' }); + }); + + it('maps the API error and does not redirect on a non-ok save (incl. 409)', async () => { + const post = vi.fn().mockResolvedValue({ + response: { ok: false, status: 409 }, + error: { code: 'CONFLICT' } + }); + vi.mocked(createApiClient).mockReturnValue({ POST: post } as never); + + const result = await actions.save( + saveEvent({ title: 'Umzug', type: 'PERSONAL', eventDate: '1925-04-01' }) + ); + expect(result).toMatchObject({ status: 409 }); + expect(result.data.error).toBeTruthy(); + }); +});