PasswordResetV3Test.php 6.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186
  1. <?php
  2. use App\Mail\V3\PasswordResetMail;
  3. use App\Models\UserInfo;
  4. use Illuminate\Foundation\Testing\RefreshDatabase;
  5. use Illuminate\Support\Facades\Mail;
  6. uses(RefreshDatabase::class);
  7. /**
  8. * v3 找回密码。这里断言的是形状而非契约(spectator 未安装)。
  9. *
  10. * 端点:
  11. * POST /api/v3/password-resets 发重置邮件(任何邮箱都 204)
  12. * GET /api/v3/password-resets/{token} 查看:给哪个账号、何时过期
  13. * PATCH /api/v3/password-resets/{token} 设新密码,token 作废
  14. */
  15. // 测试环境的 cache 是 array,每条测试重建应用时清零,限流计数不会串到下一条
  16. beforeEach(fn () => Mail::fake());
  17. /**
  18. * 走一遍申请流程,从发出的邮件里取回明文 token。
  19. */
  20. function requestResetToken(string $email): string
  21. {
  22. test()->postJson('/api/v3/password-resets', ['email' => $email])->assertNoContent();
  23. $token = null;
  24. Mail::assertSent(PasswordResetMail::class, function (PasswordResetMail $mail) use (&$token) {
  25. $token = substr($mail->url, strrpos($mail->url, '/') + 1);
  26. return true;
  27. });
  28. return $token;
  29. }
  30. it('mails a reset link pointing at dashboard-v6 and stores only a digest', function () {
  31. makeStudio('reset-me');
  32. $this->postJson('/api/v3/password-resets', ['email' => 'reset-me@example.test', 'lang' => 'zh-Hans'])
  33. ->assertNoContent();
  34. Mail::assertSent(PasswordResetMail::class, function (PasswordResetMail $mail) {
  35. $token = substr($mail->url, strrpos($mail->url, '/') + 1);
  36. $stored = UserInfo::where('username', 'reset-me')->value('reset_password_token');
  37. return $mail->hasTo('reset-me@example.test')
  38. && str_starts_with($mail->url, rtrim(config('mint.server.dashboard_v6_base_path'), '/').'/anonymous/reset-password/')
  39. && $mail->lang === 'zh-Hans'
  40. && $stored === hash('sha256', $token)
  41. && $stored !== $token;
  42. });
  43. });
  44. it('falls back to en for a language without a mail template', function () {
  45. makeStudio('reset-lang');
  46. $this->postJson('/api/v3/password-resets', ['email' => 'reset-lang@example.test', 'lang' => 'my'])
  47. ->assertNoContent();
  48. Mail::assertSent(PasswordResetMail::class, fn (PasswordResetMail $mail) => $mail->lang === 'en');
  49. });
  50. it('answers 204 for an unknown email without sending anything', function () {
  51. $this->postJson('/api/v3/password-resets', ['email' => 'nobody@example.test'])
  52. ->assertNoContent();
  53. Mail::assertNothingSent();
  54. });
  55. it('rejects a malformed email', function () {
  56. $this->postJson('/api/v3/password-resets', ['email' => 'not-an-email'])
  57. ->assertStatus(422)
  58. ->assertJsonPath('errors.email.0', fn ($m) => is_string($m));
  59. });
  60. it('ignores a client supplied dashboard url', function () {
  61. makeStudio('reset-host');
  62. $this->postJson('/api/v3/password-resets', [
  63. 'email' => 'reset-host@example.test',
  64. 'dashboard' => 'https://evil.example',
  65. ])->assertNoContent();
  66. Mail::assertSent(PasswordResetMail::class, fn (PasswordResetMail $mail) => ! str_contains($mail->url, 'evil.example'));
  67. });
  68. it('throttles repeated requests for the same email', function () {
  69. makeStudio('reset-flood');
  70. foreach (range(1, 5) as $_) {
  71. $this->postJson('/api/v3/password-resets', ['email' => 'reset-flood@example.test'])->assertNoContent();
  72. }
  73. $this->postJson('/api/v3/password-resets', ['email' => 'reset-flood@example.test'])->assertStatus(429);
  74. });
  75. it('shows which account a token resets', function () {
  76. makeStudio('reset-show');
  77. $token = requestResetToken('reset-show@example.test');
  78. $data = $this->getJson("/api/v3/password-resets/{$token}")
  79. ->assertOk()
  80. ->json('data');
  81. expect($data)->toHaveKeys(['username', 'expires_at'])
  82. ->and($data['username'])->toBe('reset-show')
  83. ->and($data['expires_at'])->toMatch('/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/')
  84. ->and($data)->not->toHaveKey('password');
  85. });
  86. it('answers 404 for an unknown token', function () {
  87. $this->getJson('/api/v3/password-resets/'.str_repeat('a', 64))
  88. ->assertNotFound()
  89. ->assertHeader('Content-Type', 'application/problem+json');
  90. });
  91. it('answers 404 for an expired token', function () {
  92. makeStudio('reset-old');
  93. $token = requestResetToken('reset-old@example.test');
  94. $this->travel(61)->minutes();
  95. $this->getJson("/api/v3/password-resets/{$token}")->assertNotFound();
  96. $this->patchJson("/api/v3/password-resets/{$token}", [
  97. 'password' => 'new-secret',
  98. 'password_confirmation' => 'new-secret',
  99. ])->assertNotFound();
  100. });
  101. it('does not accept the raw uuid tokens written by v2', function () {
  102. $uid = makeStudio('reset-v2');
  103. UserInfo::where('userid', $uid)->update([
  104. 'reset_password_token' => 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
  105. 'reset_password_sent_at' => now(),
  106. ]);
  107. $this->getJson('/api/v3/password-resets/'.str_repeat('a', 64))->assertNotFound();
  108. });
  109. it('sets the new password and burns the token', function () {
  110. makeStudio('reset-done');
  111. $token = requestResetToken('reset-done@example.test');
  112. $this->patchJson("/api/v3/password-resets/{$token}", [
  113. 'password' => 'new-secret',
  114. 'password_confirmation' => 'new-secret',
  115. ])->assertNoContent();
  116. $user = UserInfo::where('username', 'reset-done')->first();
  117. expect($user->password)->toBe(md5('new-secret'))
  118. ->and($user->reset_password_token)->toBeNull();
  119. // v2 登录仍认这个新密码(md5 比对),v4 / mobile 不受影响
  120. $this->postJson('/api/v2/sign-in', ['username' => 'reset-done', 'password' => 'new-secret'])
  121. ->assertJsonPath('ok', true);
  122. $this->getJson("/api/v3/password-resets/{$token}")->assertNotFound();
  123. });
  124. it('rejects a mismatched confirmation', function () {
  125. makeStudio('reset-typo');
  126. $token = requestResetToken('reset-typo@example.test');
  127. $this->patchJson("/api/v3/password-resets/{$token}", [
  128. 'password' => 'new-secret',
  129. 'password_confirmation' => 'new-secreT',
  130. ])->assertStatus(422)->assertJsonStructure(['errors' => ['password']]);
  131. expect(UserInfo::where('username', 'reset-typo')->value('password'))->toBe('x');
  132. });
  133. it('rejects a too short password', function () {
  134. makeStudio('reset-short');
  135. $token = requestResetToken('reset-short@example.test');
  136. $this->patchJson("/api/v3/password-resets/{$token}", [
  137. 'password' => '12345',
  138. 'password_confirmation' => '12345',
  139. ])->assertStatus(422);
  140. });
  141. it('does not register PUT', function () {
  142. $this->putJson('/api/v3/password-resets/'.str_repeat('a', 64), [])->assertStatus(405);
  143. });