request('POST', '/api/auth/magic-link', ['email' => 'ada@example.com']); self::assertSame(202, $response->getStatusCode()); } public function test_a_magic_link_request_validates_the_address(): void { $response = $this->request('POST', '/api/auth/magic-link', ['email' => 'not-an-email']); self::assertSame(422, $response->getStatusCode()); self::assertArrayHasKey('email', $this->decode($response)['error']['details']); } public function test_opening_the_link_creates_the_account_and_signs_in(): void { $this->request('POST', '/api/auth/magic-link', ['email' => 'Ada@example.com']); $response = $this->request('POST', '/api/auth/verify-email', ['token' => $this->tokenFromEmail()]); self::assertSame(200, $response->getStatusCode()); $body = $this->decode($response); self::assertSame('ada@example.com', $body['user']['email']); self::assertTrue($body['user']['email_verified']); self::assertNotNull($body['user']['email_verified_at']); self::assertNull($body['user']['pending_email']); self::assertArrayNotHasKey('password', $body['user']); self::assertArrayNotHasKey('password_hash', $body['user']); self::assertNotEmpty($body['token']); } public function test_a_second_request_signs_in_the_same_account(): void { $this->request('POST', '/api/auth/magic-link', ['email' => 'ada@example.com']); $firstId = $this->decode( $this->request('POST', '/api/auth/verify-email', ['token' => $this->tokenFromEmail()]), )['user']['id']; $this->db()->prepare('UPDATE users SET verification_email_sent_at = NULL WHERE email = :e') ->execute(['e' => 'ada@example.com']); $this->request('POST', '/api/auth/magic-link', ['email' => 'ADA@EXAMPLE.COM']); $secondId = $this->decode( $this->request('POST', '/api/auth/verify-email', ['token' => $this->tokenFromEmail()]), )['user']['id']; self::assertSame($firstId, $secondId); } public function test_the_link_is_not_sent_again_within_the_resend_interval(): void { $this->request('POST', '/api/auth/magic-link', ['email' => 'ada@example.com']); $response = $this->request('POST', '/api/auth/magic-link', ['email' => 'ada@example.com']); self::assertSame(202, $response->getStatusCode()); self::assertCount(1, $this->sentEmails()); } public function test_the_resend_interval_is_configurable(): void { $this->reconfigure(['MAGIC_LINK_RESEND_SECONDS' => '0']); $this->request('POST', '/api/auth/magic-link', ['email' => 'ada@example.com']); $this->request('POST', '/api/auth/magic-link', ['email' => 'ada@example.com']); self::assertCount(2, $this->sentEmails()); } public function test_me_requires_a_valid_token(): void { $unauthorised = $this->request('GET', '/api/me'); self::assertSame(401, $unauthorised->getStatusCode()); $response = $this->request('GET', '/api/me', null, $this->authHeader('linus@example.com')); self::assertSame(200, $response->getStatusCode()); self::assertSame('linus@example.com', $this->decode($response)['user']['email']); } public function test_registration_can_be_turned_off_for_a_new_address(): void { $this->reconfigure(['APP_ALLOW_REGISTRATION' => 'false']); $response = $this->request('POST', '/api/auth/magic-link', ['email' => 'nobody@example.com']); // Same response either way -- no enumeration signal. self::assertSame(202, $response->getStatusCode()); self::assertSame([], $this->sentEmails()); self::assertSame(0, (int) $this->db()->query('SELECT COUNT(*) FROM users')->fetchColumn()); } public function test_turning_off_registration_does_not_block_an_existing_user(): void { // Sign up while registration is still open. $this->request('POST', '/api/auth/magic-link', ['email' => 'ada@example.com']); $this->request('POST', '/api/auth/verify-email', ['token' => $this->tokenFromEmail()]); $this->db()->prepare('UPDATE users SET verification_email_sent_at = NULL WHERE email = :e') ->execute(['e' => 'ada@example.com']); $this->reconfigure(['APP_ALLOW_REGISTRATION' => 'false']); $response = $this->request('POST', '/api/auth/magic-link', ['email' => 'ada@example.com']); self::assertSame(202, $response->getStatusCode()); self::assertSame('ada@example.com', $this->lastEmail()['to']); } }