feat: add safe user directory query

This commit is contained in:
Bastian Wagner
2026-07-31 22:12:53 +02:00
parent bcb4207233
commit c382234746
6 changed files with 506 additions and 0 deletions

View File

@@ -0,0 +1,208 @@
import { RoleEnum } from '../roles/roles.enum';
import { StatusEnum } from '../statuses/statuses.enum';
import { UsersService } from './users.service';
describe('UsersService directory', () => {
const teamA = { id: 10, name: 'Alpha', alias: 'alpha' };
const teamB = { id: 20, name: 'Bravo', alias: 'bravo' };
const playerRole = { id: 1, name: 'Player' };
const users = [
user(1, 'Riley', 'Reader', 'reader@example.com'),
user(2, 'Emma', 'Shared', 'emma@example.com'),
user(3, 'Iva', 'Inactive', 'iva@example.com', StatusEnum.inactive),
user(4, 'Otis', 'Outside', 'otis@example.com'),
user(5, 'Morgan', 'Multiple', 'morgan@example.com'),
user(
6,
'Ada',
'Admin',
'admin@example.com',
StatusEnum.active,
RoleEnum.admin,
),
];
const players = [
assignment(101, users[0], teamA),
assignment(201, users[1], teamA),
assignment(301, users[2], teamA, false),
assignment(401, users[3], teamB),
assignment(501, users[4], teamA),
assignment(502, users[4], teamB),
];
const usersRepository = { find: jest.fn() };
const playersRepository = { find: jest.fn() };
let service: UsersService;
beforeEach(() => {
jest.resetAllMocks();
usersRepository.find.mockResolvedValue(users);
playersRepository.find.mockResolvedValue(players);
service = new UsersService(
usersRepository as any,
playersRepository as any,
);
});
it('hides users and assignments from teams that the requester does not share', async () => {
const result = await directoryFor(users[0]);
expect(result.data.map((entry) => entry.id)).toEqual([1, 2, 3, 5]);
expect(result.data.find((entry) => entry.id === 4)).toBeUndefined();
expect(result.data.find((entry) => entry.id === 5).assignments).toEqual([
assignmentSummary(501, 'Morgan', 'Multiple', true, teamA),
]);
});
it('redacts email and authentication secrets for a non-admin requester', async () => {
const result = await directoryFor(users[0]);
const entry = result.data.find((candidate) => candidate.id === 2);
expect(entry).toEqual({
id: 2,
firstName: 'Emma',
lastName: 'Shared',
status: { id: StatusEnum.active, name: 'Active' },
assignments: [assignmentSummary(201, 'Emma', 'Shared', true, teamA)],
});
expect(entry).not.toHaveProperty('email');
expect(entry).not.toHaveProperty('password');
expect(entry).not.toHaveProperty('hash');
expect(entry).not.toHaveProperty('socialId');
});
it('keeps inactive users and inactive assignments visible in shared teams', async () => {
const result = await directoryFor(users[0]);
expect(result.data.find((entry) => entry.id === 3)).toEqual({
id: 3,
firstName: 'Iva',
lastName: 'Inactive',
status: { id: StatusEnum.inactive, name: 'Inactive' },
assignments: [assignmentSummary(301, 'Iva', 'Inactive', false, teamA)],
});
});
it('returns every user and assignment with email and role for an admin requester', async () => {
const result = await directoryFor(users[5]);
const multiple = result.data.find((entry) => entry.id === 5);
const outsider = result.data.find((entry) => entry.id === 4);
expect(result.data).toHaveLength(6);
expect(outsider).toMatchObject({
email: 'otis@example.com',
role: { id: RoleEnum.user, name: 'User' },
});
expect(multiple.assignments).toEqual([
assignmentSummary(501, 'Morgan', 'Multiple', true, teamA),
assignmentSummary(502, 'Morgan', 'Multiple', true, teamB),
]);
expect(multiple).not.toHaveProperty('password');
expect(multiple).not.toHaveProperty('hash');
expect(multiple).not.toHaveProperty('socialId');
});
it('deduplicates a user with assignments in more than one shared team before pagination', async () => {
const result = await directoryFor(users[0], { page: 2, limit: 2 });
expect(result.data.map((entry) => entry.id)).toEqual([3, 5]);
expect(result.total).toBe(4);
expect(result.hasNextPage).toBe(false);
});
it('searches visible names case-insensitively without exposing outside-team users', async () => {
const matched = await directoryFor(users[0], { search: 'mOrGaN' });
const hidden = await directoryFor(users[0], { search: 'outside' });
expect(matched.data.map((entry) => entry.id)).toEqual([5]);
expect(hidden.data).toEqual([]);
});
it('paginates the deduplicated, filtered directory and reports the next page', async () => {
const result = await directoryFor(users[0], { page: 1, limit: 2 });
expect(result).toMatchObject({
page: 1,
limit: 2,
total: 4,
hasNextPage: true,
});
expect(result.data.map((entry) => entry.id)).toEqual([1, 2]);
});
function directoryFor(
requester: typeof users[number],
query: { page?: number; limit?: number; search?: string } = {},
) {
return (service as any).findDirectory(requester, {
page: 1,
limit: 20,
...query,
});
}
function user(
id: number,
firstName: string,
lastName: string,
email: string,
statusId = StatusEnum.active,
roleId = RoleEnum.user,
) {
return {
id,
firstName,
lastName,
email,
password: `password-${id}`,
hash: `hash-${id}`,
socialId: `social-${id}`,
provider: 'email',
previousPassword: `previous-password-${id}`,
status: {
id: statusId,
name: statusId === StatusEnum.active ? 'Active' : 'Inactive',
},
role: {
id: roleId,
name: roleId === RoleEnum.admin ? 'Admin' : 'User',
},
};
}
function assignment(
id: number,
playerUser: typeof users[number],
team: typeof teamA,
active = true,
) {
return {
id,
firstName: playerUser.firstName,
lastName: playerUser.lastName,
active,
user: playerUser,
team,
teamRole: playerRole,
};
}
function assignmentSummary(
id: number,
firstName: string,
lastName: string,
active: boolean,
team: typeof teamA,
) {
return {
id,
firstName,
lastName,
active,
team,
teamRole: playerRole,
};
}
});