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}