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:
@@ -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}`}
|
||||
|
||||
@@ -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,
|
||||
})),
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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' }}>
|
||||
|
||||
@@ -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('');
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user