feat(stammbaum): server-clamped initial view from ?cx&cy&z (#692)

The server load parses and sanitises the shareable pan/zoom params (degrading
Infinity/NaN, clamping zoom) into initialView, which seeds the page view. A
crafted link can no longer blank the SVG (Nora). US-PANEL-002 AC2 groundwork.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-05-29 16:58:36 +02:00
parent 396c87f8ab
commit 8d29bb10e2
4 changed files with 85 additions and 10 deletions

View File

@@ -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 };
}

View File

@@ -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<string | null>(
const selectedNode = $derived(data.nodes.find((n) => n.id === selectedId) ?? null);
let view = $state<PanZoomState>(DEFAULT_VIEW);
let view = $state<PanZoomState>(data.initialView);
function zoomIn() {
view = { ...view, z: clampZoom(view.z + ZOOM_STEP_KB) };
}

View File

@@ -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<typeof createApiClient>);
}
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);
});
});

View File

@@ -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();