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('