From b37aa6155edfd36f922eb82979a0d6573757f14f Mon Sep 17 00:00:00 2001 From: Marcel Date: Fri, 12 Jun 2026 11:32:39 +0200 Subject: [PATCH 01/13] test(geschichte): rewrite false-safety-net null-status tests to catch CWE-639 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rename list_passes_null_status_through_for_BLOG_WRITER_so_drafts_are_visible to list_with_null_status_and_BLOG_WRITE_returns_PUBLISHED_not_all_stories and rewrite to verify eq(PUBLISHED) is passed — this test is now RED against the vulnerable list() implementation. Strengthen list_forces_PUBLISHED_status_for_reader_without_BLOG_WRITE with eq(PUBLISHED) and isNull() matchers — both tests are now real regression fixtures. Co-Authored-By: Claude Sonnet 4.6 --- .../geschichte/GeschichteServiceTest.java | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/backend/src/test/java/org/raddatz/familienarchiv/geschichte/GeschichteServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/geschichte/GeschichteServiceTest.java index 2e87c50c..86cdd2a2 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/geschichte/GeschichteServiceTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/geschichte/GeschichteServiceTest.java @@ -2,6 +2,7 @@ package org.raddatz.familienarchiv.geschichte; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; @@ -35,6 +36,7 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; @@ -228,21 +230,18 @@ class GeschichteServiceTest { geschichteService.list(null, List.of(), null, 50); - verify(geschichteRepository).findSummaries(any(), any(), any(), anyLong(), any()); + verify(geschichteRepository).findSummaries(eq(GeschichteStatus.PUBLISHED), isNull(), any(), anyLong(), any()); } @Test - void list_passes_null_status_through_for_BLOG_WRITER_so_drafts_are_visible() { + void list_with_null_status_and_BLOG_WRITE_returns_PUBLISHED_not_all_stories() { authenticateAs(writer, Permission.BLOG_WRITE); - GeschichteSummary s1 = mock(GeschichteSummary.class); - GeschichteSummary s2 = mock(GeschichteSummary.class); when(geschichteRepository.findSummaries(any(), any(), any(), anyLong(), any())) - .thenReturn(List.of(s1, s2)); + .thenReturn(List.of()); - List out = geschichteService.list(null, List.of(), null, 50); + geschichteService.list(null, List.of(), null, 50); - assertThat(out).hasSize(2); - verify(geschichteRepository).findSummaries(any(), any(), any(), anyLong(), any()); + verify(geschichteRepository).findSummaries(eq(GeschichteStatus.PUBLISHED), isNull(), any(), anyLong(), any()); } @Test -- 2.49.1 From 4541f90ce8c4a3ef02c084b923cc9c42208c97cd Mon Sep 17 00:00:00 2001 From: Marcel Date: Fri, 12 Jun 2026 11:40:44 +0200 Subject: [PATCH 02/13] test(geschichte): add security regression tests for CWE-639 null-status and DRAFT scoping Co-Authored-By: Claude Sonnet 4.6 --- .../geschichte/GeschichteServiceTest.java | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/backend/src/test/java/org/raddatz/familienarchiv/geschichte/GeschichteServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/geschichte/GeschichteServiceTest.java index 86cdd2a2..261b5bd9 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/geschichte/GeschichteServiceTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/geschichte/GeschichteServiceTest.java @@ -307,6 +307,32 @@ class GeschichteServiceTest { assertThat(out).hasSizeLessThanOrEqualTo(200); } + @Test + @DisplayName("security: null status for blog writer returns PUBLISHED, never leaks drafts") + void list_with_blog_writer_and_null_status_returns_PUBLISHED_not_all_drafts() { + authenticateAs(writer, Permission.BLOG_WRITE); + when(geschichteRepository.findSummaries(any(), any(), any(), anyLong(), any())) + .thenReturn(List.of()); + + geschichteService.list(null, List.of(), null, 50); + + verify(geschichteRepository).findSummaries( + eq(GeschichteStatus.PUBLISHED), isNull(), any(), anyLong(), any()); + } + + @Test + @DisplayName("security: DRAFT status scopes to current user only") + void list_with_DRAFT_status_scopes_to_current_user_not_all_authors() { + authenticateAs(writer, Permission.BLOG_WRITE); + when(geschichteRepository.findSummaries(any(), any(), any(), anyLong(), any())) + .thenReturn(List.of()); + + geschichteService.list(GeschichteStatus.DRAFT, List.of(), null, 50); + + verify(geschichteRepository).findSummaries( + eq(GeschichteStatus.DRAFT), eq(writer.getId()), any(), anyLong(), any()); + } + // ─── create ────────────────────────────────────────────────────────────── @Test -- 2.49.1 From e3140c4f99c4593139b86afb994178ac2d97765d Mon Sep 17 00:00:00 2001 From: Marcel Date: Fri, 12 Jun 2026 11:43:38 +0200 Subject: [PATCH 03/13] fix(geschichte): null status always resolves to PUBLISHED, fixing CWE-639 A blog writer passing null status previously forwarded null to the repository, returning all stories including other authors' drafts. Now only an explicit DRAFT request (blog writer only) scopes to the caller's own stories. Co-Authored-By: Claude Sonnet 4.6 --- .../familienarchiv/geschichte/GeschichteService.java | 9 +++++++-- .../familienarchiv/geschichte/GeschichteServiceTest.java | 1 + 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/backend/src/main/java/org/raddatz/familienarchiv/geschichte/GeschichteService.java b/backend/src/main/java/org/raddatz/familienarchiv/geschichte/GeschichteService.java index cbc8f6d1..ffc932e7 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/geschichte/GeschichteService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/geschichte/GeschichteService.java @@ -117,12 +117,17 @@ public class GeschichteService { * *

Returns a {@link GeschichteSummary} projection — never carries items, preventing * LazyInitializationException on the non-transactional list path. + * + *

Security: {@code null} status always resolves to PUBLISHED — even for blog writers. + * Only an explicit {@code DRAFT} request scopes the query to the caller's own drafts. + * This prevents CWE-639: a blog writer passing {@code null} must not see all authors' drafts. */ public List list(GeschichteStatus status, List personIds, UUID documentId, int limit) { - GeschichteStatus effective = currentUserHasBlogWrite() ? status : GeschichteStatus.PUBLISHED; + boolean isDraftRequest = currentUserHasBlogWrite() && status == GeschichteStatus.DRAFT; + GeschichteStatus effective = isDraftRequest ? GeschichteStatus.DRAFT : GeschichteStatus.PUBLISHED; int safeLimit = limit <= 0 ? DEFAULT_LIMIT : Math.min(limit, MAX_LIMIT); - UUID authorId = effective == GeschichteStatus.DRAFT ? currentUser().getId() : null; + UUID authorId = isDraftRequest ? currentUser().getId() : null; // When personIds is empty, personCount=0 short-circuits the IN() predicate. // Pass a sentinel UUID to avoid invalid empty IN() SQL while the predicate is skipped. diff --git a/backend/src/test/java/org/raddatz/familienarchiv/geschichte/GeschichteServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/geschichte/GeschichteServiceTest.java index 261b5bd9..1d183a3c 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/geschichte/GeschichteServiceTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/geschichte/GeschichteServiceTest.java @@ -324,6 +324,7 @@ class GeschichteServiceTest { @DisplayName("security: DRAFT status scopes to current user only") void list_with_DRAFT_status_scopes_to_current_user_not_all_authors() { authenticateAs(writer, Permission.BLOG_WRITE); + when(userService.findByEmail(writer.getEmail())).thenReturn(writer); when(geschichteRepository.findSummaries(any(), any(), any(), anyLong(), any())) .thenReturn(List.of()); -- 2.49.1 From 66281c992915f95c5567b335cbbefeb575b110f0 Mon Sep 17 00:00:00 2001 From: Marcel Date: Fri, 12 Jun 2026 11:46:24 +0200 Subject: [PATCH 04/13] test(geschichten): add failing tests for draft fetch in page loader RED: loader does not yet call parent() or fetch DRAFT stories. Also extracts settled() helper to $lib/shared/server/settled.ts and seeds makeData/callLoad factories with drafts/parent defaults. Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/lib/shared/server/settled.ts | 5 ++ frontend/src/routes/+page.server.ts | 7 +- .../routes/geschichten/page.server.test.ts | 68 ++++++++++++++++++- .../routes/geschichten/page.svelte.spec.ts | 1 + 4 files changed, 73 insertions(+), 8 deletions(-) create mode 100644 frontend/src/lib/shared/server/settled.ts diff --git a/frontend/src/lib/shared/server/settled.ts b/frontend/src/lib/shared/server/settled.ts new file mode 100644 index 00000000..2fe321b8 --- /dev/null +++ b/frontend/src/lib/shared/server/settled.ts @@ -0,0 +1,5 @@ +export function settled(res: PromiseSettledResult | undefined): T | null { + if (res?.status !== 'fulfilled') return null; + const v = res.value as { response: Response; data: unknown }; + return v.response.ok ? ((v.data as T) ?? null) : null; +} diff --git a/frontend/src/routes/+page.server.ts b/frontend/src/routes/+page.server.ts index f1aea7d8..efcbeb18 100644 --- a/frontend/src/routes/+page.server.ts +++ b/frontend/src/routes/+page.server.ts @@ -1,6 +1,7 @@ import { redirect } from '@sveltejs/kit'; import { createApiClient } from '$lib/shared/api.server'; import type { components } from '$lib/generated/api'; +import { settled } from '$lib/shared/server/settled'; type StatsDTO = components['schemas']['StatsDTO']; type TranscriptionQueueItemDTO = components['schemas']['TranscriptionQueueItemDTO']; @@ -14,12 +15,6 @@ type DocumentListItem = components['schemas']['DocumentListItem']; type GeschichteSummary = components['schemas']['GeschichteSummary']; type TagTreeNodeDTO = components['schemas']['TagTreeNodeDTO']; -function settled(res: PromiseSettledResult | undefined): T | null { - if (res?.status !== 'fulfilled') return null; - const v = res.value as { response: Response; data: unknown }; - return v.response.ok ? ((v.data as T) ?? null) : null; -} - export async function load({ fetch, parent }) { const { canWrite, canAnnotate, canBlogWrite } = await parent(); // READ_ALL without WRITE_ALL or ANNOTATE_ALL — see ADR-007. diff --git a/frontend/src/routes/geschichten/page.server.test.ts b/frontend/src/routes/geschichten/page.server.test.ts index c9ce8b94..fd532424 100644 --- a/frontend/src/routes/geschichten/page.server.test.ts +++ b/frontend/src/routes/geschichten/page.server.test.ts @@ -24,11 +24,18 @@ function makeUrl(params: Record = {}) { return url; } -function callLoad(url: URL) { +function callLoad(url: URL, parentData: { canBlogWrite?: boolean } = {}) { return load({ url, request: new Request('http://localhost/geschichten'), - fetch: vi.fn() as unknown as typeof fetch + fetch: vi.fn() as unknown as typeof fetch, + parent: vi.fn().mockResolvedValue({ + canBlogWrite: false, + canWrite: false, + canAnnotate: false, + user: null, + ...parentData + }) }); } @@ -191,3 +198,60 @@ describe('geschichten page load — documentFilter title resolution', () => { ); }); }); + +// ─── draft fetch ────────────────────────────────────────────────────────────── + +describe('geschichten page load — draft fetch', () => { + it('makes a second API call for DRAFT stories when canBlogWrite is true', async () => { + const mockGet = mockApi(); + + await callLoad(makeUrl(), { canBlogWrite: true }); + + const draftCall = mockGet.mock.calls.find( + ([, opts]: [string, { params?: { query?: { status?: string } } }]) => + opts?.params?.query?.status === 'DRAFT' + ); + expect(draftCall).toBeDefined(); + }); + + it('does NOT make a DRAFT API call when canBlogWrite is false', async () => { + const mockGet = mockApi(); + + await callLoad(makeUrl(), { canBlogWrite: false }); + + const draftCall = mockGet.mock.calls.find( + ([, opts]: [string, { params?: { query?: { status?: string } } }]) => + opts?.params?.query?.status === 'DRAFT' + ); + expect(draftCall).toBeUndefined(); + }); + + it('returns empty drafts array when the DRAFT fetch rejects', async () => { + const mockGet = vi + .fn() + .mockResolvedValueOnce({ response: { ok: true, status: 200 }, data: [] }) + .mockRejectedValueOnce(new Error('network error')); + vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType< + typeof createApiClient + >); + + const result = await callLoad(makeUrl(), { canBlogWrite: true }); + + expect(result.drafts).toEqual([]); + }); + + it('returns drafts in page data when canBlogWrite is true', async () => { + const draft = { id: 'draft-1', title: 'My Draft', status: 'DRAFT' }; + const mockGet = vi + .fn() + .mockResolvedValueOnce({ response: { ok: true, status: 200 }, data: [] }) + .mockResolvedValueOnce({ response: { ok: true, status: 200 }, data: [draft] }); + vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType< + typeof createApiClient + >); + + const result = await callLoad(makeUrl(), { canBlogWrite: true }); + + expect(result.drafts).toEqual([draft]); + }); +}); diff --git a/frontend/src/routes/geschichten/page.svelte.spec.ts b/frontend/src/routes/geschichten/page.svelte.spec.ts index 410b1678..a5caab98 100644 --- a/frontend/src/routes/geschichten/page.svelte.spec.ts +++ b/frontend/src/routes/geschichten/page.svelte.spec.ts @@ -26,6 +26,7 @@ function person(id: string, displayName: string) { function makeData(overrides: Partial = {}): PageData { return { geschichten: [], + drafts: [], personFilters: [], documentFilter: null, canBlogWrite: false, -- 2.49.1 From d5bfe77a73e542a1a3b27d35206591cfb6a672ba Mon Sep 17 00:00:00 2001 From: Marcel Date: Fri, 12 Jun 2026 11:47:11 +0200 Subject: [PATCH 05/13] feat(geschichten): fetch own drafts in page loader for blog writers Blog writers now get a separate resilient DRAFT fetch alongside the PUBLISHED list. A network failure degrades to drafts: [] rather than a 500, so the overview stays usable even if the draft fetch times out. Co-Authored-By: Claude Sonnet 4.6 --- .../src/routes/geschichten/+page.server.ts | 30 ++++++++++++++----- 1 file changed, 23 insertions(+), 7 deletions(-) diff --git a/frontend/src/routes/geschichten/+page.server.ts b/frontend/src/routes/geschichten/+page.server.ts index 76828641..d89a14b7 100644 --- a/frontend/src/routes/geschichten/+page.server.ts +++ b/frontend/src/routes/geschichten/+page.server.ts @@ -1,20 +1,23 @@ import { error } from '@sveltejs/kit'; import { createApiClient, extractErrorCode } from '$lib/shared/api.server'; import { getErrorMessage } from '$lib/shared/errors'; +import { settled } from '$lib/shared/server/settled'; import type { components } from '$lib/generated/api'; import type { PageServerLoad } from './$types'; type Person = components['schemas']['Person']; +type GeschichteSummary = components['schemas']['GeschichteSummary']; const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; -export const load: PageServerLoad = async ({ url, fetch }) => { +export const load: PageServerLoad = async ({ url, fetch, parent }) => { + const { canBlogWrite } = await parent(); const api = createApiClient(fetch); const personIds = url.searchParams.getAll('personId'); const rawDocumentId = url.searchParams.get('documentId'); const documentId = rawDocumentId && UUID_PATTERN.test(rawDocumentId) ? rawDocumentId : null; - const [listResult, docResult, ...personResults] = await Promise.all([ + const [listSettled, draftsSettled, docSettled, ...personResults] = await Promise.allSettled([ api.GET('/api/geschichten', { params: { query: { @@ -24,20 +27,32 @@ export const load: PageServerLoad = async ({ url, fetch }) => { } } }), + canBlogWrite + ? api.GET('/api/geschichten', { params: { query: { status: 'DRAFT' } } }) + : Promise.resolve(null), documentId ? api.GET('/api/documents/{id}', { params: { path: { id: documentId } } }) : Promise.resolve(null), ...personIds.map((id) => api.GET('/api/persons/{id}', { params: { path: { id } } })) ]); - if (!listResult.response.ok) { - throw error(listResult.response.status, getErrorMessage(extractErrorCode(listResult.error))); + const listResult = listSettled.status === 'fulfilled' ? listSettled.value : null; + if (!listResult?.response.ok) { + throw error( + listResult?.response.status ?? 500, + getErrorMessage(extractErrorCode(listResult?.error)) + ); } - const personFilters = personResults - .filter((r) => r && r.response.ok && r.data) - .map((r) => r!.data!) as Person[]; + const drafts: GeschichteSummary[] = canBlogWrite + ? (settled(draftsSettled) ?? []) + : []; + const personFilters = personResults + .filter((r) => r.status === 'fulfilled' && r.value?.response.ok && r.value?.data) + .map((r) => (r as PromiseFulfilledResult<{ response: Response; data: Person }>).value.data); + + const docResult = docSettled.status === 'fulfilled' ? docSettled.value : null; let documentFilter: { id: string; title: string | null } | null = null; if (documentId) { if (docResult && docResult.response.ok && docResult.data) { @@ -53,6 +68,7 @@ export const load: PageServerLoad = async ({ url, fetch }) => { return { geschichten: listResult.data ?? [], + drafts, personFilters, documentFilter }; -- 2.49.1 From df66cf6605a21c9785a9630e5a0bd13b1f1d0e1d Mon Sep 17 00:00:00 2001 From: Marcel Date: Fri, 12 Jun 2026 11:48:02 +0200 Subject: [PATCH 06/13] test(GeschichteListRow): add failing tests for DRAFT status badge RED: component has no draft badge yet. Co-Authored-By: Claude Sonnet 4.6 --- .../GeschichteListRow.svelte.spec.ts | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/frontend/src/lib/geschichte/GeschichteListRow.svelte.spec.ts b/frontend/src/lib/geschichte/GeschichteListRow.svelte.spec.ts index 52babd9c..4c430716 100644 --- a/frontend/src/lib/geschichte/GeschichteListRow.svelte.spec.ts +++ b/frontend/src/lib/geschichte/GeschichteListRow.svelte.spec.ts @@ -91,4 +91,34 @@ describe('GeschichteListRow', () => { render(GeschichteListRow, { props: { geschichte: baseRow() } }); expect(document.body.textContent).toContain('Anna Schmidt'); }); + + it('shows no draft badge for PUBLISHED stories', async () => { + render(GeschichteListRow, { props: { geschichte: baseRow({ status: 'PUBLISHED' }) } }); + expect(document.querySelector('[data-testid="draft-badge"]')).toBeNull(); + expect(document.querySelector('[data-testid="draft-badge-mobile"]')).toBeNull(); + }); + + it('shows desktop draft badge for DRAFT stories', async () => { + render(GeschichteListRow, { props: { geschichte: baseRow({ status: 'DRAFT' }) } }); + const badge = document.querySelector('[data-testid="draft-badge"]'); + expect(badge).not.toBeNull(); + }); + + it('shows mobile draft badge for DRAFT stories', async () => { + render(GeschichteListRow, { props: { geschichte: baseRow({ status: 'DRAFT' }) } }); + const badge = document.querySelector('[data-testid="draft-badge-mobile"]'); + expect(badge).not.toBeNull(); + }); + + it('draft badge is a plain ', async () => { + render(GeschichteListRow, { props: { geschichte: baseRow({ status: 'DRAFT' }) } }); + const badge = document.querySelector('[data-testid="draft-badge"]'); + expect(badge?.tagName.toLowerCase()).toBe('span'); + }); + + it('draft badge uses text-xs label size', async () => { + render(GeschichteListRow, { props: { geschichte: baseRow({ status: 'DRAFT' }) } }); + const badge = document.querySelector('[data-testid="draft-badge"]'); + expect(badge!.className).toContain('text-xs'); + }); }); -- 2.49.1 From b6266985436e8dac6c2b7a1fd491a0d0dd9e3460 Mon Sep 17 00:00:00 2001 From: Marcel Date: Fri, 12 Jun 2026 11:50:10 +0200 Subject: [PATCH 07/13] feat(GeschichteListRow): show DRAFT badge on desktop meta column and mobile row Co-Authored-By: Claude Sonnet 4.6 --- frontend/messages/de.json | 4 ++++ frontend/messages/en.json | 4 ++++ frontend/messages/es.json | 4 ++++ .../lib/geschichte/GeschichteListRow.svelte | 21 +++++++++++++++++-- 4 files changed, 31 insertions(+), 2 deletions(-) diff --git a/frontend/messages/de.json b/frontend/messages/de.json index 961597fe..bfc2d084 100644 --- a/frontend/messages/de.json +++ b/frontend/messages/de.json @@ -1042,6 +1042,10 @@ "geschichten_filter_document_chip": "Gefiltert nach Brief:", "geschichten_filter_remove_document_chip": "Brief {title} aus Filter entfernen", "geschichten_empty_for_document": "Noch keine Geschichten zu diesem Brief", + "geschichten_published_heading": "Veröffentlicht", + "geschichten_drafts_heading": "Entwürfe", + "geschichten_draft_badge": "Entwurf", + "geschichten_drafts_unfiltered_caption": "(alle Entwürfe)", "geschichten_back_to_index": "Zurück zu Geschichten", "geschichten_published_on": "veröffentlicht am {date}", "journey_compiled_on": "zusammengestellt am {date}", diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 7d957e6a..2ec576aa 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -1042,6 +1042,10 @@ "geschichten_filter_document_chip": "Filtered by letter:", "geschichten_filter_remove_document_chip": "Remove letter {title} from filter", "geschichten_empty_for_document": "No stories reference this letter yet", + "geschichten_published_heading": "Published", + "geschichten_drafts_heading": "Drafts", + "geschichten_draft_badge": "Draft", + "geschichten_drafts_unfiltered_caption": "(all drafts)", "geschichten_back_to_index": "Back to stories", "geschichten_published_on": "published on {date}", "journey_compiled_on": "compiled on {date}", diff --git a/frontend/messages/es.json b/frontend/messages/es.json index 6ad45fcc..0bcd27ac 100644 --- a/frontend/messages/es.json +++ b/frontend/messages/es.json @@ -1042,6 +1042,10 @@ "geschichten_filter_document_chip": "Filtrado por carta:", "geschichten_filter_remove_document_chip": "Quitar la carta {title} del filtro", "geschichten_empty_for_document": "Aún no hay historias sobre esta carta", + "geschichten_published_heading": "Publicadas", + "geschichten_drafts_heading": "Borradores", + "geschichten_draft_badge": "Borrador", + "geschichten_drafts_unfiltered_caption": "(todos los borradores)", "geschichten_back_to_index": "Volver a Historias", "geschichten_published_on": "publicada el {date}", "journey_compiled_on": "recopilada el {date}", diff --git a/frontend/src/lib/geschichte/GeschichteListRow.svelte b/frontend/src/lib/geschichte/GeschichteListRow.svelte index 0138443b..f64a0cd1 100644 --- a/frontend/src/lib/geschichte/GeschichteListRow.svelte +++ b/frontend/src/lib/geschichte/GeschichteListRow.svelte @@ -7,12 +7,13 @@ import type { components } from '$lib/generated/api'; type GeschichteRow = Pick< components['schemas']['GeschichteSummary'], - 'id' | 'title' | 'body' | 'type' | 'author' | 'publishedAt' + 'id' | 'title' | 'body' | 'type' | 'status' | 'author' | 'publishedAt' >; let { geschichte }: { geschichte: GeschichteRow } = $props(); const isJourney = $derived(geschichte.type === 'JOURNEY'); +const isDraft = $derived(geschichte.status === 'DRAFT'); const publishedAt = $derived(formatPublishedAt(geschichte.publishedAt, 'short')); @@ -44,12 +45,20 @@ const authorName = $derived(formatAuthorName(geschichte.author)); {m.journey_badge_list()} {/if} + {#if isDraft} + + {m.geschichten_draft_badge()} + + {/if}

-
+
{authorName} + {#if isDraft} + + {m.geschichten_draft_badge()} + + {/if} {#if publishedAt} {publishedAt} {/if} -- 2.49.1 From cb87695834d3c65ec8a7d7073a56d10fbc985cda Mon Sep 17 00:00:00 2001 From: Marcel Date: Fri, 12 Jun 2026 11:51:04 +0200 Subject: [PATCH 08/13] =?UTF-8?q?test(geschichten/page):=20add=20failing?= =?UTF-8?q?=20tests=20for=20Entw=C3=BCrfe=20section?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RED: page does not yet render a drafts section. Co-Authored-By: Claude Sonnet 4.6 --- .../routes/geschichten/page.svelte.spec.ts | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/frontend/src/routes/geschichten/page.svelte.spec.ts b/frontend/src/routes/geschichten/page.svelte.spec.ts index a5caab98..42604a9e 100644 --- a/frontend/src/routes/geschichten/page.svelte.spec.ts +++ b/frontend/src/routes/geschichten/page.svelte.spec.ts @@ -350,3 +350,46 @@ describe('geschichten page — multi-person filter chips', () => { expect(addEl.className).toContain('h-11'); }); }); + +// ─── Entwürfe section ───────────────────────────────────────────────────────── + +describe('geschichten page — Entwürfe section', () => { + const draft = () => + ({ + id: 'draft-1', + title: 'Mein Entwurf', + body: '

test

', + type: 'STORY', + status: 'DRAFT', + author: { firstName: 'Max', lastName: 'Muster' }, + publishedAt: null + }) as unknown as PageData['geschichten'][0]; + + it('Entwürfe section is hidden when drafts array is empty', async () => { + render(Page, { data: makeData({ drafts: [] }) }); + const heading = Array.from(document.querySelectorAll('h2')).find( + (h) => h.textContent?.includes('Entwürfe') || h.textContent?.includes('Drafts') + ); + expect(heading).toBeUndefined(); + }); + + it('Entwürfe section is visible when drafts are present', async () => { + render(Page, { data: makeData({ drafts: [draft()] as PageData['geschichten'] }) }); + const heading = Array.from(document.querySelectorAll('h2')).find( + (h) => h.textContent?.includes('Entwürfe') || h.textContent?.includes('Drafts') + ); + expect(heading).not.toBeUndefined(); + }); + + it('renders a row for each draft story', async () => { + render(Page, { data: makeData({ drafts: [draft()] as PageData['geschichten'] }) }); + const link = document.querySelector('a[href="/geschichten/draft-1"]'); + expect(link).not.toBeNull(); + }); + + it('draft row shows the draft badge', async () => { + render(Page, { data: makeData({ drafts: [draft()] as PageData['geschichten'] }) }); + const badge = document.querySelector('[data-testid="draft-badge"]'); + expect(badge).not.toBeNull(); + }); +}); -- 2.49.1 From 439b2133bd111ab251ff1b84402f37cd60ad06e1 Mon Sep 17 00:00:00 2001 From: Marcel Date: Fri, 12 Jun 2026 11:52:13 +0200 Subject: [PATCH 09/13] =?UTF-8?q?feat(geschichten):=20add=20Entw=C3=BCrfe?= =?UTF-8?q?=20section=20above=20published=20list=20for=20blog=20writers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drafts appear in a separate unfiltered section at the top of the overview, clearly separated by a divider and labelled with the draft badge on each row. Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/routes/geschichten/+page.svelte | 21 +++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/frontend/src/routes/geschichten/+page.svelte b/frontend/src/routes/geschichten/+page.svelte index d14f950e..5b572477 100644 --- a/frontend/src/routes/geschichten/+page.svelte +++ b/frontend/src/routes/geschichten/+page.svelte @@ -133,7 +133,26 @@ function removeDocument() {
{/if} - + + {#if data.drafts.length > 0} +
+

+ {m.geschichten_drafts_heading()} + {m.geschichten_drafts_unfiltered_caption()} +

+
    + {#each data.drafts as g (g.id)} +
  • + +
  • + {/each} +
+
+ {/if} + + {#if data.geschichten.length === 0}
{emptyMessage} -- 2.49.1 From 2185150990a93663e79ae088034e07e89acde54d Mon Sep 17 00:00:00 2001 From: Marcel Date: Fri, 12 Jun 2026 18:53:48 +0200 Subject: [PATCH 10/13] =?UTF-8?q?test(geschichten/page):=20add=20failing?= =?UTF-8?q?=20tests=20for=20gated=20Ver=C3=B6ffentlicht=20heading?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- .../src/routes/geschichten/page.svelte.spec.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/frontend/src/routes/geschichten/page.svelte.spec.ts b/frontend/src/routes/geschichten/page.svelte.spec.ts index 42604a9e..059eb589 100644 --- a/frontend/src/routes/geschichten/page.svelte.spec.ts +++ b/frontend/src/routes/geschichten/page.svelte.spec.ts @@ -392,4 +392,20 @@ describe('geschichten page — Entwürfe section', () => { const badge = document.querySelector('[data-testid="draft-badge"]'); expect(badge).not.toBeNull(); }); + + it('Veröffentlicht heading is present when the Entwürfe section is visible', async () => { + render(Page, { data: makeData({ drafts: [draft()] as PageData['geschichten'] }) }); + const heading = Array.from(document.querySelectorAll('h2')).find( + (h) => h.textContent?.includes('Veröffentlicht') || h.textContent?.includes('Published') + ); + expect(heading).not.toBeUndefined(); + }); + + it('Veröffentlicht heading is absent when there are no drafts', async () => { + render(Page, { data: makeData({ drafts: [] }) }); + const heading = Array.from(document.querySelectorAll('h2')).find( + (h) => h.textContent?.includes('Veröffentlicht') || h.textContent?.includes('Published') + ); + expect(heading).toBeUndefined(); + }); }); -- 2.49.1 From 52019f7e6981d4021fba5f8e0ad76f828f8cfdad Mon Sep 17 00:00:00 2001 From: Marcel Date: Fri, 12 Jun 2026 18:54:34 +0200 Subject: [PATCH 11/13] =?UTF-8?q?feat(geschichten):=20show=20Ver=C3=B6ffen?= =?UTF-8?q?tlicht=20heading=20when=20Entw=C3=BCrfe=20section=20is=20visibl?= =?UTF-8?q?e?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- frontend/src/routes/geschichten/+page.svelte | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/frontend/src/routes/geschichten/+page.svelte b/frontend/src/routes/geschichten/+page.svelte index 5b572477..b1b43cb9 100644 --- a/frontend/src/routes/geschichten/+page.svelte +++ b/frontend/src/routes/geschichten/+page.svelte @@ -153,6 +153,12 @@ function removeDocument() { {/if} + {#if data.drafts.length > 0} + +

+ {m.geschichten_published_heading()} +

+ {/if} {#if data.geschichten.length === 0}
{emptyMessage} -- 2.49.1 From f26874937dbcac33200494ddbaa795ae7afb3100 Mon Sep 17 00:00:00 2001 From: Marcel Date: Fri, 12 Jun 2026 19:44:48 +0200 Subject: [PATCH 12/13] test(geschichte): drop duplicate null-status security test list_with_null_status_and_BLOG_WRITE_returns_PUBLISHED_not_all_stories was byte-for-byte identical to the @DisplayName("security: ...") variant; keep the named one. Co-Authored-By: Claude Fable 5 --- .../geschichte/GeschichteServiceTest.java | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/backend/src/test/java/org/raddatz/familienarchiv/geschichte/GeschichteServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/geschichte/GeschichteServiceTest.java index 1d183a3c..61962436 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/geschichte/GeschichteServiceTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/geschichte/GeschichteServiceTest.java @@ -233,17 +233,6 @@ class GeschichteServiceTest { verify(geschichteRepository).findSummaries(eq(GeschichteStatus.PUBLISHED), isNull(), any(), anyLong(), any()); } - @Test - void list_with_null_status_and_BLOG_WRITE_returns_PUBLISHED_not_all_stories() { - authenticateAs(writer, Permission.BLOG_WRITE); - when(geschichteRepository.findSummaries(any(), any(), any(), anyLong(), any())) - .thenReturn(List.of()); - - geschichteService.list(null, List.of(), null, 50); - - verify(geschichteRepository).findSummaries(eq(GeschichteStatus.PUBLISHED), isNull(), any(), anyLong(), any()); - } - @Test void list_invokes_repository_findSummaries_when_filtering_by_single_personId() { authenticateAs(reader, Permission.READ_ALL); -- 2.49.1 From 17b0625a731966d3671af28beb00ad7e743109c1 Mon Sep 17 00:00:00 2001 From: Marcel Date: Fri, 12 Jun 2026 19:45:22 +0200 Subject: [PATCH 13/13] fix(shared): null-harden settled() against placeholder slots A Promise.resolve(null) placeholder (e.g. the gated drafts slot) fulfils with a null value; settled() dereferenced v.response unconditionally and threw. Now any nullish value resolves to null. Adds unit tests for all settled() branches. Co-Authored-By: Claude Fable 5 --- .../src/lib/shared/server/settled.test.ts | 37 +++++++++++++++++++ frontend/src/lib/shared/server/settled.ts | 4 +- 2 files changed, 39 insertions(+), 2 deletions(-) create mode 100644 frontend/src/lib/shared/server/settled.test.ts diff --git a/frontend/src/lib/shared/server/settled.test.ts b/frontend/src/lib/shared/server/settled.test.ts new file mode 100644 index 00000000..26ca88ec --- /dev/null +++ b/frontend/src/lib/shared/server/settled.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vitest'; +import { settled } from './settled'; + +describe('settled', () => { + it('returns the data for a fulfilled ok response', () => { + const res: PromiseSettledResult = { + status: 'fulfilled', + value: { response: { ok: true } as Response, data: [{ id: '1' }] } + }; + expect(settled<{ id: string }[]>(res)).toEqual([{ id: '1' }]); + }); + + it('returns null for a fulfilled non-ok response', () => { + const res: PromiseSettledResult = { + status: 'fulfilled', + value: { response: { ok: false, status: 403 } as Response, data: undefined } + }; + expect(settled(res)).toBeNull(); + }); + + it('returns null for a rejected result', () => { + const res: PromiseSettledResult = { + status: 'rejected', + reason: new Error('network error') + }; + expect(settled(res)).toBeNull(); + }); + + it('returns null for undefined input', () => { + expect(settled(undefined)).toBeNull(); + }); + + it('returns null for a fulfilled null value (Promise.resolve(null) placeholder slot)', () => { + const res: PromiseSettledResult = { status: 'fulfilled', value: null }; + expect(settled(res)).toBeNull(); + }); +}); diff --git a/frontend/src/lib/shared/server/settled.ts b/frontend/src/lib/shared/server/settled.ts index 2fe321b8..41dde16d 100644 --- a/frontend/src/lib/shared/server/settled.ts +++ b/frontend/src/lib/shared/server/settled.ts @@ -1,5 +1,5 @@ export function settled(res: PromiseSettledResult | undefined): T | null { if (res?.status !== 'fulfilled') return null; - const v = res.value as { response: Response; data: unknown }; - return v.response.ok ? ((v.data as T) ?? null) : null; + const v = res.value as { response: Response; data: unknown } | null; + return v?.response?.ok ? ((v.data as T) ?? null) : null; } -- 2.49.1