the browser-facing portion of osu!
at master 13 kB view raw
1<?php 2 3// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the GNU Affero General Public License v3.0. 4// See the LICENCE file in the repository root for full licence text. 5 6namespace Tests; 7 8use App\Events\NewPrivateNotificationEvent; 9use App\Http\Middleware\AuthApi; 10use App\Jobs\Notifications\BroadcastNotificationBase; 11use App\Libraries\OAuth\EncodeToken; 12use App\Libraries\Search\ScoreSearch; 13use App\Libraries\Session\Store as SessionStore; 14use App\Models\Beatmapset; 15use App\Models\Build; 16use App\Models\Multiplayer\PlaylistItem; 17use App\Models\Multiplayer\ScoreLink; 18use App\Models\OAuth\Client; 19use App\Models\ScoreToken; 20use App\Models\User; 21use Artisan; 22use Carbon\CarbonInterface; 23use DMS\PHPUnitExtensions\ArraySubset\ArraySubsetAsserts; 24use Illuminate\Database\DatabaseManager; 25use Illuminate\Database\Eloquent\Model; 26use Illuminate\Foundation\Testing\DatabaseTransactions; 27use Illuminate\Foundation\Testing\TestCase as BaseTestCase; 28use Illuminate\Support\Testing\Fakes\MailFake; 29use Laravel\Passport\Passport; 30use Laravel\Passport\Token; 31use Queue; 32use ReflectionMethod; 33use ReflectionProperty; 34 35class TestCase extends BaseTestCase 36{ 37 use ArraySubsetAsserts, CreatesApplication, DatabaseTransactions; 38 39 protected $connectionsToTransact = [ 40 'mysql', 41 'mysql-chat', 42 'mysql-mp', 43 'mysql-store', 44 'mysql-updates', 45 ]; 46 47 protected array $expectedCountsCallbacks = []; 48 49 public static function regularOAuthScopesDataProvider() 50 { 51 $data = []; 52 53 foreach (Passport::scopes()->pluck('id') as $scope) { 54 // just skip over any scopes that require special conditions for now. 55 if (in_array($scope, ['chat.read', 'chat.write', 'chat.write_manage', 'delegate'], true)) { 56 continue; 57 } 58 59 $data[] = [$scope]; 60 } 61 62 return $data; 63 } 64 65 public static function withDbAccess(callable $callback): void 66 { 67 $db = static::createApp()->make('db'); 68 69 $callback(); 70 71 static::resetAppDb($db); 72 } 73 74 protected static function createClientToken(Build $build, ?int $clientTime = null): string 75 { 76 $data = strtoupper(bin2hex($build->hash).bin2hex(pack('V', $clientTime ?? time()))); 77 $expected = hash_hmac('sha1', $data, ''); 78 79 return strtoupper(bin2hex(random_bytes(40)).$data.$expected.'00'); 80 } 81 82 protected static function fileList($path, $suffix) 83 { 84 return array_map( 85 fn ($file) => [basename($file, $suffix), $path], 86 glob("{$path}/*{$suffix}"), 87 ); 88 } 89 90 protected static function reindexScores() 91 { 92 $search = new ScoreSearch(); 93 $search->deleteAll(); 94 $search->refresh(); 95 Artisan::call('es:index-scores:queue', [ 96 '--all' => true, 97 '--no-interaction' => true, 98 ]); 99 $search->indexWait(); 100 } 101 102 protected static function resetAppDb(DatabaseManager $database): void 103 { 104 foreach (array_keys($GLOBALS['cfg']['database']['connections']) as $name) { 105 $connection = $database->connection($name); 106 107 $connection->rollBack(); 108 $connection->disconnect(); 109 } 110 } 111 112 protected static function roomAddPlay(User $user, PlaylistItem $playlistItem, array $scoreParams): ScoreLink 113 { 114 return $playlistItem->room->completePlay( 115 static::roomStartPlay($user, $playlistItem), 116 [ 117 'accuracy' => 0.5, 118 'beatmap_id' => $playlistItem->beatmap_id, 119 'ended_at' => json_time(new \DateTime()), 120 'max_combo' => 1, 121 'ruleset_id' => $playlistItem->ruleset_id, 122 'statistics' => ['good' => 1], 123 'total_score' => 10, 124 'user_id' => $user->getKey(), 125 ...$scoreParams, 126 ], 127 ); 128 } 129 130 protected static function roomStartPlay(User $user, PlaylistItem $playlistItem): ScoreToken 131 { 132 return $playlistItem->room->startPlay($user, $playlistItem, [ 133 'beatmap_hash' => $playlistItem->beatmap->checksum, 134 'build_id' => 0, 135 ]); 136 } 137 138 protected function setUp(): void 139 { 140 $this->beforeApplicationDestroyed(fn () => $this->runExpectedCountsCallbacks()); 141 142 parent::setUp(); 143 144 // change config setting because we need more than 1 for the tests. 145 config_set('osu.oauth.max_user_clients', 100); 146 147 // Disable caching for the BeatmapTagsController and TagsController tests 148 // because otherwise multiple run of the tests may use stale cache data. 149 config_set('osu.tags.beatmap_tags_cache_duration', 0); 150 config_set('osu.tags.tags_cache_duration', 0); 151 152 // Force connections to reset even if transactional tests were not used. 153 // Should fix tests going wonky when different queue drivers are used, or anything that 154 // breaks assumptions of object destructor timing. 155 $db = $this->app->make('db'); 156 $this->beforeApplicationDestroyed(fn () => static::resetAppDb($db)); 157 } 158 159 protected function tearDown(): void 160 { 161 parent::tearDown(); 162 163 $this->expectedCountsCallbacks = []; 164 } 165 166 /** 167 * Act as a User with OAuth scope permissions. 168 */ 169 protected function actAsScopedUser(?User $user, ?array $scopes = ['*'], ?Client $client = null): static 170 { 171 return $this->actingWithToken($this->createToken( 172 $user, 173 $scopes, 174 $client ?? Client::factory()->create(), 175 )); 176 } 177 178 protected function actAsUser(?User $user, bool $verified = false, $driver = null): static 179 { 180 if ($user !== null) { 181 $this->be($user, $driver)->withSession(['verified' => $verified]); 182 } 183 184 return $this; 185 } 186 187 /** 188 * This is for tests that will skip the request middleware stack. 189 * 190 * @param Token $token OAuth token. 191 * @param string $driver Auth driver to use. 192 * @return void 193 */ 194 protected function actAsUserWithToken(Token $token, $driver = null): static 195 { 196 $guard = app('auth')->guard($driver); 197 $user = $token->getResourceOwner(); 198 199 if ($user === null) { 200 $guard->logout(); 201 } else { 202 $guard->setUser($user); 203 $user->withAccessToken($token); 204 } 205 206 // This is for test that do not make actual requests; 207 // tests that make requests will override this value with a new one 208 // and the token gets resolved in middleware. 209 request()->attributes->set(AuthApi::REQUEST_OAUTH_TOKEN_KEY, $token); 210 211 app('auth')->shouldUse($driver); 212 213 return $this; 214 } 215 216 protected function actingAsVerified($user): static 217 { 218 return $this->actAsUser($user, true); 219 } 220 221 protected function actingWithToken($token): static 222 { 223 return $this->actAsUserWithToken($token) 224 ->withToken(EncodeToken::encodeAccessToken($token)); 225 } 226 227 protected function assertEqualsUpToOneSecond(CarbonInterface $expected, CarbonInterface $actual): void 228 { 229 $this->assertTrue($expected->diffInSeconds($actual) < 2); 230 } 231 232 protected function createAllowedScopesDataProvider(array $allowedScopes) 233 { 234 $data = Passport::scopes()->pluck('id')->map(function ($scope) use ($allowedScopes) { 235 return [[$scope], in_array($scope, $allowedScopes, true)]; 236 })->all(); 237 238 // scopeless tokens should fail in general. 239 $data[] = [[], false]; 240 241 return $data; 242 } 243 244 protected function createVerifiedSession($user): SessionStore 245 { 246 $ret = SessionStore::findOrNew(); 247 $ret->put(\Auth::getName(), $user->getKey()); 248 $ret->put('verified', true); 249 $ret->migrate(false); 250 $ret->save(); 251 252 return $ret; 253 } 254 255 protected function clearMailFake() 256 { 257 $mailer = app('mailer'); 258 if ($mailer instanceof MailFake) { 259 $this->invokeSetProperty($mailer, 'mailables', []); 260 $this->invokeSetProperty($mailer, 'queuedMailables', []); 261 } 262 } 263 264 /** 265 * Creates an OAuth token for the specified authorizing user. 266 * 267 * @param User|null $user The user that authorized the token. 268 * @param array|null $scopes scopes granted 269 * @param Client|null $client The client the token belongs to. 270 * @return Token 271 */ 272 protected function createToken(?User $user, ?array $scopes = null, ?Client $client = null) 273 { 274 return ($client ?? Client::factory()->create())->tokens()->create([ 275 'expires_at' => now()->addDays(1), 276 'id' => uniqid(), 277 'revoked' => false, 278 'scopes' => $scopes, 279 'user_id' => $user?->getKey(), 280 'verified' => true, 281 ]); 282 } 283 284 protected function expectCountChange(callable $callback, int $change, string $message = '') 285 { 286 $traceEntry = debug_backtrace(0, 1)[0]; 287 if ($message !== '') { 288 $message .= "\n"; 289 } 290 $message .= "{$traceEntry['file']}:{$traceEntry['line']}"; 291 292 $this->expectedCountsCallbacks[] = [ 293 'callback' => $callback, 294 'expected' => $callback() + $change, 295 'message' => $message, 296 ]; 297 } 298 299 protected function expectExceptionCallable(callable $callable, ?string $exceptionClass, ?string $exceptionMessage = null) 300 { 301 try { 302 $callable(); 303 } catch (\Throwable $e) { 304 $this->assertSame($exceptionClass, $e::class, "{$e->getFile()}:{$e->getLine()}"); 305 306 if ($exceptionMessage !== null) { 307 $this->assertSame($exceptionMessage, $e->getMessage()); 308 } 309 310 return; 311 } 312 313 // trigger fail if expecting exception but doesn't fail. 314 if ($exceptionClass !== null) { 315 static::fail("Did not throw expected {$exceptionClass}"); 316 } 317 } 318 319 protected function inReceivers(Model $model, NewPrivateNotificationEvent|BroadcastNotificationBase $obj): bool 320 { 321 return in_array($model->getKey(), $obj->getReceiverIds(), true); 322 } 323 324 protected function invokeMethod($obj, string $name, array $params = []) 325 { 326 $method = new ReflectionMethod($obj, $name); 327 $method->setAccessible(true); 328 329 return $method->invokeArgs($obj, $params); 330 } 331 332 protected function invokeProperty($obj, string $name) 333 { 334 $property = new ReflectionProperty($obj, $name); 335 $property->setAccessible(true); 336 337 return $property->getValue($obj); 338 } 339 340 protected function invokeSetProperty($obj, string $name, $value) 341 { 342 $property = new ReflectionProperty($obj, $name); 343 $property->setAccessible(true); 344 345 $property->setValue($obj, $value); 346 } 347 348 protected function makeBeatmapsetDiscussionPostParams(Beatmapset $beatmapset, string $messageType) 349 { 350 return [ 351 'beatmapset_id' => $beatmapset->getKey(), 352 'beatmap_discussion' => [ 353 'message_type' => $messageType, 354 ], 355 'beatmap_discussion_post' => [ 356 'message' => 'Hello', 357 ], 358 ]; 359 } 360 361 protected function normalizeHTML($html) 362 { 363 return str_replace('<br />', "<br />\n", str_replace("\n", '', preg_replace('/>\s*</s', '><', trim($html)))); 364 } 365 366 protected function runFakeQueue() 367 { 368 collect(Queue::pushedJobs())->flatten(1)->each(function ($job) { 369 $job['job']->handle(); 370 }); 371 372 // clear queue jobs after running 373 // FIXME: this won't work if a job queues another job and you want to run that job. 374 $this->invokeSetProperty(app('queue'), 'jobs', []); 375 } 376 377 protected function withInterOpHeader($url, ?callable $callback = null) 378 { 379 if ($callback === null) { 380 $timestampedUrl = $url; 381 } else { 382 $connector = strpos($url, '?') === false ? '?' : '&'; 383 $timestampedUrl = $url.$connector.'timestamp='.time(); 384 } 385 386 $this->withHeaders([ 387 'X-LIO-Signature' => hash_hmac('sha1', $timestampedUrl, $GLOBALS['cfg']['osu']['legacy']['shared_interop_secret']), 388 ]); 389 390 return $callback === null ? $this : $callback($timestampedUrl); 391 } 392 393 protected function withPersistentSession(SessionStore $session): static 394 { 395 $session->save(); 396 397 return $this->withCookies([ 398 $session->getName() => $session->getId(), 399 ]); 400 } 401 402 private function runExpectedCountsCallbacks() 403 { 404 foreach ($this->expectedCountsCallbacks as $expectedCount) { 405 $after = $expectedCount['callback'](); 406 $this->assertSame($expectedCount['expected'], $after, $expectedCount['message']); 407 } 408 } 409}