SignUpV3Test.php 9.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268
  1. <?php
  2. use App\Mail\V3\EmailCertificationMail;
  3. use App\Models\Channel;
  4. use App\Models\Invite;
  5. use App\Models\UserInfo;
  6. use Illuminate\Foundation\Testing\RefreshDatabase;
  7. use Illuminate\Support\Facades\Mail;
  8. use Illuminate\Support\Str;
  9. uses(RefreshDatabase::class);
  10. /**
  11. * v3 注册。这里断言的是形状而非契约(spectator 未安装)。
  12. *
  13. * 端点:
  14. * POST /api/v3/email-certifications 发 6 位验证码
  15. * POST /api/v3/invites 验证码换 invite(服务端比对)
  16. * GET /api/v3/invites/{invite} 查看可用的 invite(邀请注册页)
  17. * POST /api/v3/users 凭 invite 建账号
  18. */
  19. // 测试环境的 cache 是 array,每条测试重建应用时清零,验证码与限流计数不会串
  20. beforeEach(fn () => Mail::fake());
  21. /**
  22. * 发一次验证码,从邮件里取回明文。
  23. */
  24. function requestSignUpCode(string $email): string
  25. {
  26. test()->postJson('/api/v3/email-certifications', ['email' => $email])->assertNoContent();
  27. $code = null;
  28. Mail::assertSent(EmailCertificationMail::class, function (EmailCertificationMail $mail) use ($email, &$code) {
  29. if ($mail->hasTo($email)) {
  30. $code = $mail->code;
  31. }
  32. return true;
  33. });
  34. return $code;
  35. }
  36. function makeInvite(string $email, string $status = 'invited', ?string $inviter = null): string
  37. {
  38. $id = (string) Str::uuid();
  39. (new Invite)->forceFill([
  40. 'id' => $id,
  41. 'email' => $email,
  42. 'status' => $status,
  43. 'user_uid' => $inviter ?? (string) Str::uuid(),
  44. ])->save();
  45. return $id;
  46. }
  47. /**
  48. * @return array<string, string>
  49. */
  50. function signUpPayload(string $invite, array $overrides = []): array
  51. {
  52. return array_merge([
  53. 'invite' => $invite,
  54. 'username' => 'new_user_1',
  55. 'password' => 'secret-pw',
  56. 'password_confirmation' => 'secret-pw',
  57. 'lang' => 'zh-Hans',
  58. ], $overrides);
  59. }
  60. // ── email-certifications ─────────────────────────────────────────────
  61. it('mails a 6 digit code and never returns it', function () {
  62. $response = $this->postJson('/api/v3/email-certifications', ['email' => 'fresh@example.test', 'lang' => 'zh-Hant'])
  63. ->assertNoContent();
  64. expect($response->getContent())->toBe('');
  65. Mail::assertSent(EmailCertificationMail::class, fn (EmailCertificationMail $mail) => $mail->hasTo('fresh@example.test')
  66. && preg_match('/^\d{6}$/', $mail->code) === 1
  67. && $mail->lang === 'zh-Hant');
  68. });
  69. it('refuses to send a code to a registered email, case-insensitively', function () {
  70. makeStudio('taken');
  71. $this->postJson('/api/v3/email-certifications', ['email' => 'TAKEN@example.test'])
  72. ->assertStatus(422)
  73. ->assertJsonStructure(['errors' => ['email']]);
  74. Mail::assertNothingSent();
  75. });
  76. it('rejects a malformed email', function () {
  77. $this->postJson('/api/v3/email-certifications', ['email' => 'nope'])->assertStatus(422);
  78. });
  79. it('throttles codes for the same email', function () {
  80. foreach (range(1, 5) as $_) {
  81. $this->postJson('/api/v3/email-certifications', ['email' => 'flood@example.test'])->assertNoContent();
  82. }
  83. $this->postJson('/api/v3/email-certifications', ['email' => 'flood@example.test'])->assertStatus(429);
  84. });
  85. // ── invites ──────────────────────────────────────────────────────────
  86. it('exchanges a correct code for an invite, once', function () {
  87. $code = requestSignUpCode('verify@example.test');
  88. $data = $this->postJson('/api/v3/invites', ['email' => 'verify@example.test', 'code' => $code])
  89. ->assertCreated()
  90. ->json('data');
  91. expect($data)->toHaveKeys(['id', 'email', 'status', 'created_at'])
  92. ->and($data['email'])->toBe('verify@example.test')
  93. ->and($data['status'])->toBe('invited')
  94. ->and($data)->not->toHaveKey('user_uid')
  95. ->and(Invite::where('id', $data['id'])->exists())->toBeTrue();
  96. // 验证码一次性
  97. $this->postJson('/api/v3/invites', ['email' => 'verify@example.test', 'code' => $code])
  98. ->assertStatus(422);
  99. });
  100. it('rejects a wrong code without creating an invite', function () {
  101. requestSignUpCode('wrong@example.test');
  102. $this->postJson('/api/v3/invites', ['email' => 'wrong@example.test', 'code' => '000000'])
  103. ->assertStatus(422)
  104. ->assertJsonStructure(['errors' => ['code']]);
  105. expect(Invite::where('email', 'wrong@example.test')->exists())->toBeFalse();
  106. });
  107. it('burns the code after too many wrong attempts', function () {
  108. $code = requestSignUpCode('brute@example.test');
  109. $wrong = $code === '111111' ? '222222' : '111111';
  110. foreach (range(1, 5) as $_) {
  111. $this->postJson('/api/v3/invites', ['email' => 'brute@example.test', 'code' => $wrong])->assertStatus(422);
  112. }
  113. $this->postJson('/api/v3/invites', ['email' => 'brute@example.test', 'code' => $code])->assertStatus(422);
  114. });
  115. it('reuses an existing studio invite and keeps its inviter', function () {
  116. $inviter = makeStudio('inviter');
  117. $id = makeInvite('invited@example.test', 'invited', $inviter);
  118. $code = requestSignUpCode('invited@example.test');
  119. $data = $this->postJson('/api/v3/invites', ['email' => 'invited@example.test', 'code' => $code])
  120. ->assertOk()
  121. ->json('data');
  122. expect($data['id'])->toBe($id)
  123. ->and(Invite::find($id)->user_uid)->toBe($inviter);
  124. });
  125. it('shows a pending invite', function () {
  126. $id = makeInvite('show@example.test');
  127. $this->getJson("/api/v3/invites/{$id}")
  128. ->assertOk()
  129. ->assertJsonPath('data.email', 'show@example.test');
  130. });
  131. it('hides used, unknown and malformed invites', function () {
  132. $used = makeInvite('used@example.test', 'sign-up');
  133. $this->getJson("/api/v3/invites/{$used}")->assertNotFound();
  134. $this->getJson('/api/v3/invites/'.Str::uuid())->assertNotFound();
  135. $this->getJson('/api/v3/invites/not-a-uuid')->assertNotFound();
  136. });
  137. // ── users ────────────────────────────────────────────────────────────
  138. it('registers an account the same shape v2 would', function () {
  139. $invite = makeInvite('member@example.test');
  140. $data = $this->postJson('/api/v3/users', signUpPayload($invite))
  141. ->assertCreated()
  142. ->json('data');
  143. expect($data)->toHaveKeys(['id', 'username', 'nickname', 'email', 'created_at'])
  144. ->and($data)->not->toHaveKey('password')
  145. ->and($data['email'])->toBe('member@example.test')
  146. ->and($data['nickname'])->toBe('new_user_1');
  147. $user = UserInfo::where('userid', $data['id'])->first();
  148. expect($user->password)->toBe(md5('secret-pw'))
  149. ->and(json_decode($user->role, true))->toBe(['basic']);
  150. $draft = Channel::where('owner_uid', $user->userid)->first();
  151. expect($draft->name)->toBe('draft')
  152. ->and($draft->status)->toBe(5)
  153. ->and($draft->lang)->toBe('zh-Hans')
  154. ->and((int) $draft->editor_id)->toBe($user->id);
  155. expect(Invite::find($invite)->status)->toBe('sign-up');
  156. // v2 登录认这个账号(v4 / mobile 不受影响)
  157. $this->postJson('/api/v2/sign-in', ['username' => 'new_user_1', 'password' => 'secret-pw'])
  158. ->assertJsonPath('ok', true);
  159. });
  160. it('uses the given nickname', function () {
  161. $invite = makeInvite('nick@example.test');
  162. $this->postJson('/api/v3/users', signUpPayload($invite, ['nickname' => ' 觉音 ']))
  163. ->assertCreated()
  164. ->assertJsonPath('data.nickname', '觉音');
  165. });
  166. it('rejects a used invite', function () {
  167. $invite = makeInvite('twice@example.test');
  168. $this->postJson('/api/v3/users', signUpPayload($invite))->assertCreated();
  169. $this->postJson('/api/v3/users', signUpPayload($invite, ['username' => 'another_1']))
  170. ->assertStatus(422)
  171. ->assertJsonStructure(['errors' => ['invite']]);
  172. });
  173. it('rejects an invite whose email is already registered', function () {
  174. makeStudio('already');
  175. $invite = makeInvite('already@example.test');
  176. $this->postJson('/api/v3/users', signUpPayload($invite))
  177. ->assertStatus(422)
  178. ->assertJsonStructure(['errors' => ['invite']]);
  179. });
  180. it('rejects a taken username', function () {
  181. makeStudio('new_user_1');
  182. $invite = makeInvite('dup@example.test');
  183. $this->postJson('/api/v3/users', signUpPayload($invite))
  184. ->assertStatus(422)
  185. ->assertJsonStructure(['errors' => ['username']]);
  186. });
  187. it('rejects malformed account fields', function (array $overrides, string $field) {
  188. $invite = makeInvite('bad@example.test');
  189. $this->postJson('/api/v3/users', signUpPayload($invite, $overrides))
  190. ->assertStatus(422)
  191. ->assertJsonStructure(['errors' => [$field]]);
  192. expect(Invite::find($invite)->status)->toBe('invited');
  193. })->with([
  194. 'username with non-ascii' => [['username' => 'abc中文def'], 'username'],
  195. 'username too short' => [['username' => 'abc'], 'username'],
  196. 'password mismatch' => [['password_confirmation' => 'other-pw'], 'password'],
  197. 'password too short' => [['password' => '12345', 'password_confirmation' => '12345'], 'password'],
  198. 'missing lang' => [['lang' => null], 'lang'],
  199. 'invite not a uuid' => [['invite' => 'abc'], 'invite'],
  200. ]);
  201. it('walks the whole self sign-up flow', function () {
  202. $code = requestSignUpCode('walk@example.test');
  203. $invite = $this->postJson('/api/v3/invites', ['email' => 'walk@example.test', 'code' => $code])
  204. ->assertCreated()
  205. ->json('data.id');
  206. $this->postJson('/api/v3/users', signUpPayload($invite, ['username' => 'walker_1']))
  207. ->assertCreated()
  208. ->assertJsonPath('data.email', 'walk@example.test');
  209. });