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), ]; let userRows: ReturnType[]; let assignmentRows: ReturnType[]; let total: number; const usersRepository = { find: jest.fn(() => { throw new Error('directory queries must use a safe database projection'); }), createQueryBuilder: jest.fn(() => createDirectoryUsersQuery()), }; const playersRepository = { find: jest.fn(() => { throw new Error('directory queries must use a safe database projection'); }), createQueryBuilder: jest.fn((alias: string) => alias === 'requesterPlayer' ? createSharedTeamsQuery() : createDirectoryAssignmentsQuery(), ), }; let service: UsersService; beforeEach(() => { jest.clearAllMocks(); setDirectoryResult( [users[0], users[1], users[2], users[4]], [players[0], players[1], players[2], players[4]], ); 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('treats an inactive requester assignment as a shared team membership', async () => { setDirectoryResult( [users[0], users[1]], [ assignment(101, users[0], teamA, false), assignment(201, users[1], teamA), ], ); const result = await directoryFor(users[0]); expect(result.data.map((entry) => entry.id)).toEqual([1, 2]); }); it('does not hydrate whole user entities for the directory', async () => { const result = await directoryFor(users[0]); expect( result.data.every( (entry) => !('password' in entry || 'hash' in entry || 'socialId' in entry), ), ).toBe(true); }); it('returns every user and assignment with email and role for an admin requester', async () => { setDirectoryResult(users, players, true); 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 () => { setDirectoryResult( [users[2], users[4]], [players[2], players[4]], false, 4, ); 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 () => { setDirectoryResult([users[4]], [players[4]]); const matched = await directoryFor(users[0], { search: 'mOrGaN' }); setDirectoryResult([], []); 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 () => { setDirectoryResult( [users[0], users[1]], [players[0], players[1]], false, 4, ); 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, }; } function setDirectoryResult( directoryUsers: typeof users, directoryPlayers: typeof players, isAdmin = false, directoryTotal = directoryUsers.length, ) { userRows = directoryUsers.map((directoryUser) => directoryUserRow(directoryUser, isAdmin), ); assignmentRows = directoryPlayers.map(directoryAssignmentRow); total = directoryTotal; } function directoryUserRow( directoryUser: typeof users[number], isAdmin = false, ) { return { user_id: directoryUser.id, first_name: directoryUser.firstName, last_name: directoryUser.lastName, status_id: directoryUser.status.id, status_name: directoryUser.status.name, ...(isAdmin ? { email: directoryUser.email, role_id: directoryUser.role.id, role_name: directoryUser.role.name, } : {}), }; } function directoryAssignmentRow(directoryPlayer: typeof players[number]) { return { player_id: directoryPlayer.id, user_id: directoryPlayer.user.id, first_name: directoryPlayer.firstName, last_name: directoryPlayer.lastName, active: directoryPlayer.active, team_id: directoryPlayer.team.id, team_name: directoryPlayer.team.name, team_alias: directoryPlayer.team.alias, team_role_id: directoryPlayer.teamRole.id, team_role_name: directoryPlayer.teamRole.name, }; } function createDirectoryUsersQuery() { const query: any = { leftJoin: () => query, innerJoin: () => query, setParameters: () => query, setParameter: () => query, andWhere: () => query, distinct: () => query, orderBy: () => query, offset: () => query, limit: () => query, select: (selection: string | string[]) => { const selected = Array.isArray(selection) ? selection : [selection]; if (selected.join(' ').match(/password|hash|socialId|provider/i)) { throw new Error( 'directory projection contains an authentication field', ); } return query; }, clone: () => createDirectoryCountQuery(), getRawMany: () => Promise.resolve(userRows), }; return query; } function createDirectoryCountQuery() { const query: any = { select: () => query, getRawOne: () => Promise.resolve({ total: String(total) }), }; return query; } function createSharedTeamsQuery() { const query: any = { select: () => query, where: () => query, getQuery: () => 'SELECT requesterPlayer.teamId FROM player requesterPlayer', getParameters: () => ({ requesterId: users[0].id }), }; return query; } function createDirectoryAssignmentsQuery() { const query: any = { innerJoin: () => query, leftJoin: () => query, select: () => query, where: () => query, andWhere: () => query, setParameters: () => query, orderBy: () => query, getRawMany: () => Promise.resolve(assignmentRows), }; return query; } });