- Move api.server.ts, errors.ts, types.ts, utils.ts, relativeTime.ts to lib/shared/ - Move person relationship components to lib/person/relationship/ - Move Stammbaum components to lib/person/genealogy/ - Move HelpPopover to lib/shared/primitives/ - Update all import paths across routes, specs, and lib files - Update vi.mock() paths in server-project test files - Remove now-empty legacy directories (components/, hooks/, server/, etc.) - Update vite.config.ts coverage include paths for new structure - Update frontend/CLAUDE.md to reflect domain-based lib/ layout Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
351 lines
14 KiB
TypeScript
351 lines
14 KiB
TypeScript
import { describe, it, expect } from 'vitest';
|
|
import {
|
|
detectMention,
|
|
escapeHtml,
|
|
extractContent,
|
|
renderBody,
|
|
renderTranscriptionBody
|
|
} from './mention';
|
|
import type { MentionDTO, PersonMention } from '$lib/shared/types';
|
|
|
|
// ─── escapeHtml ───────────────────────────────────────────────────────────────
|
|
|
|
describe('escapeHtml', () => {
|
|
it('escapes ampersand', () => {
|
|
expect(escapeHtml('AT&T')).toBe('AT&T');
|
|
});
|
|
|
|
it('escapes less-than and greater-than', () => {
|
|
expect(escapeHtml('<script>')).toBe('<script>');
|
|
});
|
|
|
|
it('escapes double quote', () => {
|
|
expect(escapeHtml('say "hi"')).toBe('say "hi"');
|
|
});
|
|
|
|
it('returns empty string unchanged', () => {
|
|
expect(escapeHtml('')).toBe('');
|
|
});
|
|
|
|
it('escapes ampersand before other entities to avoid double-encoding', () => {
|
|
expect(escapeHtml('a&<b')).toBe('a&<b');
|
|
});
|
|
|
|
it('escapes apostrophe to '', () => {
|
|
expect(escapeHtml("d'Artagnan")).toBe('d'Artagnan');
|
|
});
|
|
|
|
it('does not collapse already-encoded entities (re-escapes the &)', () => {
|
|
// escapeHtml is idempotent by composition: the second pass re-escapes
|
|
// the & that was added by the first. Pin the property so the helper
|
|
// can't be "cleverly" optimised to skip it.
|
|
expect(escapeHtml('&')).toBe('&amp;');
|
|
});
|
|
});
|
|
|
|
// ─── detectMention ────────────────────────────────────────────────────────────
|
|
|
|
describe('detectMention', () => {
|
|
it('returns null when text has no @', () => {
|
|
expect(detectMention('hello world', 11)).toBeNull();
|
|
});
|
|
|
|
it('returns null when @ is not the most recent trigger word', () => {
|
|
// cursor is past a completed mention (next word started)
|
|
expect(detectMention('hello @Hans Müller more', 22)).toBeNull();
|
|
});
|
|
|
|
it('returns empty string immediately after @', () => {
|
|
expect(detectMention('hello @', 7)).toBe('');
|
|
});
|
|
|
|
it('returns query text after @', () => {
|
|
expect(detectMention('hello @Han', 10)).toBe('Han');
|
|
});
|
|
|
|
it('returns null when @ is preceded by a letter (email address pattern)', () => {
|
|
expect(detectMention('user@example', 12)).toBeNull();
|
|
});
|
|
|
|
it('returns query for @ at the very start of string', () => {
|
|
expect(detectMention('@Hans', 5)).toBe('Hans');
|
|
});
|
|
|
|
it('returns null when cursor is before the @', () => {
|
|
expect(detectMention('@Hans', 0)).toBeNull();
|
|
});
|
|
});
|
|
|
|
// ─── extractContent ───────────────────────────────────────────────────────────
|
|
|
|
describe('extractContent', () => {
|
|
it('returns empty arrays for empty string', () => {
|
|
const result = extractContent('', []);
|
|
expect(result.content).toBe('');
|
|
expect(result.mentionedUserIds).toEqual([]);
|
|
});
|
|
|
|
it('returns plain content unchanged when no candidates', () => {
|
|
const result = extractContent('Hello world', []);
|
|
expect(result.content).toBe('Hello world');
|
|
expect(result.mentionedUserIds).toEqual([]);
|
|
});
|
|
|
|
it('extracts user id when @FirstName LastName is in content', () => {
|
|
const candidates: MentionDTO[] = [{ id: 'uuid-1', firstName: 'Hans', lastName: 'Müller' }];
|
|
const result = extractContent('Hey @Hans Müller how are you?', candidates);
|
|
expect(result.mentionedUserIds).toContain('uuid-1');
|
|
});
|
|
|
|
it('deduplicates user ids when same user mentioned twice', () => {
|
|
const candidates: MentionDTO[] = [{ id: 'uuid-1', firstName: 'Hans', lastName: 'Müller' }];
|
|
const result = extractContent('@Hans Müller and @Hans Müller again', candidates);
|
|
expect(result.mentionedUserIds).toHaveLength(1);
|
|
expect(result.mentionedUserIds).toContain('uuid-1');
|
|
});
|
|
|
|
it('collects multiple distinct users', () => {
|
|
const candidates: MentionDTO[] = [
|
|
{ id: 'uuid-1', firstName: 'Hans', lastName: 'Müller' },
|
|
{ id: 'uuid-2', firstName: 'Anna', lastName: 'Schmidt' }
|
|
];
|
|
const result = extractContent('@Hans Müller and @Anna Schmidt', candidates);
|
|
expect(result.mentionedUserIds).toContain('uuid-1');
|
|
expect(result.mentionedUserIds).toContain('uuid-2');
|
|
});
|
|
});
|
|
|
|
// ─── renderBody ───────────────────────────────────────────────────────────────
|
|
|
|
describe('renderBody', () => {
|
|
it('returns escaped plain text when no mentions', () => {
|
|
expect(renderBody('Hello world', [])).toBe('Hello world');
|
|
});
|
|
|
|
it('escapes < and > in content', () => {
|
|
const result = renderBody('<script>alert(1)</script>', []);
|
|
expect(result).toContain('<script>');
|
|
expect(result).not.toContain('<script>');
|
|
});
|
|
|
|
it('escapes & in content', () => {
|
|
const result = renderBody('AT&T', []);
|
|
expect(result).toContain('AT&T');
|
|
});
|
|
|
|
it('wraps @mention in a mention span', () => {
|
|
const mentions: MentionDTO[] = [{ id: 'uuid-1', firstName: 'Hans', lastName: 'Müller' }];
|
|
const result = renderBody('Hey @Hans Müller!', mentions);
|
|
expect(result).toContain('<span');
|
|
expect(result).toContain('class="mention"');
|
|
expect(result).toContain('Hans Müller');
|
|
});
|
|
|
|
it('does not double-encode already escaped text', () => {
|
|
const mentions: MentionDTO[] = [{ id: 'uuid-1', firstName: 'Hans', lastName: 'Müller' }];
|
|
const result = renderBody('Check @Hans Müller', mentions);
|
|
expect(result).not.toContain('&');
|
|
});
|
|
|
|
it('replaces all occurrences of the same mention', () => {
|
|
const mentions: MentionDTO[] = [{ id: 'uuid-1', firstName: 'Hans', lastName: 'Müller' }];
|
|
const result = renderBody('@Hans Müller and @Hans Müller', mentions);
|
|
const spanCount = (result.match(/<span /g) ?? []).length;
|
|
expect(spanCount).toBe(2);
|
|
});
|
|
|
|
it('escapes HTML special chars in mention display names', () => {
|
|
const mentions: MentionDTO[] = [{ id: 'u1', firstName: '<script>', lastName: 'alert(1)' }];
|
|
const result = renderBody('@<script> alert(1)', mentions);
|
|
expect(result).not.toContain('<script>');
|
|
expect(result).toContain('<script>');
|
|
});
|
|
|
|
it('converts newlines to <br>', () => {
|
|
const result = renderBody('line1\nline2', []);
|
|
expect(result).toContain('<br>');
|
|
expect(result).not.toContain('\n');
|
|
});
|
|
});
|
|
|
|
// ─── renderTranscriptionBody ──────────────────────────────────────────────────
|
|
|
|
describe('renderTranscriptionBody', () => {
|
|
const auguste: PersonMention = {
|
|
personId: '550e8400-e29b-41d4-a716-446655440000',
|
|
displayName: 'Auguste Raddatz'
|
|
};
|
|
const hans: PersonMention = {
|
|
personId: '550e8400-e29b-41d4-a716-446655440001',
|
|
displayName: 'Hans'
|
|
};
|
|
|
|
it('returns empty string for empty input', () => {
|
|
expect(renderTranscriptionBody('', [])).toBe('');
|
|
});
|
|
|
|
it('returns escaped plain text when no mentions', () => {
|
|
expect(renderTranscriptionBody('Hello world', [])).toBe('Hello world');
|
|
});
|
|
|
|
it('escapes < and > in plain block text', () => {
|
|
const result = renderTranscriptionBody('<script>alert(1)</script>', []);
|
|
expect(result).toBe('<script>alert(1)</script>');
|
|
expect(result).not.toContain('<script>');
|
|
});
|
|
|
|
it('escapes & in plain block text', () => {
|
|
expect(renderTranscriptionBody('AT&T', [])).toBe('AT&T');
|
|
});
|
|
|
|
it('replaces @DisplayName with anchor link to /persons/{personId}', () => {
|
|
const result = renderTranscriptionBody('Brief an @Auguste Raddatz vom Mai', [auguste]);
|
|
expect(result).toContain(`<a href="/persons/${auguste.personId}"`);
|
|
expect(result).toContain('class="person-mention"');
|
|
expect(result).toContain(`data-person-id="${auguste.personId}"`);
|
|
expect(result).toContain('>Auguste Raddatz</a>');
|
|
});
|
|
|
|
it('strips the @ prefix from rendered link text (read mode)', () => {
|
|
const result = renderTranscriptionBody('Hallo @Auguste Raddatz!', [auguste]);
|
|
// The anchor body is the bare display name — no leading @
|
|
expect(result).not.toMatch(/>@Auguste Raddatz</);
|
|
expect(result).toMatch(/>Auguste Raddatz</);
|
|
});
|
|
|
|
it('removes the trigger @ from the surrounding text (no orphan @ before the link)', () => {
|
|
const result = renderTranscriptionBody('Brief an @Auguste Raddatz vom Mai', [auguste]);
|
|
// No bare @ remains where the mention was
|
|
expect(result).not.toMatch(/@<a/);
|
|
});
|
|
|
|
it('replaces all occurrences of the same mention', () => {
|
|
const result = renderTranscriptionBody('@Auguste Raddatz und @Auguste Raddatz', [auguste]);
|
|
const anchorCount = (result.match(/<a /g) ?? []).length;
|
|
expect(anchorCount).toBe(2);
|
|
});
|
|
|
|
it('does not replace plain-text occurrences without the @ trigger', () => {
|
|
const result = renderTranscriptionBody('Auguste Raddatz war hier', [auguste]);
|
|
expect(result).not.toContain('<a ');
|
|
expect(result).toBe('Auguste Raddatz war hier');
|
|
});
|
|
|
|
it('processes longer displayNames first to avoid prefix shadowing', () => {
|
|
const SHORT_ID = '11111111-1111-4111-8111-111111111111';
|
|
const LONG_ID = '22222222-2222-4222-8222-222222222222';
|
|
const augusteShort: PersonMention = { personId: SHORT_ID, displayName: 'Auguste' };
|
|
const augusteLong: PersonMention = {
|
|
personId: LONG_ID,
|
|
displayName: 'Auguste Raddatz'
|
|
};
|
|
// Sidecar order is short-first; longer match must still win for the long text
|
|
const result = renderTranscriptionBody('@Auguste Raddatz schreibt @Auguste', [
|
|
augusteShort,
|
|
augusteLong
|
|
]);
|
|
expect(result).toContain(`href="/persons/${LONG_ID}"`);
|
|
expect(result).toContain(`href="/persons/${SHORT_ID}"`);
|
|
// The "Raddatz" suffix must not leak inside the short-name anchor
|
|
expect(result).not.toMatch(/>Auguste<\/a> Raddatz/);
|
|
});
|
|
|
|
it('does not match @ followed by extra word characters (word boundary)', () => {
|
|
// Sidecar contains "Hans"; text contains "@HansMüller" — no link.
|
|
const result = renderTranscriptionBody('Brief an @HansMüller', [hans]);
|
|
expect(result).not.toContain('<a ');
|
|
expect(result).toContain('@HansM');
|
|
});
|
|
|
|
it('first-sidecar-wins when two entries share the same displayName', () => {
|
|
// Two persons named "Hans" — first sidecar entry wins for all occurrences.
|
|
const FIRST_ID = '33333333-3333-4333-8333-333333333333';
|
|
const SECOND_ID = '44444444-4444-4444-8444-444444444444';
|
|
const hansFirst: PersonMention = { personId: FIRST_ID, displayName: 'Hans' };
|
|
const hansSecond: PersonMention = { personId: SECOND_ID, displayName: 'Hans' };
|
|
const result = renderTranscriptionBody('@Hans und @Hans', [hansFirst, hansSecond]);
|
|
expect(result).toContain(`href="/persons/${FIRST_ID}"`);
|
|
expect(result).not.toContain(`href="/persons/${SECOND_ID}"`);
|
|
const anchorCount = (result.match(/<a /g) ?? []).length;
|
|
expect(anchorCount).toBe(2);
|
|
});
|
|
|
|
it('escapes HTML in displayName to prevent stored XSS', () => {
|
|
const xss: PersonMention = {
|
|
personId: '55555555-5555-4555-8555-555555555555',
|
|
displayName: '<script>alert(1)</script>'
|
|
};
|
|
const result = renderTranscriptionBody('Hi @<script>alert(1)</script> there', [xss]);
|
|
expect(result).not.toContain('<script>');
|
|
expect(result).toContain('<script>');
|
|
});
|
|
|
|
it('escapes <img onerror=...> payloads in surrounding block text', () => {
|
|
const result = renderTranscriptionBody('<img src=x onerror=alert(1)> hello', []);
|
|
expect(result).not.toContain('<img');
|
|
expect(result).toContain('<img');
|
|
});
|
|
|
|
it('does not double-encode HTML-entity-already-encoded payloads', () => {
|
|
// `&lt;script&gt;` is already-escaped HTML in the source text.
|
|
// renderTranscriptionBody must escape the literal & once → `&amp;lt;...`
|
|
// — never silently decode pre-escaped entities.
|
|
const result = renderTranscriptionBody('text &lt;script&gt;', []);
|
|
expect(result).toBe('text &amp;lt;script&amp;gt;');
|
|
});
|
|
|
|
it('escapes quotes in displayName so they cannot break the href attribute', () => {
|
|
const tricky: PersonMention = {
|
|
personId: '66666666-6666-4666-8666-666666666666',
|
|
displayName: 'O"Brien'
|
|
};
|
|
const result = renderTranscriptionBody('@O"Brien', [tricky]);
|
|
// The raw `"` from the displayName must never appear inside the rendered link
|
|
// — it would terminate the attribute value early and let an attacker craft
|
|
// arbitrary attributes on the anchor. It must arrive at the browser as ".
|
|
expect(result).toMatch(/>O"Brien<\/a>/);
|
|
expect(result).not.toMatch(/>O"Brien<\/a>/);
|
|
});
|
|
|
|
it('renders nothing when mentionedPersons is undefined-empty and no @ triggers', () => {
|
|
const result = renderTranscriptionBody('Plain old transcription text.', []);
|
|
expect(result).toBe('Plain old transcription text.');
|
|
});
|
|
|
|
it('skips substitution when personId is not a UUID (defense in depth)', () => {
|
|
// Nora #5551: if personId ever flowed in from a less-sanitised source
|
|
// (a future "external person" or a bad sidecar), the renderer must not
|
|
// emit a clickable link. The escaped text remains as plain content.
|
|
const evil: PersonMention = {
|
|
personId: 'javascript:alert(1)',
|
|
displayName: 'Evil Link'
|
|
};
|
|
const result = renderTranscriptionBody('Hi @Evil Link!', [evil]);
|
|
expect(result).not.toContain('<a ');
|
|
expect(result).not.toContain('javascript:');
|
|
// The @-trigger and displayName are preserved as plain text
|
|
expect(result).toContain('@Evil Link');
|
|
});
|
|
|
|
it('skips substitution when personId is an absolute URL', () => {
|
|
const evil: PersonMention = {
|
|
personId: 'https://evil.example/persons/abc',
|
|
displayName: 'Phisher'
|
|
};
|
|
const result = renderTranscriptionBody('Hi @Phisher', [evil]);
|
|
expect(result).not.toContain('<a ');
|
|
expect(result).not.toContain('https://evil.example');
|
|
});
|
|
|
|
it('still substitutes when personId is a well-formed UUID', () => {
|
|
// Sanity check that the validation does not over-reject valid IDs.
|
|
const valid: PersonMention = {
|
|
personId: '550e8400-e29b-41d4-a716-446655440000',
|
|
displayName: 'Auguste Raddatz'
|
|
};
|
|
const result = renderTranscriptionBody('Brief an @Auguste Raddatz', [valid]);
|
|
expect(result).toContain('<a ');
|
|
expect(result).toContain('href="/persons/550e8400-e29b-41d4-a716-446655440000"');
|
|
});
|
|
});
|