diff --git a/app/Connect/Actions/AwardAchievementAction.php b/app/Connect/Actions/AwardAchievementAction.php index b55021ae15..1fa059364b 100644 --- a/app/Connect/Actions/AwardAchievementAction.php +++ b/app/Connect/Actions/AwardAchievementAction.php @@ -14,6 +14,7 @@ use App\Models\PlayerGame; use App\Models\StaticData; use App\Models\User; +use App\Platform\Actions\ResumePlayerSessionAction; use App\Platform\Jobs\UnlockPlayerAchievementJob; use Illuminate\Http\Request; use Illuminate\Support\Carbon; @@ -167,6 +168,19 @@ protected function process(): array return $this->unsupportedSystem('Cannot unlock achievements for unsupported console.'); } + $playerSession = app()->make(ResumePlayerSessionAction::class)->execute( + $this->user, + $this->achievement->game, + ($this->gameHash && !$this->gameHash->isMultiDiscGameHash()) ? $this->gameHash : null, + timestamp: $this->when, + userAgent: $this->userAgent, + ipAddress: $this->ipAddress, + ); + + if ($this->connectWarning) { + $this->connectWarning->player_session_id = $playerSession->id; + } + $playerGame = PlayerGame::query() ->where('user_id', $this->user->id) ->where('game_id', $this->achievement->game_id) diff --git a/app/Connect/Support/GeneratesConnectWarnings.php b/app/Connect/Support/GeneratesConnectWarnings.php index 400190dbd0..ab1b21a7dc 100644 --- a/app/Connect/Support/GeneratesConnectWarnings.php +++ b/app/Connect/Support/GeneratesConnectWarnings.php @@ -8,8 +8,11 @@ use App\Models\ConnectWarning; use App\Models\Game; use App\Platform\Services\UserAgentService; +use App\Support\Alerts\SuspiciousConnectWarningAlert; +use Carbon\Carbon; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; +use Illuminate\Support\Facades\Cache; trait GeneratesConnectWarnings { @@ -23,6 +26,7 @@ public function handleRequest(Request $request): JsonResponse if ($this->connectWarning !== null) { $this->finalizeWarning(); $this->connectWarning->save(); + $this->sendNotifications(); } return $result; @@ -87,4 +91,17 @@ private function finalizeWarning(): void } } } + + private function sendNotifications(): void + { + if (str_contains($this->connectWarning->smells, 'repeated_validation') + || str_contains($this->connectWarning->smells, 'wrong_client')) { + + // only send one notification per user per day + $key = 'user:' . strtolower($this->connectWarning->username) . ':connect_warning_notification'; + if (Cache::add($key, '1', Carbon::now()->addDay())) { + (new SuspiciousConnectWarningAlert($this->connectWarning))->send(); + } + } + } } diff --git a/app/Support/Alerts/SuspiciousConnectWarningAlert.php b/app/Support/Alerts/SuspiciousConnectWarningAlert.php new file mode 100644 index 0000000000..3a27a47224 --- /dev/null +++ b/app/Support/Alerts/SuspiciousConnectWarningAlert.php @@ -0,0 +1,84 @@ +warning->username)->firstOrFail(); + + $gameId = match ($this->warning->method) { + 'submitlbentry' => Leaderboard::find($this->warning->related_id)?->game_id, + default => Achievement::find($this->warning->related_id)?->game_id, + }; + + if ($gameId) { + // repeated_validation takes precedence + if (str_contains($this->warning->smells, 'repeated_validation')) { + return $this->repeatedValidationDiscordMessage($user, $gameId); + } + + if (str_contains($this->warning->smells, 'wrong_client')) { + return $this->wrongClientDiscordMessage($user, $gameId); + } + } + + return ""; + } + + /** + * "[Scott]() unlocked [Sonic the Hedgehog]() achievements using PPSSPP - [Activity]()" + */ + private function wrongClientDiscordMessage(User $user, int $gameId): string + { + $playerUrl = route('user.show', ['user' => $user]); + + $game = Game::findOrFail($gameId); + $gameUrl = route('game.show', ['game' => $game]); + + $activityUrl = route('user.game.activity.show', ['user' => $user, 'game' => $game]); + + return sprintf( + match ($this->warning->method) { + 'submitlbentry' => '[%s](<%s>) submitted [%s](<%s>) leaderboard entries using %s - [Activity](<%s>)', + default => '[%s](<%s>) unlocked [%s](<%s>) achievements using %s - [Activity](<%s>)', + }, + $user->display_name, + $playerUrl, + $game->title, + $gameUrl, + $this->warning->user_agent, + $activityUrl, + ); + } + + /** + * "[Scott]() used the same incorrect validation hash for multiple unlocks - [Activity]()" + */ + private function repeatedValidationDiscordMessage(User $user, int $gameId): string + { + $playerUrl = route('user.show', ['user' => $user]); + $activityUrl = route('user.game.activity.show', ['user' => $user, 'game' => $gameId]); + + return sprintf( + '[%s](<%s>) used the same incorrect validation hash for multiple unlocks - [Activity](<%s>)', + $user->display_name, + $playerUrl, + $activityUrl, + ); + } +} diff --git a/tests/Feature/Connect/AwardAchievementTest.php b/tests/Feature/Connect/AwardAchievementTest.php index 3984052802..2c5b8a5eaf 100644 --- a/tests/Feature/Connect/AwardAchievementTest.php +++ b/tests/Feature/Connect/AwardAchievementTest.php @@ -19,8 +19,10 @@ use App\Models\System; use App\Models\User; use App\Platform\Enums\UnlockMode; +use App\Support\Alerts\Jobs\SendAlertWebhookJob; use Illuminate\Foundation\Testing\LazilyRefreshDatabase; use Illuminate\Support\Carbon; +use Illuminate\Support\Facades\Queue; use Illuminate\Support\Str; use Tests\Feature\Concerns\TestsEmulatorUserAgent; use Tests\Feature\Platform\Concerns\TestsPlayerAchievements; @@ -149,6 +151,12 @@ public static function getWarning(Achievement $achievement): ConnectWarning ->whereNull('extra') ->first(); } + + public static function initializeFakeQueue(): void + { + Queue::fake([SendAlertWebhookJob::class]); + config(['services.discord.alerts_webhook.suspicious_connect_warning' => 'https://discord.com/api/webhooks/test']); + } } beforeEach(function () { @@ -2487,6 +2495,8 @@ public static function getWarning(Achievement $achievement): ConnectWarning $unlock1Date = $now->clone()->subMinutes(65); $this->addHardcoreUnlock($this->user, $achievement1, $unlock1Date, $gameHash); + AwardAchievementTestHelpers::initializeFakeQueue(); + // $userAgentValid is associated to the "Test Client". Attach a differing system to that. $otherSystem = System::factory()->create(); Emulator::where('name', 'Test Client')->first()->systems()->attach($otherSystem->id); @@ -2530,6 +2540,11 @@ public static function getWarning(Achievement $achievement): ConnectWarning $this->assertEquals($validationHash, $warning->validation_hash); $this->assertEquals('wrong_client', $warning->smells); $this->assertEquals($this->userAgentValid, $warning->user_agent); + + // notification should be sent + Queue::assertPushedOn('alerts', SendAlertWebhookJob::class, function ($job) { + return $job->webhookUrl === 'https://discord.com/api/webhooks/test'; + }); }); test('user agent for incorrect system on multi-system client does not generate warning', function () { @@ -2647,6 +2662,8 @@ public static function getWarning(Achievement $achievement): ConnectWarning $unlock1Date = $now->clone()->subMinutes(65); $this->addHardcoreUnlock($this->user, $achievement1, $unlock1Date, $gameHash); + AwardAchievementTestHelpers::initializeFakeQueue(); + // do the hardcore unlock $validationHash = md5('This is the wrong validation hash'); $scoreBefore = $this->user->points_hardcore; @@ -2686,6 +2703,9 @@ public static function getWarning(Achievement $achievement): ConnectWarning $this->assertEquals($validationHash, $warning->validation_hash); $this->assertEquals('bad_validation', $warning->smells); $this->assertEquals($this->userAgentValid, $warning->user_agent); + + // notification should not be sent + Queue::assertNotPushed(SendAlertWebhookJob::class); }); test('validation hash containing 0 offset still unlocks hardcore achievements', function () { @@ -2795,6 +2815,8 @@ public static function getWarning(Achievement $achievement): ConnectWarning $unlock1Date = $now->clone()->subMinutes(65); $this->addHardcoreUnlock($this->user, $achievement1, $unlock1Date, $gameHash); + AwardAchievementTestHelpers::initializeFakeQueue(); + // do the hardcore unlock $validationHash = md5('This is the wrong validation hash'); $scoreBefore = $this->user->points_hardcore; @@ -2845,5 +2867,84 @@ public static function getWarning(Achievement $achievement): ConnectWarning $this->assertEquals($validationHash, $warning->validation_hash); $this->assertEquals('bad_validation,repeated_validation', $warning->smells); $this->assertEquals($this->userAgentValid, $warning->user_agent); + + // notification should be sent + Queue::assertPushedOn('alerts', SendAlertWebhookJob::class, function ($job) { + return $job->webhookUrl === 'https://discord.com/api/webhooks/test'; + }); + }); + + test('only one warning notification per user per day', function () { + $data = AwardAchievementTestHelpers::createGame(); + $game = $data['game']; + $achievement1 = $data['achievements'][0]; + $achievement3 = $data['achievements'][2]; + $achievement4 = $data['achievements'][3]; + $gameHash = $data['gameHash']; + $now = Carbon::now(); + + $unlock1Date = $now->clone()->subMinutes(65); + $this->addHardcoreUnlock($this->user, $achievement1, $unlock1Date, $gameHash); + + AwardAchievementTestHelpers::initializeFakeQueue(); + + // do the hardcore unlock + $validationHash = md5('This is the wrong validation hash'); + $scoreBefore = $this->user->points_hardcore; + $softcoreScoreBefore = $this->user->points; + $truePointsBefore = $this->user->points_weighted; + + ConnectWarning::create([ + 'method' => 'awardachievement', + 'username' => $this->user->username, + 'related_type' => 'achievement', + 'related_id' => $achievement1->id, + 'hardcore' => 1, + 'validation_hash' => $validationHash, + 'smells' => 'bad_validation', + 'user_agent' => $this->userAgentValid, + ]); + + $this->withHeaders(['User-Agent' => $this->userAgentValid]) + ->get($this->apiUrl('awardachievement', [ + 'a' => $achievement3->id, + 'h' => 1, + 'm' => $gameHash->md5, + 'v' => $validationHash, + ])) + ->assertStatus(200) + ->assertExactJson([ + 'Success' => true, + 'AchievementID' => $achievement3->id, + 'AchievementsRemaining' => 4, + 'Score' => $scoreBefore + $achievement3->points, + 'SoftcoreScore' => $softcoreScoreBefore, + ]); + + // notification should be sent + Queue::assertPushedOn('alerts', SendAlertWebhookJob::class, function ($job) { + return $job->webhookUrl === 'https://discord.com/api/webhooks/test'; + }); + + AwardAchievementTestHelpers::initializeFakeQueue(); + + $this->withHeaders(['User-Agent' => $this->userAgentValid]) + ->get($this->apiUrl('awardachievement', [ + 'a' => $achievement4->id, + 'h' => 1, + 'm' => $gameHash->md5, + 'v' => $validationHash, + ])) + ->assertStatus(200) + ->assertExactJson([ + 'Success' => true, + 'AchievementID' => $achievement4->id, + 'AchievementsRemaining' => 3, + 'Score' => $scoreBefore + $achievement3->points + $achievement4->points, + 'SoftcoreScore' => $softcoreScoreBefore, + ]); + + // notification should not be sent + Queue::assertNotPushed(SendAlertWebhookJob::class); }); }); diff --git a/tests/Feature/Support/Alerts/SuspiciousConnectWarningAlertTest.php b/tests/Feature/Support/Alerts/SuspiciousConnectWarningAlertTest.php new file mode 100644 index 0000000000..c1c3b3ce73 --- /dev/null +++ b/tests/Feature/Support/Alerts/SuspiciousConnectWarningAlertTest.php @@ -0,0 +1,157 @@ +create(); + $user = User::factory()->create(['username' => 'Scott', 'display_name' => 'Scott']); + $game = Game::factory()->create(['title' => 'Sonic the Hedgehog', 'system_id' => $system->id]); + $achievement = Achievement::factory()->create(['game_id' => $game->id]); + + $validationHash = md5($achievement->id . $user->display_name . '1'); + $warning = new ConnectWarning([ + 'method' => 'awardachievement', + 'username' => $user->display_name, + 'user_agent' => 'TestUserAgent/1.0', + 'validation_hash' => $validationHash, + 'related_type' => 'achievement', + 'related_id' => $achievement->id, + 'hardcore' => 1, + 'smells' => 'wrong_client', + ]); + $alert = new SuspiciousConnectWarningAlert($warning); + + // Act + $message = $alert->toDiscordMessage(); + + // Assert + $this->assertStringContainsString('Scott', $message); + $this->assertStringContainsString('Sonic the Hedgehog', $message); + $this->assertStringContainsString('TestUserAgent', $message); + $this->assertStringContainsString('achievements', $message); + $this->assertStringContainsString(route('user.show', ['user' => $user]), $message); + $this->assertStringContainsString(route('game.show', ['game' => $game]), $message); + $this->assertStringContainsString(route('user.game.activity.show', ['user' => $user, 'game' => $game]), $message); + } + + public function testToWrongClientDiscordMessageFormatsCorrectlyForLeaderboard(): void + { + // Arrange + $system = System::factory()->create(); + $user = User::factory()->create(['username' => 'Scott', 'display_name' => 'Scott']); + $game = Game::factory()->create(['title' => 'Sonic the Hedgehog', 'system_id' => $system->id]); + $leaderboard = Leaderboard::factory()->create(['game_id' => $game->id]); + + $validationHash = md5($leaderboard->id . $user->display_name . '1234'); + $warning = new ConnectWarning([ + 'method' => 'submitlbentry', + 'username' => $user->display_name, + 'user_agent' => 'TestUserAgent/1.0', + 'validation_hash' => $validationHash, + 'related_type' => 'leaderboard', + 'related_id' => $leaderboard->id, + 'extra' => 1234, + 'smells' => 'wrong_client', + ]); + $alert = new SuspiciousConnectWarningAlert($warning); + + // Act + $message = $alert->toDiscordMessage(); + + // Assert + $this->assertStringContainsString('Scott', $message); + $this->assertStringContainsString('Sonic the Hedgehog', $message); + $this->assertStringContainsString('TestUserAgent', $message); + $this->assertStringContainsString('leaderboard entries', $message); + $this->assertStringContainsString(route('user.show', ['user' => $user]), $message); + $this->assertStringContainsString(route('game.show', ['game' => $game]), $message); + $this->assertStringContainsString(route('user.game.activity.show', ['user' => $user, 'game' => $game]), $message); + } + + public function testToRepeatedValidationDiscordMessageFormatsCorrectlyForAchievement(): void + { + // Arrange + $system = System::factory()->create(); + $user = User::factory()->create(['username' => 'Scott', 'display_name' => 'Scott']); + $game = Game::factory()->create(['title' => 'Sonic the Hedgehog', 'system_id' => $system->id]); + $achievement = Achievement::factory()->create(['game_id' => $game->id]); + + $validationHash = md5($achievement->id . $user->display_name . '1'); + $warning = new ConnectWarning([ + 'method' => 'awardachievement', + 'username' => $user->display_name, + 'user_agent' => 'TestUserAgent/1.0', + 'validation_hash' => $validationHash, + 'related_type' => 'achievement', + 'related_id' => $achievement->id, + 'hardcore' => 1, + 'smells' => 'repeated_validation', + ]); + $alert = new SuspiciousConnectWarningAlert($warning); + + // Act + $message = $alert->toDiscordMessage(); + + // Assert + $this->assertStringContainsString('Scott', $message); + $this->assertStringContainsString('same incorrect validation', $message); + $this->assertStringContainsString(route('user.show', ['user' => $user]), $message); + $this->assertStringContainsString(route('user.game.activity.show', ['user' => $user, 'game' => $game]), $message); + } + + public function testSendDispatchesJobWhenWebhookUrlExists(): void + { + // Arrange + Queue::fake(); + + config(['services.discord.alerts_webhook.suspicious_connect_warning' => 'https://discord.com/api/webhooks/test']); + + $system = System::factory()->create(); + $user = User::factory()->create(); + $game = Game::factory()->create(['system_id' => $system->id]); + $achievement = Achievement::factory()->create(['game_id' => $game->id]); + + $validationHash = md5($achievement->id . $user->display_name . '1'); + $warning = new ConnectWarning([ + 'method' => 'awardachievement', + 'username' => $user->display_name, + 'user_agent' => 'TestUserAgent/1.0', + 'validation_hash' => $validationHash, + 'related_type' => 'achievement', + 'related_id' => $achievement->id, + 'hardcore' => 1, + 'smells' => 'wrong_client', + ]); + $alert = new SuspiciousConnectWarningAlert($warning); + + // Act + $result = $alert->send(); + + // Assert + $this->assertTrue($result); + + Queue::assertPushedOn('alerts', SendAlertWebhookJob::class, function ($job) { + return $job->webhookUrl === 'https://discord.com/api/webhooks/test'; + }); + } +}