feat(lesereisen): JourneyItemCard, JourneyInterlude, JourneyReader with XSS + omit-rule specs
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
123
frontend/src/lib/geschichte/JourneyItemCard.svelte.spec.ts
Normal file
123
frontend/src/lib/geschichte/JourneyItemCard.svelte.spec.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page } from 'vitest/browser';
|
||||
import type { components } from '$lib/generated/api';
|
||||
|
||||
const { default: JourneyItemCard } = await import('./JourneyItemCard.svelte');
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__xss_note?: number;
|
||||
}
|
||||
}
|
||||
|
||||
type JourneyItemView = components['schemas']['JourneyItemView'];
|
||||
|
||||
const baseItem = (overrides: Partial<JourneyItemView> = {}): JourneyItemView => ({
|
||||
id: 'item1',
|
||||
position: 0,
|
||||
document: {
|
||||
id: 'd1',
|
||||
title: 'Brief an Helene',
|
||||
documentDate: '1923-05-15',
|
||||
datePrecision: 'FULL'
|
||||
},
|
||||
...overrides
|
||||
});
|
||||
|
||||
describe('JourneyItemCard', () => {
|
||||
it('renders the document title', async () => {
|
||||
render(JourneyItemCard, { props: { item: baseItem() } });
|
||||
|
||||
await expect.element(page.getByText('Brief an Helene')).toBeVisible();
|
||||
});
|
||||
|
||||
it('renders the document date when documentDate is present', async () => {
|
||||
render(JourneyItemCard, { props: { item: baseItem() } });
|
||||
|
||||
await expect.element(page.getByText(/1923/)).toBeVisible();
|
||||
});
|
||||
|
||||
it('whole card is a single <a> element', async () => {
|
||||
render(JourneyItemCard, { props: { item: baseItem() } });
|
||||
|
||||
const link = document.querySelector('a');
|
||||
expect(link).not.toBeNull();
|
||||
expect(link?.href).toContain('/documents/d1');
|
||||
});
|
||||
|
||||
it('link has dated aria-label when documentDate is present', async () => {
|
||||
render(JourneyItemCard, { props: { item: baseItem() } });
|
||||
|
||||
const link = document.querySelector('a');
|
||||
expect(link?.getAttribute('aria-label')).toContain('Brief');
|
||||
expect(link?.getAttribute('aria-label')).toContain('1923');
|
||||
});
|
||||
|
||||
it('link has undated aria-label when documentDate is absent', async () => {
|
||||
render(JourneyItemCard, {
|
||||
props: {
|
||||
item: baseItem({
|
||||
document: { id: 'd2', title: 'Ohne Datum', datePrecision: 'NONE' }
|
||||
})
|
||||
}
|
||||
});
|
||||
|
||||
const link = document.querySelector('a');
|
||||
expect(link?.getAttribute('aria-label')).toBe('Brief öffnen');
|
||||
});
|
||||
|
||||
it('omits date text when documentDate is absent', async () => {
|
||||
render(JourneyItemCard, {
|
||||
props: {
|
||||
item: baseItem({
|
||||
document: { id: 'd2', title: 'Ohne Datum', datePrecision: 'NONE' }
|
||||
})
|
||||
}
|
||||
});
|
||||
|
||||
await expect.element(page.getByText(/1923/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders ✎ glyph and note text when note is present', async () => {
|
||||
render(JourneyItemCard, { props: { item: baseItem({ note: 'Ein wichtiger Brief' }) } });
|
||||
|
||||
expect(document.body.textContent).toContain('✎');
|
||||
await expect.element(page.getByText('Ein wichtiger Brief')).toBeVisible();
|
||||
});
|
||||
|
||||
it('omits annotation block when note is blank or whitespace', async () => {
|
||||
render(JourneyItemCard, { props: { item: baseItem({ note: ' ' }) } });
|
||||
|
||||
expect(document.body.textContent).not.toContain('✎');
|
||||
});
|
||||
|
||||
it('omits annotation block when note is absent', async () => {
|
||||
render(JourneyItemCard, { props: { item: baseItem({ note: undefined }) } });
|
||||
|
||||
expect(document.body.textContent).not.toContain('✎');
|
||||
});
|
||||
|
||||
it('link meets 44px touch-target (min-h-[44px] class)', async () => {
|
||||
render(JourneyItemCard, { props: { item: baseItem() } });
|
||||
|
||||
const link = document.querySelector('a');
|
||||
expect(link?.className).toContain('min-h-[44px]');
|
||||
});
|
||||
|
||||
it('XSS: note is rendered as plaintext — injected payload does not execute', async () => {
|
||||
// Note uses Svelte text interpolation ({note}), NOT {@html}.
|
||||
render(JourneyItemCard, {
|
||||
props: {
|
||||
item: baseItem({
|
||||
note: '<img src=x onerror="window.__xss_note=1">'
|
||||
})
|
||||
}
|
||||
});
|
||||
|
||||
expect(window.__xss_note).toBeUndefined();
|
||||
expect(document.body.textContent).toContain('<img src=x onerror=');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user