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
6declare(strict_types=1);
7
8namespace Tests\Controllers;
9
10use App\Http\Middleware\ThrottleRequests;
11use App\Libraries\User\PasswordResetData;
12use App\Mail\PasswordReset;
13use App\Models\User;
14use Carbon\CarbonImmutable;
15use Illuminate\Support\Facades\Mail;
16use Tests\TestCase;
17
18class PasswordResetControllerTest extends TestCase
19{
20 private string $origCacheDefault;
21
22 private static function randomPassword(): string
23 {
24 return str_random(10);
25 }
26
27 public function testCreate()
28 {
29 $user = User::factory()->create();
30
31 Mail::fake();
32 $this
33 ->post($this->path(), ['username' => $user->username])
34 ->assertRedirect(route('password-reset.reset', ['username' => $user->username]));
35 Mail::assertQueued(PasswordReset::class);
36 }
37
38 public function testCreateWithEmail()
39 {
40 $user = User::factory()->create();
41
42 Mail::fake();
43 $this
44 ->post($this->path(), ['username' => $user->user_email])
45 ->assertRedirect(route('password-reset.reset', ['username' => $user->user_email]));
46 Mail::assertQueued(PasswordReset::class);
47 }
48
49 public function testCreateInvalidUser()
50 {
51 Mail::fake();
52 $this->post($this->path(), ['username' => '_invaliduser'])->assertStatus(422);
53 Mail::assertNothingOutgoing();
54 }
55
56 public function testCreateSendMailOnce()
57 {
58 $user = User::factory()->create();
59
60 Mail::fake();
61 $this->post($this->path(), ['username' => $user->username])->assertRedirect();
62 $this->post($this->path(), ['username' => $user->username])->assertRedirect();
63 Mail::assertQueuedCount(1);
64 }
65
66 public function testCreateSendMailByUsernameParameter()
67 {
68 $user = User::factory()->create();
69
70 Mail::fake();
71 $this->post($this->path(), ['username' => $user->username])->assertRedirect();
72 $this->post($this->path(), ['username' => $user->user_email])->assertRedirect();
73 Mail::assertQueuedCount(2);
74 }
75
76 public function testIndex()
77 {
78 $this->get($this->path())->assertSuccessful();
79 }
80
81 public function testResendMail()
82 {
83 $user = User::factory()->create();
84
85 Mail::fake();
86 $this->generateKey($user);
87 $data = PasswordResetData::find($user, $user->username);
88 $data->attrs['canResendMailAfter'] = 0;
89 $data->save();
90
91 $this->post(route('password-reset.resend-mail', ['username' => $user->username]))->assertSuccessful();
92 Mail::assertQueuedCount(2);
93 }
94
95 public function testResendMailDuplicate()
96 {
97 $user = User::factory()->create();
98
99 Mail::fake();
100 $this->generateKey($user);
101
102 $this->post(route('password-reset.resend-mail', ['username' => $user->username]))->assertSuccessful();
103 Mail::assertQueuedCount(1);
104 }
105
106 public function testResendMailNonexistent()
107 {
108 $user = User::factory()->create();
109
110 Mail::fake();
111 $this->post(route('password-reset.resend-mail', ['username' => $user->username]))->assertRedirect($this->path());
112 Mail::assertQueuedCount(0);
113 }
114
115 public function testReset()
116 {
117 $this
118 ->get(route('password-reset.reset', ['username' => 'test']))
119 ->assertSuccessful();
120 }
121
122 public function testResetMissingUsername()
123 {
124 $this
125 ->get(route('password-reset.reset'))
126 ->assertStatus(422);
127 }
128
129 public function testUpdate()
130 {
131 $user = User::factory()->create();
132
133 $key = $this->generateKey($user);
134
135 $newPassword = static::randomPassword();
136
137 $this->put($this->path(), [
138 'key' => $key,
139 'user' => [
140 'password' => $newPassword,
141 'password_confirmation' => $newPassword,
142 ],
143 'username' => $user->username,
144 ])->assertRedirect(route('home'));
145
146 $this->assertTrue($user->fresh()->checkPassword($newPassword));
147 }
148
149 public function testUpdateChangedEmailExternally()
150 {
151 $password = static::randomPassword();
152 $user = User::factory()->create(['password' => $password, 'password_confirmation' => $password]);
153
154 $key = $this->generateKey($user);
155
156 $user->update(['user_email' => "new+{$user->user_email}"]);
157
158 $tryNewPassword = static::randomPassword();
159
160 $this->put($this->path(), [
161 'key' => $key,
162 'user' => [
163 'password' => $tryNewPassword,
164 'password_confirmation' => $tryNewPassword,
165 ],
166 'username' => $user->username,
167 ])->assertRedirect($this->path())
168 ->assertSessionHas('popup', osu_trans('password_reset.error.expired'));
169
170 $this->assertTrue($user->fresh()->checkPassword($password));
171 }
172
173 public function testUpdateChangedPasswordExternally()
174 {
175 $user = User::factory()->create();
176
177 $key = $this->generateKey($user);
178
179 $newPassword = static::randomPassword();
180
181 $user->update([
182 'password' => $newPassword,
183 'password_confirmation' => $newPassword,
184 ]);
185
186 $tryNewPassword = static::randomPassword();
187
188 $this->put($this->path(), [
189 'key' => $key,
190 'user' => [
191 'password' => $tryNewPassword,
192 'password_confirmation' => $tryNewPassword,
193 ],
194 ])->assertRedirect()
195 ->assertSessionHas('popup', osu_trans('password_reset.error.invalid'));
196
197 $this->assertTrue($user->fresh()->checkPassword($newPassword));
198 }
199
200 public function testUpdateFromInactive(): void
201 {
202 $changeTime = CarbonImmutable::now()->subMinutes(1);
203 $user = User::factory()->create(['user_lastvisit' => $changeTime->subYears(10)]);
204
205 $key = $this->generateKey($user);
206
207 $newPassword = static::randomPassword();
208
209 $this->put($this->path(), [
210 'key' => $key,
211 'user' => [
212 'password' => $newPassword,
213 'password_confirmation' => $newPassword,
214 ],
215 'username' => $user->username,
216 ])->assertRedirect(route('home'));
217
218 $user = $user->fresh();
219 $this->assertTrue($user->checkPassword($newPassword));
220 $this->assertTrue($user->user_lastvisit->greaterThan($changeTime));
221 }
222
223 public function testUpdateInvalidUsername()
224 {
225 $user = User::factory()->create();
226
227 $key = $this->generateKey($user);
228
229 $newPassword = static::randomPassword();
230
231 $this->put($this->path(), [
232 'key' => $key,
233 'user' => [
234 'password' => $newPassword,
235 'password_confirmation' => $newPassword,
236 ],
237 'username' => "x{$user->username}",
238 ])->assertRedirect($this->path())
239 ->assertSessionHas('popup', osu_trans('password_reset.error.invalid'));
240
241 $this->assertFalse($user->fresh()->checkPassword($newPassword));
242 }
243
244 public function testUpdateInvalidConfirmation()
245 {
246 $user = User::factory()->create();
247
248 $key = $this->generateKey($user);
249
250 $newPassword = static::randomPassword();
251
252 $this->put($this->path(), [
253 'key' => $key,
254 'user' => [
255 'password' => $newPassword,
256 'password_confirmation' => "{$newPassword}!",
257 ],
258 'username' => $user->username,
259 ])->assertStatus(422);
260
261 $this->assertFalse($user->fresh()->checkPassword($newPassword));
262 }
263
264 public function testUpdateInvalidKey()
265 {
266 $user = User::factory()->create();
267
268 $this->post($this->path(), ['username' => $user->username]);
269
270 $newPassword = static::randomPassword();
271
272 $this->put($this->path(), [
273 'key' => '_invalidkey',
274 'user' => [
275 'password' => $newPassword,
276 'password_confirmation' => $newPassword,
277 ],
278 'username' => $user->username,
279 ])->assertStatus(422);
280
281 $this->assertFalse($user->fresh()->checkPassword($newPassword));
282 }
283
284 protected function setUp(): void
285 {
286 parent::setUp();
287 $this->withoutMiddleware(ThrottleRequests::class);
288 // There's no easy way to clear data cache in redis otherwise
289 $this->origCacheDefault = $GLOBALS['cfg']['cache']['default'];
290 config_set('cache.default', 'array');
291 }
292
293 protected function tearDown(): void
294 {
295 parent::tearDown();
296 config_set('cache.default', $this->origCacheDefault);
297 }
298
299 private function generateKey(User $user): string
300 {
301 $username = $user->username;
302 PasswordResetData::find($user, $username)?->delete();
303 PasswordResetData::create($user, $username);
304
305 return PasswordResetData::find($user, $username)->attrs['key'];
306 }
307
308 private function path(): string
309 {
310 static $path;
311
312 return $path ??= route('password-reset');
313 }
314}