. Licensed under the GNU Affero General Public License v3.0. // See the LICENCE file in the repository root for full licence text. namespace Tests; use App\Events\NewPrivateNotificationEvent; use App\Http\Middleware\AuthApi; use App\Jobs\Notifications\BroadcastNotificationBase; use App\Libraries\OAuth\EncodeToken; use App\Libraries\Search\ScoreSearch; use App\Libraries\Session\Store as SessionStore; use App\Models\Beatmapset; use App\Models\Build; use App\Models\Multiplayer\PlaylistItem; use App\Models\Multiplayer\ScoreLink; use App\Models\OAuth\Client; use App\Models\ScoreToken; use App\Models\User; use Artisan; use Carbon\CarbonInterface; use DMS\PHPUnitExtensions\ArraySubset\ArraySubsetAsserts; use Illuminate\Database\DatabaseManager; use Illuminate\Database\Eloquent\Model; use Illuminate\Foundation\Testing\DatabaseTransactions; use Illuminate\Foundation\Testing\TestCase as BaseTestCase; use Illuminate\Support\Testing\Fakes\MailFake; use Laravel\Passport\Passport; use Laravel\Passport\Token; use Queue; use ReflectionMethod; use ReflectionProperty; class TestCase extends BaseTestCase { use ArraySubsetAsserts, CreatesApplication, DatabaseTransactions; protected $connectionsToTransact = [ 'mysql', 'mysql-chat', 'mysql-mp', 'mysql-store', 'mysql-updates', ]; protected array $expectedCountsCallbacks = []; public static function regularOAuthScopesDataProvider() { $data = []; foreach (Passport::scopes()->pluck('id') as $scope) { // just skip over any scopes that require special conditions for now. if (in_array($scope, ['chat.read', 'chat.write', 'chat.write_manage', 'delegate'], true)) { continue; } $data[] = [$scope]; } return $data; } public static function withDbAccess(callable $callback): void { $db = static::createApp()->make('db'); $callback(); static::resetAppDb($db); } protected static function createClientToken(Build $build, ?int $clientTime = null): string { $data = strtoupper(bin2hex($build->hash).bin2hex(pack('V', $clientTime ?? time()))); $expected = hash_hmac('sha1', $data, ''); return strtoupper(bin2hex(random_bytes(40)).$data.$expected.'00'); } protected static function fileList($path, $suffix) { return array_map( fn ($file) => [basename($file, $suffix), $path], glob("{$path}/*{$suffix}"), ); } protected static function reindexScores() { $search = new ScoreSearch(); $search->deleteAll(); $search->refresh(); Artisan::call('es:index-scores:queue', [ '--all' => true, '--no-interaction' => true, ]); $search->indexWait(); } protected static function resetAppDb(DatabaseManager $database): void { foreach (array_keys($GLOBALS['cfg']['database']['connections']) as $name) { $connection = $database->connection($name); $connection->rollBack(); $connection->disconnect(); } } protected static function roomAddPlay(User $user, PlaylistItem $playlistItem, array $scoreParams): ScoreLink { return $playlistItem->room->completePlay( static::roomStartPlay($user, $playlistItem), [ 'accuracy' => 0.5, 'beatmap_id' => $playlistItem->beatmap_id, 'ended_at' => json_time(new \DateTime()), 'max_combo' => 1, 'ruleset_id' => $playlistItem->ruleset_id, 'statistics' => ['good' => 1], 'total_score' => 10, 'user_id' => $user->getKey(), ...$scoreParams, ], ); } protected static function roomStartPlay(User $user, PlaylistItem $playlistItem): ScoreToken { return $playlistItem->room->startPlay($user, $playlistItem, [ 'beatmap_hash' => $playlistItem->beatmap->checksum, 'build_id' => 0, ]); } protected function setUp(): void { $this->beforeApplicationDestroyed(fn () => $this->runExpectedCountsCallbacks()); parent::setUp(); // change config setting because we need more than 1 for the tests. config_set('osu.oauth.max_user_clients', 100); // Disable caching for the BeatmapTagsController and TagsController tests // because otherwise multiple run of the tests may use stale cache data. config_set('osu.tags.beatmap_tags_cache_duration', 0); config_set('osu.tags.tags_cache_duration', 0); // Force connections to reset even if transactional tests were not used. // Should fix tests going wonky when different queue drivers are used, or anything that // breaks assumptions of object destructor timing. $db = $this->app->make('db'); $this->beforeApplicationDestroyed(fn () => static::resetAppDb($db)); } protected function tearDown(): void { parent::tearDown(); $this->expectedCountsCallbacks = []; } /** * Act as a User with OAuth scope permissions. */ protected function actAsScopedUser(?User $user, ?array $scopes = ['*'], ?Client $client = null): static { return $this->actingWithToken($this->createToken( $user, $scopes, $client ?? Client::factory()->create(), )); } protected function actAsUser(?User $user, bool $verified = false, $driver = null): static { if ($user !== null) { $this->be($user, $driver)->withSession(['verified' => $verified]); } return $this; } /** * This is for tests that will skip the request middleware stack. * * @param Token $token OAuth token. * @param string $driver Auth driver to use. * @return void */ protected function actAsUserWithToken(Token $token, $driver = null): static { $guard = app('auth')->guard($driver); $user = $token->getResourceOwner(); if ($user === null) { $guard->logout(); } else { $guard->setUser($user); $user->withAccessToken($token); } // This is for test that do not make actual requests; // tests that make requests will override this value with a new one // and the token gets resolved in middleware. request()->attributes->set(AuthApi::REQUEST_OAUTH_TOKEN_KEY, $token); app('auth')->shouldUse($driver); return $this; } protected function actingAsVerified($user): static { return $this->actAsUser($user, true); } protected function actingWithToken($token): static { return $this->actAsUserWithToken($token) ->withToken(EncodeToken::encodeAccessToken($token)); } protected function assertEqualsUpToOneSecond(CarbonInterface $expected, CarbonInterface $actual): void { $this->assertTrue($expected->diffInSeconds($actual) < 2); } protected function createAllowedScopesDataProvider(array $allowedScopes) { $data = Passport::scopes()->pluck('id')->map(function ($scope) use ($allowedScopes) { return [[$scope], in_array($scope, $allowedScopes, true)]; })->all(); // scopeless tokens should fail in general. $data[] = [[], false]; return $data; } protected function createVerifiedSession($user): SessionStore { $ret = SessionStore::findOrNew(); $ret->put(\Auth::getName(), $user->getKey()); $ret->put('verified', true); $ret->migrate(false); $ret->save(); return $ret; } protected function clearMailFake() { $mailer = app('mailer'); if ($mailer instanceof MailFake) { $this->invokeSetProperty($mailer, 'mailables', []); $this->invokeSetProperty($mailer, 'queuedMailables', []); } } /** * Creates an OAuth token for the specified authorizing user. * * @param User|null $user The user that authorized the token. * @param array|null $scopes scopes granted * @param Client|null $client The client the token belongs to. * @return Token */ protected function createToken(?User $user, ?array $scopes = null, ?Client $client = null) { return ($client ?? Client::factory()->create())->tokens()->create([ 'expires_at' => now()->addDays(1), 'id' => uniqid(), 'revoked' => false, 'scopes' => $scopes, 'user_id' => $user?->getKey(), 'verified' => true, ]); } protected function expectCountChange(callable $callback, int $change, string $message = '') { $traceEntry = debug_backtrace(0, 1)[0]; if ($message !== '') { $message .= "\n"; } $message .= "{$traceEntry['file']}:{$traceEntry['line']}"; $this->expectedCountsCallbacks[] = [ 'callback' => $callback, 'expected' => $callback() + $change, 'message' => $message, ]; } protected function expectExceptionCallable(callable $callable, ?string $exceptionClass, ?string $exceptionMessage = null) { try { $callable(); } catch (\Throwable $e) { $this->assertSame($exceptionClass, $e::class, "{$e->getFile()}:{$e->getLine()}"); if ($exceptionMessage !== null) { $this->assertSame($exceptionMessage, $e->getMessage()); } return; } // trigger fail if expecting exception but doesn't fail. if ($exceptionClass !== null) { static::fail("Did not throw expected {$exceptionClass}"); } } protected function inReceivers(Model $model, NewPrivateNotificationEvent|BroadcastNotificationBase $obj): bool { return in_array($model->getKey(), $obj->getReceiverIds(), true); } protected function invokeMethod($obj, string $name, array $params = []) { $method = new ReflectionMethod($obj, $name); $method->setAccessible(true); return $method->invokeArgs($obj, $params); } protected function invokeProperty($obj, string $name) { $property = new ReflectionProperty($obj, $name); $property->setAccessible(true); return $property->getValue($obj); } protected function invokeSetProperty($obj, string $name, $value) { $property = new ReflectionProperty($obj, $name); $property->setAccessible(true); $property->setValue($obj, $value); } protected function makeBeatmapsetDiscussionPostParams(Beatmapset $beatmapset, string $messageType) { return [ 'beatmapset_id' => $beatmapset->getKey(), 'beatmap_discussion' => [ 'message_type' => $messageType, ], 'beatmap_discussion_post' => [ 'message' => 'Hello', ], ]; } protected function normalizeHTML($html) { return str_replace('
', "
\n", str_replace("\n", '', preg_replace('/>\s*<', trim($html)))); } protected function runFakeQueue() { collect(Queue::pushedJobs())->flatten(1)->each(function ($job) { $job['job']->handle(); }); // clear queue jobs after running // FIXME: this won't work if a job queues another job and you want to run that job. $this->invokeSetProperty(app('queue'), 'jobs', []); } protected function withInterOpHeader($url, ?callable $callback = null) { if ($callback === null) { $timestampedUrl = $url; } else { $connector = strpos($url, '?') === false ? '?' : '&'; $timestampedUrl = $url.$connector.'timestamp='.time(); } $this->withHeaders([ 'X-LIO-Signature' => hash_hmac('sha1', $timestampedUrl, $GLOBALS['cfg']['osu']['legacy']['shared_interop_secret']), ]); return $callback === null ? $this : $callback($timestampedUrl); } protected function withPersistentSession(SessionStore $session): static { $session->save(); return $this->withCookies([ $session->getName() => $session->getId(), ]); } private function runExpectedCountsCallbacks() { foreach ($this->expectedCountsCallbacks as $expectedCount) { $after = $expectedCount['callback'](); $this->assertSame($expectedCount['expected'], $after, $expectedCount['message']); } } }