fix: query user directory safely

This commit is contained in:
Bastian Wagner
2026-07-31 22:29:15 +02:00
parent c382234746
commit d738b49cbf
3 changed files with 428 additions and 99 deletions

View File

@@ -32,14 +32,33 @@ describe('UsersService directory', () => {
assignment(502, users[4], teamB),
];
const usersRepository = { find: jest.fn() };
const playersRepository = { find: jest.fn() };
let userRows: ReturnType<typeof directoryUserRow>[];
let assignmentRows: ReturnType<typeof directoryAssignmentRow>[];
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.resetAllMocks();
usersRepository.find.mockResolvedValue(users);
playersRepository.find.mockResolvedValue(players);
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,
@@ -85,7 +104,34 @@ describe('UsersService directory', () => {
});
});
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);
@@ -105,6 +151,13 @@ describe('UsersService directory', () => {
});
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]);
@@ -113,7 +166,9 @@ describe('UsersService directory', () => {
});
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]);
@@ -121,6 +176,13 @@ describe('UsersService directory', () => {
});
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({
@@ -205,4 +267,111 @@ describe('UsersService directory', () => {
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;
}
});