Never show a raw identifier as a person's name, handle or initials

A participant's profile can resolve and still carry an empty name, a WebID,
an actor path or another identifier-shaped value. safeUserName,
safeUserUsername and safeUserInitials turn those into a neutral placeholder,
and every screen that shows a person goes through them: event participants,
participant list, friends, invitations, meeting points, connect, profile,
share profile, edit-profile avatar and someone else's profile.

An absent user renders as before, blank while loading. Your own profile never
titles itself « Participant » for an empty name; other people do. Initials
derived from an identifier become '?'.

The check is start-of-value only: a leading `word:` counts as a scheme, so a
name such as « DJ:Kool » is masked too, and an identifier embedded mid-string
is not. Both are known and accepted for now. The edit-profile form still
prefills its inputs with the raw values.

Screen-level tests render each screen with identifier-shaped and empty names,
and turn red when any sanitizer call is reverted.
This commit is contained in:
Sylvain Duchesne
2026-10-13 20:58:06 +02:00
parent 6ee95c9e68
commit 812844e99d
13 changed files with 411 additions and 40 deletions
+20 -14
View File
@@ -2,6 +2,7 @@ import { useLayoutEffect } from 'react';
import { ArrowLeft } from 'lucide-react';
import { Button, Avatar, EventCover, EventMeetingPoints, showToast, Text, type MeetingPointData } from '../../../shared/components/sketchy';
import { useFestipodData } from '../../../shared/context/FestipodDataContext';
import { safeUserName, safeUserInitials, safeUserUsername } from '../../../shared/context/festipodDataBase';
import { eventPath, useNavigate, useParams, userPath } from '../../../app/router';
export function EventDetailScreen() {
@@ -206,21 +207,26 @@ export function EventDetailScreen() {
<div style={{ fontSize: 12, fontWeight: 700, color: '#999', textTransform: 'uppercase', letterSpacing: 1, marginBottom: 10 }}>
Participants ({event.participantCount})
</div>
{knownParticipants.map((p) => (
<div
key={p.id}
onClick={() => navigate(userPath(p.id))}
style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '10px 0', borderBottom: '1px solid #f5f5f5', cursor: 'pointer' }}
>
<Avatar initials={p.initials} size={38} color="#2B6CB0" />
<div style={{ flex: 1 }}>
<div className="user-content" style={{ fontSize: 14, fontWeight: 600 }}>{p.name}</div>
{p.username && (
<div style={{ fontSize: 12, color: '#888', marginTop: 2 }}>{p.username}</div>
)}
{knownParticipants.map((p) => {
const displayName = safeUserName(p);
const displayUsername = safeUserUsername(p);
const displayInitials = safeUserInitials(p);
return (
<div
key={p.id}
onClick={() => navigate(userPath(p.id))}
style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '10px 0', borderBottom: '1px solid #f5f5f5', cursor: 'pointer' }}
>
<Avatar initials={displayInitials} size={38} color="#2B6CB0" />
<div style={{ flex: 1 }}>
<div className="user-content" style={{ fontSize: 14, fontWeight: 600 }}>{displayName}</div>
{displayUsername && (
<div style={{ fontSize: 12, color: '#888', marginTop: 2 }}>{displayUsername}</div>
)}
</div>
</div>
</div>
))}
);
})}
{Array.from({ length: unknownCount }, (_, i) => (
<div
key={`unknown-${i}`}
+7 -6
View File
@@ -2,6 +2,7 @@ import { useState } from 'react';
import { ArrowLeft } from 'lucide-react';
import { Header, Input, Text, Avatar, Checkbox, Button, showToast } from '../../../shared/components/sketchy';
import { useFestipodData } from '../../../shared/context/FestipodDataContext';
import { safeUserName, safeUserInitials, safeUserUsername } from '../../../shared/context/festipodDataBase';
import { eventPath, useNavigate, useParams } from '../../../app/router';
export function InviteScreen() {
@@ -54,9 +55,9 @@ export function InviteScreen() {
padding: '4px 10px',
}}
>
<Avatar initials={friend.initials} color="#E8590C" size={22} />
<Avatar initials={safeUserInitials(friend)} color="#E8590C" size={22} />
<Text style={{ margin: 0, fontSize: 13, color: '#C05621', fontWeight: 600 }}>
{friend.name.split(' ')[0]}
{safeUserName(friend).split(' ')[0]}
</Text>
</div>
))}
@@ -155,11 +156,11 @@ export function InviteScreen() {
background: selected.has(friend.id) ? '#FFF7ED' : 'transparent',
}}
>
<Avatar initials={friend.initials} color="#2B6CB0" size="sm" />
<Avatar initials={safeUserInitials(friend)} color="#2B6CB0" size="sm" />
<div style={{ flex: 1, marginLeft: 12 }}>
<Text className="user-content" style={{ margin: 0, fontWeight: 'bold' }}>{friend.name}</Text>
{friend.username && (
<Text style={{ margin: 0, fontSize: 13, color: '#888' }}>{friend.username}</Text>
<Text className="user-content" style={{ margin: 0, fontWeight: 'bold' }}>{safeUserName(friend)}</Text>
{safeUserUsername(friend) && (
<Text style={{ margin: 0, fontSize: 13, color: '#888' }}>{safeUserUsername(friend)}</Text>
)}
</div>
<Checkbox checked={selected.has(friend.id)} />
@@ -2,6 +2,7 @@ import React, { useState } from 'react';
import { ArrowLeft } from 'lucide-react';
import { Button, Avatar, Input, showToast } from '../../../shared/components/sketchy';
import { useFestipodData } from '../../../shared/context/FestipodDataContext';
import { safeUserName, safeUserInitials } from '../../../shared/context/festipodDataBase';
import { eventPath, useNavigate, useParams } from '../../../app/router';
export function MeetingPointsScreen() {
@@ -85,8 +86,8 @@ export function MeetingPointsScreen() {
if (!friend) return null;
return (
<div key={id} style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '6px 12px 6px 6px', borderRadius: 20, background: '#f5f5f5' }}>
<Avatar initials={friend.initials} color="#2B6CB0" size={22} />
<span style={{ fontSize: 12, fontWeight: 500 }}>{friend.name.split(' ')[0]}</span>
<Avatar initials={safeUserInitials(friend)} color="#2B6CB0" size={22} />
<span style={{ fontSize: 12, fontWeight: 500 }}>{safeUserName(friend).split(' ')[0]}</span>
<span onClick={() => removeInvited(id)} style={{ fontSize: 14, color: '#bbb', cursor: 'pointer' }}>×</span>
</div>
);
@@ -1,6 +1,7 @@
import { ArrowLeft } from 'lucide-react';
import { Header, Avatar, Text, Input } from '../../../shared/components/sketchy';
import { useFestipodData } from '../../../shared/context/FestipodDataContext';
import { safeUserName, safeUserInitials, safeUserUsername } from '../../../shared/context/festipodDataBase';
import { eventPath, useNavigate, useParams, userPath } from '../../../app/router';
const COLORS = ['#E8590C', '#2B6CB0', '#9C4DC7', '#38A169', '#D69E2E', '#E53E3E'];
@@ -23,9 +24,9 @@ export function ParticipantsListScreen() {
const rows = [
...participants.map((p, i) => ({
key: p.id,
initials: p.initials,
name: p.name,
username: p.username,
initials: safeUserInitials(p),
name: safeUserName(p),
username: safeUserUsername(p),
color: COLORS[i % COLORS.length] ?? '#888',
known: true,
})),
+3 -2
View File
@@ -2,6 +2,7 @@ import React, { useEffect, useState } from 'react';
import { ArrowLeft } from 'lucide-react';
import { Header, Avatar, Text, Button, showToast } from '../../../shared/components/sketchy';
import { useFestipodData } from '../../../shared/context/FestipodDataContext';
import { safeUserName, safeUserInitials } from '../../../shared/context/festipodDataBase';
import { useNavigate, useGoBack } from '../../../app/router';
type Mode = 'choice' | 'show' | 'scan';
@@ -96,10 +97,10 @@ export function ConnectScreen() {
borderRadius: '50%',
}}
>
<Avatar initials={currentUser?.initials ?? '?'} color="#E8590C" size="sm" />
<Avatar initials={safeUserInitials(currentUser)} color="#E8590C" size="sm" />
</div>
</div>
<Text className="user-content" style={{ fontWeight: 'bold', margin: '0 0 4px 0' }}>{currentUser?.name}</Text>
<Text className="user-content" style={{ fontWeight: 'bold', margin: '0 0 4px 0' }}>{safeUserName(currentUser, '')}</Text>
<Text style={{ color: '#888', margin: 0, fontSize: 13 }}>
Montrez ce QR code pour permettre à l'autre personne de vous connecter.
</Text>
@@ -1,6 +1,7 @@
import { ArrowLeft } from 'lucide-react';
import { Header, Text, Avatar, Input, Button, BottomNav } from '../../../shared/components/sketchy';
import { useFestipodData } from '../../../shared/context/FestipodDataContext';
import { safeUserName, safeUserInitials, safeUserUsername } from '../../../shared/context/festipodDataBase';
import { useNavigate, userPath } from '../../../app/router';
const COLORS = ['#2B6CB0', '#9C4DC7', '#38A169', '#D69E2E', '#E53E3E', '#E8590C'];
@@ -35,11 +36,11 @@ export function FriendsListScreen() {
borderBottom: '1px solid #f5f5f5',
}}
>
<Avatar initials={person.initials} color={COLORS[i % COLORS.length]} size="sm" />
<Avatar initials={safeUserInitials(person)} color={COLORS[i % COLORS.length]} size="sm" />
<div style={{ flex: 1 }}>
<Text className="user-content" style={{ margin: 0, fontWeight: 'bold' }}>{person.name}</Text>
<Text className="user-content" style={{ margin: 0, fontWeight: 'bold' }}>{safeUserName(person)}</Text>
<Text style={{ margin: 0, fontSize: 13, color: '#888' }}>
<span className="user-content">{person.username}</span>
<span className="user-content">{safeUserUsername(person)}</span>
{person.eventsCount != null && ` · ${person.eventsCount} événements`}
</Text>
</div>
+4 -3
View File
@@ -1,5 +1,6 @@
import { Header, Avatar, Title, Text, Button, Card, Divider, BottomNav, Tag } from '../../../shared/components/sketchy';
import { useFestipodData } from '../../../shared/context/FestipodDataContext';
import { safeUserName, safeUserInitials, safeUserUsername } from '../../../shared/context/festipodDataBase';
import { eventPath, useNavigate } from '../../../app/router';
export function ProfileScreen() {
@@ -18,9 +19,9 @@ export function ProfileScreen() {
<div style={{ flex: 1, overflow: 'auto' }}>
<div style={{ padding: 24, textAlign: 'center' }}>
<Avatar initials={user?.initials ?? '?'} color="#E8590C" size="lg" />
<Title className="user-content" style={{ marginTop: 16, marginBottom: 4 }}>{user?.name}</Title>
<Text className="user-content" style={{ margin: 0, color: '#888' }}>{user?.username}</Text>
<Avatar initials={safeUserInitials(user)} color="#E8590C" size="lg" />
<Title className="user-content" style={{ marginTop: 16, marginBottom: 4 }}>{safeUserName(user, '')}</Title>
<Text className="user-content" style={{ margin: 0, color: '#888' }}>{safeUserUsername(user)}</Text>
<div style={{ display: 'flex', justifyContent: 'center', gap: 32, marginTop: 20 }}>
<div style={{ textAlign: 'center' }}>
@@ -1,13 +1,15 @@
import { ArrowLeft } from 'lucide-react';
import { Header, Text, Button, Card, Divider, Avatar } from '../../../shared/components/sketchy';
import { useFestipodData } from '../../../shared/context/FestipodDataContext';
import { safeUserName, safeUserInitials, safeUserUsername } from '../../../shared/context/festipodDataBase';
import { useNavigate } from '../../../app/router';
export function ShareProfileScreen() {
const navigate = useNavigate();
const { currentUser } = useFestipodData();
const user = currentUser;
const profileLink = `festipod.app/u/${(user?.username ?? '').replace('@', '')}`;
const shareHandle = safeUserUsername(user).replace('@', '');
const profileLink = shareHandle ? `festipod.app/u/${shareHandle}` : '';
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
@@ -47,11 +49,11 @@ export function ShareProfileScreen() {
padding: 4,
borderRadius: '50%',
}}>
<Avatar initials={user?.initials ?? '?'} color="#E8590C" size="sm" />
<Avatar initials={safeUserInitials(user)} color="#E8590C" size="sm" />
</div>
</div>
<Text className="user-content" style={{ fontWeight: 'bold', margin: '0 0 4px 0' }}>{user?.name}</Text>
<Text className="user-content" style={{ fontWeight: 'bold', margin: '0 0 4px 0' }}>{safeUserName(user, '')}</Text>
<Text style={{ color: '#888', margin: 0, fontSize: 14 }}>
Scannez pour me retrouver sur Festipod
</Text>
@@ -1,6 +1,7 @@
import React, { useState } from 'react';
import { Header, Text, Input, Button, Avatar, showToast } from '../../../shared/components/sketchy';
import { useFestipodData } from '../../../shared/context/FestipodDataContext';
import { safeUserInitials } from '../../../shared/context/festipodDataBase';
import { useNavigate } from '../../../app/router';
export function UpdateProfileScreen() {
@@ -41,7 +42,7 @@ export function UpdateProfileScreen() {
<div style={{ flex: 1, padding: 16, overflow: 'auto' }}>
<div style={{ textAlign: 'center', marginBottom: 24 }}>
<Avatar initials={user?.initials ?? '?'} color="#E8590C" size="lg" />
<Avatar initials={safeUserInitials(user)} color="#E8590C" size="lg" />
<Button style={{ marginTop: 12 }}>
Changer la photo
</Button>
@@ -1,6 +1,7 @@
import { ArrowLeft } from 'lucide-react';
import { Header, Avatar, Title, Text, Button, Card, Badge, Divider } from '../../../shared/components/sketchy';
import { useFestipodData } from '../../../shared/context/FestipodDataContext';
import { safeUserName, safeUserInitials, safeUserUsername } from '../../../shared/context/festipodDataBase';
import { eventPath, useNavigate, useGoBack, useParams } from '../../../app/router';
export function UserProfileScreen() {
@@ -30,9 +31,9 @@ export function UserProfileScreen() {
<div style={{ flex: 1, overflow: 'auto' }}>
<div style={{ padding: 24, textAlign: 'center' }}>
<Avatar initials={viewedUser?.initials ?? '?'} color="#2B6CB0" size="lg" />
<Title className="user-content" style={{ marginTop: 16, marginBottom: 4 }}>{viewedUser?.name}</Title>
<Text className="user-content" style={{ margin: 0, color: '#888' }}>{viewedUser?.username}</Text>
<Avatar initials={safeUserInitials(viewedUser)} color="#2B6CB0" size="lg" />
<Title className="user-content" style={{ marginTop: 16, marginBottom: 4 }}>{safeUserName(viewedUser, viewedUser?.id === currentUserId ? '' : undefined)}</Title>
<Text className="user-content" style={{ margin: 0, color: '#888' }}>{safeUserUsername(viewedUser)}</Text>
<div style={{ display: 'flex', justifyContent: 'center', gap: 32, marginTop: 20 }}>
<div style={{ textAlign: 'center' }}>
+185
View File
@@ -0,0 +1,185 @@
import { expect, test, describe } from 'bun:test';
import { buildQueries, resolveParticipantUser, safeUserName, safeUserInitials, safeUserUsername } from './festipodDataBase';
import type { FpEventData, FpUserData, FpParticipationData, FpMeetingPointData, FpFriendshipData } from '../data/types';
describe('resolveParticipantUser', () => {
const users: FpUserData[] = [
{ id: 'user-1', name: 'Marie Dupont', initials: 'MD', username: '@mariedupont' },
{ id: 'http://localhost:3000/alice', name: 'Alice', initials: 'A', username: '@alice' },
];
test('matches by exact profile id', () => {
expect(resolveParticipantUser('user-1', users)?.name).toBe('Marie Dupont');
expect(resolveParticipantUser('http://localhost:3000/alice', users)?.name).toBe('Alice');
});
test('matches the legacy urn:festipod:user:<handle> form on normalized username', () => {
expect(resolveParticipantUser('urn:festipod:user:mariedupont', users)?.name).toBe('Marie Dupont');
});
test('returns undefined when the participant profile is not available', () => {
expect(resolveParticipantUser('http://localhost:3000/bob', users)).toBeUndefined();
expect(resolveParticipantUser('urn:festipod:user:unknown', users)).toBeUndefined();
});
});
describe('getEventParticipants', () => {
const event: FpEventData = {
id: 'event-1',
title: 'Concert',
description: '',
date: '2026-10-10',
location: 'Lyon',
participantCount: 2,
};
const users: FpUserData[] = [
{ id: 'user-1', name: 'Marie Dupont', initials: 'MD', username: '@mariedupont' },
{ id: 'user-2', name: 'Jean Durand', initials: 'JD', username: '@jeandurand' },
];
const participations: FpParticipationData[] = [
{ id: 'p1', eventId: 'event-1', userId: 'user-1', isConfirmed: true },
{ id: 'p2', eventId: 'event-1', userId: 'http://localhost:3000/bob', isConfirmed: true },
{ id: 'p3', eventId: 'event-2', userId: 'user-2', isConfirmed: true },
];
const queries = buildQueries(
[event],
users,
participations,
[] as FpMeetingPointData[],
[] as FpFriendshipData[],
'user-1',
);
test('returns only participants whose profile resolved', () => {
const found = queries.getEventParticipants('event-1');
expect(found.map(u => u.id)).toEqual(['user-1']);
});
test('never exposes an unresolved attendee id as a name', () => {
const found = queries.getEventParticipants('event-1');
for (const u of found) {
expect(u.name).not.toMatch(/https?:\/\//);
expect(u.name).not.toMatch(/^\//);
expect(u.name).not.toMatch(/^urn:/);
expect(u.username).not.toMatch(/https?:\/\//);
}
});
});
describe('safeUserName', () => {
const user = (name: string): FpUserData => ({
id: 'u-1',
name,
initials: 'U',
username: '@user',
});
test('keeps a human-readable name', () => {
expect(safeUserName(user('Marie Dupont'))).toBe('Marie Dupont');
});
test('replaces an empty name with a neutral placeholder', () => {
expect(safeUserName(user(''))).toBe('Participant');
});
test('replaces a WebID/actor URI with a neutral placeholder', () => {
expect(safeUserName(user('http://localhost:3000/alice'))).toBe('Participant');
expect(safeUserName(user('https://example.com/as/actor/bob'))).toBe('Participant');
});
test('replaces an actor path with a neutral placeholder', () => {
expect(safeUserName(user('/as/actor/alice'))).toBe('Participant');
expect(safeUserName(user('/profiles/alice'))).toBe('Participant');
});
test('replaces a URN, DID, mailto or tel identifier with a neutral placeholder', () => {
expect(safeUserName(user('urn:festipod:user:alice'))).toBe('Participant');
expect(safeUserName(user('did:ng:abc123'))).toBe('Participant');
expect(safeUserName(user('mailto:alice@example.com'))).toBe('Participant');
expect(safeUserName(user('tel:+33123456789'))).toBe('Participant');
});
test('masks a resolved profile whose name happens to be a raw URI', () => {
const resolved: FpUserData = {
id: 'http://localhost:3000/alice',
name: 'http://localhost:3000/alice',
initials: 'HA',
username: '@alice',
};
expect(safeUserName(resolved)).toBe('Participant');
expect(safeUserInitials(resolved)).toBe('?');
});
});
describe('absent user and own-profile fallback', () => {
const user = (name: string): FpUserData => ({ id: 'u-1', name, initials: 'U', username: '@user' });
test('an absent user renders blank name and username, as before the sanitizers', () => {
expect(safeUserName(undefined)).toBe('');
expect(safeUserName(undefined, '')).toBe('');
expect(safeUserUsername(undefined)).toBe('');
});
test('an empty name stays empty when the caller passes an empty fallback (own profile)', () => {
expect(safeUserName(user(''), '')).toBe('');
expect(safeUserName(user(' '), '')).toBe('');
});
test('an identifier-shaped name is masked even with an empty fallback', () => {
expect(safeUserName(user('http://localhost:3000/alice'), '')).toBe('Participant');
});
test('a human name is unchanged whatever the fallback', () => {
expect(safeUserName(user('Marie Dupont'), '')).toBe('Marie Dupont');
});
});
describe('safeUserInitials', () => {
const user = (name: string, initials: string): FpUserData => ({
id: 'u-1',
name,
initials,
username: '@user',
});
test('keeps human initials', () => {
expect(safeUserInitials(user('Marie Dupont', 'MD'))).toBe('MD');
});
test('falls back to ? when the initials are missing or identifier-shaped', () => {
expect(safeUserInitials(user('Marie Dupont', ''))).toBe('?');
expect(safeUserInitials(user('Marie Dupont', '/as/actor/bob'))).toBe('?');
});
test('returns ? when the name is identifier-shaped (derived and explicit initials are indistinguishable)', () => {
expect(safeUserInitials(user('http://localhost:3000/alice', 'HT'))).toBe('?');
expect(safeUserInitials(user('http://localhost:3000/alice', 'AA'))).toBe('?');
expect(safeUserInitials(user('urn:festipod:user:bob', 'BB'))).toBe('?');
});
test('returns ? for an absent user', () => {
expect(safeUserInitials(undefined)).toBe('?');
});
});
describe('safeUserUsername', () => {
const user = (username: string): FpUserData => ({
id: 'u-1',
name: 'Nom',
initials: 'N',
username,
});
test('keeps a human handle', () => {
expect(safeUserUsername(user('@alice'))).toBe('@alice');
});
test('drops an identifier-shaped username', () => {
expect(safeUserUsername(user('http://localhost:3000/alice'))).toBe('');
expect(safeUserUsername(user('/as/actor/alice'))).toBe('');
});
test('drops an empty username', () => {
expect(safeUserUsername(user(''))).toBe('');
});
});
+57
View File
@@ -169,6 +169,63 @@ export const UNSET_PROFILE = {
username: '(pseudo non défini)',
} as const;
// ── Display sanitizers: the UI must never render a raw identifier as a name ──
// A participant's profile may resolve but still carry an empty name, a WebID,
// an actor path, or any other identifier-shaped value instead of a human name.
// These helpers are the last line of defence: they turn such values into a
// neutral placeholder ("Participant", close to the "Participant inconnu" that
// EventDetailScreen and ParticipantsListScreen show for unresolved attendees).
// An ABSENT user (still loading / not resolved) is not masked: it renders as
// before the sanitizers existed (blank name, blank username, '?' initials), and
// screens showing the current user's own profile pass `emptyFallback: ''` so an
// empty name stays blank (cf. UNSET_PROFILE for the unset-profile wording).
/** True when `value` looks like an identifier rather than a human-readable name. */
function looksLikeIdentifier(value: string): boolean {
const trimmed = value.trim();
if (trimmed.length === 0) return false; // empty is handled separately
// Any URI scheme (http:, mailto:, tel:, did:, urn:, ipfs:, …) or an absolute
// resource path (/as/actor/…, /profiles/…). Any leading `word:` is treated
// as a scheme, so a human name that starts that way ("DJ:Kool", "Lucie:)") is
// masked too — a known false positive, accepted for now. Only the START of
// the value is checked: a value embedding an identifier mid-string
// ("Bob https://…", "<https://…>") is not masked.
return /^[a-z][a-z0-9+.-]*:/i.test(trimmed) || trimmed.startsWith('/');
}
/**
* A displayable name: the user's name unless it is empty or identifier-shaped.
* An absent user gives ''. `emptyFallback` is what an EMPTY name becomes
* ('Participant' by default; '' on the current user's own profile screens);
* an identifier-shaped name is always masked as 'Participant'.
*/
export function safeUserName(user: FpUserData | undefined, emptyFallback = 'Participant'): string {
if (!user) return '';
const name = user.name?.trim() ?? '';
if (name.length === 0) return emptyFallback;
return looksLikeIdentifier(name) ? 'Participant' : name;
}
/** A displayable username: the user's username unless it is identifier-shaped. */
export function safeUserUsername(user: FpUserData | undefined): string {
const username = user?.username?.trim() ?? '';
if (username.length > 0 && !looksLikeIdentifier(username)) return username;
return '';
}
/**
* Safe initials for an avatar: explicit valid initials survive; otherwise '?'.
* An identifier-shaped name forces '?': initials derived from it ("HT" from
* "http://…") cannot be told apart from explicit ones.
*/
export function safeUserInitials(user: FpUserData | undefined): string {
if (!user) return '?';
if (looksLikeIdentifier(user.name ?? '')) return '?';
const initials = user.initials?.trim() ?? '';
if (initials.length > 0 && !looksLikeIdentifier(initials)) return initials;
return '?';
}
/**
* The shared in-memory queries — the same logic for every provider, fed with
* that provider's arrays. Cross-entity derivations only: nothing here touches
@@ -0,0 +1,113 @@
/**
* Screen-level wiring of the display sanitizers: renders the real screens
* (renderHelper + LocalDataProvider over the seed arrays, which are patched per
* test and restored) and asserts that no identifier-shaped value reaches the DOM.
*/
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
import { renderScreen, getRenderedDocument } from './renderHelper';
import { seedUsers } from '../data/seedData';
import type { FpUserData } from '../data/types';
const ID = 'http://localhost:3000/alice';
let originals: FpUserData[] = [];
beforeEach(() => {
originals = seedUsers.map((u) => ({ ...u }));
});
afterEach(() => {
seedUsers.forEach((u, i) => Object.assign(u, originals[i]));
});
function patch(id: string, fields: Partial<FpUserData>): void {
const u = seedUsers.find((x) => x.id === id);
if (!u) throw new Error(`seed user ${id} missing`);
Object.assign(u, fields);
}
async function textOf(screenId: string, path: string): Promise<string> {
await renderScreen(screenId, path);
return getRenderedDocument()?.body.textContent ?? '';
}
describe('lists of other people mask identifier-shaped profiles', () => {
for (const [screenId, path] of [
['participants', '/events/event-1/participants'],
['event-detail', '/events/event-1'],
] as const) {
test(`${screenId}: identifier name/username/initials never rendered`, async () => {
patch('user-2', { name: ID, username: ID, initials: 'HT' });
const text = await textOf(screenId, path);
expect(text).not.toContain('localhost');
expect(text).not.toContain('HT');
expect(text).toContain('Participant');
expect(text).toContain('?');
});
}
});
describe('someone else with an empty name', () => {
for (const [screenId, path] of [
['participants', '/events/event-1/participants'],
['event-detail', '/events/event-1'],
['user-profile', '/users/user-2'],
] as const) {
test(`${screenId}: shown as « Participant »`, async () => {
patch('user-2', { name: '' });
// Not the « Participants (N) » heading nor the « Participant inconnu » placeholder.
expect(await textOf(screenId, path)).toMatch(/Participant(?!s| inconnu)/);
});
}
});
describe('own profile screens', () => {
test('profile: identifier name/username/initials are not rendered', async () => {
patch('user-1', { name: ID, username: ID, initials: 'HT' });
const text = await textOf('profile', '/profile');
expect(text).not.toContain('localhost');
expect(text).not.toContain('HT');
});
test('profile: an empty name is not titled « Participant »', async () => {
patch('user-1', { name: '' });
expect(await textOf('profile', '/profile')).not.toContain('Participant');
});
test('share-profile: no identifier in the name, initials or festipod.app/u/ link', async () => {
patch('user-1', { name: ID, username: ID, initials: 'HT' });
const text = await textOf('share-profile', '/profile/share');
expect(text).not.toContain('localhost');
expect(text).not.toContain('HT');
expect(text).not.toContain('festipod.app/u/');
});
test('share-profile: a human username still builds the link; empty name is not « Participant »', async () => {
patch('user-1', { name: '' });
const text = await textOf('share-profile', '/profile/share');
expect(text).toContain('festipod.app/u/mariedupont');
expect(text).not.toContain('Participant');
});
test('edit-profile: identifier initials are not rendered in the avatar', async () => {
patch('user-1', { initials: ID });
expect(await textOf('edit-profile', '/profile/edit')).not.toContain('localhost');
});
});
describe('user-profile', () => {
test('another person with an identifier-shaped profile is masked', async () => {
patch('user-2', { name: ID, username: ID, initials: 'HT' });
const text = await textOf('user-profile', '/users/user-2');
expect(text).not.toContain('localhost');
expect(text).not.toContain('HT');
expect(text).toContain('Participant');
});
test('the current user viewed here with an empty name is not titled « Participant »', async () => {
patch('user-1', { name: '' });
expect(await textOf('user-profile', '/users/user-1')).not.toContain('Participant');
});
test('an unresolved user id renders no « Participant » placeholder', async () => {
expect(await textOf('user-profile', '/users/unknown-id')).not.toContain('Participant');
});
});