diff --git a/frontend/src/lib/utils/mention.spec.ts b/frontend/src/lib/utils/mention.spec.ts index 47a84645..0323bf20 100644 --- a/frontend/src/lib/utils/mention.spec.ts +++ b/frontend/src/lib/utils/mention.spec.ts @@ -232,9 +232,11 @@ describe('renderTranscriptionBody', () => { }); it('processes longer displayNames first to avoid prefix shadowing', () => { - const augusteShort: PersonMention = { personId: 'p-short', displayName: 'Auguste' }; + 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: 'p-long', + personId: LONG_ID, displayName: 'Auguste Raddatz' }; // Sidecar order is short-first; longer match must still win for the long text @@ -242,8 +244,8 @@ describe('renderTranscriptionBody', () => { augusteShort, augusteLong ]); - expect(result).toContain('href="/persons/p-long"'); - expect(result).toContain('href="/persons/p-short"'); + 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/); }); @@ -257,18 +259,20 @@ describe('renderTranscriptionBody', () => { it('first-sidecar-wins when two entries share the same displayName', () => { // Two persons named "Hans" — first sidecar entry wins for all occurrences. - const hansFirst: PersonMention = { personId: 'p-first', displayName: 'Hans' }; - const hansSecond: PersonMention = { personId: 'p-second', displayName: 'Hans' }; + 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/p-first"'); - expect(result).not.toContain('href="/persons/p-second"'); + expect(result).toContain(`href="/persons/${FIRST_ID}"`); + expect(result).not.toContain(`href="/persons/${SECOND_ID}"`); const anchorCount = (result.match(/ { const xss: PersonMention = { - personId: 'p-xss', + personId: '55555555-5555-4555-8555-555555555555', displayName: '' }; const result = renderTranscriptionBody('Hi @ there', [xss]); @@ -292,7 +296,7 @@ describe('renderTranscriptionBody', () => { it('escapes quotes in displayName so they cannot break the href attribute', () => { const tricky: PersonMention = { - personId: 'p-quote', + personId: '66666666-6666-4666-8666-666666666666', displayName: 'O"Brien' }; const result = renderTranscriptionBody('@O"Brien', [tricky]); @@ -307,4 +311,40 @@ describe('renderTranscriptionBody', () => { 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(' { + const evil: PersonMention = { + personId: 'https://evil.example/persons/abc', + displayName: 'Phisher' + }; + const result = renderTranscriptionBody('Hi @Phisher', [evil]); + expect(result).not.toContain(' { + // 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('