diff --git a/frontend/src/routes/stammbaum/+page.server.ts b/frontend/src/routes/stammbaum/+page.server.ts index f060df29..f9d5a2a5 100644 --- a/frontend/src/routes/stammbaum/+page.server.ts +++ b/frontend/src/routes/stammbaum/+page.server.ts @@ -1,8 +1,9 @@ import { error, redirect } from '@sveltejs/kit'; import { createApiClient, extractErrorCode } from '$lib/shared/api.server'; import { getErrorMessage } from '$lib/shared/errors'; +import { parsePanZoomParams } from '$lib/person/genealogy/panZoom'; -export async function load({ fetch }) { +export async function load({ fetch, url }) { const api = createApiClient(fetch); const result = await api.GET('/api/network'); @@ -12,6 +13,15 @@ export async function load({ fetch }) { throw error(result.response.status, getErrorMessage(extractErrorCode(result.error))); } + // Sanitise the shareable pan/zoom params server-side so a crafted link + // (?z=Infinity, ?cx=NaN) degrades to a safe view before reaching layout + // geometry (Nora #692). + const initialView = parsePanZoomParams({ + cx: url.searchParams.get('cx'), + cy: url.searchParams.get('cy'), + z: url.searchParams.get('z') + }); + const network = result.data!; - return { nodes: network.nodes ?? [], edges: network.edges ?? [] }; + return { nodes: network.nodes ?? [], edges: network.edges ?? [], initialView }; } diff --git a/frontend/src/routes/stammbaum/+page.svelte b/frontend/src/routes/stammbaum/+page.svelte index 24f9a98d..69ed0cfc 100644 --- a/frontend/src/routes/stammbaum/+page.svelte +++ b/frontend/src/routes/stammbaum/+page.svelte @@ -17,7 +17,7 @@ type PersonNodeDTO = components['schemas']['PersonNodeDTO']; type RelationshipDTO = components['schemas']['RelationshipDTO']; interface Props { - data: { nodes: PersonNodeDTO[]; edges: RelationshipDTO[] }; + data: { nodes: PersonNodeDTO[]; edges: RelationshipDTO[]; initialView: PanZoomState }; } let { data }: Props = $props(); @@ -31,7 +31,7 @@ let selectedId = $state( const selectedNode = $derived(data.nodes.find((n) => n.id === selectedId) ?? null); -let view = $state(DEFAULT_VIEW); +let view = $state(data.initialView); function zoomIn() { view = { ...view, z: clampZoom(view.z + ZOOM_STEP_KB) }; } diff --git a/frontend/src/routes/stammbaum/page.server.test.ts b/frontend/src/routes/stammbaum/page.server.test.ts new file mode 100644 index 00000000..f755ad14 --- /dev/null +++ b/frontend/src/routes/stammbaum/page.server.test.ts @@ -0,0 +1,56 @@ +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 { DEFAULT_VIEW, MAX_ZOOM } from '$lib/person/genealogy/panZoom'; + +beforeEach(() => vi.clearAllMocks()); + +function mockNetwork() { + vi.mocked(createApiClient).mockReturnValue({ + GET: vi.fn().mockResolvedValue({ response: { ok: true }, data: { nodes: [], edges: [] } }) + } as unknown as ReturnType); +} + +function loadEvent(query = '') { + const url = new URL(`http://localhost/stammbaum${query}`); + return { + fetch: vi.fn() as unknown as typeof fetch, + request: new Request(url), + url + }; +} + +describe('/stammbaum +page.server load — initialView', () => { + it('returns DEFAULT_VIEW when no pan/zoom params are present', async () => { + mockNetwork(); + const { load } = await import('./+page.server'); + const result = await load(loadEvent() as never); + expect(result.initialView).toEqual(DEFAULT_VIEW); + }); + + it('parses and returns valid ?cx&cy&z params', async () => { + mockNetwork(); + const { load } = await import('./+page.server'); + const result = await load(loadEvent('?cx=120&cy=-40&z=1.5') as never); + expect(result.initialView).toEqual({ x: 120, y: -40, z: 1.5 }); + }); + + it('degrades a crafted ?z=Infinity to a safe view (Nora #692)', async () => { + mockNetwork(); + const { load } = await import('./+page.server'); + const result = await load(loadEvent('?z=Infinity&cx=NaN') as never); + expect(result.initialView).toEqual(DEFAULT_VIEW); + }); + + it('clamps an out-of-range zoom server-side', async () => { + mockNetwork(); + const { load } = await import('./+page.server'); + const result = await load(loadEvent('?z=99') as never); + expect(result.initialView.z).toBe(MAX_ZOOM); + }); +}); diff --git a/frontend/src/routes/stammbaum/page.svelte.test.ts b/frontend/src/routes/stammbaum/page.svelte.test.ts index a788c102..a1a4bcc7 100644 --- a/frontend/src/routes/stammbaum/page.svelte.test.ts +++ b/frontend/src/routes/stammbaum/page.svelte.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect, vi, afterEach } from 'vitest'; import { cleanup, render } from 'vitest-browser-svelte'; import { page } from 'vitest/browser'; +import { DEFAULT_VIEW } from '$lib/person/genealogy/panZoom'; const mockPage = { url: new URL('http://localhost/stammbaum'), @@ -28,7 +29,7 @@ describe('stammbaum page', () => { it('shows the empty state when there are no family nodes', async () => { mockPage.url = new URL('http://localhost/stammbaum'); const Stammbaum = await loadComponent(); - render(Stammbaum, { props: { data: { nodes: [], edges: [] } } }); + render(Stammbaum, { props: { data: { nodes: [], edges: [], initialView: DEFAULT_VIEW } } }); await expect .element(page.getByRole('heading', { name: /noch keine familienmitglieder/i })) @@ -41,7 +42,7 @@ describe('stammbaum page', () => { it('hides zoom controls when there are no nodes', async () => { mockPage.url = new URL('http://localhost/stammbaum'); const Stammbaum = await loadComponent(); - render(Stammbaum, { props: { data: { nodes: [], edges: [] } } }); + render(Stammbaum, { props: { data: { nodes: [], edges: [], initialView: DEFAULT_VIEW } } }); await expect.element(page.getByRole('button', { name: /vergrößern/i })).not.toBeInTheDocument(); await expect @@ -52,7 +53,9 @@ describe('stammbaum page', () => { it('renders the page heading and zoom controls when nodes are present', async () => { mockPage.url = new URL('http://localhost/stammbaum'); const Stammbaum = await loadComponent(); - render(Stammbaum, { props: { data: { nodes: sampleNodes, edges: [] } } }); + render(Stammbaum, { + props: { data: { nodes: sampleNodes, edges: [], initialView: DEFAULT_VIEW } } + }); await expect.element(page.getByRole('heading', { name: /stammbaum/i })).toBeVisible(); await expect.element(page.getByRole('button', { name: /vergrößern/i })).toBeVisible(); @@ -62,7 +65,9 @@ describe('stammbaum page', () => { it('preselects a node when the URL has a focus query param matching an existing node', async () => { mockPage.url = new URL('http://localhost/stammbaum?focus=p-1'); const Stammbaum = await loadComponent(); - render(Stammbaum, { props: { data: { nodes: sampleNodes, edges: [] } } }); + render(Stammbaum, { + props: { data: { nodes: sampleNodes, edges: [], initialView: DEFAULT_VIEW } } + }); await expect.element(page.getByRole('complementary')).toBeVisible(); }); @@ -70,7 +75,9 @@ describe('stammbaum page', () => { it('does not preselect when the focus param does not match any node', async () => { mockPage.url = new URL('http://localhost/stammbaum?focus=missing'); const Stammbaum = await loadComponent(); - render(Stammbaum, { props: { data: { nodes: sampleNodes, edges: [] } } }); + render(Stammbaum, { + props: { data: { nodes: sampleNodes, edges: [], initialView: DEFAULT_VIEW } } + }); await expect.element(page.getByRole('complementary')).not.toBeInTheDocument(); }); @@ -78,7 +85,9 @@ describe('stammbaum page', () => { it('clamps the zoom level when the zoom-out button is clicked many times', async () => { mockPage.url = new URL('http://localhost/stammbaum'); const Stammbaum = await loadComponent(); - render(Stammbaum, { props: { data: { nodes: sampleNodes, edges: [] } } }); + render(Stammbaum, { + props: { data: { nodes: sampleNodes, edges: [], initialView: DEFAULT_VIEW } } + }); const zoomOut = page.getByRole('button', { name: /verkleinern/i }); for (let i = 0; i < 10; i++) await zoomOut.click();