Skip to content

Commit 0237149

Browse files
authored
fix(games): don't let unverified players join games as a substitute (#2609)
1 parent d9799b4 commit 0237149

10 files changed

+240
-36
lines changed

src/games/gateways/games.gateway.spec.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,17 @@ import { Player } from '@/players/models/player';
88
import { Types } from 'mongoose';
99
import { PlayerId } from '@/players/types/player-id';
1010
import { GameId } from '../game-id';
11+
import { PlayersService } from '@/players/services/players.service';
12+
import { ConfigurationService } from '@/configuration/services/configuration.service';
13+
import { PlayerBansService } from '@/players/services/player-bans.service';
1114

1215
jest.mock('../services/player-substitution.service');
1316
jest.mock('socket.io');
17+
jest.mock('@/players/services/players.service', () => ({
18+
PlayersService: jest.fn().mockImplementation(() => ({})),
19+
}));
20+
jest.mock('@/configuration/services/configuration.service');
21+
jest.mock('@/players/services/player-bans.service');
1422

1523
const mockGame = {
1624
id: 'FAKE_GAME_ID',
@@ -25,7 +33,14 @@ describe('GamesGateway', () => {
2533

2634
beforeEach(async () => {
2735
const module: TestingModule = await Test.createTestingModule({
28-
providers: [GamesGateway, PlayerSubstitutionService, Events],
36+
providers: [
37+
GamesGateway,
38+
PlayerSubstitutionService,
39+
Events,
40+
PlayersService,
41+
ConfigurationService,
42+
PlayerBansService,
43+
],
2944
}).compile();
3045

3146
gateway = module.get<GamesGateway>(GamesGateway);

src/games/gateways/games.gateway.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
forwardRef,
88
OnModuleInit,
99
UseInterceptors,
10+
UseGuards,
1011
} from '@nestjs/common';
1112
import { Events } from '@/events/events';
1213
import { WebsocketEvent } from '@/websocket-event';
@@ -17,6 +18,7 @@ import { WebsocketEventEmitter } from '@/shared/websocket-event-emitter';
1718
import { Types } from 'mongoose';
1819
import { GameId } from '../game-id';
1920
import { PlayerId } from '@/players/types/player-id';
21+
import { CanReplacePlayerGuard } from '../guards/can-replace-player.guard';
2022

2123
@WebSocketGateway()
2224
export class GamesGateway
@@ -32,6 +34,7 @@ export class GamesGateway
3234
}
3335

3436
@WsAuthorized()
37+
@UseGuards(CanReplacePlayerGuard)
3538
@SubscribeMessage('replace player')
3639
@UseInterceptors(SerializerInterceptor)
3740
async replacePlayer(
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
import { PlayersService } from '@/players/services/players.service';
2+
import { CanReplacePlayerGuard } from './can-replace-player.guard';
3+
import { PlayerBansService } from '@/players/services/player-bans.service';
4+
import { ConfigurationService } from '@/configuration/services/configuration.service';
5+
import { Player } from '@/players/models/player';
6+
import { ExecutionContext } from '@nestjs/common';
7+
import { WsException } from '@nestjs/websockets';
8+
import { PlayerDeniedError } from '@/shared/errors/player-denied.error';
9+
import { PlayerBan } from '@/players/models/player-ban';
10+
11+
jest.mock('@/configuration/services/configuration.service');
12+
jest.mock('@/players/services/player-bans.service');
13+
jest.mock('@/players/services/players.service');
14+
15+
describe('CanReplacePlayerGuard', () => {
16+
let guard: CanReplacePlayerGuard;
17+
let playersService: jest.Mocked<PlayersService>;
18+
let playerBansService: jest.Mocked<PlayerBansService>;
19+
let configurationService: jest.Mocked<ConfigurationService>;
20+
21+
beforeEach(() => {
22+
playersService = new PlayersService(
23+
// @ts-expect-error
24+
null,
25+
null,
26+
null,
27+
null,
28+
null,
29+
null,
30+
null,
31+
) as jest.Mocked<PlayersService>;
32+
playerBansService = new PlayerBansService(
33+
// @ts-ignore
34+
null,
35+
null,
36+
null,
37+
null,
38+
) as jest.Mocked<PlayerBansService>;
39+
configurationService = new ConfigurationService(
40+
// @ts-ignore
41+
null,
42+
null,
43+
) as jest.Mocked<ConfigurationService>;
44+
guard = new CanReplacePlayerGuard(
45+
playersService,
46+
configurationService,
47+
playerBansService,
48+
);
49+
});
50+
51+
beforeEach(() => {
52+
configurationService.get.mockImplementation((key: string) =>
53+
Promise.resolve(
54+
{
55+
'queue.deny_players_with_no_skill_assigned': false,
56+
}[key],
57+
),
58+
);
59+
playerBansService.getPlayerActiveBans.mockResolvedValue([]);
60+
});
61+
62+
it('should be defined', () => {
63+
expect(guard).toBeDefined();
64+
});
65+
66+
describe('#canActivate()', () => {
67+
let client: { user?: Player };
68+
let context: jest.Mocked<ExecutionContext>;
69+
70+
beforeEach(() => {
71+
client = {};
72+
context = {
73+
switchToWs: jest.fn().mockImplementation(() => ({
74+
getClient: jest.fn().mockReturnValue(client),
75+
getData: jest.fn(),
76+
})),
77+
} as unknown as jest.Mocked<ExecutionContext>;
78+
});
79+
80+
describe('when not logged in', () => {
81+
it('should deny', async () => {
82+
await expect(guard.canActivate(context)).rejects.toThrow(WsException);
83+
});
84+
});
85+
86+
describe('when the player has not accepted rules', () => {
87+
let player: Player;
88+
89+
beforeEach(() => {
90+
player = new Player();
91+
player.hasAcceptedRules = false;
92+
client.user = player;
93+
playersService.getById.mockResolvedValue(player);
94+
});
95+
96+
it('should deny', async () => {
97+
await expect(guard.canActivate(context)).rejects.toThrow(
98+
PlayerDeniedError,
99+
);
100+
});
101+
});
102+
103+
describe('when the player has no skill assigned', () => {
104+
let player: Player;
105+
106+
beforeEach(() => {
107+
player = new Player();
108+
player.hasAcceptedRules = true;
109+
client.user = player;
110+
playersService.getById.mockResolvedValue(player);
111+
});
112+
113+
describe('and he should be denied', () => {
114+
beforeEach(() => {
115+
configurationService.get.mockImplementation((key: string) =>
116+
Promise.resolve(
117+
{
118+
'queue.deny_players_with_no_skill_assigned': true,
119+
}[key],
120+
),
121+
);
122+
});
123+
124+
it('should deny', async () => {
125+
await expect(guard.canActivate(context)).rejects.toThrow(
126+
PlayerDeniedError,
127+
);
128+
});
129+
});
130+
131+
describe("but it's ok", () => {
132+
it('should allow', async () => {
133+
expect(await guard.canActivate(context)).toBe(true);
134+
});
135+
});
136+
});
137+
138+
describe('when the player is banned', () => {
139+
let player: Player;
140+
141+
beforeEach(() => {
142+
player = new Player();
143+
player.hasAcceptedRules = true;
144+
client.user = player;
145+
playersService.getById.mockResolvedValue(player);
146+
147+
const ban = new PlayerBan();
148+
playerBansService.getPlayerActiveBans.mockResolvedValue([ban]);
149+
});
150+
151+
it('should deny', async () => {
152+
await expect(guard.canActivate(context)).rejects.toThrow(
153+
PlayerDeniedError,
154+
);
155+
});
156+
});
157+
});
158+
});
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import { ConfigurationService } from '@/configuration/services/configuration.service';
2+
import { PlayerBansService } from '@/players/services/player-bans.service';
3+
import { PlayersService } from '@/players/services/players.service';
4+
import {
5+
DenyReason,
6+
PlayerDeniedError,
7+
} from '@/shared/errors/player-denied.error';
8+
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
9+
import { WsException } from '@nestjs/websockets';
10+
import { Socket } from 'socket.io';
11+
12+
@Injectable()
13+
export class CanReplacePlayerGuard implements CanActivate {
14+
constructor(
15+
private readonly playersService: PlayersService,
16+
private readonly configurationService: ConfigurationService,
17+
private readonly playerBansService: PlayerBansService,
18+
) {}
19+
20+
async canActivate(context: ExecutionContext): Promise<boolean> {
21+
const socket = context.switchToWs().getClient<Socket>();
22+
if (!socket.user) {
23+
throw new WsException('not logged in');
24+
}
25+
26+
const player = await this.playersService.getById(socket.user._id);
27+
if (!player.hasAcceptedRules) {
28+
throw new PlayerDeniedError(player, DenyReason.playerHasNotAcceptedRules);
29+
}
30+
31+
if (
32+
!player.skill &&
33+
(await this.configurationService.get<boolean>(
34+
'queue.deny_players_with_no_skill_assigned',
35+
))
36+
) {
37+
throw new PlayerDeniedError(player, DenyReason.noSkillAssigned);
38+
}
39+
40+
const bans = await this.playerBansService.getPlayerActiveBans(player._id);
41+
if (bans.length > 0) {
42+
throw new PlayerDeniedError(player, DenyReason.playerIsBanned);
43+
}
44+
45+
return true;
46+
}
47+
}

src/games/services/player-substitution.service.spec.ts

Lines changed: 2 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import { GameId } from '../game-id';
2525
import { GameEventType } from '../models/game-event-type';
2626
import { SubstituteRequested } from '../models/events/substitute-requested';
2727
import { PlayerReplaced } from '../models/events/player-replaced';
28+
import { PlayerDeniedError } from '@/shared/errors/player-denied.error';
2829

2930
jest.mock('@/players/services/players.service');
3031
jest.mock('./games.service');
@@ -325,28 +326,6 @@ describe('PlayerSubstitutionService', () => {
325326
expect(event?._id.equals(mockGame._id)).toBe(true);
326327
});
327328

328-
describe('when the replacement player is banned', () => {
329-
beforeEach(() => {
330-
const end = new Date();
331-
end.setHours(end.getHours() + 1);
332-
playerBansService.getPlayerActiveBans = () =>
333-
Promise.resolve([
334-
{
335-
player: 'FAKE_PLAYERID',
336-
admin: 'FAKE_ADMINID',
337-
start: new Date(),
338-
end,
339-
} as any,
340-
]);
341-
});
342-
343-
it('should reject', async () => {
344-
await expect(
345-
service.replacePlayer(mockGame._id, player1._id, player3._id),
346-
).rejects.toThrow('player is banned');
347-
});
348-
});
349-
350329
describe('when replacing an active player', () => {
351330
it('should reject', async () => {
352331
await expect(
@@ -406,7 +385,7 @@ describe('PlayerSubstitutionService', () => {
406385
it('should reject', async () => {
407386
await expect(
408387
service.replacePlayer(mockGame._id, player1._id, player3._id),
409-
).rejects.toThrow('player is involved in a currently running game');
388+
).rejects.toThrow(PlayerDeniedError);
410389
});
411390
});
412391

src/games/services/player-substitution.service.ts

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,10 @@ import { Mutex } from 'async-mutex';
2323
import { GameId } from '../game-id';
2424
import { PlayerId } from '@/players/types/player-id';
2525
import { GameEventType } from '../models/game-event-type';
26+
import {
27+
DenyReason,
28+
PlayerDeniedError,
29+
} from '@/shared/errors/player-denied.error';
2630

2731
/**
2832
* A service that handles player substitution logic.
@@ -175,14 +179,6 @@ export class PlayerSubstitutionService implements OnModuleInit {
175179
) {
176180
return await this.mutex.runExclusive(async () => {
177181
const replacement = await this.playersService.getById(replacementId);
178-
179-
if (
180-
(await this.playerBansService.getPlayerActiveBans(replacementId))
181-
.length > 0
182-
) {
183-
throw new Error('player is banned');
184-
}
185-
186182
const game = await this.gamesService.getById(gameId);
187183
const slot = game.slots.find(
188184
(slot) =>
@@ -236,7 +232,10 @@ export class PlayerSubstitutionService implements OnModuleInit {
236232
}
237233

238234
if (replacement.activeGame) {
239-
throw new Error('player is involved in a currently running game');
235+
throw new PlayerDeniedError(
236+
replacement,
237+
DenyReason.playerIsInvolvedInGame,
238+
);
240239
}
241240

242241
if (game.findPlayerSlot(replacementId)) {

src/queue/guards/can-join-queue.guard.spec.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { PlayersService } from '@/players/services/players.service';
77
import { ExecutionContext } from '@nestjs/common';
88
import { WsException } from '@nestjs/websockets';
99
import { Types } from 'mongoose';
10-
import { PlayerDeniedError } from '../errors/player-denied.error';
10+
import { PlayerDeniedError } from '../../shared/errors/player-denied.error';
1111
import { CanJoinQueueGuard } from './can-join-queue.guard';
1212

1313
jest.mock('@/configuration/services/configuration.service');

src/queue/guards/can-join-queue.guard.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,10 @@ import { PlayersService } from '@/players/services/players.service';
44
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
55
import { WsException } from '@nestjs/websockets';
66
import { Socket } from 'socket.io';
7-
import { DenyReason, PlayerDeniedError } from '../errors/player-denied.error';
7+
import {
8+
DenyReason,
9+
PlayerDeniedError,
10+
} from '../../shared/errors/player-denied.error';
811

912
@Injectable()
1013
export class CanJoinQueueGuard implements CanActivate {

src/shared/filters/all-exceptions.filter.spec.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { Player } from '@/players/models/player';
22
import {
33
DenyReason,
44
PlayerDeniedError,
5-
} from '@/queue/errors/player-denied.error';
5+
} from '@/shared/errors/player-denied.error';
66
import { AllExceptionsFilter } from './all-exceptions.filter';
77

88
describe('AllExceptionsFilter', () => {

0 commit comments

Comments
 (0)