fix: query user directory safely
This commit is contained in:
@@ -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;
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user