feat: secure penalty catalog management
This commit is contained in:
@@ -20,6 +20,9 @@ export type LOGEVENT =
|
|||||||
| 'admin_user_status_update'
|
| 'admin_user_status_update'
|
||||||
| 'admin_player_assign'
|
| 'admin_player_assign'
|
||||||
| 'admin_player_unlink'
|
| 'admin_player_unlink'
|
||||||
|
| 'penalty_catalog_create'
|
||||||
|
| 'penalty_catalog_update'
|
||||||
|
| 'penalty_catalog_delete'
|
||||||
| 'team_create';
|
| 'team_create';
|
||||||
|
|
||||||
export type LOGLEVEL = 'FATAL' | 'ERROR' | 'WARN' | 'INFO' | 'DEBUG' | 'TRACE';
|
export type LOGLEVEL = 'FATAL' | 'ERROR' | 'WARN' | 'INFO' | 'DEBUG' | 'TRACE';
|
||||||
|
|||||||
@@ -1,19 +1,31 @@
|
|||||||
import { ApiProperty } from '@nestjs/swagger';
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
import { IsNotEmpty } from 'class-validator';
|
import { Transform } from 'class-transformer';
|
||||||
import { Team } from 'src/teams/entities/team.entity';
|
import {
|
||||||
|
IsInt,
|
||||||
|
IsNumber,
|
||||||
|
IsString,
|
||||||
|
Length,
|
||||||
|
Max,
|
||||||
|
Min,
|
||||||
|
} from 'class-validator';
|
||||||
|
|
||||||
export class CreatePenaltyDTO {
|
export class CreatePenaltyDTO {
|
||||||
@ApiProperty({ example: 2342 })
|
@ApiProperty({ example: 2342 })
|
||||||
@IsNotEmpty()
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
teamId: number;
|
teamId: number;
|
||||||
|
|
||||||
@ApiProperty({ example: 1 })
|
@ApiProperty({ example: 1 })
|
||||||
@IsNotEmpty()
|
@IsNumber({ maxDecimalPlaces: 2 })
|
||||||
|
@Min(0.01)
|
||||||
|
@Max(10000)
|
||||||
amount: number;
|
amount: number;
|
||||||
|
|
||||||
team?: Team;
|
|
||||||
|
|
||||||
@ApiProperty({ example: 'Zu spät kommen' })
|
@ApiProperty({ example: 'Zu spät kommen' })
|
||||||
@IsNotEmpty()
|
@Transform(({ value }) =>
|
||||||
|
typeof value === 'string' ? value.trim() : value,
|
||||||
|
)
|
||||||
|
@IsString()
|
||||||
|
@Length(1, 120)
|
||||||
description: string;
|
description: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export class PenaltyResponseDTO {
|
||||||
|
id: number;
|
||||||
|
description: string;
|
||||||
|
amount: number;
|
||||||
|
createdAt: Date;
|
||||||
|
}
|
||||||
19
myteamwallet_backend/src/penalty/dto/update-penalty.dto.ts
Normal file
19
myteamwallet_backend/src/penalty/dto/update-penalty.dto.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import { Transform } from 'class-transformer';
|
||||||
|
import { IsNumber, IsString, Length, Max, Min } from 'class-validator';
|
||||||
|
|
||||||
|
export class UpdatePenaltyDTO {
|
||||||
|
@ApiProperty({ example: 'Zu spät kommen' })
|
||||||
|
@Transform(({ value }) =>
|
||||||
|
typeof value === 'string' ? value.trim() : value,
|
||||||
|
)
|
||||||
|
@IsString()
|
||||||
|
@Length(1, 120)
|
||||||
|
description: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 5 })
|
||||||
|
@IsNumber({ maxDecimalPlaces: 2 })
|
||||||
|
@Min(0.01)
|
||||||
|
@Max(10000)
|
||||||
|
amount: number;
|
||||||
|
}
|
||||||
@@ -1,45 +1,62 @@
|
|||||||
import {
|
import {
|
||||||
Body,
|
Body,
|
||||||
Controller,
|
Controller,
|
||||||
|
Delete,
|
||||||
Get,
|
Get,
|
||||||
|
HttpCode,
|
||||||
|
HttpStatus,
|
||||||
Param,
|
Param,
|
||||||
|
ParseIntPipe,
|
||||||
|
Patch,
|
||||||
Post,
|
Post,
|
||||||
Req,
|
Request,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { AuthGuard } from '@nestjs/passport';
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
import { ApiBearerAuth } from '@nestjs/swagger';
|
import { ApiBearerAuth } from '@nestjs/swagger';
|
||||||
import { Roles } from 'src/roles/roles.decorator';
|
|
||||||
import { RolesGuard } from 'src/roles/roles.guard';
|
|
||||||
import { CreatePenaltyDTO } from './dto/create-penalty.dto';
|
import { CreatePenaltyDTO } from './dto/create-penalty.dto';
|
||||||
|
import { UpdatePenaltyDTO } from './dto/update-penalty.dto';
|
||||||
import { PenaltyService } from './penalty.service';
|
import { PenaltyService } from './penalty.service';
|
||||||
|
|
||||||
|
type AuthenticatedRequest = { user: { id: number } };
|
||||||
|
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@Controller({
|
@UseGuards(AuthGuard('jwt'))
|
||||||
path: 'penalty',
|
@Controller({ path: 'penalty', version: '1' })
|
||||||
version: '1',
|
|
||||||
})
|
|
||||||
export class PenaltyController {
|
export class PenaltyController {
|
||||||
constructor(private service: PenaltyService) {}
|
constructor(private readonly service: PenaltyService) {}
|
||||||
|
|
||||||
@Roles([])
|
@Get(':teamId')
|
||||||
@Get()
|
getTeamPenalties(
|
||||||
getIt(@Req() req: any) {
|
@Request() request: AuthenticatedRequest,
|
||||||
const userId = req.user?.id;
|
@Param('teamId', ParseIntPipe) teamId: number,
|
||||||
return this.service.getAll(userId);
|
) {
|
||||||
|
return this.service.getTeamPenalties(request.user.id, teamId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Roles([])
|
|
||||||
@UseGuards(AuthGuard('jwt'), RolesGuard)
|
|
||||||
@Get(':id')
|
|
||||||
getTeams(@Param('id') teamId: string) {
|
|
||||||
return this.service.getTeamPenalties(teamId);
|
|
||||||
}
|
|
||||||
|
|
||||||
@UseGuards(AuthGuard('jwt'), RolesGuard)
|
|
||||||
@Post()
|
@Post()
|
||||||
createPenalty(@Req() req: any, @Body() createPenaltyDto: CreatePenaltyDTO) {
|
createPenalty(
|
||||||
const userId = req.user?.id;
|
@Request() request: AuthenticatedRequest,
|
||||||
return this.service.createPenalty(createPenaltyDto, userId);
|
@Body() dto: CreatePenaltyDTO,
|
||||||
|
) {
|
||||||
|
return this.service.createPenalty(dto, request.user.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':penaltyId')
|
||||||
|
updatePenalty(
|
||||||
|
@Request() request: AuthenticatedRequest,
|
||||||
|
@Param('penaltyId', ParseIntPipe) penaltyId: number,
|
||||||
|
@Body() dto: UpdatePenaltyDTO,
|
||||||
|
) {
|
||||||
|
return this.service.updatePenalty(penaltyId, dto, request.user.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':penaltyId')
|
||||||
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
|
async deletePenalty(
|
||||||
|
@Request() request: AuthenticatedRequest,
|
||||||
|
@Param('penaltyId', ParseIntPipe) penaltyId: number,
|
||||||
|
): Promise<void> {
|
||||||
|
await this.service.deletePenalty(penaltyId, request.user.id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
116
myteamwallet_backend/src/penalty/penalty.http.spec.ts
Normal file
116
myteamwallet_backend/src/penalty/penalty.http.spec.ts
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
import {
|
||||||
|
INestApplication,
|
||||||
|
UnauthorizedException,
|
||||||
|
ValidationPipe,
|
||||||
|
VersioningType,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
|
import { Test } from '@nestjs/testing';
|
||||||
|
import * as request from 'supertest';
|
||||||
|
import validationOptions from '../utils/validation-options';
|
||||||
|
import { PenaltyController } from './penalty.controller';
|
||||||
|
import { PenaltyService } from './penalty.service';
|
||||||
|
|
||||||
|
describe('penalty catalog HTTP boundary', () => {
|
||||||
|
let app: INestApplication;
|
||||||
|
const penalty = {
|
||||||
|
id: 8,
|
||||||
|
description: 'Zu spät',
|
||||||
|
amount: 5,
|
||||||
|
createdAt: '2026-01-02T00:00:00.000Z',
|
||||||
|
};
|
||||||
|
const service = {
|
||||||
|
getTeamPenalties: jest.fn(() => [penalty]),
|
||||||
|
createPenalty: jest.fn(() => penalty),
|
||||||
|
updatePenalty: jest.fn(() => penalty),
|
||||||
|
deletePenalty: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const module = await Test.createTestingModule({
|
||||||
|
controllers: [PenaltyController],
|
||||||
|
providers: [{ provide: PenaltyService, useValue: service }],
|
||||||
|
})
|
||||||
|
.overrideGuard(AuthGuard('jwt'))
|
||||||
|
.useValue({
|
||||||
|
canActivate(context) {
|
||||||
|
const httpRequest = context.switchToHttp().getRequest();
|
||||||
|
if (httpRequest.headers.authorization !== 'Bearer user') {
|
||||||
|
throw new UnauthorizedException();
|
||||||
|
}
|
||||||
|
httpRequest.user = { id: 42, role: { id: 2 } };
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.compile();
|
||||||
|
app = module.createNestApplication();
|
||||||
|
app.setGlobalPrefix('api');
|
||||||
|
app.enableVersioning({ type: VersioningType.URI });
|
||||||
|
app.useGlobalPipes(new ValidationPipe(validationOptions));
|
||||||
|
await app.init();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => app.close());
|
||||||
|
beforeEach(() => jest.clearAllMocks());
|
||||||
|
|
||||||
|
it('requires authentication and passes the actor to a team read', async () => {
|
||||||
|
await request(app.getHttpServer()).get('/api/v1/penalty/5').expect(401);
|
||||||
|
await request(app.getHttpServer())
|
||||||
|
.get('/api/v1/penalty/5')
|
||||||
|
.set('Authorization', 'Bearer user')
|
||||||
|
.expect(200, [penalty]);
|
||||||
|
expect(service.getTeamPenalties).toHaveBeenCalledWith(42, 5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('validates and strips create fields before invoking the service', async () => {
|
||||||
|
await request(app.getHttpServer())
|
||||||
|
.post('/api/v1/penalty')
|
||||||
|
.set('Authorization', 'Bearer user')
|
||||||
|
.send({
|
||||||
|
teamId: 5,
|
||||||
|
description: ' Zu spät ',
|
||||||
|
amount: 5.25,
|
||||||
|
team: { id: 99 },
|
||||||
|
})
|
||||||
|
.expect(201, penalty);
|
||||||
|
expect(service.createPenalty).toHaveBeenCalledWith(
|
||||||
|
{ teamId: 5, description: 'Zu spät', amount: 5.25 },
|
||||||
|
42,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
[{ teamId: 5, description: '', amount: 5 }],
|
||||||
|
[{ teamId: 5, description: 'x'.repeat(121), amount: 5 }],
|
||||||
|
[{ teamId: 5, description: 'Test', amount: 0 }],
|
||||||
|
[{ teamId: 5, description: 'Test', amount: 10000.01 }],
|
||||||
|
[{ teamId: 5, description: 'Test', amount: 1.001 }],
|
||||||
|
[{ teamId: true, description: 'Test', amount: 1 }],
|
||||||
|
])('rejects invalid create input: %p', async (body) => {
|
||||||
|
await request(app.getHttpServer())
|
||||||
|
.post('/api/v1/penalty')
|
||||||
|
.set('Authorization', 'Bearer user')
|
||||||
|
.send(body)
|
||||||
|
.expect(422);
|
||||||
|
expect(service.createPenalty).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exposes update and delete with numeric IDs and safe payloads', async () => {
|
||||||
|
await request(app.getHttpServer())
|
||||||
|
.patch('/api/v1/penalty/8')
|
||||||
|
.set('Authorization', 'Bearer user')
|
||||||
|
.send({ description: ' Neu ', amount: 2 })
|
||||||
|
.expect(200, penalty);
|
||||||
|
expect(service.updatePenalty).toHaveBeenCalledWith(
|
||||||
|
8,
|
||||||
|
{ description: 'Neu', amount: 2 },
|
||||||
|
42,
|
||||||
|
);
|
||||||
|
|
||||||
|
await request(app.getHttpServer())
|
||||||
|
.delete('/api/v1/penalty/8')
|
||||||
|
.set('Authorization', 'Bearer user')
|
||||||
|
.expect(204);
|
||||||
|
expect(service.deletePenalty).toHaveBeenCalledWith(8, 42);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,29 +1,18 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { PenaltyController } from './penalty.controller';
|
|
||||||
import { PenaltyService } from './penalty.service';
|
|
||||||
import { LoggingModule } from 'src/database/logging/logging.module';
|
|
||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
import { TeamWalletTransactionType } from 'src/team-wallet-transactions/entities/team-wallet-transaction-type.entity';
|
import { LoggingModule } from 'src/database/logging/logging.module';
|
||||||
import { TeamWalletTransaction } from 'src/team-wallet-transactions/entities/team-wallet-transaction.entity';
|
|
||||||
import { Team } from 'src/teams/entities/team.entity';
|
import { Team } from 'src/teams/entities/team.entity';
|
||||||
import { User } from 'src/users/entities/user.entity';
|
import { TeamsModule } from 'src/teams/teams.module';
|
||||||
import { Role } from 'src/roles/entities/role.entity';
|
import { PenaltyController } from './penalty.controller';
|
||||||
import { PenaltyEntity } from './entities/penalty.entity';
|
import { PenaltyEntity } from './entities/penalty.entity';
|
||||||
import { Player } from 'src/players/entities/player.entity';
|
import { PenaltyService } from './penalty.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
controllers: [PenaltyController],
|
controllers: [PenaltyController],
|
||||||
providers: [PenaltyService],
|
providers: [PenaltyService],
|
||||||
imports: [
|
imports: [
|
||||||
TypeOrmModule.forFeature([
|
TypeOrmModule.forFeature([Team, PenaltyEntity]),
|
||||||
User,
|
TeamsModule,
|
||||||
Role,
|
|
||||||
TeamWalletTransaction,
|
|
||||||
TeamWalletTransactionType,
|
|
||||||
Team,
|
|
||||||
PenaltyEntity,
|
|
||||||
Player,
|
|
||||||
]),
|
|
||||||
LoggingModule,
|
LoggingModule,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|||||||
203
myteamwallet_backend/src/penalty/penalty.service.spec.ts
Normal file
203
myteamwallet_backend/src/penalty/penalty.service.spec.ts
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
import { ConflictException, NotFoundException } from '@nestjs/common';
|
||||||
|
import { PenaltyService } from './penalty.service';
|
||||||
|
|
||||||
|
describe('PenaltyService catalog management', () => {
|
||||||
|
const readRepository = { find: jest.fn(), findOne: jest.fn() };
|
||||||
|
const teamReadRepository = { findOne: jest.fn() };
|
||||||
|
const teamQuery = {
|
||||||
|
where: jest.fn().mockReturnThis(),
|
||||||
|
setLock: jest.fn().mockReturnThis(),
|
||||||
|
getOne: jest.fn(),
|
||||||
|
};
|
||||||
|
const duplicateQuery = {
|
||||||
|
where: jest.fn().mockReturnThis(),
|
||||||
|
andWhere: jest.fn().mockReturnThis(),
|
||||||
|
getCount: jest.fn(),
|
||||||
|
};
|
||||||
|
const teamRepository = { createQueryBuilder: jest.fn(() => teamQuery) };
|
||||||
|
const writeRepository = {
|
||||||
|
createQueryBuilder: jest.fn(() => duplicateQuery),
|
||||||
|
create: jest.fn((value) => value),
|
||||||
|
save: jest.fn(),
|
||||||
|
findOne: jest.fn(),
|
||||||
|
remove: jest.fn(),
|
||||||
|
};
|
||||||
|
const manager = {
|
||||||
|
getRepository: jest.fn((entity) =>
|
||||||
|
entity.name === 'Team' ? teamRepository : writeRepository,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
const dataSource = { transaction: jest.fn((work) => work(manager)) };
|
||||||
|
const access = { assertMember: jest.fn(), assertManager: jest.fn() };
|
||||||
|
const logger = { info: jest.fn() };
|
||||||
|
let service: PenaltyService;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
teamQuery.getOne.mockResolvedValue({ id: 5 });
|
||||||
|
duplicateQuery.getCount.mockResolvedValue(0);
|
||||||
|
teamReadRepository.findOne.mockResolvedValue({ id: 5 });
|
||||||
|
service = new PenaltyService(
|
||||||
|
readRepository as any,
|
||||||
|
teamReadRepository as any,
|
||||||
|
dataSource as any,
|
||||||
|
access as any,
|
||||||
|
logger as any,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns not found instead of leaking an unknown team as an empty catalog', async () => {
|
||||||
|
teamReadRepository.findOne.mockResolvedValue(null);
|
||||||
|
|
||||||
|
await expect(service.getTeamPenalties(42, 999)).rejects.toBeInstanceOf(
|
||||||
|
NotFoundException,
|
||||||
|
);
|
||||||
|
expect(access.assertMember).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('authorizes team reads, sorts them, and maps only safe fields', async () => {
|
||||||
|
readRepository.find.mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: 8,
|
||||||
|
description: 'Zu spät',
|
||||||
|
amount: '5.50',
|
||||||
|
createdAt: new Date('2026-01-02T00:00:00.000Z'),
|
||||||
|
team: { id: 5, secret: 'hidden' },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
await expect(service.getTeamPenalties(42, 5)).resolves.toEqual([
|
||||||
|
{
|
||||||
|
id: 8,
|
||||||
|
description: 'Zu spät',
|
||||||
|
amount: 5.5,
|
||||||
|
createdAt: new Date('2026-01-02T00:00:00.000Z'),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(access.assertMember).toHaveBeenCalledWith(42, 5);
|
||||||
|
expect(readRepository.find).toHaveBeenCalledWith({
|
||||||
|
where: { team: { id: 5 } },
|
||||||
|
order: { description: 'ASC' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates a normalized entry under a team lock and audits in the transaction', async () => {
|
||||||
|
writeRepository.save.mockImplementation(async (value) => ({
|
||||||
|
id: 9,
|
||||||
|
createdAt: new Date('2026-01-03T00:00:00.000Z'),
|
||||||
|
...value,
|
||||||
|
}));
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.createPenalty(
|
||||||
|
{ teamId: 5, description: ' Handy in der Kabine ', amount: 3.25 },
|
||||||
|
42,
|
||||||
|
),
|
||||||
|
).resolves.toMatchObject({
|
||||||
|
id: 9,
|
||||||
|
description: 'Handy in der Kabine',
|
||||||
|
amount: 3.25,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(teamQuery.setLock).toHaveBeenCalledWith('pessimistic_write');
|
||||||
|
expect(access.assertManager).toHaveBeenCalledWith(42, 5, manager);
|
||||||
|
expect(logger.info).toHaveBeenCalledWith(
|
||||||
|
{
|
||||||
|
event: 'penalty_catalog_create',
|
||||||
|
details: 'teamId=5 penaltyId=9 action=create',
|
||||||
|
userId: 42,
|
||||||
|
},
|
||||||
|
manager,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a normalized duplicate before writing or auditing', async () => {
|
||||||
|
duplicateQuery.getCount.mockResolvedValue(1);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.createPenalty(
|
||||||
|
{ teamId: 5, description: ' ZU SPÄT ', amount: 5 },
|
||||||
|
42,
|
||||||
|
),
|
||||||
|
).rejects.toBeInstanceOf(ConflictException);
|
||||||
|
expect(writeRepository.save).not.toHaveBeenCalled();
|
||||||
|
expect(logger.info).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates an entry in its owning team and excludes itself from duplicate detection', async () => {
|
||||||
|
readRepository.findOne.mockResolvedValue({ id: 8, team: { id: 5 } });
|
||||||
|
writeRepository.findOne.mockResolvedValue({
|
||||||
|
id: 8,
|
||||||
|
team: { id: 5 },
|
||||||
|
description: 'Alt',
|
||||||
|
amount: 1,
|
||||||
|
createdAt: new Date('2026-01-02T00:00:00.000Z'),
|
||||||
|
});
|
||||||
|
writeRepository.save.mockImplementation(async (value) => value);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.updatePenalty(
|
||||||
|
8,
|
||||||
|
{ description: ' Neu ', amount: 2.5 },
|
||||||
|
42,
|
||||||
|
),
|
||||||
|
).resolves.toMatchObject({ id: 8, description: 'Neu', amount: 2.5 });
|
||||||
|
expect(duplicateQuery.andWhere).toHaveBeenCalledWith(
|
||||||
|
'penalty.id != :penaltyId',
|
||||||
|
{ penaltyId: 8 },
|
||||||
|
);
|
||||||
|
expect(logger.info).toHaveBeenCalledWith(
|
||||||
|
{
|
||||||
|
event: 'penalty_catalog_update',
|
||||||
|
details: 'teamId=5 penaltyId=8 action=update',
|
||||||
|
userId: 42,
|
||||||
|
},
|
||||||
|
manager,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deletes an entry permanently after manager authorization and audits it', async () => {
|
||||||
|
readRepository.findOne.mockResolvedValue({ id: 8, team: { id: 5 } });
|
||||||
|
const penalty = { id: 8, team: { id: 5 } };
|
||||||
|
writeRepository.findOne.mockResolvedValue(penalty);
|
||||||
|
|
||||||
|
await service.deletePenalty(8, 42);
|
||||||
|
|
||||||
|
expect(writeRepository.remove).toHaveBeenCalledWith(penalty);
|
||||||
|
expect(logger.info).toHaveBeenCalledWith(
|
||||||
|
{
|
||||||
|
event: 'penalty_catalog_delete',
|
||||||
|
details: 'teamId=5 penaltyId=8 action=delete',
|
||||||
|
userId: 42,
|
||||||
|
},
|
||||||
|
manager,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns not found when a mutation target does not exist', async () => {
|
||||||
|
readRepository.findOne.mockResolvedValue(null);
|
||||||
|
|
||||||
|
await expect(service.deletePenalty(999, 42)).rejects.toBeInstanceOf(
|
||||||
|
NotFoundException,
|
||||||
|
);
|
||||||
|
expect(dataSource.transaction).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects the mutation when transactional audit persistence fails', async () => {
|
||||||
|
writeRepository.save.mockResolvedValue({
|
||||||
|
id: 9,
|
||||||
|
team: { id: 5 },
|
||||||
|
description: 'Neu',
|
||||||
|
amount: 2,
|
||||||
|
createdAt: new Date(),
|
||||||
|
});
|
||||||
|
logger.info.mockRejectedValue(new Error('audit unavailable'));
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.createPenalty(
|
||||||
|
{ teamId: 5, description: 'Neu', amount: 2 },
|
||||||
|
42,
|
||||||
|
),
|
||||||
|
).rejects.toThrow('audit unavailable');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,61 +1,190 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import {
|
||||||
import { CreatePenaltyDTO } from './dto/create-penalty.dto';
|
ConflictException,
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
} from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { LoggingService } from 'src/database/logging/logging.service';
|
||||||
import { Team } from 'src/teams/entities/team.entity';
|
import { Team } from 'src/teams/entities/team.entity';
|
||||||
import { Repository } from 'typeorm';
|
import { TeamAccessService } from 'src/teams/team-access.service';
|
||||||
|
import { DataSource, EntityManager, Repository } from 'typeorm';
|
||||||
|
import { CreatePenaltyDTO } from './dto/create-penalty.dto';
|
||||||
|
import { PenaltyResponseDTO } from './dto/penalty-response.dto';
|
||||||
|
import { UpdatePenaltyDTO } from './dto/update-penalty.dto';
|
||||||
import { PenaltyEntity } from './entities/penalty.entity';
|
import { PenaltyEntity } from './entities/penalty.entity';
|
||||||
import { Player } from 'src/players/entities/player.entity';
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class PenaltyService {
|
export class PenaltyService {
|
||||||
constructor(
|
constructor(
|
||||||
@InjectRepository(Team)
|
|
||||||
private teamRepository: Repository<Team>,
|
|
||||||
@InjectRepository(Player)
|
|
||||||
private playerRepository: Repository<Player>,
|
|
||||||
@InjectRepository(PenaltyEntity)
|
@InjectRepository(PenaltyEntity)
|
||||||
private repository: Repository<PenaltyEntity>,
|
private readonly repository: Repository<PenaltyEntity>,
|
||||||
|
@InjectRepository(Team)
|
||||||
|
private readonly teamRepository: Repository<Team>,
|
||||||
|
private readonly dataSource: DataSource,
|
||||||
|
private readonly access: TeamAccessService,
|
||||||
|
private readonly logger: LoggingService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async createPenalty(dto: CreatePenaltyDTO, userId: string) {
|
async getTeamPenalties(
|
||||||
const player = await this.playerRepository.findOne({
|
userId: number,
|
||||||
where: { user: { id: Number(userId) }, team: { id: dto.teamId } },
|
teamId: number,
|
||||||
|
): Promise<PenaltyResponseDTO[]> {
|
||||||
|
const team = await this.teamRepository.findOne({ where: { id: teamId } });
|
||||||
|
if (!team) throw new NotFoundException('Team nicht gefunden.');
|
||||||
|
await this.access.assertMember(userId, teamId);
|
||||||
|
const penalties = await this.repository.find({
|
||||||
|
where: { team: { id: teamId } },
|
||||||
|
order: { description: 'ASC' },
|
||||||
|
});
|
||||||
|
return penalties.map((penalty) => this.toResponse(penalty));
|
||||||
|
}
|
||||||
|
|
||||||
|
createPenalty(
|
||||||
|
dto: CreatePenaltyDTO,
|
||||||
|
userId: number,
|
||||||
|
): Promise<PenaltyResponseDTO> {
|
||||||
|
return this.dataSource.transaction(async (manager) => {
|
||||||
|
const team = await this.lockTeam(manager, dto.teamId);
|
||||||
|
await this.access.assertManager(userId, team.id, manager);
|
||||||
|
const repository = manager.getRepository(PenaltyEntity);
|
||||||
|
const description = dto.description.trim();
|
||||||
|
await this.assertUniqueDescription(repository, team.id, description);
|
||||||
|
const saved = await repository.save(
|
||||||
|
repository.create({ team, description, amount: dto.amount }),
|
||||||
|
);
|
||||||
|
await this.logger.info(
|
||||||
|
{
|
||||||
|
event: 'penalty_catalog_create',
|
||||||
|
details: `teamId=${team.id} penaltyId=${saved.id} action=create`,
|
||||||
|
userId,
|
||||||
|
},
|
||||||
|
manager,
|
||||||
|
);
|
||||||
|
return this.toResponse(saved);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async updatePenalty(
|
||||||
|
penaltyId: number,
|
||||||
|
dto: UpdatePenaltyDTO,
|
||||||
|
userId: number,
|
||||||
|
): Promise<PenaltyResponseDTO> {
|
||||||
|
const owner = await this.findOwner(penaltyId);
|
||||||
|
return this.dataSource.transaction(async (manager) => {
|
||||||
|
const team = await this.lockTeam(manager, owner.team.id);
|
||||||
|
await this.access.assertManager(userId, team.id, manager);
|
||||||
|
const repository = manager.getRepository(PenaltyEntity);
|
||||||
|
const penalty = await this.findTransactionalPenalty(
|
||||||
|
repository,
|
||||||
|
penaltyId,
|
||||||
|
team.id,
|
||||||
|
);
|
||||||
|
const description = dto.description.trim();
|
||||||
|
await this.assertUniqueDescription(
|
||||||
|
repository,
|
||||||
|
team.id,
|
||||||
|
description,
|
||||||
|
penaltyId,
|
||||||
|
);
|
||||||
|
penalty.description = description;
|
||||||
|
penalty.amount = dto.amount;
|
||||||
|
const saved = await repository.save(penalty);
|
||||||
|
await this.logger.info(
|
||||||
|
{
|
||||||
|
event: 'penalty_catalog_update',
|
||||||
|
details: `teamId=${team.id} penaltyId=${penaltyId} action=update`,
|
||||||
|
userId,
|
||||||
|
},
|
||||||
|
manager,
|
||||||
|
);
|
||||||
|
return this.toResponse(saved);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async deletePenalty(penaltyId: number, userId: number): Promise<void> {
|
||||||
|
const owner = await this.findOwner(penaltyId);
|
||||||
|
await this.dataSource.transaction(async (manager) => {
|
||||||
|
const team = await this.lockTeam(manager, owner.team.id);
|
||||||
|
await this.access.assertManager(userId, team.id, manager);
|
||||||
|
const repository = manager.getRepository(PenaltyEntity);
|
||||||
|
const penalty = await this.findTransactionalPenalty(
|
||||||
|
repository,
|
||||||
|
penaltyId,
|
||||||
|
team.id,
|
||||||
|
);
|
||||||
|
await repository.remove(penalty);
|
||||||
|
await this.logger.info(
|
||||||
|
{
|
||||||
|
event: 'penalty_catalog_delete',
|
||||||
|
details: `teamId=${team.id} penaltyId=${penaltyId} action=delete`,
|
||||||
|
userId,
|
||||||
|
},
|
||||||
|
manager,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async findOwner(penaltyId: number): Promise<PenaltyEntity> {
|
||||||
|
const penalty = await this.repository.findOne({
|
||||||
|
where: { id: penaltyId },
|
||||||
relations: ['team'],
|
relations: ['team'],
|
||||||
});
|
});
|
||||||
|
if (!penalty?.team) throw new NotFoundException('Strafe nicht gefunden.');
|
||||||
|
return penalty;
|
||||||
|
}
|
||||||
|
|
||||||
if (!player || !player.team) {
|
private async lockTeam(manager: EntityManager, teamId: number): Promise<Team> {
|
||||||
return;
|
const team = await manager
|
||||||
|
.getRepository(Team)
|
||||||
|
.createQueryBuilder('team')
|
||||||
|
.where('team.id = :teamId', { teamId })
|
||||||
|
.setLock('pessimistic_write')
|
||||||
|
.getOne();
|
||||||
|
if (!team) throw new NotFoundException('Team nicht gefunden.');
|
||||||
|
return team;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async findTransactionalPenalty(
|
||||||
|
repository: Repository<PenaltyEntity>,
|
||||||
|
penaltyId: number,
|
||||||
|
teamId: number,
|
||||||
|
): Promise<PenaltyEntity> {
|
||||||
|
const penalty = await repository.findOne({
|
||||||
|
where: { id: penaltyId, team: { id: teamId } },
|
||||||
|
relations: ['team'],
|
||||||
|
});
|
||||||
|
if (!penalty) throw new NotFoundException('Strafe nicht gefunden.');
|
||||||
|
return penalty;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async assertUniqueDescription(
|
||||||
|
repository: Repository<PenaltyEntity>,
|
||||||
|
teamId: number,
|
||||||
|
description: string,
|
||||||
|
penaltyId?: number,
|
||||||
|
): Promise<void> {
|
||||||
|
const query = repository
|
||||||
|
.createQueryBuilder('penalty')
|
||||||
|
.where('penalty.teamId = :teamId', { teamId })
|
||||||
|
.andWhere('LOWER(TRIM(penalty.description)) = :description', {
|
||||||
|
description: description.toLocaleLowerCase('de'),
|
||||||
|
});
|
||||||
|
if (penaltyId !== undefined) {
|
||||||
|
query.andWhere('penalty.id != :penaltyId', { penaltyId });
|
||||||
|
}
|
||||||
|
if ((await query.getCount()) > 0) {
|
||||||
|
throw new ConflictException(
|
||||||
|
'Ein Eintrag mit dieser Beschreibung existiert bereits.',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
dto.team = player.team;
|
|
||||||
|
|
||||||
const e = this.repository.create(dto);
|
|
||||||
|
|
||||||
return this.repository.save(e);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
getAll(userId: string) {
|
private toResponse(penalty: PenaltyEntity): PenaltyResponseDTO {
|
||||||
const id = Number(userId);
|
return {
|
||||||
|
id: penalty.id,
|
||||||
return this.repository.find({
|
description: penalty.description,
|
||||||
where: {
|
amount: Number(penalty.amount),
|
||||||
team: { players: { user: { id } } },
|
createdAt: penalty.createdAt,
|
||||||
},
|
};
|
||||||
relations: ['team'],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async getTeamPenalties(teamId: string | number) {
|
|
||||||
teamId = Number(teamId);
|
|
||||||
|
|
||||||
const res = await this.repository.find({
|
|
||||||
where: {
|
|
||||||
team: { id: teamId },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return res.map((r) => {
|
|
||||||
r.amount = Number(r.amount);
|
|
||||||
return r;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,6 +65,30 @@ describe('TeamAccessService', () => {
|
|||||||
await expect(service.assertManager(2, 9)).resolves.toBeUndefined();
|
await expect(service.assertManager(2, 9)).resolves.toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('uses repositories from the supplied transaction manager', async () => {
|
||||||
|
const transactionalUserRepository = {
|
||||||
|
findOne: jest.fn().mockResolvedValue({
|
||||||
|
id: 2,
|
||||||
|
role: { id: RoleEnum.user },
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
const transactionalPlayerRepository = {
|
||||||
|
find: jest
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue([{ active: true, teamRole: { id: 3 } }]),
|
||||||
|
};
|
||||||
|
const manager = {
|
||||||
|
getRepository: jest
|
||||||
|
.fn()
|
||||||
|
.mockReturnValueOnce(transactionalUserRepository)
|
||||||
|
.mockReturnValueOnce(transactionalPlayerRepository),
|
||||||
|
};
|
||||||
|
|
||||||
|
await expect(service.assertManager(2, 9, manager as any)).resolves.toBeUndefined();
|
||||||
|
expect(userRepository.findOne).not.toHaveBeenCalled();
|
||||||
|
expect(playerRepository.find).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
['a player', [{ active: true, teamRole: { id: 1 } }]],
|
['a player', [{ active: true, teamRole: { id: 1 } }]],
|
||||||
['a second treasurer', [{ active: true, teamRole: { id: 2 } }]],
|
['a second treasurer', [{ active: true, teamRole: { id: 2 } }]],
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { InjectRepository } from '@nestjs/typeorm';
|
|||||||
import { Player } from '../players/entities/player.entity';
|
import { Player } from '../players/entities/player.entity';
|
||||||
import { RoleEnum } from '../roles/roles.enum';
|
import { RoleEnum } from '../roles/roles.enum';
|
||||||
import { User } from '../users/entities/user.entity';
|
import { User } from '../users/entities/user.entity';
|
||||||
import { Repository } from 'typeorm';
|
import { EntityManager, Repository } from 'typeorm';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class TeamAccessService {
|
export class TeamAccessService {
|
||||||
@@ -14,23 +14,35 @@ export class TeamAccessService {
|
|||||||
private readonly playerRepository: Repository<Player>,
|
private readonly playerRepository: Repository<Player>,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async assertMember(userId: number, teamId: number): Promise<void> {
|
async assertMember(
|
||||||
await this.assertMinimumRole(userId, teamId, 1);
|
userId: number,
|
||||||
|
teamId: number,
|
||||||
|
manager?: EntityManager,
|
||||||
|
): Promise<void> {
|
||||||
|
await this.assertMinimumRole(userId, teamId, 1, manager);
|
||||||
}
|
}
|
||||||
|
|
||||||
async assertManager(userId: number, teamId: number): Promise<void> {
|
async assertManager(
|
||||||
await this.assertMinimumRole(userId, teamId, 3);
|
userId: number,
|
||||||
|
teamId: number,
|
||||||
|
manager?: EntityManager,
|
||||||
|
): Promise<void> {
|
||||||
|
await this.assertMinimumRole(userId, teamId, 3, manager);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async assertMinimumRole(
|
private async assertMinimumRole(
|
||||||
userId: number,
|
userId: number,
|
||||||
teamId: number,
|
teamId: number,
|
||||||
minimumRole: number,
|
minimumRole: number,
|
||||||
|
manager?: EntityManager,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const user = await this.userRepository.findOne({ where: { id: userId } });
|
const userRepository = manager?.getRepository(User) ?? this.userRepository;
|
||||||
|
const playerRepository =
|
||||||
|
manager?.getRepository(Player) ?? this.playerRepository;
|
||||||
|
const user = await userRepository.findOne({ where: { id: userId } });
|
||||||
if (user?.role?.id === RoleEnum.admin) return;
|
if (user?.role?.id === RoleEnum.admin) return;
|
||||||
|
|
||||||
const players = await this.playerRepository.find({
|
const players = await playerRepository.find({
|
||||||
where: { user: { id: userId }, team: { id: teamId } },
|
where: { user: { id: userId }, team: { id: teamId } },
|
||||||
});
|
});
|
||||||
const highestActiveRole = players
|
const highestActiveRole = players
|
||||||
|
|||||||
@@ -31,5 +31,6 @@ import { PenaltyEntity } from '../penalty/entities/penalty.entity';
|
|||||||
],
|
],
|
||||||
controllers: [TeamsController, PublicTeamsController],
|
controllers: [TeamsController, PublicTeamsController],
|
||||||
providers: [TeamsService, TeamAccessService, PublicTeamAccessService],
|
providers: [TeamsService, TeamAccessService, PublicTeamAccessService],
|
||||||
|
exports: [TeamAccessService],
|
||||||
})
|
})
|
||||||
export class TeamsModule {}
|
export class TeamsModule {}
|
||||||
|
|||||||
Reference in New Issue
Block a user