Merge branch 'feature/admin-user-management'

This commit is contained in:
Bastian Wagner
2026-08-01 11:50:27 +02:00
57 changed files with 4897 additions and 260 deletions

View File

@@ -0,0 +1,30 @@
import { GUARDS_METADATA } from '@nestjs/common/constants';
import { AuthController } from './auth.controller';
import { AuthRegisterLoginDto } from './dto/auth-register-login.dto';
describe('AuthController session enforcement', () => {
it('protects GET auth/me with JWT validation', () => {
const guards = Reflect.getMetadata(
GUARDS_METADATA,
AuthController.prototype.me,
);
expect(guards).toBeDefined();
expect(guards).toHaveLength(1);
});
it('does not expose self-deletion that can race with an admin promotion', () => {
expect(AuthController.prototype).not.toHaveProperty('delete');
});
it('uses the narrow validated registration DTO instead of an untyped body', () => {
const parameterTypes = Reflect.getMetadata(
'design:paramtypes',
AuthController.prototype,
'register',
);
expect(parameterTypes[0]).toBe(AuthRegisterLoginDto);
expect(AuthRegisterLoginDto.prototype).not.toHaveProperty('linkPlayerId');
});
});

View File

@@ -8,7 +8,6 @@ import {
Post,
UseGuards,
Patch,
Delete,
UseInterceptors,
ClassSerializerInterceptor,
SerializeOptions,
@@ -27,6 +26,7 @@ import {
ApiOkResponse,
} from '@nestjs/swagger';
import { CreateInviteDTO } from './dto/create-invite.dto';
import { AuthRegisterLoginDto } from './dto/auth-register-login.dto';
@ApiTags('Auth')
@Controller({
@@ -51,7 +51,7 @@ export class AuthController {
@Post('email/register')
@HttpCode(HttpStatus.CREATED)
async register(@Body() createUserDto: any) {
async register(@Body() createUserDto: AuthRegisterLoginDto) {
return this.service.register(createUserDto);
}
@@ -81,7 +81,7 @@ export class AuthController {
groups: ['exposeProvider'],
})
@Get('me')
// @UseGuards(AuthGuard('jwt'))
@UseGuards(AuthGuard('jwt'))
@HttpCode(HttpStatus.OK)
public me(@Request() request: Request) {
return this.service.me(request.headers['authorization']);
@@ -95,14 +95,6 @@ export class AuthController {
return this.service.update(request.user, userDto);
}
@ApiBearerAuth()
@Delete('me')
@UseGuards(AuthGuard('jwt'))
@HttpCode(HttpStatus.OK)
public async delete(@Request() request) {
return this.service.softDelete(request.user);
}
@ApiOperation({
summary: 'Erstellt Registrierungstoken',
description:

View File

@@ -0,0 +1,291 @@
import { ForbiddenException } from '@nestjs/common';
import { AuthProvidersEnum } from './auth-providers.enum';
import { AuthService } from './auth.service';
import { RoleEnum } from '../roles/roles.enum';
import { StatusEnum } from '../statuses/statuses.enum';
describe('AuthService inactive-user enforcement and safe logging', () => {
let jwtService: any;
let usersService: any;
let logger: any;
let dataSource: any;
let confirmationUser: any;
let lockedUserQuery: any;
let userRepository: any;
let service: AuthService;
let mailService: any;
beforeEach(() => {
jwtService = {
sign: jest.fn(() => 'signed-token'),
verify: jest.fn(),
decode: jest.fn(),
};
usersService = {
findOne: jest.fn(),
update: jest.fn(),
create: jest.fn(),
linkPlayerToUserId: jest.fn(),
};
logger = { info: jest.fn(), debug: jest.fn() };
mailService = { userSignUp: jest.fn() };
confirmationUser = user(StatusEnum.inactive);
confirmationUser.hash = 'confirmation-hash';
lockedUserQuery = {
where: jest.fn().mockReturnThis(),
setLock: jest.fn().mockReturnThis(),
getOne: jest.fn(() => confirmationUser),
};
userRepository = {
createQueryBuilder: jest.fn(() => lockedUserQuery),
save: jest.fn((value) => Promise.resolve(value)),
};
const manager = { getRepository: jest.fn(() => userRepository) };
dataSource = {
transaction: jest.fn((work) => work(manager)),
};
service = new AuthService(
jwtService,
usersService,
{} as any,
mailService,
logger,
dataSource,
);
});
it('rejects password login for an inactive user before issuing a token', async () => {
usersService.findOne.mockResolvedValue(user(StatusEnum.inactive));
await expect(
service.validateLogin({
email: 'inactive@example.com',
password: 'password',
}),
).rejects.toBeInstanceOf(ForbiddenException);
expect(jwtService.sign).not.toHaveBeenCalled();
expect(logger.info).toHaveBeenCalledWith({
event: 'user_login_fail',
details: 'userId=2 reason=inactive',
userId: 2,
});
});
it('rejects social login when the existing account is inactive', async () => {
const inactive = socialUser(
AuthProvidersEnum.google,
RoleEnum.user,
StatusEnum.inactive,
);
configureSocialQueries([inactive], inactive);
await expect(
service.validateSocialLogin(AuthProvidersEnum.google, {
id: 'social-id',
email: inactive.email,
firstName: 'Inactive',
lastName: 'User',
}),
).rejects.toBeInstanceOf(ForbiddenException);
expect(usersService.update).not.toHaveBeenCalled();
expect(jwtService.sign).not.toHaveBeenCalled();
});
it('reloads and rejects a concurrently deactivated social user instead of signing stale state', async () => {
const stale = socialUser(
AuthProvidersEnum.google,
RoleEnum.admin,
StatusEnum.active,
);
const current = socialUser(
AuthProvidersEnum.google,
RoleEnum.user,
StatusEnum.inactive,
);
configureSocialQueries([stale], current);
await expect(
service.validateSocialLogin(AuthProvidersEnum.google, {
id: stale.socialId,
email: 'updated@example.com',
}),
).rejects.toBeInstanceOf(ForbiddenException);
expect(dataSource.transaction).toHaveBeenCalledTimes(1);
expect(userRepository.save).not.toHaveBeenCalledWith(stale);
expect(usersService.update).not.toHaveBeenCalled();
expect(jwtService.sign).not.toHaveBeenCalled();
});
it.each([
AuthProvidersEnum.facebook,
AuthProvidersEnum.google,
AuthProvidersEnum.twitter,
AuthProvidersEnum.apple,
])(
'locks and reloads an existing %s user, narrowly updates email, and signs the current role',
async (provider) => {
const stale = socialUser(provider, RoleEnum.admin, StatusEnum.active);
const current = socialUser(provider, RoleEnum.user, StatusEnum.active);
configureSocialQueries([stale], current);
const result = await service.validateSocialLogin(provider, {
id: stale.socialId,
email: 'updated@example.com',
});
expect(userRepository.update).toHaveBeenCalledWith(stale.id, {
email: 'updated@example.com',
});
expect(userRepository.save).not.toHaveBeenCalledWith(stale);
expect(jwtService.sign).toHaveBeenCalledWith({
id: current.id,
role: current.role,
});
expect(result.user).toBe(current);
},
);
it('never includes an email in an unknown-user login audit event', async () => {
usersService.findOne.mockResolvedValue(undefined);
await expect(
service.validateLogin({
email: 'secret@example.com',
password: 'secret-password',
}),
).rejects.toBeDefined();
expect(logger.info).toHaveBeenCalledWith({
event: 'user_login_fail',
details: 'reason=user_not_found',
userId: -1,
});
expect(JSON.stringify(logger.info.mock.calls)).not.toContain(
'secret@example.com',
);
expect(JSON.stringify(logger.info.mock.calls)).not.toContain(
'secret-password',
);
});
it('never includes a rejected invite token in logging details', async () => {
jwtService.verify.mockImplementation(() => {
throw new Error('invalid');
});
await expect(
service.getTeamFromInvite('secret-token'),
).rejects.toBeDefined();
expect(logger.info).toHaveBeenCalledWith({
event: 'user_invite_link_validate_fail',
details: 'invitation validation failed',
userId: 0,
});
expect(JSON.stringify(logger.info.mock.calls)).not.toContain(
'secret-token',
);
});
it('does not refresh an inactive user through the me endpoint flow', async () => {
jwtService.verify.mockReturnValue({ id: 2 });
usersService.findOne.mockResolvedValue(user(StatusEnum.inactive));
await expect(service.me('Bearer existing-token')).rejects.toBeInstanceOf(
ForbiddenException,
);
expect(jwtService.sign).not.toHaveBeenCalled();
});
it('serializes email confirmation on the user row and consumes the hash', async () => {
await service.confirmEmail('confirmation-hash');
expect(dataSource.transaction).toHaveBeenCalledTimes(1);
expect(lockedUserQuery.setLock).toHaveBeenCalledWith(
'pessimistic_write',
undefined,
['user'],
);
expect(confirmationUser.status).toEqual({ id: StatusEnum.active });
expect(confirmationUser.hash).toBeNull();
expect(userRepository.save).toHaveBeenCalledWith(confirmationUser);
});
it('ignores a public registration player id and never mutates player ownership', async () => {
usersService.create.mockResolvedValue({
id: 8,
email: 'new@example.com',
});
await service.register({
email: 'new@example.com',
password: 'password',
firstName: 'New',
lastName: 'User',
linkPlayerId: 101,
} as any);
expect(usersService.create.mock.calls[0][0]).not.toHaveProperty(
'linkPlayerId',
);
expect(usersService.linkPlayerToUserId).not.toHaveBeenCalled();
});
function user(statusId: StatusEnum) {
return {
id: 2,
email: 'inactive@example.com',
password: 'password-hash',
provider: AuthProvidersEnum.email,
role: { id: RoleEnum.user, name: 'User' },
status: {
id: statusId,
name: statusId === StatusEnum.active ? 'Active' : 'Inactive',
},
};
}
function socialUser(
provider: AuthProvidersEnum,
roleId: RoleEnum,
statusId: StatusEnum,
) {
return {
...user(statusId),
email: 'old@example.com',
socialId: `${provider}-id`,
provider,
role: { id: roleId, name: roleId === RoleEnum.admin ? 'Admin' : 'User' },
hash: 'stale-hash',
};
}
function configureSocialQueries(candidates: any[], current: any) {
const candidateQuery = chain({ getMany: jest.fn(() => candidates) });
const reloadQuery = chain({ getOne: jest.fn(() => current) });
userRepository.createQueryBuilder = jest.fn((alias: string) =>
alias === 'socialCandidate' ? candidateQuery : reloadQuery,
);
userRepository.update = jest.fn();
}
function chain(overrides: Record<string, jest.Mock>) {
const query: Record<string, jest.Mock> = {};
[
'leftJoinAndSelect',
'where',
'orWhere',
'andWhere',
'setParameter',
'setParameters',
'setLock',
'orderBy',
].forEach((method) => {
query[method] = jest.fn(() => query);
});
return Object.assign(query, overrides);
}
});

View File

@@ -1,4 +1,10 @@
import { HttpException, HttpStatus, Injectable } from '@nestjs/common';
import {
ForbiddenException,
HttpException,
HttpStatus,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { User } from '../users/entities/user.entity';
import * as bcrypt from 'bcryptjs';
@@ -19,6 +25,7 @@ import { ForgotService } from 'src/forgot/forgot.service';
import { MailService } from 'src/mail/mail.service';
import { CreateInviteDTO } from './dto/create-invite.dto';
import { LoggingService } from 'src/database/logging/logging.service';
import { DataSource } from 'typeorm';
@Injectable()
export class AuthService {
@@ -28,6 +35,7 @@ export class AuthService {
private forgotService: ForgotService,
private mailService: MailService,
private logger: LoggingService,
private dataSource: DataSource,
) {}
async validateLogin(
@@ -40,7 +48,7 @@ export class AuthService {
if (!user) {
await this.logger.info({
event: 'user_login_fail',
details: `mail not found: ${loginDto.email}`,
details: 'reason=user_not_found',
userId: -1,
});
throw new HttpException(
@@ -54,6 +62,8 @@ export class AuthService {
);
}
await this.assertActiveUser(user);
if (user.provider !== AuthProvidersEnum.email) {
throw new HttpException(
{
@@ -79,7 +89,7 @@ export class AuthService {
await this.logger.info({
event: 'user_login_success',
details: `logged in: ${loginDto.email}`,
details: `userId=${user.id}`,
userId: user.id,
});
@@ -87,7 +97,7 @@ export class AuthService {
} else {
await this.logger.info({
event: 'user_login_fail',
details: `incorrect password for user: ${loginDto.email}`,
details: `userId=${user.id} reason=incorrect_password`,
userId: user.id,
});
@@ -107,57 +117,68 @@ export class AuthService {
authProvider: string,
socialData: SocialInterface,
): Promise<{ token: string; user: User }> {
let user: User;
const socialEmail = socialData.email?.toLowerCase();
const userByEmail = await this.usersService.findOne({
email: socialEmail,
});
user = await this.usersService.findOne({
socialId: socialData.id,
provider: authProvider,
});
if (user) {
if (socialEmail && !userByEmail) {
user.email = socialEmail;
return this.dataSource.transaction(async (manager) => {
const repository = manager.getRepository(User);
const candidateQuery = repository
.createQueryBuilder('socialCandidate')
.where(
'socialCandidate.socialId = :socialId AND socialCandidate.provider = :authProvider',
{ socialId: socialData.id, authProvider },
);
if (socialEmail) {
candidateQuery.orWhere('socialCandidate.email = :socialEmail', {
socialEmail,
});
}
await this.usersService.update(user.id, user);
} else if (userByEmail) {
user = userByEmail;
} else {
const role = plainToClass(Role, {
id: RoleEnum.user,
const candidates = await candidateQuery
.setLock('pessimistic_write', undefined, ['socialCandidate'])
.orderBy('socialCandidate.id', 'ASC')
.getMany();
const socialUser = candidates.find(
(candidate) =>
candidate.socialId === socialData.id &&
candidate.provider === authProvider,
);
const emailUser = socialEmail
? candidates.find((candidate) => candidate.email === socialEmail)
: undefined;
let user = socialUser ?? emailUser;
if (!user) {
user = await repository.save(
repository.create({
email: socialEmail,
firstName: socialData.firstName,
lastName: socialData.lastName,
socialId: socialData.id,
provider: authProvider,
role: { id: RoleEnum.user } as Role,
status: { id: StatusEnum.active } as Status,
}),
);
} else if (
socialUser &&
socialEmail &&
!emailUser &&
socialUser.email !== socialEmail
) {
await repository.update(socialUser.id, { email: socialEmail });
}
const currentUser = await repository
.createQueryBuilder('currentSocialUser')
.leftJoinAndSelect('currentSocialUser.role', 'role')
.leftJoinAndSelect('currentSocialUser.status', 'status')
.where('currentSocialUser.id = :userId', { userId: user.id })
.setLock('pessimistic_write', undefined, ['currentSocialUser'])
.getOne();
if (!currentUser) throw new UnauthorizedException();
await this.assertActiveUser(currentUser);
const token = await this.jwtService.sign({
id: currentUser.id,
role: currentUser.role,
});
const status = plainToClass(Status, {
id: StatusEnum.active,
});
user = await this.usersService.create({
email: socialEmail,
firstName: socialData.firstName,
lastName: socialData.lastName,
socialId: socialData.id,
provider: authProvider,
role,
status,
});
user = await this.usersService.findOne({
id: user.id,
});
}
const jwtToken = await this.jwtService.sign({
id: user.id,
role: user.role,
return { token, user: currentUser };
});
return {
token: jwtToken,
user,
};
}
async register(dto: AuthRegisterLoginDto): Promise<void> {
@@ -167,8 +188,10 @@ export class AuthService {
.digest('hex');
const user = await this.usersService.create({
...dto,
email: dto.email,
password: dto.password,
firstName: dto.firstName,
lastName: dto.lastName,
role: {
id: RoleEnum.user,
} as Role,
@@ -178,13 +201,9 @@ export class AuthService {
hash,
});
if (user && dto.linkPlayerId != null) {
await this.usersService.linkPlayerToUserId(user, dto.linkPlayerId);
}
await this.logger.info({
event: 'user_create',
details: `user created with mail: ${dto.email}`,
details: `userId=${user.id}`,
userId: user.id,
});
@@ -198,25 +217,26 @@ export class AuthService {
}
async confirmEmail(hash: string): Promise<void> {
const user = await this.usersService.findOne({
hash,
await this.dataSource.transaction(async (manager) => {
const repository = manager.getRepository(User);
const user = await repository
.createQueryBuilder('user')
.where('user.hash = :hash', { hash })
.setLock('pessimistic_write', undefined, ['user'])
.getOne();
if (!user) {
throw new HttpException(
{
status: HttpStatus.NOT_FOUND,
error: `notFound`,
},
HttpStatus.NOT_FOUND,
);
}
user.hash = null;
user.status = plainToClass(Status, { id: StatusEnum.active });
await repository.save(user);
});
if (!user) {
throw new HttpException(
{
status: HttpStatus.NOT_FOUND,
error: `notFound`,
},
HttpStatus.NOT_FOUND,
);
}
user.hash = null;
user.status = plainToClass(Status, {
id: StatusEnum.active,
});
await user.save();
}
async forgotPassword(email: string): Promise<void> {
@@ -281,40 +301,31 @@ export class AuthService {
async me(token: string): Promise<User> {
token = token.replace('Bearer ', '');
let role: any;
let payload: any;
let refreshToken = false;
try {
role = this.jwtService.verify(token);
const u = await this.usersService.findOne({
id: role.id,
});
await this.logger.debug({
event: 'user_token_verification_success',
details: `Email: ${u.email}`,
userId: u.id,
});
return u;
} catch (error) {
const role = this.jwtService.decode(token);
const user = await this.usersService.findOne({
id: (role as any).id,
});
payload = this.jwtService.verify(token);
} catch {
payload = this.jwtService.decode(token);
refreshToken = true;
}
if (!payload?.id) throw new UnauthorizedException();
const user = await this.usersService.findOne({ id: payload.id });
if (!user) throw new UnauthorizedException();
await this.assertActiveUser(user);
if (refreshToken) {
const t = await this.jwtService.sign({
id: user.id,
role: user.role,
});
user['token'] = t;
await this.logger.debug({
event: 'user_token_verification_success',
details: `Email: ${user.email}`,
userId: user.id,
});
return user;
}
await this.logger.debug({
event: 'user_token_verification_success',
details: `userId=${user.id}`,
userId: user.id,
});
return user;
}
async update(user: User, userDto: AuthUpdateDto): Promise<User> {
@@ -360,10 +371,6 @@ export class AuthService {
});
}
async softDelete(user: User): Promise<void> {
await this.usersService.softDelete(user.id);
}
async createTeamInvite(object: CreateInviteDTO) {
const token = await this.jwtService.sign(object, {
expiresIn: '30d',
@@ -394,7 +401,7 @@ export class AuthService {
} catch {
await this.logger.info({
event: 'user_invite_link_validate_fail',
details: `validation failed for token ${token}`,
details: 'invitation validation failed',
userId: 0,
});
@@ -404,4 +411,14 @@ export class AuthService {
);
}
}
private async assertActiveUser(user: User): Promise<void> {
if (user.status?.id === StatusEnum.active) return;
await this.logger.info({
event: 'user_login_fail',
details: `userId=${user.id} reason=inactive`,
userId: user.id,
});
throw new ForbiddenException('User account is inactive');
}
}

View File

@@ -23,7 +23,4 @@ export class AuthRegisterLoginDto {
@ApiProperty({ example: 'Doe' })
@IsNotEmpty()
lastName: string;
@ApiProperty({ example: 27 })
linkPlayerId: number | null;
}

View File

@@ -0,0 +1,67 @@
import { UnauthorizedException } from '@nestjs/common';
import { RoleEnum } from '../../roles/roles.enum';
import { StatusEnum } from '../../statuses/statuses.enum';
import { JwtStrategy } from './jwt.strategy';
describe('JwtStrategy', () => {
const jwtService = {} as any;
const configService = { get: jest.fn(() => 'secret') } as any;
let usersService: any;
let strategy: JwtStrategy;
beforeEach(() => {
usersService = { findOne: jest.fn() };
strategy = new JwtStrategy(jwtService, configService, usersService);
});
it('reloads the current database user and replaces a stale token role', async () => {
usersService.findOne.mockResolvedValue({
id: 2,
role: { id: RoleEnum.user, name: 'User' },
status: { id: StatusEnum.active, name: 'Active' },
password: 'must-not-be-exposed',
});
const result = await strategy.validate({
id: 2,
role: { id: RoleEnum.admin },
iat: 1,
exp: 2,
} as any);
expect(usersService.findOne).toHaveBeenCalledWith({ id: 2 });
expect(result).toEqual({
id: 2,
role: { id: RoleEnum.user, name: 'User' },
status: { id: StatusEnum.active, name: 'Active' },
});
expect(result).not.toHaveProperty('password');
});
it('rejects a missing or soft-deleted database user', async () => {
usersService.findOne.mockResolvedValue(undefined);
await expect(
strategy.validate({ id: 2, iat: 1, exp: 2 } as any),
).rejects.toBeInstanceOf(UnauthorizedException);
});
it('rejects an inactive current database user', async () => {
usersService.findOne.mockResolvedValue({
id: 2,
role: { id: RoleEnum.user },
status: { id: StatusEnum.inactive },
});
await expect(
strategy.validate({ id: 2, iat: 1, exp: 2 } as any),
).rejects.toBeInstanceOf(UnauthorizedException);
});
it('rejects a payload without an id without querying the database', async () => {
await expect(
strategy.validate({ iat: 1, exp: 2 } as any),
).rejects.toBeInstanceOf(UnauthorizedException);
expect(usersService.findOne).not.toHaveBeenCalled();
});
});

View File

@@ -4,6 +4,8 @@ import { JwtService } from '@nestjs/jwt';
import { PassportStrategy } from '@nestjs/passport';
import { User } from '../../users/entities/user.entity';
import { ConfigService } from '@nestjs/config';
import { UsersService } from '../../users/users.service';
import { StatusEnum } from '../../statuses/statuses.enum';
type JwtPayload = Pick<User, 'id' | 'role'> & { iat: number; exp: number };
@@ -12,6 +14,7 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(
private jwtService: JwtService,
private configService: ConfigService,
private usersService: UsersService,
) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
@@ -19,10 +22,18 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
});
}
public validate(payload: JwtPayload) {
public async validate(payload: JwtPayload) {
if (!payload.id) {
throw new UnauthorizedException();
}
return payload;
const user = await this.usersService.findOne({ id: payload.id });
if (!user || user.status?.id !== StatusEnum.active) {
throw new UnauthorizedException();
}
return {
id: user.id,
role: user.role ?? null,
status: user.status ?? null,
};
}
}

View File

@@ -1,18 +1,25 @@
import { Test, TestingModule } from '@nestjs/testing';
import { LoggingService } from './logging.service';
describe('LoggingService', () => {
let service: LoggingService;
it('can persist an event through the caller transaction manager', async () => {
const defaultRepository = { save: jest.fn() };
const transactionRepository = { save: jest.fn() };
const manager = {
getRepository: jest.fn(() => transactionRepository),
} as any;
const service = new LoggingService(defaultRepository as any);
const event = {
event: 'admin_user_profile_update' as const,
details: 'targetUserId=2',
userId: 1,
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [LoggingService],
}).compile();
await service.info(event, manager);
service = module.get<LoggingService>(LoggingService);
});
it('should be defined', () => {
expect(service).toBeDefined();
expect(transactionRepository.save).toHaveBeenCalledWith({
...event,
level: 'INFO',
});
expect(defaultRepository.save).not.toHaveBeenCalled();
});
});

View File

@@ -1,6 +1,6 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { EntityManager, Repository } from 'typeorm';
import { CreateLogDTO } from './dto/create-log.dto';
import { LogEntry } from './entities/log-entry.entity';
import { LOGEVENT } from './model/logging-event.type';
@@ -23,22 +23,25 @@ export class LoggingService {
});
}
async info({
event,
details,
userId,
}: {
event: LOGEVENT;
details: string;
userId: number;
}) {
async info(
{
event,
details,
userId,
}: {
event: LOGEVENT;
details: string;
userId: number;
},
manager?: EntityManager,
) {
const e: CreateLogDTO = {
event,
details,
userId,
level: 'INFO',
};
await this.repository.save(e);
await (manager?.getRepository(LogEntry) ?? this.repository).save(e);
}
async error({

View File

@@ -15,6 +15,11 @@ export type LOGEVENT =
| 'transaction_create_fail'
| 'transaction_reverse'
| 'player_creation'
| 'admin_user_profile_update'
| 'admin_user_role_update'
| 'admin_user_status_update'
| 'admin_player_assign'
| 'admin_player_unlink'
| 'team_create';
export type LOGLEVEL = 'FATAL' | 'ERROR' | 'WARN' | 'INFO' | 'DEBUG' | 'TRACE';

View File

@@ -0,0 +1,19 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddPlayerLookupIndexes1785517200000 implements MigrationInterface {
name = 'AddPlayerLookupIndexes1785517200000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
'CREATE INDEX "IDX_player_team_id" ON "player" ("teamId")',
);
await queryRunner.query(
'CREATE INDEX "IDX_player_user_id" ON "player" ("userId")',
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query('DROP INDEX "IDX_player_user_id"');
await queryRunner.query('DROP INDEX "IDX_player_team_id"');
}
}

View File

@@ -0,0 +1,33 @@
import { getMetadataArgsStorage } from 'typeorm';
import { Player } from '../../players/entities/player.entity';
import { AddPlayerLookupIndexes1785517200000 } from './1785517200000-AddPlayerLookupIndexes';
describe('AddPlayerLookupIndexes1785517200000', () => {
it('adds reversible indexes for team and user foreign-key lookups', async () => {
const queryRunner = { query: jest.fn() } as any;
const migration = new AddPlayerLookupIndexes1785517200000();
await migration.up(queryRunner);
expect(queryRunner.query.mock.calls.map(([sql]) => sql)).toEqual([
'CREATE INDEX "IDX_player_team_id" ON "player" ("teamId")',
'CREATE INDEX "IDX_player_user_id" ON "player" ("userId")',
]);
queryRunner.query.mockClear();
await migration.down(queryRunner);
expect(queryRunner.query.mock.calls.map(([sql]) => sql)).toEqual([
'DROP INDEX "IDX_player_user_id"',
'DROP INDEX "IDX_player_team_id"',
]);
});
it('keeps entity index metadata aligned with the migration', () => {
const playerIndexes = getMetadataArgsStorage()
.indices.filter((index) => index.target === Player)
.map((index) => index.name);
expect(playerIndexes).toEqual(
expect.arrayContaining(['IDX_player_team_id', 'IDX_player_user_id']),
);
});
});

View File

@@ -3,9 +3,11 @@ import {
BeforeInsert,
Column,
Entity,
Index,
ManyToOne,
OneToMany,
PrimaryGeneratedColumn,
RelationId,
} from 'typeorm';
import { EntityHelper } from 'src/utils/entity-helper';
import { TeamRole } from 'src/team-roles/entities/team-roles.entity';
@@ -29,6 +31,7 @@ export class Player extends EntityHelper {
})
teamRole?: TeamRole | null;
@Index('IDX_player_team_id')
@ManyToOne(() => Team, {
eager: true,
})
@@ -37,11 +40,15 @@ export class Player extends EntityHelper {
@Column({ type: 'decimal', precision: 10, scale: 2, default: 0 })
balance: number;
@Index('IDX_player_user_id')
@ManyToOne(() => User, (user) => user.players, {
eager: true,
})
user?: User | null;
@RelationId((player: Player) => player.user)
userId?: number | null;
@OneToMany(() => Transaction, (transaction) => transaction.player)
transactions: Transaction[];

View File

@@ -0,0 +1,145 @@
import { GUARDS_METADATA, PATH_METADATA } from '@nestjs/common/constants';
import { validate } from 'class-validator';
import { plainToInstance } from 'class-transformer';
import { RoleEnum } from '../roles/roles.enum';
import { RolesGuard } from '../roles/roles.guard';
import { AdminUsersController } from './admin-users.controller';
import {
AdminPlayerQueryDto,
AdminUserProfileDto,
AdminUserRoleDto,
AdminUserStatusDto,
} from './dto/admin-user.dto';
describe('AdminUsersController', () => {
const service = {
updateProfile: jest.fn(),
updateRole: jest.fn(),
updateStatus: jest.fn(),
findPlayers: jest.fn(),
assignPlayer: jest.fn(),
unlinkPlayer: jest.fn(),
};
const controller = new AdminUsersController(service as any);
const actor = { user: { id: 7, role: { id: RoleEnum.admin } } };
beforeEach(() => jest.clearAllMocks());
it('uses a separate versioned admin/users controller guarded by the global admin role', () => {
expect(Reflect.getMetadata(PATH_METADATA, AdminUsersController)).toBe(
'admin/users',
);
expect(Reflect.getMetadata('roles', AdminUsersController)).toEqual([
RoleEnum.admin,
]);
expect(
Reflect.getMetadata(GUARDS_METADATA, AdminUsersController),
).toContain(RolesGuard);
});
it('passes the authenticated actor and target to profile, role, and status mutations', async () => {
await controller.updateProfile(actor as any, 2, {
firstName: 'New',
lastName: 'Name',
});
await controller.updateRole(actor as any, 2, { role: RoleEnum.user });
await controller.updateStatus(actor as any, 2, {
status: 2,
});
expect(service.updateProfile).toHaveBeenCalledWith(7, 2, {
firstName: 'New',
lastName: 'Name',
});
expect(service.updateRole).toHaveBeenCalledWith(7, 2, RoleEnum.user);
expect(service.updateStatus).toHaveBeenCalledWith(7, 2, 2);
});
it('passes player search and atomic assignment operations to the service', async () => {
const query = { assignment: 'unassigned' as const, page: 2, limit: 10 };
await controller.findPlayers(query);
await controller.assignPlayer(actor as any, 3, 11);
await controller.unlinkPlayer(actor as any, 3, 11);
expect(service.findPlayers).toHaveBeenCalledWith(query);
expect(service.assignPlayer).toHaveBeenCalledWith(7, 3, 11);
expect(service.unlinkPlayer).toHaveBeenCalledWith(7, 3, 11);
});
});
describe('admin user DTOs', () => {
it('accepts omitted and string profile names but rejects explicit nulls', async () => {
const omitted = plainToInstance(AdminUserProfileDto, {});
const names = plainToInstance(AdminUserProfileDto, {
firstName: 'Ada',
lastName: 'Admin',
});
const nulls = plainToInstance(AdminUserProfileDto, {
firstName: null,
lastName: null,
});
expect(await validate(omitted)).toEqual([]);
expect(await validate(names)).toEqual([]);
expect(await validate(nulls)).not.toEqual([]);
});
it('accepts only profile names in the profile DTO', async () => {
const dto = plainToInstance(AdminUserProfileDto, {
firstName: 'Ada',
lastName: 'Admin',
email: 'must-not-pass@example.com',
role: RoleEnum.user,
status: 2,
});
expect(await validate(dto, { whitelist: true })).toEqual([]);
expect(dto).toEqual({ firstName: 'Ada', lastName: 'Admin' });
});
it('rejects role and status ids outside their enums', async () => {
const role = plainToInstance(AdminUserRoleDto, { role: 999 });
const status = plainToInstance(AdminUserStatusDto, { status: 999 });
expect(await validate(role)).not.toEqual([]);
expect(await validate(status)).not.toEqual([]);
});
it('rejects numeric-enum reverse-map names instead of sending strings to integer foreign keys', async () => {
const role = plainToInstance(AdminUserRoleDto, { role: 'admin' });
const status = plainToInstance(AdminUserStatusDto, { status: 'inactive' });
expect(await validate(role)).not.toEqual([]);
expect(await validate(status)).not.toEqual([]);
});
it.each([true, '1'])(
'rejects non-number role and status bodies: %p',
async (value) => {
const role = plainToInstance(AdminUserRoleDto, { role: value });
const status = plainToInstance(AdminUserStatusDto, { status: value });
expect(await validate(role)).not.toEqual([]);
expect(await validate(status)).not.toEqual([]);
},
);
it('validates player assignment filters and pagination bounds', async () => {
const valid = plainToInstance(AdminPlayerQueryDto, {
assignment: 'assigned',
teamId: '4',
page: '2',
limit: '50',
});
const invalid = plainToInstance(AdminPlayerQueryDto, {
assignment: 'someone',
page: 0,
limit: 51,
});
expect(await validate(valid)).toEqual([]);
expect(valid).toMatchObject({ teamId: 4, page: 2, limit: 50 });
expect(await validate(invalid)).not.toEqual([]);
});
});

View File

@@ -0,0 +1,102 @@
import {
Body,
Controller,
Delete,
Get,
HttpCode,
HttpStatus,
Param,
ParseIntPipe,
Patch,
Put,
Query,
Request,
UseGuards,
} from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { AuthGuard } from '@nestjs/passport';
import { Roles } from '../roles/roles.decorator';
import { RoleEnum } from '../roles/roles.enum';
import { RolesGuard } from '../roles/roles.guard';
import { AdminUsersService } from './admin-users.service';
import {
AdminPlayerQueryDto,
AdminUserProfileDto,
AdminUserRoleDto,
AdminUserStatusDto,
} from './dto/admin-user.dto';
type AdminRequest = { user: { id: number; role: { id: RoleEnum } } };
@ApiBearerAuth()
@ApiTags('Admin users')
@UseGuards(AuthGuard('jwt'), RolesGuard)
@Roles([RoleEnum.admin])
@Controller({ path: 'admin/users', version: '1' })
export class AdminUsersController {
constructor(private readonly adminUsersService: AdminUsersService) {}
@Patch(':id/profile')
@HttpCode(HttpStatus.OK)
updateProfile(
@Request() request: AdminRequest,
@Param('id', ParseIntPipe) id: number,
@Body() dto: AdminUserProfileDto,
) {
return this.adminUsersService.updateProfile(request.user.id, id, dto);
}
@Patch(':id/role')
@HttpCode(HttpStatus.OK)
updateRole(
@Request() request: AdminRequest,
@Param('id', ParseIntPipe) id: number,
@Body() dto: AdminUserRoleDto,
) {
return this.adminUsersService.updateRole(request.user.id, id, dto.role);
}
@Patch(':id/status')
@HttpCode(HttpStatus.OK)
updateStatus(
@Request() request: AdminRequest,
@Param('id', ParseIntPipe) id: number,
@Body() dto: AdminUserStatusDto,
) {
return this.adminUsersService.updateStatus(request.user.id, id, dto.status);
}
@Get('players')
@HttpCode(HttpStatus.OK)
findPlayers(@Query() query: AdminPlayerQueryDto) {
return this.adminUsersService.findPlayers(query);
}
@Put(':userId/players/:playerId')
@HttpCode(HttpStatus.OK)
assignPlayer(
@Request() request: AdminRequest,
@Param('userId', ParseIntPipe) userId: number,
@Param('playerId', ParseIntPipe) playerId: number,
) {
return this.adminUsersService.assignPlayer(
request.user.id,
userId,
playerId,
);
}
@Delete(':userId/players/:playerId')
@HttpCode(HttpStatus.OK)
unlinkPlayer(
@Request() request: AdminRequest,
@Param('userId', ParseIntPipe) userId: number,
@Param('playerId', ParseIntPipe) playerId: number,
) {
return this.adminUsersService.unlinkPlayer(
request.user.id,
userId,
playerId,
);
}
}

View File

@@ -0,0 +1,173 @@
import {
INestApplication,
UnauthorizedException,
ValidationPipe,
VersioningType,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
import { Test } from '@nestjs/testing';
import * as request from 'supertest';
import { useContainer } from 'class-validator';
import { DataSource } from 'typeorm';
import { AuthController } from '../auth/auth.controller';
import { AuthService } from '../auth/auth.service';
import { RoleEnum } from '../roles/roles.enum';
import { RolesGuard } from '../roles/roles.guard';
import validationOptions from '../utils/validation-options';
import { IsNotExist } from '../utils/validators/is-not-exists.validator';
import { AdminUsersController } from './admin-users.controller';
import { AdminUsersService } from './admin-users.service';
import { UsersController } from './users.controller';
import { UsersService } from './users.service';
describe('admin user HTTP security boundary', () => {
let app: INestApplication;
const safeUser = {
id: 2,
firstName: 'Target',
lastName: 'User',
email: 'target@example.com',
role: { id: RoleEnum.user, name: 'User' },
status: { id: 1, name: 'Active' },
assignments: [],
};
const adminService = {
updateProfile: jest.fn(() => safeUser),
updateRole: jest.fn(() => safeUser),
updateStatus: jest.fn(() => safeUser),
findPlayers: jest.fn(),
assignPlayer: jest.fn(),
unlinkPlayer: jest.fn(),
};
const usersService = {
findDirectory: jest.fn(),
findMyTeams: jest.fn(() => [
{
id: 11,
firstName: 'Pat',
lastName: 'Player',
team: { id: 4, name: 'Alpha' },
},
]),
};
const authService = { register: jest.fn() };
beforeAll(async () => {
const module = await Test.createTestingModule({
controllers: [AdminUsersController, UsersController, AuthController],
providers: [
Reflector,
RolesGuard,
IsNotExist,
{
provide: DataSource,
useValue: {
getRepository: () => ({ findOne: jest.fn(() => undefined) }),
},
},
{ provide: AdminUsersService, useValue: adminService },
{ provide: UsersService, useValue: usersService },
{ provide: AuthService, useValue: authService },
],
})
.overrideGuard(AuthGuard('jwt'))
.useValue({
canActivate(context) {
const httpRequest = context.switchToHttp().getRequest();
const token = httpRequest.headers.authorization;
if (token === 'Bearer admin') {
httpRequest.user = { id: 1, role: { id: RoleEnum.admin } };
return true;
}
if (token === 'Bearer user') {
httpRequest.user = { id: 7, role: { id: RoleEnum.user } };
return true;
}
throw new UnauthorizedException();
},
})
.compile();
app = module.createNestApplication();
useContainer(app, { fallbackOnErrors: true });
app.setGlobalPrefix('api');
app.enableVersioning({ type: VersioningType.URI });
app.useGlobalPipes(new ValidationPipe(validationOptions));
await app.init();
});
afterAll(() => app.close());
beforeEach(() => jest.clearAllMocks());
it('enforces JWT and current global admin role on an admin mutation', async () => {
await request(app.getHttpServer())
.patch('/api/v1/admin/users/2/role')
.send({ role: RoleEnum.user })
.expect(401);
await request(app.getHttpServer())
.patch('/api/v1/admin/users/2/role')
.set('Authorization', 'Bearer user')
.send({ role: RoleEnum.user })
.expect(403);
await request(app.getHttpServer())
.patch('/api/v1/admin/users/2/role')
.set('Authorization', 'Bearer admin')
.send({ role: RoleEnum.user })
.expect(200, safeUser);
});
it.each([true, '1'])(
'rejects non-numeric role JSON at the HTTP validation boundary: %p',
async (role) => {
await request(app.getHttpServer())
.patch('/api/v1/admin/users/2/role')
.set('Authorization', 'Bearer admin')
.send({ role })
.expect(422);
expect(adminService.updateRole).not.toHaveBeenCalled();
},
);
it('strips public player assignment input from registration', async () => {
await request(app.getHttpServer())
.post('/api/v1/auth/email/register')
.send({
email: 'new@example.com',
password: 'password',
firstName: 'New',
lastName: 'User',
linkPlayerId: 101,
})
.expect(201);
expect(authService.register).toHaveBeenCalledWith({
email: 'new@example.com',
password: 'password',
firstName: 'New',
lastName: 'User',
});
});
it('restricts team bootstrap to self and serializes only the safe projection', async () => {
await request(app.getHttpServer())
.get('/api/v1/users/8/teams')
.set('Authorization', 'Bearer user')
.expect(403);
const response = await request(app.getHttpServer())
.get('/api/v1/users/7/teams')
.set('Authorization', 'Bearer user')
.expect(200);
expect(response.body).toEqual([
{
id: 11,
firstName: 'Pat',
lastName: 'Player',
team: { id: 4, name: 'Alpha' },
},
]);
expect(JSON.stringify(response.body)).not.toMatch(
/provider|socialId|password|hash|balance|createdAt|updatedAt/i,
);
});
});

View File

@@ -0,0 +1,330 @@
import { ConflictException, ForbiddenException } from '@nestjs/common';
import { RoleEnum } from '../roles/roles.enum';
import { StatusEnum } from '../statuses/statuses.enum';
import { AdminUsersService } from './admin-users.service';
describe('AdminUsersService', () => {
const team = { id: 10, name: 'Alpha', alias: 'alpha' };
const teamRole = { id: 1, name: 'Player' };
const actorId = 1;
let target: any;
let activeAdmins: any[];
let player: any;
let playerRows: any[];
let playerTotal: number;
let lockQuery: any;
let lockedUserQuery: any;
let playerQuery: any;
let lockedPlayerQuery: any;
let userRepository: any;
let playerRepository: any;
let manager: any;
let dataSource: any;
let logger: any;
let service: AdminUsersService;
beforeEach(() => {
target = user(2, RoleEnum.user, StatusEnum.active);
activeAdmins = [user(actorId, RoleEnum.admin, StatusEnum.active)];
player = assignment(101, null);
playerRows = [];
playerTotal = 0;
lockQuery = chain({ getMany: jest.fn(() => activeAdmins) });
lockedUserQuery = chain({ getOne: jest.fn(() => target) });
playerQuery = chain({
getRawMany: jest.fn(() => playerRows),
getCount: jest.fn(() => playerTotal),
});
lockedPlayerQuery = chain({ getOne: jest.fn(() => player) });
userRepository = {
createQueryBuilder: jest.fn((alias: string) =>
alias === 'lockedUser' ? lockedUserQuery : lockQuery,
),
findOne: jest.fn(() => target),
save: jest.fn((value) => Promise.resolve(value)),
};
playerRepository = {
createQueryBuilder: jest.fn((alias: string) =>
alias === 'lockedPlayer' ? lockedPlayerQuery : playerQuery,
),
findOne: jest.fn(() => player),
save: jest.fn((value) => Promise.resolve(value)),
};
manager = {
getRepository: jest.fn((entity) =>
entity.name === 'User' ? userRepository : playerRepository,
),
};
dataSource = {
manager,
transaction: jest.fn((work) => work(manager)),
};
logger = { info: jest.fn() };
service = new AdminUsersService(dataSource, logger);
});
it('updates only names and returns an explicit Task 1-compatible safe admin summary', async () => {
target.players = [assignment(101, target)];
const result = await service.updateProfile(actorId, target.id, {
firstName: 'New',
lastName: 'Name',
});
expect(userRepository.save).toHaveBeenCalledWith(
expect.objectContaining({ firstName: 'New', lastName: 'Name' }),
);
expect(result).toEqual({
id: 2,
firstName: 'New',
lastName: 'Name',
email: 'user-2@example.com',
role: { id: RoleEnum.user, name: 'User' },
status: { id: StatusEnum.active, name: 'Active' },
assignments: [playerSummary(101)],
});
expect(result).not.toHaveProperty('password');
expect(result).not.toHaveProperty('hash');
expect(result).not.toHaveProperty('socialId');
expect(logger.info).toHaveBeenCalledWith(
{
event: 'admin_user_profile_update',
details: 'targetUserId=2',
userId: actorId,
},
manager,
);
expect(lockedUserQuery.setLock).toHaveBeenCalledWith(
'pessimistic_write',
undefined,
['lockedUser'],
);
expect(lockedUserQuery.leftJoinAndSelect).toHaveBeenCalledTimes(2);
});
it('rejects self-demotion and self-deactivation before saving', async () => {
target = user(actorId, RoleEnum.admin, StatusEnum.active);
await expect(
service.updateRole(actorId, actorId, RoleEnum.user),
).rejects.toBeInstanceOf(ForbiddenException);
await expect(
service.updateStatus(actorId, actorId, StatusEnum.inactive),
).rejects.toBeInstanceOf(ForbiddenException);
expect(userRepository.save).not.toHaveBeenCalled();
});
it('locks the active-admin set and rejects loss of the last active admin', async () => {
target = user(2, RoleEnum.admin, StatusEnum.active);
activeAdmins = [target];
await expect(
service.updateRole(actorId, target.id, RoleEnum.user),
).rejects.toBeInstanceOf(ConflictException);
expect(lockQuery.setLock).toHaveBeenCalledWith(
'pessimistic_write',
undefined,
['user'],
);
expect(dataSource.transaction).toHaveBeenCalledTimes(1);
expect(userRepository.save).not.toHaveBeenCalled();
});
it('allows a locked role change when another active admin remains', async () => {
target = user(2, RoleEnum.admin, StatusEnum.active);
activeAdmins = [target, user(3, RoleEnum.admin, StatusEnum.active)];
const result = await service.updateRole(actorId, target.id, RoleEnum.user);
expect(target.role).toEqual({ id: RoleEnum.user });
expect(result.role).toEqual({ id: RoleEnum.user, name: undefined });
expect(logger.info).toHaveBeenCalledWith(
{
event: 'admin_user_role_update',
details: 'targetUserId=2 roleId=2',
userId: actorId,
},
manager,
);
});
it('deactivates under the same lock without changing player assignments', async () => {
target = user(2, RoleEnum.user, StatusEnum.active);
target.players = [assignment(101, target)];
const result = await service.updateStatus(
actorId,
target.id,
StatusEnum.inactive,
);
expect(target.status).toEqual({ id: StatusEnum.inactive });
expect(target.hash).toBeNull();
expect(result.assignments).toEqual([playerSummary(101)]);
expect(playerRepository.save).not.toHaveBeenCalled();
});
it('returns an explicit paginated player projection with safe current-user summaries', async () => {
playerRows = [
{
player_id: 101,
first_name: 'Pat',
last_name: 'Player',
active: 1,
team_id: 10,
team_name: 'Alpha',
team_alias: 'alpha',
user_id: 2,
user_first_name: 'Target',
user_last_name: 'User',
status_id: StatusEnum.active,
status_name: 'Active',
},
];
playerTotal = 1;
const result = await service.findPlayers({
search: 'pat',
teamId: 10,
assignment: 'assigned',
page: 1,
limit: 20,
});
expect(result).toEqual({
data: [
{
id: 101,
firstName: 'Pat',
lastName: 'Player',
active: true,
team,
currentUser: {
id: 2,
firstName: 'Target',
lastName: 'User',
status: { id: StatusEnum.active, name: 'Active' },
},
},
],
page: 1,
limit: 20,
total: 1,
hasNextPage: false,
});
expect(playerQuery.andWhere).toHaveBeenCalledWith(
'player.teamId = :teamId',
{
teamId: 10,
},
);
expect(playerQuery.andWhere).toHaveBeenCalledWith(
'player.userId IS NOT NULL',
);
});
it('atomically reassigns a locked player and returns the refreshed target summary', async () => {
const previous = user(9, RoleEnum.user, StatusEnum.active);
player = assignment(101, previous);
target.players = [player];
const result = await service.assignPlayer(actorId, target.id, player.id);
expect(dataSource.transaction).toHaveBeenCalledTimes(1);
expect(lockedPlayerQuery.setLock).toHaveBeenCalledWith('pessimistic_write');
expect(player.user).toBe(target);
expect(playerRepository.save).toHaveBeenCalledWith(player);
expect(result.id).toBe(target.id);
expect(logger.info).toHaveBeenCalledWith(
{
event: 'admin_player_assign',
details: 'targetUserId=2 playerId=101 previousUserId=9',
userId: actorId,
},
manager,
);
});
it('unlinks only when the locked player is currently linked to the target user', async () => {
player = assignment(101, user(9, RoleEnum.user, StatusEnum.active));
await expect(
service.unlinkPlayer(actorId, target.id, player.id),
).rejects.toBeInstanceOf(ConflictException);
expect(playerRepository.save).not.toHaveBeenCalled();
player.user = target;
player.userId = target.id;
await service.unlinkPlayer(actorId, target.id, player.id);
expect(player.user).toBeNull();
expect(playerRepository.save).toHaveBeenCalledWith(player);
});
function user(id: number, roleId: RoleEnum, statusId: StatusEnum) {
return {
id,
firstName: id === 2 ? 'Target' : 'Admin',
lastName: id === 2 ? 'User' : String(id),
email: `user-${id}@example.com`,
password: `password-${id}`,
hash: `hash-${id}`,
socialId: `social-${id}`,
role: {
id: roleId,
name: roleId === RoleEnum.admin ? 'Admin' : 'User',
},
status: {
id: statusId,
name: statusId === StatusEnum.active ? 'Active' : 'Inactive',
},
players: [],
};
}
function assignment(id: number, assignedUser: any) {
return {
id,
firstName: 'Pat',
lastName: 'Player',
active: true,
team,
teamRole,
user: assignedUser,
userId: assignedUser?.id ?? null,
};
}
function playerSummary(id: number) {
return {
id,
firstName: 'Pat',
lastName: 'Player',
active: true,
team,
teamRole,
};
}
function chain(overrides: Record<string, jest.Mock>) {
const query: Record<string, jest.Mock> = {};
[
'innerJoin',
'innerJoinAndSelect',
'leftJoinAndSelect',
'leftJoin',
'select',
'where',
'andWhere',
'setParameter',
'setParameters',
'setLock',
'orderBy',
'offset',
'limit',
].forEach((method) => {
query[method] = jest.fn(() => query);
});
return Object.assign(query, overrides);
}
});

View File

@@ -0,0 +1,381 @@
import {
ConflictException,
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { Brackets, DataSource, EntityManager, Repository } from 'typeorm';
import { LoggingService } from '../database/logging/logging.service';
import { Player } from '../players/entities/player.entity';
import { Role } from '../roles/entities/role.entity';
import { RoleEnum } from '../roles/roles.enum';
import { Status } from '../statuses/entities/status.entity';
import { StatusEnum } from '../statuses/statuses.enum';
import {
AdminPlayerPageDto,
AdminPlayerSummaryDto,
} from './dto/admin-player-response.dto';
import { AdminPlayerQueryDto, AdminUserProfileDto } from './dto/admin-user.dto';
import { AdminUserDirectorySummaryDto } from './dto/user-directory-response.dto';
import { User } from './entities/user.entity';
type PlayerSearchRow = {
player_id: number | string;
first_name: string;
last_name: string;
active: boolean;
team_id: number | string;
team_name: string;
team_alias: string;
user_id: number | string | null;
user_first_name: string | null;
user_last_name: string | null;
status_id: number | string | null;
status_name: string | null;
};
@Injectable()
export class AdminUsersService {
constructor(
private readonly dataSource: DataSource,
private readonly logger: LoggingService,
) {}
async updateProfile(
actorId: number,
targetUserId: number,
dto: AdminUserProfileDto,
): Promise<AdminUserDirectorySummaryDto> {
const summary = await this.dataSource.transaction(async (manager) => {
const repository = manager.getRepository(User);
const target = await this.findLockedUser(repository, targetUserId);
if (dto.firstName !== undefined) target.firstName = dto.firstName;
if (dto.lastName !== undefined) target.lastName = dto.lastName;
await repository.save(target);
const summary = await this.findAdminSummary(manager, targetUserId);
await this.log(
manager,
'admin_user_profile_update',
actorId,
`targetUserId=${targetUserId}`,
);
return summary;
});
return summary;
}
async updateRole(
actorId: number,
targetUserId: number,
roleId: RoleEnum,
): Promise<AdminUserDirectorySummaryDto> {
const summary = await this.dataSource.transaction(async (manager) => {
const activeAdmins = await this.lockActiveAdmins(manager);
const repository = manager.getRepository(User);
const target = await this.findLockedUser(repository, targetUserId);
const isDemotion =
target.role?.id === RoleEnum.admin && roleId !== RoleEnum.admin;
if (actorId === targetUserId && isDemotion) {
throw new ForbiddenException('Administrators cannot demote themselves');
}
if (
isDemotion &&
target.status?.id === StatusEnum.active &&
activeAdmins.length <= 1
) {
throw new ConflictException('At least one active admin must remain');
}
target.role = { id: roleId } as Role;
await repository.save(target);
const summary = await this.findAdminSummary(manager, targetUserId);
await this.log(
manager,
'admin_user_role_update',
actorId,
`targetUserId=${targetUserId} roleId=${roleId}`,
);
return summary;
});
return summary;
}
async updateStatus(
actorId: number,
targetUserId: number,
statusId: StatusEnum,
): Promise<AdminUserDirectorySummaryDto> {
const summary = await this.dataSource.transaction(async (manager) => {
const activeAdmins = await this.lockActiveAdmins(manager);
const repository = manager.getRepository(User);
const target = await this.findLockedUser(repository, targetUserId);
const isDeactivation =
target.status?.id === StatusEnum.active &&
statusId === StatusEnum.inactive;
if (actorId === targetUserId && isDeactivation) {
throw new ForbiddenException(
'Administrators cannot deactivate themselves',
);
}
if (
isDeactivation &&
target.role?.id === RoleEnum.admin &&
activeAdmins.length <= 1
) {
throw new ConflictException('At least one active admin must remain');
}
target.status = { id: statusId } as Status;
if (statusId === StatusEnum.inactive) target.hash = null;
await repository.save(target);
const summary = await this.findAdminSummary(manager, targetUserId);
await this.log(
manager,
'admin_user_status_update',
actorId,
`targetUserId=${targetUserId} statusId=${statusId}`,
);
return summary;
});
return summary;
}
async findPlayers(query: AdminPlayerQueryDto): Promise<AdminPlayerPageDto> {
const page = query.page ?? 1;
const limit = query.limit ?? 20;
const repository = this.dataSource.manager.getRepository(Player);
const builder = repository
.createQueryBuilder('player')
.innerJoin('player.team', 'team')
.leftJoin('player.user', 'currentUser')
.leftJoin('currentUser.status', 'currentStatus');
const term = query.search?.trim().toLocaleLowerCase();
if (term) {
builder
.andWhere(
new Brackets((where) =>
where
.where('LOWER(player.firstName) LIKE :playerSearch')
.orWhere('LOWER(player.lastName) LIKE :playerSearch')
.orWhere('LOWER(team.name) LIKE :playerSearch'),
),
)
.setParameter('playerSearch', `%${term}%`);
}
if (query.teamId !== undefined) {
builder.andWhere('player.teamId = :teamId', { teamId: query.teamId });
}
if (query.assignment === 'assigned') {
builder.andWhere('player.userId IS NOT NULL');
} else if (query.assignment === 'unassigned') {
builder.andWhere('player.userId IS NULL');
}
const total = await builder.getCount();
const rows = await builder
.select([
'player.id AS player_id',
'player.firstName AS first_name',
'player.lastName AS last_name',
'player.active AS active',
'team.id AS team_id',
'team.name AS team_name',
'team.alias AS team_alias',
'currentUser.id AS user_id',
'currentUser.firstName AS user_first_name',
'currentUser.lastName AS user_last_name',
'currentStatus.id AS status_id',
'currentStatus.name AS status_name',
])
.orderBy('player.id', 'ASC')
.offset((page - 1) * limit)
.limit(limit)
.getRawMany<PlayerSearchRow>();
return {
data: rows.map((row) => this.mapPlayerSearchRow(row)),
page,
limit,
total,
hasNextPage: page * limit < total,
};
}
async assignPlayer(
actorId: number,
targetUserId: number,
playerId: number,
): Promise<AdminUserDirectorySummaryDto> {
let previousUserId: number | null = null;
const summary = await this.dataSource.transaction(async (manager) => {
const userRepository = manager.getRepository(User);
const playerRepository = manager.getRepository(Player);
const target = await userRepository.findOne({
where: { id: targetUserId },
});
if (!target) throw new NotFoundException('User not found');
const lockedPlayer = await this.findLockedPlayer(
playerRepository,
playerId,
);
previousUserId = lockedPlayer.userId ?? null;
lockedPlayer.user = target;
await playerRepository.save(lockedPlayer);
const summary = await this.findAdminSummary(manager, targetUserId);
await this.log(
manager,
'admin_player_assign',
actorId,
`targetUserId=${targetUserId} playerId=${playerId} previousUserId=${
previousUserId ?? 'none'
}`,
);
return summary;
});
return summary;
}
async unlinkPlayer(
actorId: number,
targetUserId: number,
playerId: number,
): Promise<AdminUserDirectorySummaryDto> {
const summary = await this.dataSource.transaction(async (manager) => {
const playerRepository = manager.getRepository(Player);
const lockedPlayer = await this.findLockedPlayer(
playerRepository,
playerId,
);
if (lockedPlayer.userId !== targetUserId) {
throw new ConflictException('Player is not assigned to this user');
}
lockedPlayer.user = null;
await playerRepository.save(lockedPlayer);
const summary = await this.findAdminSummary(manager, targetUserId);
await this.log(
manager,
'admin_player_unlink',
actorId,
`targetUserId=${targetUserId} playerId=${playerId}`,
);
return summary;
});
return summary;
}
private lockActiveAdmins(manager: EntityManager): Promise<User[]> {
return manager
.getRepository(User)
.createQueryBuilder('user')
.innerJoinAndSelect('user.role', 'role')
.innerJoinAndSelect('user.status', 'status')
.where('role.id = :adminRole', { adminRole: RoleEnum.admin })
.andWhere('status.id = :activeStatus', {
activeStatus: StatusEnum.active,
})
.setLock('pessimistic_write', undefined, ['user'])
.orderBy('user.id', 'ASC')
.getMany();
}
private async findLockedUser(
repository: Repository<User>,
userId: number,
): Promise<User> {
const user = await repository
.createQueryBuilder('lockedUser')
.leftJoinAndSelect('lockedUser.role', 'role')
.leftJoinAndSelect('lockedUser.status', 'status')
.where('lockedUser.id = :userId', { userId })
.setLock('pessimistic_write', undefined, ['lockedUser'])
.getOne();
if (!user) throw new NotFoundException('User not found');
return user;
}
private async findLockedPlayer(
repository: Repository<Player>,
playerId: number,
): Promise<Player> {
const player = await repository
.createQueryBuilder('lockedPlayer')
.where('lockedPlayer.id = :playerId', { playerId })
.setLock('pessimistic_write')
.getOne();
if (!player) throw new NotFoundException('Player not found');
return player;
}
private async findAdminSummary(
manager: EntityManager,
userId: number,
): Promise<AdminUserDirectorySummaryDto> {
const user = await manager.getRepository(User).findOne({
where: { id: userId },
relations: {
role: true,
status: true,
players: { team: true, teamRole: true },
},
});
if (!user) throw new NotFoundException('User not found');
return {
id: user.id,
firstName: user.firstName,
lastName: user.lastName,
email: user.email,
role: user.role ? { id: user.role.id, name: user.role.name } : null,
status: user.status
? { id: user.status.id, name: user.status.name }
: null,
assignments: (user.players ?? []).map((player) => ({
id: player.id,
firstName: player.firstName,
lastName: player.lastName,
active: player.active,
team: {
id: player.team.id,
name: player.team.name,
alias: player.team.alias,
},
teamRole: player.teamRole
? { id: player.teamRole.id, name: player.teamRole.name }
: null,
})),
};
}
private mapPlayerSearchRow(row: PlayerSearchRow): AdminPlayerSummaryDto {
return {
id: Number(row.player_id),
firstName: row.first_name,
lastName: row.last_name,
active: Boolean(row.active),
team: {
id: Number(row.team_id),
name: row.team_name,
alias: row.team_alias,
},
currentUser:
row.user_id === null
? null
: {
id: Number(row.user_id),
firstName: row.user_first_name,
lastName: row.user_last_name,
status:
row.status_id === null
? null
: {
id: Number(row.status_id),
name: row.status_name ?? undefined,
},
},
};
}
private log(
manager: EntityManager,
event: Parameters<LoggingService['info']>[0]['event'],
userId: number,
details: string,
) {
return this.logger.info({ event, userId, details }, manager);
}
}

View File

@@ -0,0 +1,31 @@
import { UserDirectoryReferenceDto } from './user-directory-response.dto';
export class AdminPlayerTeamDto {
id: number;
name: string;
alias: string;
}
export class AdminPlayerCurrentUserDto {
id: number;
firstName: string | null;
lastName: string | null;
status: UserDirectoryReferenceDto | null;
}
export class AdminPlayerSummaryDto {
id: number;
firstName: string;
lastName: string;
active: boolean;
team: AdminPlayerTeamDto;
currentUser: AdminPlayerCurrentUserDto | null;
}
export class AdminPlayerPageDto {
data: AdminPlayerSummaryDto[];
page: number;
limit: number;
total: number;
hasNextPage: boolean;
}

View File

@@ -0,0 +1,68 @@
import { Type } from 'class-transformer';
import {
IsIn,
IsInt,
IsOptional,
IsString,
Max,
MaxLength,
Min,
ValidateIf,
} from 'class-validator';
import { RoleEnum } from '../../roles/roles.enum';
import { StatusEnum } from '../../statuses/statuses.enum';
export class AdminUserProfileDto {
@ValidateIf((_, value) => value !== undefined)
@IsString()
@MaxLength(255)
firstName?: string;
@ValidateIf((_, value) => value !== undefined)
@IsString()
@MaxLength(255)
lastName?: string;
}
export class AdminUserRoleDto {
@IsInt()
@IsIn([RoleEnum.admin, RoleEnum.user])
role: RoleEnum;
}
export class AdminUserStatusDto {
@IsInt()
@IsIn([StatusEnum.active, StatusEnum.inactive])
status: StatusEnum;
}
export type AdminPlayerAssignmentFilter = 'all' | 'assigned' | 'unassigned';
export class AdminPlayerQueryDto {
@IsOptional()
@IsString()
search?: string;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
teamId?: number;
@IsOptional()
@IsIn(['all', 'assigned', 'unassigned'])
assignment: AdminPlayerAssignmentFilter = 'all';
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
page = 1;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(50)
limit = 20;
}

View File

@@ -1,13 +1,7 @@
import { Transform } from 'class-transformer';
import { ApiProperty } from '@nestjs/swagger';
import { Role } from '../../roles/entities/role.entity';
import {
IsEmail,
IsNotEmpty,
IsOptional,
MinLength,
Validate,
} from 'class-validator';
import { IsEmail, IsNotEmpty, MinLength, Validate } from 'class-validator';
import { Status } from '../../statuses/entities/status.entity';
import { IsNotExist } from '../../utils/validators/is-not-exists.validator';
import { IsExist } from '../../utils/validators/is-exists.validator';
@@ -51,6 +45,4 @@ export class CreateUserDto {
status?: Status;
hash?: string | null;
linkPlayerId?: number | null;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -0,0 +1,11 @@
export class UserTeamReferenceDto {
id: number;
name: string;
}
export class UserTeamPlayerDto {
id: number;
firstName: string;
lastName: string;
team: UserTeamReferenceDto;
}

View File

@@ -0,0 +1,25 @@
import { UsersController } from './users.controller';
import { ForbiddenException } from '@nestjs/common';
describe('UsersController admin mutation isolation', () => {
it('does not expose generic create, update, or delete handlers that bypass safeguards', () => {
expect(UsersController.prototype).not.toHaveProperty('create');
expect(UsersController.prototype).not.toHaveProperty('update');
expect(UsersController.prototype).not.toHaveProperty('remove');
expect(UsersController.prototype).not.toHaveProperty('findAll');
expect(UsersController.prototype).not.toHaveProperty('findOne');
});
it('restricts team bootstrap to the authenticated user id', async () => {
const service = { findMyTeams: jest.fn(() => []) };
const controller = new UsersController(service as any);
const request = { user: { id: 7, role: { id: 2 } } };
expect(() => (controller as any).findTeamsOfPlayer(request, 8)).toThrow(
ForbiddenException,
);
await (controller as any).findTeamsOfPlayer(request, 7);
expect(service.findMyTeams).toHaveBeenCalledWith(7);
});
});

View File

@@ -1,27 +1,23 @@
import {
Controller,
Get,
Post,
Body,
Patch,
Param,
Delete,
UseGuards,
Query,
DefaultValuePipe,
ParseIntPipe,
HttpStatus,
HttpCode,
Request,
ForbiddenException,
ParseIntPipe,
} from '@nestjs/common';
import { UsersService } from './users.service';
import { CreateUserDto } from './dto/create-user.dto';
import { UpdateUserDto } from './dto/update-user.dto';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { Roles } from 'src/roles/roles.decorator';
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)
@@ -33,57 +29,26 @@ import { infinityPagination } from 'src/utils/infinity-pagination';
export class UsersController {
constructor(private readonly usersService: UsersService) {}
@Roles([RoleEnum.admin])
@Post()
@HttpCode(HttpStatus.CREATED)
create(@Body() createProfileDto: CreateUserDto) {
return this.usersService.create(createProfileDto);
}
@Roles([RoleEnum.admin])
@Get()
@Roles([RoleEnum.user, RoleEnum.admin])
@Get('directory')
@HttpCode(HttpStatus.OK)
async findAll(
@Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number,
@Query('limit', new DefaultValuePipe(10), ParseIntPipe) limit: number,
findDirectory(
@Request() request: { user: Pick<User, 'id' | 'role'> },
@Query() query: UserDirectoryQueryDto,
) {
if (limit > 50) {
limit = 50;
}
return infinityPagination(
await this.usersService.findManyWithPagination({
page,
limit,
}),
{ page, limit },
);
}
@Roles([RoleEnum.admin])
@Get(':id')
@HttpCode(HttpStatus.OK)
findOne(@Param('id') id: string) {
return this.usersService.findOne({ id: +id });
return this.usersService.findDirectory(request.user, query);
}
@Roles([RoleEnum.user, RoleEnum.admin])
@Get(':id/teams')
@HttpCode(HttpStatus.OK)
findTeamsOfPlayer(@Param('id') id: string) {
return this.usersService.findTeams({ id: +id });
}
@Roles([RoleEnum.admin])
@Patch(':id')
@HttpCode(HttpStatus.OK)
update(@Param('id') id: number, @Body() updateProfileDto: UpdateUserDto) {
return this.usersService.update(id, updateProfileDto);
}
@Roles([RoleEnum.admin])
@Delete(':id')
remove(@Param('id') id: number) {
return this.usersService.softDelete(id);
findTeamsOfPlayer(
@Request() request: { user: Pick<User, 'id' | 'role'> },
@Param('id', ParseIntPipe) id: number,
) {
if (request.user.id !== id) {
throw new ForbiddenException('Users may only load their own teams');
}
return this.usersService.findMyTeams(id);
}
}

View File

@@ -7,11 +7,14 @@ import { IsExist } from 'src/utils/validators/is-exists.validator';
import { IsNotExist } from 'src/utils/validators/is-not-exists.validator';
import { Team } from 'src/teams/entities/team.entity';
import { Player } from 'src/players/entities/player.entity';
import { LoggingModule } from 'src/database/logging/logging.module';
import { AdminUsersController } from './admin-users.controller';
import { AdminUsersService } from './admin-users.service';
@Module({
imports: [TypeOrmModule.forFeature([User, Team, Player])],
controllers: [UsersController],
providers: [IsExist, IsNotExist, UsersService],
imports: [TypeOrmModule.forFeature([User, Team, Player]), LoggingModule],
controllers: [UsersController, AdminUsersController],
providers: [IsExist, IsNotExist, UsersService, AdminUsersService],
exports: [UsersService],
})
export class UsersModule {}

View File

@@ -0,0 +1,390 @@
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;
let rejectActiveSharedTeamPredicate: boolean;
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();
rejectActiveSharedTeamPredicate = false;
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 () => {
rejectActiveSharedTeamPredicate = true;
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: (predicate: string) => {
if (
rejectActiveSharedTeamPredicate &&
/requesterPlayer\.active/i.test(predicate)
) {
throw new Error(
'shared-team membership must not filter inactive requester assignments',
);
}
return 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;
}
});

View File

@@ -3,10 +3,20 @@ import { InjectRepository } from '@nestjs/typeorm';
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 { Brackets, Repository, SelectQueryBuilder } 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,
} from './dto/user-directory-response.dto';
import { User } from './entities/user.entity';
import { UserTeamPlayerDto } from './dto/user-team-response.dto';
@Injectable()
export class UsersService {
@@ -30,6 +40,61 @@ export class UsersService {
});
}
async findDirectory(
requester: Pick<User, 'id' | 'role'>,
query: UserDirectoryQueryDto,
): Promise<UserDirectoryPageDto> {
const isAdmin = requester.role?.id === RoleEnum.admin;
const page = query.page ?? 1;
const limit = query.limit ?? 20;
const sharedTeamsQuery = isAdmin
? undefined
: this.createSharedTeamsQuery(requester.id);
const directoryQuery = this.createDirectoryUsersQuery(
isAdmin,
sharedTeamsQuery,
query.search,
);
const totalResult = await directoryQuery
.clone()
.select('COUNT(DISTINCT user.id)', 'total')
.getRawOne<{ total: string }>();
const userRows = await directoryQuery
.select(this.directoryUserSelects(isAdmin))
.distinct(true)
.orderBy('user.id', 'ASC')
.offset((page - 1) * limit)
.limit(limit)
.getRawMany<DirectoryUserRow>();
const assignments = await this.findDirectoryAssignments(
userRows.map((user) => Number(user.user_id)),
isAdmin,
sharedTeamsQuery,
);
const assignmentsByUserId = assignments.reduce((byUserId, assignment) => {
const userAssignments = byUserId.get(Number(assignment.user_id)) ?? [];
userAssignments.push(assignment);
byUserId.set(Number(assignment.user_id), userAssignments);
return byUserId;
}, new Map<number, DirectoryAssignmentRow[]>());
const total = Number(totalResult?.total ?? 0);
const data = userRows.map((user) =>
this.mapDirectoryUser(
user,
assignmentsByUserId.get(Number(user.user_id)) ?? [],
isAdmin,
),
);
return {
data,
page,
limit,
total,
hasNextPage: page * limit < total,
};
}
findOne(fields: EntityCondition<User>) {
return this.usersRepository.findOne({
where: fields,
@@ -49,32 +114,232 @@ export class UsersService {
await this.usersRepository.softDelete(id);
}
async findTeams(fields: EntityCondition<User>) {
const user = await this.findOne(fields);
if (!user) {
async findMyTeams(userId: number): Promise<UserTeamPlayerDto[]> {
const rows = await this.playersRepository
.createQueryBuilder('player')
.innerJoin('player.team', 'team')
.select([
'player.id AS player_id',
'player.firstName AS first_name',
'player.lastName AS last_name',
'team.id AS team_id',
'team.name AS team_name',
])
.where('player.userId = :userId', { userId })
.orderBy('player.id', 'ASC')
.getRawMany<{
player_id: number | string;
first_name: string;
last_name: string;
team_id: number | string;
team_name: string;
}>();
return rows.map((row) => ({
id: Number(row.player_id),
firstName: row.first_name,
lastName: row.last_name,
team: { id: Number(row.team_id), name: row.team_name },
}));
}
private createSharedTeamsQuery(requesterId: number) {
return this.playersRepository
.createQueryBuilder('requesterPlayer')
.select('requesterPlayer.teamId')
.where('requesterPlayer.userId = :requesterId', { requesterId });
}
private createDirectoryUsersQuery(
isAdmin: boolean,
sharedTeamsQuery: SelectQueryBuilder<Player> | undefined,
search: string | undefined,
) {
const query = this.usersRepository
.createQueryBuilder('user')
.leftJoin('user.status', 'status')
.leftJoin('user.role', 'role');
if (!isAdmin && sharedTeamsQuery) {
query
.innerJoin(
'user.players',
'visibleAssignment',
`visibleAssignment.teamId IN (${sharedTeamsQuery.getQuery()})`,
)
.setParameters(sharedTeamsQuery.getParameters());
}
const term = search?.trim().toLocaleLowerCase();
if (!term) {
return query;
}
const assignmentAlias = isAdmin ? 'searchAssignment' : 'visibleAssignment';
if (isAdmin) {
query.leftJoin('user.players', assignmentAlias);
}
const searchColumns = [
'user.firstName',
'user.lastName',
`${assignmentAlias}.firstName`,
`${assignmentAlias}.lastName`,
...(isAdmin ? ['user.email'] : []),
];
query.andWhere(
new Brackets((where) => {
searchColumns.forEach((column, index) => {
const expression = `LOWER(${column}) LIKE :directorySearch`;
if (index === 0) {
where.where(expression);
} else {
where.orWhere(expression);
}
});
}),
);
return query.setParameter('directorySearch', `%${term}%`);
}
private directoryUserSelects(isAdmin: boolean): string[] {
return [
'user.id AS user_id',
'user.firstName AS first_name',
'user.lastName AS last_name',
'status.id AS status_id',
'status.name AS status_name',
...(isAdmin
? [
'user.email AS email',
'role.id AS role_id',
'role.name AS role_name',
]
: []),
];
}
private async findDirectoryAssignments(
userIds: number[],
isAdmin: boolean,
sharedTeamsQuery: SelectQueryBuilder<Player> | undefined,
): Promise<DirectoryAssignmentRow[]> {
if (userIds.length === 0) {
return [];
}
const players = await this.playersRepository.find({
where: {
user: {
id: user.id,
},
},
relations: ['team'],
});
return players;
const query = this.playersRepository
.createQueryBuilder('player')
.innerJoin('player.team', 'team')
.leftJoin('player.teamRole', 'teamRole')
.select([
'player.id AS player_id',
'player.userId AS user_id',
'player.firstName AS first_name',
'player.lastName AS last_name',
'player.active AS active',
'team.id AS team_id',
'team.name AS team_name',
'team.alias AS team_alias',
'teamRole.id AS team_role_id',
'teamRole.name AS team_role_name',
])
.where('player.userId IN (:...userIds)', { userIds })
.orderBy('player.id', 'ASC');
if (!isAdmin && sharedTeamsQuery) {
query
.andWhere(`player.teamId IN (${sharedTeamsQuery.getQuery()})`)
.setParameters(sharedTeamsQuery.getParameters());
}
return query.getRawMany<DirectoryAssignmentRow>();
}
async linkPlayerToUserId(user: User, playerId: number): Promise<boolean> {
return new Promise<boolean>(async (resolve) => {
const player = await this.playersRepository.findOneByOrFail({
id: playerId,
});
private mapDirectoryUser(
user: DirectoryUserRow,
assignments: DirectoryAssignmentRow[],
includeAdminFields: boolean,
): UserDirectorySummaryDto | AdminUserDirectorySummaryDto {
const summary: UserDirectorySummaryDto = {
id: Number(user.user_id),
firstName: user.first_name,
lastName: user.last_name,
status: this.mapDirectoryReference(user.status_id, user.status_name),
assignments: assignments.map((assignment) =>
this.mapDirectoryAssignment(assignment),
),
};
player.user = user;
await this.playersRepository.save(player);
return resolve(true);
});
if (!includeAdminFields) {
return summary;
}
return {
...summary,
email: user.email,
role: this.mapDirectoryReference(user.role_id, user.role_name),
};
}
private mapDirectoryAssignment(
player: DirectoryAssignmentRow,
): UserDirectoryAssignmentDto {
return {
id: Number(player.player_id),
firstName: player.first_name,
lastName: player.last_name,
active: this.asBoolean(player.active),
team: {
id: Number(player.team_id),
name: player.team_name,
alias: player.team_alias,
},
teamRole: this.mapDirectoryReference(
player.team_role_id,
player.team_role_name,
),
};
}
private asBoolean(value: boolean | number | string): boolean {
return value === true || value === 1 || value === '1';
}
private mapDirectoryReference(
id: number | string | null,
name: string | null,
): UserDirectoryReferenceDto | null {
if (id == null) {
return null;
}
return {
id: Number(id),
name: name ?? undefined,
};
}
}
interface DirectoryUserRow {
user_id: number | string;
first_name: string | null;
last_name: string | null;
status_id: number | string | null;
status_name: string | null;
email?: string | null;
role_id?: number | string | null;
role_name?: string | null;
}
interface DirectoryAssignmentRow {
player_id: number | string;
user_id: number | string;
first_name: string;
last_name: string;
active: boolean | number | string;
team_id: number | string;
team_name: string;
team_alias: string;
team_role_id: number | string | null;
team_role_name: string | null;
}

View File

@@ -0,0 +1,49 @@
import { UsersService } from './users.service';
describe('UsersService safe current-user teams', () => {
it('returns only the player and team fields required by team bootstrap', async () => {
const rows = [
{
player_id: 11,
first_name: 'Pat',
last_name: 'Player',
team_id: 4,
team_name: 'Alpha',
user_id: 7,
provider: 'google',
social_id: 'must-not-leak',
balance: '100.00',
},
];
const query = chain({ getRawMany: jest.fn(() => rows) });
const playersRepository = {
createQueryBuilder: jest.fn(() => query),
};
const service = new UsersService({} as any, playersRepository as any);
const result = await service.findMyTeams(7);
expect(result).toEqual([
{
id: 11,
firstName: 'Pat',
lastName: 'Player',
team: { id: 4, name: 'Alpha' },
},
]);
expect(JSON.stringify(result)).not.toMatch(
/user|provider|social|role|status|password|hash|balance|createdAt|updatedAt/i,
);
expect(query.where).toHaveBeenCalledWith('player.userId = :userId', {
userId: 7,
});
});
function chain(overrides: Record<string, jest.Mock>) {
const query: Record<string, jest.Mock> = {};
['innerJoin', 'select', 'where', 'orderBy'].forEach((method) => {
query[method] = jest.fn(() => query);
});
return Object.assign(query, overrides);
}
});