Mail::fake()); /** * 发一次验证码,从邮件里取回明文。 */ function requestSignUpCode(string $email): string { test()->postJson('/api/v3/email-certifications', ['email' => $email])->assertNoContent(); $code = null; Mail::assertSent(EmailCertificationMail::class, function (EmailCertificationMail $mail) use ($email, &$code) { if ($mail->hasTo($email)) { $code = $mail->code; } return true; }); return $code; } function makeInvite(string $email, string $status = 'invited', ?string $inviter = null): string { $id = (string) Str::uuid(); (new Invite)->forceFill([ 'id' => $id, 'email' => $email, 'status' => $status, 'user_uid' => $inviter ?? (string) Str::uuid(), ])->save(); return $id; } /** * @return array */ function signUpPayload(string $invite, array $overrides = []): array { return array_merge([ 'invite' => $invite, 'username' => 'new_user_1', 'password' => 'secret-pw', 'password_confirmation' => 'secret-pw', 'lang' => 'zh-Hans', ], $overrides); } // ── email-certifications ───────────────────────────────────────────── it('mails a 6 digit code and never returns it', function () { $response = $this->postJson('/api/v3/email-certifications', ['email' => 'fresh@example.test', 'lang' => 'zh-Hant']) ->assertNoContent(); expect($response->getContent())->toBe(''); Mail::assertSent(EmailCertificationMail::class, fn (EmailCertificationMail $mail) => $mail->hasTo('fresh@example.test') && preg_match('/^\d{6}$/', $mail->code) === 1 && $mail->lang === 'zh-Hant'); }); it('refuses to send a code to a registered email, case-insensitively', function () { makeStudio('taken'); $this->postJson('/api/v3/email-certifications', ['email' => 'TAKEN@example.test']) ->assertStatus(422) ->assertJsonStructure(['errors' => ['email']]); Mail::assertNothingSent(); }); it('rejects a malformed email', function () { $this->postJson('/api/v3/email-certifications', ['email' => 'nope'])->assertStatus(422); }); it('throttles codes for the same email', function () { foreach (range(1, 5) as $_) { $this->postJson('/api/v3/email-certifications', ['email' => 'flood@example.test'])->assertNoContent(); } $this->postJson('/api/v3/email-certifications', ['email' => 'flood@example.test'])->assertStatus(429); }); // ── invites ────────────────────────────────────────────────────────── it('exchanges a correct code for an invite, once', function () { $code = requestSignUpCode('verify@example.test'); $data = $this->postJson('/api/v3/invites', ['email' => 'verify@example.test', 'code' => $code]) ->assertCreated() ->json('data'); expect($data)->toHaveKeys(['id', 'email', 'status', 'created_at']) ->and($data['email'])->toBe('verify@example.test') ->and($data['status'])->toBe('invited') ->and($data)->not->toHaveKey('user_uid') ->and(Invite::where('id', $data['id'])->exists())->toBeTrue(); // 验证码一次性 $this->postJson('/api/v3/invites', ['email' => 'verify@example.test', 'code' => $code]) ->assertStatus(422); }); it('rejects a wrong code without creating an invite', function () { requestSignUpCode('wrong@example.test'); $this->postJson('/api/v3/invites', ['email' => 'wrong@example.test', 'code' => '000000']) ->assertStatus(422) ->assertJsonStructure(['errors' => ['code']]); expect(Invite::where('email', 'wrong@example.test')->exists())->toBeFalse(); }); it('burns the code after too many wrong attempts', function () { $code = requestSignUpCode('brute@example.test'); $wrong = $code === '111111' ? '222222' : '111111'; foreach (range(1, 5) as $_) { $this->postJson('/api/v3/invites', ['email' => 'brute@example.test', 'code' => $wrong])->assertStatus(422); } $this->postJson('/api/v3/invites', ['email' => 'brute@example.test', 'code' => $code])->assertStatus(422); }); it('reuses an existing studio invite and keeps its inviter', function () { $inviter = makeStudio('inviter'); $id = makeInvite('invited@example.test', 'invited', $inviter); $code = requestSignUpCode('invited@example.test'); $data = $this->postJson('/api/v3/invites', ['email' => 'invited@example.test', 'code' => $code]) ->assertOk() ->json('data'); expect($data['id'])->toBe($id) ->and(Invite::find($id)->user_uid)->toBe($inviter); }); it('shows a pending invite', function () { $id = makeInvite('show@example.test'); $this->getJson("/api/v3/invites/{$id}") ->assertOk() ->assertJsonPath('data.email', 'show@example.test'); }); it('hides used, unknown and malformed invites', function () { $used = makeInvite('used@example.test', 'sign-up'); $this->getJson("/api/v3/invites/{$used}")->assertNotFound(); $this->getJson('/api/v3/invites/'.Str::uuid())->assertNotFound(); $this->getJson('/api/v3/invites/not-a-uuid')->assertNotFound(); }); // ── users ──────────────────────────────────────────────────────────── it('registers an account the same shape v2 would', function () { $invite = makeInvite('member@example.test'); $data = $this->postJson('/api/v3/users', signUpPayload($invite)) ->assertCreated() ->json('data'); expect($data)->toHaveKeys(['id', 'username', 'nickname', 'email', 'created_at']) ->and($data)->not->toHaveKey('password') ->and($data['email'])->toBe('member@example.test') ->and($data['nickname'])->toBe('new_user_1'); $user = UserInfo::where('userid', $data['id'])->first(); expect($user->password)->toBe(md5('secret-pw')) ->and(json_decode($user->role, true))->toBe(['basic']); $draft = Channel::where('owner_uid', $user->userid)->first(); expect($draft->name)->toBe('draft') ->and($draft->status)->toBe(5) ->and($draft->lang)->toBe('zh-Hans') ->and((int) $draft->editor_id)->toBe($user->id); expect(Invite::find($invite)->status)->toBe('sign-up'); // v2 登录认这个账号(v4 / mobile 不受影响) $this->postJson('/api/v2/sign-in', ['username' => 'new_user_1', 'password' => 'secret-pw']) ->assertJsonPath('ok', true); }); it('uses the given nickname', function () { $invite = makeInvite('nick@example.test'); $this->postJson('/api/v3/users', signUpPayload($invite, ['nickname' => ' 觉音 '])) ->assertCreated() ->assertJsonPath('data.nickname', '觉音'); }); it('rejects a used invite', function () { $invite = makeInvite('twice@example.test'); $this->postJson('/api/v3/users', signUpPayload($invite))->assertCreated(); $this->postJson('/api/v3/users', signUpPayload($invite, ['username' => 'another_1'])) ->assertStatus(422) ->assertJsonStructure(['errors' => ['invite']]); }); it('rejects an invite whose email is already registered', function () { makeStudio('already'); $invite = makeInvite('already@example.test'); $this->postJson('/api/v3/users', signUpPayload($invite)) ->assertStatus(422) ->assertJsonStructure(['errors' => ['invite']]); }); it('rejects a taken username', function () { makeStudio('new_user_1'); $invite = makeInvite('dup@example.test'); $this->postJson('/api/v3/users', signUpPayload($invite)) ->assertStatus(422) ->assertJsonStructure(['errors' => ['username']]); }); it('rejects malformed account fields', function (array $overrides, string $field) { $invite = makeInvite('bad@example.test'); $this->postJson('/api/v3/users', signUpPayload($invite, $overrides)) ->assertStatus(422) ->assertJsonStructure(['errors' => [$field]]); expect(Invite::find($invite)->status)->toBe('invited'); })->with([ 'username with non-ascii' => [['username' => 'abc中文def'], 'username'], 'username too short' => [['username' => 'abc'], 'username'], 'password mismatch' => [['password_confirmation' => 'other-pw'], 'password'], 'password too short' => [['password' => '12345', 'password_confirmation' => '12345'], 'password'], 'missing lang' => [['lang' => null], 'lang'], 'invite not a uuid' => [['invite' => 'abc'], 'invite'], ]); it('walks the whole self sign-up flow', function () { $code = requestSignUpCode('walk@example.test'); $invite = $this->postJson('/api/v3/invites', ['email' => 'walk@example.test', 'code' => $code]) ->assertCreated() ->json('data.id'); $this->postJson('/api/v3/users', signUpPayload($invite, ['username' => 'walker_1'])) ->assertCreated() ->assertJsonPath('data.email', 'walk@example.test'); });