feat: add safe user directory query
This commit is contained in:
@@ -0,0 +1,21 @@
|
||||
import { Type } from 'class-transformer';
|
||||
import { IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
|
||||
|
||||
export class UserDirectoryQueryDto {
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
page = 1;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(50)
|
||||
limit = 20;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
search?: string;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
export class UserDirectoryReferenceDto {
|
||||
id: number;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
export class UserDirectoryTeamDto {
|
||||
id: number;
|
||||
name: string;
|
||||
alias: string;
|
||||
}
|
||||
|
||||
export class UserDirectoryAssignmentDto {
|
||||
id: number;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
active: boolean;
|
||||
team: UserDirectoryTeamDto;
|
||||
teamRole: UserDirectoryReferenceDto | null;
|
||||
}
|
||||
|
||||
export class UserDirectorySummaryDto {
|
||||
id: number;
|
||||
firstName: string | null;
|
||||
lastName: string | null;
|
||||
status: UserDirectoryReferenceDto | null;
|
||||
assignments: UserDirectoryAssignmentDto[];
|
||||
}
|
||||
|
||||
export class AdminUserDirectorySummaryDto extends UserDirectorySummaryDto {
|
||||
email: string | null;
|
||||
role: UserDirectoryReferenceDto | null;
|
||||
}
|
||||
|
||||
export class UserDirectoryPageDto {
|
||||
data: Array<UserDirectorySummaryDto | AdminUserDirectorySummaryDto>;
|
||||
page: number;
|
||||
limit: number;
|
||||
total: number;
|
||||
hasNextPage: boolean;
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
ParseIntPipe,
|
||||
HttpStatus,
|
||||
HttpCode,
|
||||
Request,
|
||||
} from '@nestjs/common';
|
||||
import { UsersService } from './users.service';
|
||||
import { CreateUserDto } from './dto/create-user.dto';
|
||||
@@ -22,6 +23,8 @@ import { RoleEnum } from 'src/roles/roles.enum';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { RolesGuard } from 'src/roles/roles.guard';
|
||||
import { infinityPagination } from 'src/utils/infinity-pagination';
|
||||
import { UserDirectoryQueryDto } from './dto/user-directory-query.dto';
|
||||
import { User } from './entities/user.entity';
|
||||
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(AuthGuard('jwt'), RolesGuard)
|
||||
@@ -60,6 +63,16 @@ export class UsersController {
|
||||
);
|
||||
}
|
||||
|
||||
@Roles([RoleEnum.user, RoleEnum.admin])
|
||||
@Get('directory')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
findDirectory(
|
||||
@Request() request: { user: Pick<User, 'id' | 'role'> },
|
||||
@Query() query: UserDirectoryQueryDto,
|
||||
) {
|
||||
return this.usersService.findDirectory(request.user, query);
|
||||
}
|
||||
|
||||
@Roles([RoleEnum.admin])
|
||||
@Get(':id')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
|
||||
208
myteamwallet_backend/src/users/users.service.spec.ts
Normal file
208
myteamwallet_backend/src/users/users.service.spec.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
});
|
||||
@@ -4,8 +4,18 @@ import { Player } from 'src/players/entities/player.entity';
|
||||
import { EntityCondition } from 'src/utils/types/entity-condition.type';
|
||||
import { IPaginationOptions } from 'src/utils/types/pagination-options';
|
||||
import { Repository } from 'typeorm';
|
||||
import { RoleEnum } from '../roles/roles.enum';
|
||||
import { CreateUserDto } from './dto/create-user.dto';
|
||||
import { UpdateUserDto } from './dto/update-user.dto';
|
||||
import { UserDirectoryQueryDto } from './dto/user-directory-query.dto';
|
||||
import {
|
||||
AdminUserDirectorySummaryDto,
|
||||
UserDirectoryAssignmentDto,
|
||||
UserDirectoryPageDto,
|
||||
UserDirectoryReferenceDto,
|
||||
UserDirectorySummaryDto,
|
||||
UserDirectoryTeamDto,
|
||||
} from './dto/user-directory-response.dto';
|
||||
import { User } from './entities/user.entity';
|
||||
|
||||
@Injectable()
|
||||
@@ -30,6 +40,66 @@ export class UsersService {
|
||||
});
|
||||
}
|
||||
|
||||
async findDirectory(
|
||||
requester: Pick<User, 'id' | 'role'>,
|
||||
query: UserDirectoryQueryDto,
|
||||
): Promise<UserDirectoryPageDto> {
|
||||
const [users, players] = await Promise.all([
|
||||
this.usersRepository.find({ order: { id: 'ASC' } }),
|
||||
this.playersRepository.find({
|
||||
relations: ['user', 'team', 'teamRole'],
|
||||
order: { id: 'ASC' },
|
||||
}),
|
||||
]);
|
||||
const isAdmin = requester.role?.id === RoleEnum.admin;
|
||||
const sharedTeamIds = new Set(
|
||||
players
|
||||
.filter((player) => player.user?.id === requester.id && player.active)
|
||||
.map((player) => player.team.id),
|
||||
);
|
||||
const assignmentsByUserId = new Map<number, Player[]>();
|
||||
|
||||
for (const player of players) {
|
||||
if (!player.user || (!isAdmin && !sharedTeamIds.has(player.team.id))) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const assignments = assignmentsByUserId.get(player.user.id) ?? [];
|
||||
assignments.push(player);
|
||||
assignmentsByUserId.set(player.user.id, assignments);
|
||||
}
|
||||
|
||||
const visibleUsers = users.filter(
|
||||
(user) => isAdmin || assignmentsByUserId.has(user.id),
|
||||
);
|
||||
const searchedUsers = this.filterDirectorySearch(
|
||||
visibleUsers,
|
||||
assignmentsByUserId,
|
||||
query.search,
|
||||
isAdmin,
|
||||
);
|
||||
const total = searchedUsers.length;
|
||||
const page = query.page ?? 1;
|
||||
const limit = query.limit ?? 20;
|
||||
const data = searchedUsers
|
||||
.slice((page - 1) * limit, page * limit)
|
||||
.map((user) =>
|
||||
this.mapDirectoryUser(
|
||||
user,
|
||||
assignmentsByUserId.get(user.id) ?? [],
|
||||
isAdmin,
|
||||
),
|
||||
);
|
||||
|
||||
return {
|
||||
data,
|
||||
page,
|
||||
limit,
|
||||
total,
|
||||
hasNextPage: page * limit < total,
|
||||
};
|
||||
}
|
||||
|
||||
findOne(fields: EntityCondition<User>) {
|
||||
return this.usersRepository.findOne({
|
||||
where: fields,
|
||||
@@ -77,4 +147,89 @@ export class UsersService {
|
||||
return resolve(true);
|
||||
});
|
||||
}
|
||||
|
||||
private filterDirectorySearch(
|
||||
users: User[],
|
||||
assignmentsByUserId: Map<number, Player[]>,
|
||||
search: string | undefined,
|
||||
includeEmail: boolean,
|
||||
): User[] {
|
||||
const term = search?.trim().toLocaleLowerCase();
|
||||
if (!term) {
|
||||
return users;
|
||||
}
|
||||
|
||||
return users.filter((user) => {
|
||||
const assignments = assignmentsByUserId.get(user.id) ?? [];
|
||||
const values = [
|
||||
user.firstName,
|
||||
user.lastName,
|
||||
...(includeEmail ? [user.email] : []),
|
||||
...assignments.flatMap((assignment) => [
|
||||
assignment.firstName,
|
||||
assignment.lastName,
|
||||
]),
|
||||
];
|
||||
|
||||
return values.some((value) => value?.toLocaleLowerCase().includes(term));
|
||||
});
|
||||
}
|
||||
|
||||
private mapDirectoryUser(
|
||||
user: User,
|
||||
assignments: Player[],
|
||||
includeAdminFields: boolean,
|
||||
): UserDirectorySummaryDto | AdminUserDirectorySummaryDto {
|
||||
const summary: UserDirectorySummaryDto = {
|
||||
id: user.id,
|
||||
firstName: user.firstName,
|
||||
lastName: user.lastName,
|
||||
status: this.mapDirectoryReference(user.status),
|
||||
assignments: assignments
|
||||
.sort((left, right) => left.id - right.id)
|
||||
.map((assignment) => this.mapDirectoryAssignment(assignment)),
|
||||
};
|
||||
|
||||
if (!includeAdminFields) {
|
||||
return summary;
|
||||
}
|
||||
|
||||
return {
|
||||
...summary,
|
||||
email: user.email,
|
||||
role: this.mapDirectoryReference(user.role),
|
||||
};
|
||||
}
|
||||
|
||||
private mapDirectoryAssignment(player: Player): UserDirectoryAssignmentDto {
|
||||
return {
|
||||
id: player.id,
|
||||
firstName: player.firstName,
|
||||
lastName: player.lastName,
|
||||
active: player.active,
|
||||
team: this.mapDirectoryTeam(player.team),
|
||||
teamRole: this.mapDirectoryReference(player.teamRole),
|
||||
};
|
||||
}
|
||||
|
||||
private mapDirectoryTeam(team: Player['team']): UserDirectoryTeamDto {
|
||||
return {
|
||||
id: team.id,
|
||||
name: team.name,
|
||||
alias: team.alias,
|
||||
};
|
||||
}
|
||||
|
||||
private mapDirectoryReference(
|
||||
reference: { id: number; name?: string } | null | undefined,
|
||||
): UserDirectoryReferenceDto | null {
|
||||
if (!reference) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: reference.id,
|
||||
name: reference.name,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user