378 lines
11 KiB
TypeScript
378 lines
11 KiB
TypeScript
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<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.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;
|
|
}
|
|
});
|