getProject()['$id']; $cacheKey = $projectId; if (!empty(self::$sessionData[$cacheKey])) { return self::$sessionData[$cacheKey]; } // First create an account $accountData = $this->setupAccount(); $email = $accountData['email']; $password = $accountData['password']; $response = $this->client->call(Client::METHOD_POST, '/account/sessions/email', array_merge([ 'origin' => 'http://localhost', 'content-type' => 'application/json', 'x-appwrite-project' => $projectId, ]), [ 'email' => $email, 'password' => $password, ]); $sessionId = $response['body']['$id']; $session = $response['cookies']['a_session_' . $projectId]; self::$sessionData[$cacheKey] = array_merge($accountData, [ 'sessionId' => $sessionId, 'session' => $session, ]); return self::$sessionData[$cacheKey]; } /** * Helper to create a fresh account with session (bypasses cache). * Use this when you need a predictable session/log count for testing. */ protected function createFreshAccountWithSession(): array { $projectId = $this->getProject()['$id']; // Use more entropy to avoid collisions in parallel test execution $email = uniqid('', true) . getmypid() . bin2hex(random_bytes(4)) . '@localhost.test'; $password = 'password'; $name = 'User Name'; $response = $this->client->call(Client::METHOD_POST, '/account', array_merge([ 'origin' => 'http://localhost', 'content-type' => 'application/json', 'x-appwrite-project' => $projectId, ]), [ 'userId' => ID::unique(), 'email' => $email, 'password' => $password, 'name' => $name, ]); $this->assertEquals(201, $response['headers']['status-code']); $id = $response['body']['$id']; // Create session $response = $this->client->call(Client::METHOD_POST, '/account/sessions/email', array_merge([ 'origin' => 'http://localhost', 'content-type' => 'application/json', 'x-appwrite-project' => $projectId, ]), [ 'email' => $email, 'password' => $password, ]); $this->assertEquals(201, $response['headers']['status-code']); $sessionId = $response['body']['$id']; $session = $response['cookies']['a_session_' . $projectId]; return [ 'id' => $id, 'email' => $email, 'password' => $password, 'name' => $name, 'sessionId' => $sessionId, 'session' => $session, ]; } /** * Helper to set up a basic account */ protected function setupAccount(): array { $projectId = $this->getProject()['$id']; $cacheKey = $projectId; if (!empty(self::$accountData[$cacheKey])) { return self::$accountData[$cacheKey]; } $email = uniqid() . 'user@localhost.test'; $password = 'password'; $name = 'User Name'; $response = $this->client->call(Client::METHOD_POST, '/account', array_merge([ 'origin' => 'http://localhost', 'content-type' => 'application/json', 'x-appwrite-project' => $projectId, ]), [ 'userId' => ID::unique(), 'email' => $email, 'password' => $password, 'name' => $name, ]); $id = $response['body']['$id']; self::$accountData[$cacheKey] = [ 'id' => $id, 'email' => $email, 'password' => $password, 'name' => $name, ]; return self::$accountData[$cacheKey]; } /** * Helper to set up account with updated name */ protected function setupAccountWithUpdatedName(): array { $projectId = $this->getProject()['$id']; $cacheKey = $projectId; if (!empty(self::$updatedNameData[$cacheKey])) { return self::$updatedNameData[$cacheKey]; } $data = $this->setupAccountWithSession(); $session = $data['session']; $newName = 'Lorem'; $this->client->call(Client::METHOD_PATCH, '/account/name', array_merge([ 'origin' => 'http://localhost', 'content-type' => 'application/json', 'x-appwrite-project' => $projectId, 'cookie' => 'a_session_' . $projectId . '=' . $session, ]), [ 'name' => $newName ]); self::$updatedNameData[$cacheKey] = array_merge($data, ['name' => $newName]); return self::$updatedNameData[$cacheKey]; } /** * Helper to set up account with updated password */ protected function setupAccountWithUpdatedPassword(): array { $projectId = $this->getProject()['$id']; $cacheKey = $projectId; if (!empty(self::$updatedPasswordData[$cacheKey])) { return self::$updatedPasswordData[$cacheKey]; } $data = $this->setupAccountWithUpdatedName(); $session = $data['session']; $password = $data['password']; $this->client->call(Client::METHOD_PATCH, '/account/password', array_merge([ 'origin' => 'http://localhost', 'content-type' => 'application/json', 'x-appwrite-project' => $projectId, 'cookie' => 'a_session_' . $projectId . '=' . $session, ]), [ 'password' => 'new-password', 'oldPassword' => $password, ]); self::$updatedPasswordData[$cacheKey] = array_merge($data, ['password' => 'new-password']); return self::$updatedPasswordData[$cacheKey]; } /** * Helper to set up account with updated email */ protected function setupAccountWithUpdatedEmail(): array { $projectId = $this->getProject()['$id']; $cacheKey = $projectId; if (!empty(self::$updatedEmailData[$cacheKey])) { return self::$updatedEmailData[$cacheKey]; } $data = $this->setupAccountWithUpdatedPassword(); $session = $data['session']; $newEmail = uniqid() . 'new@localhost.test'; $this->client->call(Client::METHOD_PATCH, '/account/email', array_merge([ 'origin' => 'http://localhost', 'content-type' => 'application/json', 'x-appwrite-project' => $projectId, 'cookie' => 'a_session_' . $projectId . '=' . $session, ]), [ 'email' => $newEmail, 'password' => 'new-password', ]); self::$updatedEmailData[$cacheKey] = array_merge($data, ['email' => $newEmail]); return self::$updatedEmailData[$cacheKey]; } /** * Helper to set up account with updated prefs */ protected function setupAccountWithUpdatedPrefs(): array { $projectId = $this->getProject()['$id']; $cacheKey = $projectId; if (!empty(self::$updatedPrefsData[$cacheKey])) { return self::$updatedPrefsData[$cacheKey]; } $data = $this->setupAccountWithUpdatedEmail(); $session = $data['session']; $this->client->call(Client::METHOD_PATCH, '/account/prefs', array_merge([ 'origin' => 'http://localhost', 'content-type' => 'application/json', 'x-appwrite-project' => $projectId, 'cookie' => 'a_session_' . $projectId . '=' . $session, ]), [ 'prefs' => [ 'prefKey1' => 'prefValue1', 'prefKey2' => 'prefValue2', ] ]); self::$updatedPrefsData[$cacheKey] = $data; return self::$updatedPrefsData[$cacheKey]; } /** * Helper to set up account with verification created */ protected function setupAccountWithVerification(): array { $projectId = $this->getProject()['$id']; $cacheKey = $projectId; if (!empty(self::$verificationData[$cacheKey])) { return self::$verificationData[$cacheKey]; } $data = $this->setupAccountWithUpdatedPrefs(); $email = $data['email']; $session = $data['session']; $this->client->call(Client::METHOD_POST, '/account/verification', array_merge([ 'origin' => 'http://localhost', 'content-type' => 'application/json', 'x-appwrite-project' => $projectId, 'cookie' => 'a_session_' . $projectId . '=' . $session, ]), [ 'url' => 'http://localhost/verification', ]); $lastEmail = $this->getLastEmailByAddress($email); $tokens = $this->extractQueryParamsFromEmailLink($lastEmail['html']); $verification = $tokens['secret']; self::$verificationData[$cacheKey] = array_merge($data, ['verification' => $verification]); return self::$verificationData[$cacheKey]; } /** * Helper to set up account with verified email */ protected function setupAccountWithVerifiedEmail(): array { $projectId = $this->getProject()['$id']; $cacheKey = $projectId; if (!empty(self::$verifiedData[$cacheKey])) { return self::$verifiedData[$cacheKey]; } $data = $this->setupAccountWithVerification(); $id = $data['id']; $session = $data['session']; $verification = $data['verification']; $this->client->call(Client::METHOD_PUT, '/account/verification', array_merge([ 'origin' => 'http://localhost', 'content-type' => 'application/json', 'x-appwrite-project' => $projectId, 'cookie' => 'a_session_' . $projectId . '=' . $session, ]), [ 'userId' => $id, 'secret' => $verification, ]); self::$verifiedData[$cacheKey] = $data; return self::$verifiedData[$cacheKey]; } /** * Helper to set up account with recovery token */ protected function setupAccountWithRecovery(): array { $projectId = $this->getProject()['$id']; $cacheKey = $projectId; if (!empty(self::$recoveryData[$cacheKey])) { return self::$recoveryData[$cacheKey]; } $data = $this->setupAccountWithVerifiedEmail(); $email = $data['email']; $this->client->call(Client::METHOD_POST, '/account/recovery', array_merge([ 'origin' => 'http://localhost', 'content-type' => 'application/json', 'x-appwrite-project' => $projectId, ]), [ 'email' => $email, 'url' => 'http://localhost/recovery', ]); $lastEmail = $this->getLastEmailByAddress($email, function ($email) { $this->assertStringContainsString('Password Reset', $email['subject']); }); $tokens = $this->extractQueryParamsFromEmailLink($lastEmail['html']); $recovery = $tokens['secret']; self::$recoveryData[$cacheKey] = array_merge($data, ['recovery' => $recovery]); return self::$recoveryData[$cacheKey]; } /** * Helper to set up phone account */ protected function setupPhoneAccount(): array { $projectId = $this->getProject()['$id']; $cacheKey = $projectId; if (!empty(self::$phoneData[$cacheKey])) { return self::$phoneData[$cacheKey]; } // Ensure phone auth is enabled (may have been disabled by testPhoneVerification in parallel) $this->ensurePhoneAuthEnabled(); // Use a truly unique phone number for parallel test safety // Combine microtime, PID, and random digits to avoid collisions across parallel processes $number = '+1' . substr(str_replace('.', '', microtime(true)) . getmypid() . random_int(100, 999), -9); $response = $this->client->call(Client::METHOD_POST, '/account/tokens/phone', array_merge([ 'origin' => 'http://localhost', 'content-type' => 'application/json', 'x-appwrite-project' => $projectId, ]), [ 'userId' => ID::unique(), 'phone' => $number, ]); $this->assertEquals(201, $response['headers']['status-code']); $userId = $response['body']['userId']; $smsRequest = $this->getLastRequestForProject( $projectId, Scope::REQUEST_TYPE_SMS, [ 'header_X-Username' => 'username', 'header_X-Key' => 'password', 'method' => 'POST', ], probe: function (array $request) use ($number) { $this->assertEquals($number, $request['data']['to'] ?? null); } ); $this->assertNotEmpty($smsRequest, 'SMS request not found for phone number: ' . $number); self::$phoneData[$cacheKey] = [ 'token' => $smsRequest['data']['message'], 'id' => $userId, 'number' => $number, ]; return self::$phoneData[$cacheKey]; } /** * Helper to set up phone session */ protected function setupPhoneSession(): array { $projectId = $this->getProject()['$id']; $cacheKey = $projectId; if (!empty(self::$phoneSessionData[$cacheKey])) { return self::$phoneSessionData[$cacheKey]; } // Try up to 3 times with fresh phone accounts if session creation fails $maxRetries = 3; $lastError = ''; for ($attempt = 0; $attempt < $maxRetries; $attempt++) { // Force fresh phone account on retry if ($attempt > 0) { unset(self::$phoneData[$cacheKey]); \usleep(500000); // 500ms between retries } $data = $this->setupPhoneAccount(); $id = $data['id']; // Extract OTP token - try the raw message first, then first word $rawMessage = $data['token']; $token = \trim($rawMessage); if (\str_contains($token, ' ')) { $token = \explode(' ', $token)[0]; } $response = $this->client->call(Client::METHOD_PUT, '/account/sessions/phone', array_merge([ 'origin' => 'http://localhost', 'content-type' => 'application/json', 'x-appwrite-project' => $projectId, ]), [ 'userId' => $id, 'secret' => $token, ]); if ($response['headers']['status-code'] === 201) { $session = $response['cookies']['a_session_' . $projectId]; self::$phoneSessionData[$cacheKey] = array_merge($data, ['session' => $session]); return self::$phoneSessionData[$cacheKey]; } $lastError = 'Attempt ' . ($attempt + 1) . ': Phone session creation failed (status ' . $response['headers']['status-code'] . '). Token: "' . $token . '", Raw message: "' . $rawMessage . '", UserId: ' . $id; } $this->fail($lastError); } /** * Helper to set up phone account converted to password */ protected function setupPhoneConvertedToPassword(): array { $projectId = $this->getProject()['$id']; $cacheKey = $projectId; if (!empty(self::$phonePasswordData[$cacheKey])) { return self::$phonePasswordData[$cacheKey]; } $data = $this->setupPhoneSession(); $session = $data['session']; $email = uniqid() . 'new@localhost.test'; $password = 'new-password'; $response = $this->client->call(Client::METHOD_PATCH, '/account/email', array_merge([ 'origin' => 'http://localhost', 'content-type' => 'application/json', 'x-appwrite-project' => $projectId, 'cookie' => 'a_session_' . $projectId . '=' . $session, ]), [ 'email' => $email, 'password' => $password, ]); $this->assertEquals(200, $response['headers']['status-code']); // Re-login with email to get a fresh session after credential change $response = $this->client->call(Client::METHOD_POST, '/account/sessions/email', array_merge([ 'origin' => 'http://localhost', 'content-type' => 'application/json', 'x-appwrite-project' => $projectId, ]), [ 'email' => $email, 'password' => $password, ]); $this->assertEquals(201, $response['headers']['status-code']); $session = $response['cookies']['a_session_' . $projectId]; self::$phonePasswordData[$cacheKey] = array_merge($data, [ 'email' => $email, 'password' => $password, 'session' => $session, ]); return self::$phonePasswordData[$cacheKey]; } /** * Helper to set up phone account with updated phone */ protected function setupPhoneUpdated(): array { $projectId = $this->getProject()['$id']; $cacheKey = $projectId; if (!empty(self::$phoneUpdatedData[$cacheKey])) { return self::$phoneUpdatedData[$cacheKey]; } $data = $this->setupPhoneConvertedToPassword(); $session = $data['session']; // Use a truly unique phone number to avoid target conflicts across parallel test runs $newPhone = '+456' . substr(str_replace('.', '', microtime(true)) . getmypid() . random_int(100, 999), -8); $response = $this->client->call(Client::METHOD_PATCH, '/account/phone', array_merge([ 'origin' => 'http://localhost', 'content-type' => 'application/json', 'x-appwrite-project' => $projectId, 'cookie' => 'a_session_' . $projectId . '=' . $session, ]), [ 'phone' => $newPhone, 'password' => 'new-password' ]); $this->assertEquals(200, $response['headers']['status-code']); self::$phoneUpdatedData[$cacheKey] = array_merge($data, ['phone' => $newPhone]); return self::$phoneUpdatedData[$cacheKey]; } /** * Helper to set up phone verification */ protected function setupPhoneVerification(): array { $projectId = $this->getProject()['$id']; $cacheKey = $projectId; if (!empty(self::$phoneVerificationData[$cacheKey])) { return self::$phoneVerificationData[$cacheKey]; } $data = $this->setupPhoneUpdated(); $session = $data['session']; $phone = $data['phone']; $response = $this->client->call(Client::METHOD_POST, '/account/verification/phone', array_merge([ 'origin' => 'http://localhost', 'content-type' => 'application/json', 'x-appwrite-project' => $projectId, 'cookie' => 'a_session_' . $projectId . '=' . $session, ])); $this->assertEquals(201, $response['headers']['status-code']); $tokenCreatedAt = $response['body']['$createdAt']; $smsRequest = $this->getLastRequestForProject( $projectId, Scope::REQUEST_TYPE_SMS, [ 'header_X-Username' => 'username', 'header_X-Key' => 'password', 'method' => 'POST', ], probe: function (array $request) use ($tokenCreatedAt, $phone) { if (!empty($phone)) { $this->assertEquals($phone, $request['data']['to'] ?? null); } $tokenRecievedAt = $request['time']; $this->assertGreaterThan($tokenCreatedAt, $tokenRecievedAt); } ); $this->assertNotEmpty($smsRequest, 'SMS request not found for phone verification'); self::$phoneVerificationData[$cacheKey] = array_merge($data, [ 'token' => \substr($smsRequest['data']['message'], 0, 6) ]); return self::$phoneVerificationData[$cacheKey]; } /** * Helper to set up magic URL account */ protected function setupMagicUrl(): array { $projectId = $this->getProject()['$id']; $cacheKey = $projectId; if (!empty(self::$magicUrlData[$cacheKey])) { return self::$magicUrlData[$cacheKey]; } $email = \time() . 'user@appwrite.io'; $response = $this->client->call(Client::METHOD_POST, '/account/tokens/magic-url', array_merge([ 'origin' => 'http://localhost', 'content-type' => 'application/json', 'x-appwrite-project' => $projectId, ]), [ 'userId' => ID::unique(), 'email' => $email, ]); $userId = $response['body']['userId']; $lastEmail = $this->getLastEmailByAddress($email); $token = substr($lastEmail['text'], strpos($lastEmail['text'], '&secret=', 0) + 8, 64); self::$magicUrlData[$cacheKey] = [ 'token' => $token, 'id' => $userId, 'email' => $email, ]; return self::$magicUrlData[$cacheKey]; } /** * Helper to set up magic URL session */ protected function setupMagicUrlSession(): array { $projectId = $this->getProject()['$id']; $cacheKey = $projectId; if (!empty(self::$magicUrlSessionData[$cacheKey])) { return self::$magicUrlSessionData[$cacheKey]; } $data = $this->setupMagicUrl(); $id = $data['id']; $token = $data['token']; $response = $this->client->call(Client::METHOD_PUT, '/account/sessions/magic-url', array_merge([ 'origin' => 'http://localhost', 'content-type' => 'application/json', 'x-appwrite-project' => $projectId, ]), [ 'userId' => $id, 'secret' => $token, ]); $sessionId = $response['body']['$id']; $session = $response['cookies']['a_session_' . $projectId]; self::$magicUrlSessionData[$cacheKey] = array_merge($data, [ 'sessionId' => $sessionId, 'session' => $session, ]); return self::$magicUrlSessionData[$cacheKey]; } /** * Helper to create an anonymous session (returns new session each time) */ protected function createAnonymousSession(): string { $projectId = $this->getProject()['$id']; $response = $this->client->call(Client::METHOD_POST, '/account/sessions/anonymous', [ 'origin' => 'http://localhost', 'content-type' => 'application/json', 'x-appwrite-project' => $projectId, ]); return $response['cookies']['a_session_' . $projectId]; } /** * Helper to delete any existing user with the given email. * Used to prevent parallel test conflicts when tests share * hardcoded emails (e.g. from mock OAuth providers). */ protected function deleteUserByEmail(string $email): void { $projectId = $this->getProject()['$id']; $apiKey = $this->getProject()['apiKey']; $response = $this->client->call(Client::METHOD_GET, '/users', [ 'content-type' => 'application/json', 'x-appwrite-project' => $projectId, 'x-appwrite-key' => $apiKey, ], [ 'queries' => [ Query::equal('email', [$email])->toString(), ], ]); if ($response['headers']['status-code'] === 200) { foreach ($response['body']['users'] ?? [] as $user) { $this->client->call(Client::METHOD_DELETE, '/users/' . $user['$id'], [ 'content-type' => 'application/json', 'x-appwrite-project' => $projectId, 'x-appwrite-key' => $apiKey, ]); } } } /** * Helper to ensure phone auth is enabled for the project. * Needed because testPhoneVerification disables it and other * parallel tests may need it. */ protected function ensurePhoneAuthEnabled(): void { $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $this->getProject()['$id'] . '/auth/phone', array_merge([ 'origin' => 'http://localhost', 'content-type' => 'application/json', 'x-appwrite-project' => 'console', 'x-appwrite-response-format' => '1.9.1', 'cookie' => 'a_session_console=' . $this->getRoot()['session'], ]), [ 'status' => true, ]); } public function testCreateAccountSession(): void { $data = $this->setupAccount(); $email = $data['email']; $password = $data['password']; /** * Test for SUCCESS */ $response = $this->client->call(Client::METHOD_POST, '/account/sessions/email', array_merge([ 'origin' => 'http://localhost', 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ]), [ 'email' => $email, 'password' => $password, ]); $this->assertEquals(201, $response['headers']['status-code']); $this->assertNotFalse(\DateTime::createFromFormat('Y-m-d\TH:i:s.uP', $response['body']['expire'])); $sessionId = $response['body']['$id']; $session = $response['cookies']['a_session_' . $this->getProject()['$id']]; $accountResponse = $this->client->call(Client::METHOD_GET, '/account', array_merge([ 'origin' => 'http://localhost', 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $session, ])); $this->assertEquals(200, $accountResponse['headers']['status-code']); $this->assertEquals($email, $accountResponse['body']['email']); // apiKey is only available in custom client test $apiKey = $this->getProject()['apiKey']; if (!empty($apiKey)) { $userId = $response['body']['userId']; $response = $this->client->call(Client::METHOD_GET, '/users/' . $userId, array_merge([ 'origin' => 'http://localhost', 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $apiKey, ])); $this->assertEquals(200, $response['headers']['status-code']); $this->assertArrayHasKey('accessedAt', $response['body']); $this->assertNotEmpty($response['body']['accessedAt']); } $response = $this->client->call(Client::METHOD_POST, '/account/sessions', array_merge([ 'origin' => 'http://localhost', 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ]), [ 'email' => $email, 'password' => $password, ]); $this->assertEquals(201, $response['headers']['status-code']); $this->assertEmpty($response['body']['secret']); $this->assertNotFalse(\DateTime::createFromFormat('Y-m-d\TH:i:s.uP', $response['body']['expire'])); /** * Test for FAILURE */ $response = $this->client->call(Client::METHOD_POST, '/account/sessions/email', array_merge([ 'origin' => 'http://localhost', 'content-type' => 'application/json', 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $session, 'x-appwrite-project' => $this->getProject()['$id'], ]), [ 'email' => $email, 'password' => $password, ]); $this->assertEquals(401, $response['headers']['status-code']); $response = $this->client->call(Client::METHOD_POST, '/account/sessions/email', array_merge([ 'origin' => 'http://localhost', 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ]), [ 'email' => $email . 'x', 'password' => $password, ]); $this->assertEquals(401, $response['headers']['status-code']); $response = $this->client->call(Client::METHOD_POST, '/account/sessions/email', array_merge([ 'origin' => 'http://localhost', 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ]), [ 'email' => $email, 'password' => $password . 'x', ]); $this->assertEquals(401, $response['headers']['status-code']); $response = $this->client->call(Client::METHOD_POST, '/account/sessions/email', array_merge([ 'origin' => 'http://localhost', 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ]), [ 'email' => '', 'password' => '', ]); $this->assertEquals(400, $response['headers']['status-code']); } public function testGetAccount(): void { $data = $this->setupAccountWithSession(); $email = $data['email']; $name = $data['name']; $session = $data['session']; /** * Test for SUCCESS */ $response = $this->client->call(Client::METHOD_GET, '/account', array_merge([ 'origin' => 'http://localhost', 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $session, ])); $this->assertEquals(200, $response['headers']['status-code']); $this->assertNotEmpty($response['body']); $this->assertNotEmpty($response['body']['$id']); $this->assertTrue((new DatetimeValidator())->isValid($response['body']['registration'])); $this->assertEquals($response['body']['email'], $email); $this->assertEquals($response['body']['name'], $name); $this->assertArrayHasKey('accessedAt', $response['body']); $this->assertNotEmpty($response['body']['accessedAt']); /** * Test for FAILURE */ $response = $this->client->call(Client::METHOD_GET, '/account', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ])); $this->assertEquals(401, $response['headers']['status-code']); $response = $this->client->call(Client::METHOD_GET, '/account', [ 'content-type' => 'application/json', 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $session . 'xx', 'x-appwrite-project' => $this->getProject()['$id'], ]); $this->assertEquals(401, $response['headers']['status-code']); } public function testGetAccountPrefs(): void { $data = $this->setupAccountWithSession(); $session = $data['session']; /** * Test for SUCCESS */ $response = $this->client->call(Client::METHOD_GET, '/account/prefs', array_merge([ 'origin' => 'http://localhost', 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $session, ])); $this->assertEquals(200, $response['headers']['status-code']); $this->assertIsArray($response['body']); $this->assertEmpty($response['body']); $this->assertCount(0, $response['body']); /** * Test for FAILURE */ $response = $this->client->call(Client::METHOD_GET, '/account/prefs', array_merge([ 'origin' => 'http://localhost', 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ])); $this->assertEquals(401, $response['headers']['status-code']); } public function testGetAccountSessions(): void { // Use fresh account for predictable session count $data = $this->createFreshAccountWithSession(); $session = $data['session']; $sessionId = $data['sessionId']; /** * Test for SUCCESS */ $response = $this->client->call(Client::METHOD_GET, '/account/sessions', array_merge([ 'origin' => 'http://localhost', 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $session, ])); $this->assertEquals(200, $response['headers']['status-code']); $this->assertEquals(1, $response['body']['total']); $this->assertEquals($sessionId, $response['body']['sessions'][0]['$id']); $this->assertEmpty($response['body']['sessions'][0]['secret']); $this->assertEquals('Windows', $response['body']['sessions'][0]['osName']); $this->assertEquals('WIN', $response['body']['sessions'][0]['osCode']); $this->assertEquals('10', $response['body']['sessions'][0]['osVersion']); $this->assertEquals('browser', $response['body']['sessions'][0]['clientType']); $this->assertEquals('Chrome', $response['body']['sessions'][0]['clientName']); $this->assertEquals('CH', $response['body']['sessions'][0]['clientCode']); $this->assertEquals('70.0', $response['body']['sessions'][0]['clientVersion']); $this->assertEquals('Blink', $response['body']['sessions'][0]['clientEngine']); $this->assertEquals('desktop', $response['body']['sessions'][0]['deviceName']); $this->assertEquals('', $response['body']['sessions'][0]['deviceBrand']); $this->assertEquals('', $response['body']['sessions'][0]['deviceModel']); $this->assertEquals($response['body']['sessions'][0]['ip'], filter_var($response['body']['sessions'][0]['ip'], FILTER_VALIDATE_IP)); $this->assertEquals('--', $response['body']['sessions'][0]['countryCode']); $this->assertEquals('Unknown', $response['body']['sessions'][0]['countryName']); $this->assertEquals(true, $response['body']['sessions'][0]['current']); $this->assertNotFalse(\DateTime::createFromFormat('Y-m-d\TH:i:s.uP', $response['body']['sessions'][0]['expire'])); /** * Test for FAILURE */ $response = $this->client->call(Client::METHOD_GET, '/account/sessions', array_merge([ 'origin' => 'http://localhost', 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ])); $this->assertEquals(401, $response['headers']['status-code']); } public function testGetAccountLogs(): void { // Use fresh account for predictable log count $data = $this->createFreshAccountWithSession(); $session = $data['session']; $headers = array_merge([ 'origin' => 'http://localhost', 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $session, ]); /** * Test for SUCCESS */ $this->assertEventually(function () use ($headers) { $response = $this->client->call(Client::METHOD_GET, '/account/logs', $headers); $this->assertEquals(200, $response['headers']['status-code']); $this->assertIsArray($response['body']['logs']); $this->assertNotEmpty($response['body']['logs']); $logCount = count($response['body']['logs']); $this->assertContains($logCount, [1, 2]); $this->assertIsNumeric($response['body']['total']); $this->assertEquals('session.create', $response['body']['logs'][0]['event']); $this->assertEquals('Windows', $response['body']['logs'][0]['osName']); $this->assertEquals('WIN', $response['body']['logs'][0]['osCode']); $this->assertEquals('10', $response['body']['logs'][0]['osVersion']); $this->assertEquals('browser', $response['body']['logs'][0]['clientType']); $this->assertEquals('Chrome', $response['body']['logs'][0]['clientName']); $this->assertEquals('CH', $response['body']['logs'][0]['clientCode']); $this->assertEquals('70.0', $response['body']['logs'][0]['clientVersion']); $this->assertEquals('Blink', $response['body']['logs'][0]['clientEngine']); $this->assertEquals('desktop', $response['body']['logs'][0]['deviceName']); $this->assertEquals('', $response['body']['logs'][0]['deviceBrand']); $this->assertEquals('', $response['body']['logs'][0]['deviceModel']); $this->assertEquals(filter_var($response['body']['logs'][0]['ip'], FILTER_VALIDATE_IP), $response['body']['logs'][0]['ip']); $this->assertEquals('--', $response['body']['logs'][0]['countryCode']); $this->assertEquals('Unknown', $response['body']['logs'][0]['countryName']); if ($logCount === 2) { $this->assertEquals('user.create', $response['body']['logs'][1]['event']); $this->assertEquals(filter_var($response['body']['logs'][1]['ip'], FILTER_VALIDATE_IP), $response['body']['logs'][1]['ip']); $this->assertTrue((new DatetimeValidator())->isValid($response['body']['logs'][1]['time'])); } $responseLimit = $this->client->call(Client::METHOD_GET, '/account/logs', $headers, [ 'queries' => [ Query::limit(1)->toString() ] ]); $this->assertEquals(200, $responseLimit['headers']['status-code']); $this->assertIsArray($responseLimit['body']['logs']); $this->assertNotEmpty($responseLimit['body']['logs']); $this->assertCount(1, $responseLimit['body']['logs']); $this->assertIsNumeric($responseLimit['body']['total']); $this->assertEquals($response['body']['logs'][0], $responseLimit['body']['logs'][0]); $responseOffset = $this->client->call(Client::METHOD_GET, '/account/logs', $headers, [ 'queries' => [ Query::offset(1)->toString() ] ]); $this->assertEquals(200, $responseOffset['headers']['status-code']); $this->assertIsArray($responseOffset['body']['logs']); $this->assertCount($logCount - 1, $responseOffset['body']['logs']); $this->assertIsNumeric($responseOffset['body']['total']); if ($logCount === 2) { $this->assertEquals($response['body']['logs'][1], $responseOffset['body']['logs'][0]); } $responseLimitOffset = $this->client->call(Client::METHOD_GET, '/account/logs', $headers, [ 'queries' => [ Query::offset(1)->toString(), Query::limit(1)->toString() ] ]); $this->assertEquals(200, $responseLimitOffset['headers']['status-code']); $this->assertIsArray($responseLimitOffset['body']['logs']); $this->assertCount(min(1, $logCount - 1), $responseLimitOffset['body']['logs']); $this->assertIsNumeric($responseLimitOffset['body']['total']); if ($logCount === 2) { $this->assertEquals($response['body']['logs'][1], $responseLimitOffset['body']['logs'][0]); } }); /** * Test for total=false */ $logsWithIncludeTotalFalse = $this->client->call(Client::METHOD_GET, '/account/logs', $headers, [ 'total' => false ]); $this->assertEquals(200, $logsWithIncludeTotalFalse['headers']['status-code']); $this->assertIsArray($logsWithIncludeTotalFalse['body']); $this->assertIsArray($logsWithIncludeTotalFalse['body']['logs']); $this->assertIsInt($logsWithIncludeTotalFalse['body']['total']); $this->assertEquals(0, $logsWithIncludeTotalFalse['body']['total']); $this->assertGreaterThan(0, count($logsWithIncludeTotalFalse['body']['logs'])); /** * Test for FAILURE */ $response = $this->client->call(Client::METHOD_GET, '/account/logs', array_merge([ 'origin' => 'http://localhost', 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ])); $this->assertEquals(401, $response['headers']['status-code']); } // TODO Add tests for OAuth2 session creation public function testUpdateAccountName(): void { $data = $this->setupAccountWithSession(); $email = $data['email']; $session = $data['session']; $newName = 'Lorem'; /** * Test for SUCCESS */ $response = $this->client->call(Client::METHOD_PATCH, '/account/name', array_merge([ 'origin' => 'http://localhost', 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $session, ]), [ 'name' => $newName ]); $this->assertEquals(200, $response['headers']['status-code']); $this->assertIsArray($response['body']); $this->assertNotEmpty($response['body']); $this->assertNotEmpty($response['body']['$id']); $this->assertTrue((new DatetimeValidator())->isValid($response['body']['registration'])); $this->assertEquals($response['body']['email'], $email); $this->assertEquals($response['body']['name'], $newName); /** * Test for FAILURE */ $response = $this->client->call(Client::METHOD_PATCH, '/account/name', array_merge([ 'origin' => 'http://localhost', 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ])); $this->assertEquals(401, $response['headers']['status-code']); $response = $this->client->call(Client::METHOD_PATCH, '/account/name', array_merge([ 'origin' => 'http://localhost', 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $session, ]), []); $this->assertEquals(400, $response['headers']['status-code']); $response = $this->client->call(Client::METHOD_PATCH, '/account/name', array_merge([ 'origin' => 'http://localhost', 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $session, ]), [ 'name' => 'ocSRq1d3QphHivJyUmYY7WMnrxyjdk5YvVwcDqx2zS0coxESN8RmsQwLWw5Whnf0WbVohuFWTRAaoKgCOO0Y0M7LwgFnZmi8881Y72222222222222222222222222222' ]); $this->assertEquals(400, $response['headers']['status-code']); } #[Retry(count: 1)] public function testUpdateAccountPassword(): void { $data = $this->setupAccountWithUpdatedName(); $email = $data['email']; $password = $data['password']; $session = $data['session']; for ($i = 0; $i < 5; $i++) { $response = $this->client->call(Client::METHOD_POST, '/account/sessions/email', array_merge([ 'origin' => 'http://localhost', 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ]), [ 'email' => $email, 'password' => $password, ]); $this->assertEquals(201, $response['headers']['status-code']); usleep(500000); } $response = $this->client->call(Client::METHOD_GET, '/account/sessions', array_merge([ 'origin' => 'http://localhost', 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $session, ])); $allSessions = array_map(fn ($sessionDetails) => $sessionDetails['$id'], $response['body']['sessions']); /** * Test for SUCCESS */ $response = $this->client->call(Client::METHOD_PATCH, '/account/password', array_merge([ 'origin' => 'http://localhost', 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $session, ]), [ 'password' => 'new-password', 'oldPassword' => $password, ]); $this->assertEquals(200, $response['headers']['status-code']); $this->assertIsArray($response['body']); $this->assertNotEmpty($response['body']); $this->assertNotEmpty($response['body']['$id']); $this->assertTrue((new DatetimeValidator())->isValid($response['body']['registration'])); $this->assertEquals($response['body']['email'], $email); $currentSessionId = $data['sessionId'] ?? ''; $response = $this->client->call(Client::METHOD_GET, '/account/sessions', array_merge([ 'origin' => 'http://localhost', 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $session, ])); $this->assertEquals(200, $response['headers']['status-code']); $this->assertEquals(1, $response['body']['total']); // checking the current session or not $this->assertEquals($currentSessionId, $response['body']['sessions'][0]['$id']); $this->assertTrue($response['body']['sessions'][0]['current']); // checking for all non active sessions are cleared foreach ($allSessions as $sessionId) { if ($currentSessionId === $sessionId) { $response = $this->client->call(Client::METHOD_GET, '/account/sessions/current', array_merge([ 'origin' => 'http://localhost', 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $session, ])); $this->assertEquals(200, $response['headers']['status-code']); } else { $response = $this->client->call(Client::METHOD_GET, '/account/sessions/'.$sessionId, array_merge([ 'origin' => 'http://localhost', 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $session, ])); $this->assertEquals(404, $response['headers']['status-code']); } } $newPassword = 'new-password'; // updating the invalidateSession to false to check sessions are not invalidated $this->updateProjectinvalidateSessionsProperty(false); for ($i = 0; $i < 5; $i++) { $response = $this->client->call(Client::METHOD_POST, '/account/sessions/email', array_merge([ 'origin' => 'http://localhost', 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ]), [ 'email' => $email, 'password' => $newPassword, ]); $this->assertEquals(201, $response['headers']['status-code']); usleep(500000); } $response = $this->client->call(Client::METHOD_GET, '/account/sessions', array_merge([ 'origin' => 'http://localhost', 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $session, ])); $allSessions = array_map(fn ($sessionDetails) => $sessionDetails['$id'], $response['body']['sessions']); $response = $this->client->call(Client::METHOD_PATCH, '/account/password', array_merge([ 'origin' => 'http://localhost', 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $session, ]), [ 'password' => $newPassword, 'oldPassword' => $newPassword, ]); $this->assertEquals(200, $response['headers']['status-code']); foreach ($allSessions as $sessionId) { $response = $this->client->call(Client::METHOD_GET, '/account/sessions/'.$sessionId, headers: array_merge([ 'origin' => 'http://localhost', 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $session, ])); $this->assertEquals(200, $response['headers']['status-code']); } // setting invalidateSession to true to check the sessions are cleared or not $this->updateProjectinvalidateSessionsProperty(true); $response = $this->client->call(Client::METHOD_PATCH, '/account/password', array_merge([ 'origin' => 'http://localhost', 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $session, ]), [ 'password' => $newPassword, 'oldPassword' => $newPassword, ]); $this->assertEquals(200, $response['headers']['status-code']); $response = $this->client->call(Client::METHOD_GET, '/account/sessions', array_merge([ 'origin' => 'http://localhost', 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $session, ])); $allSessions = array_map(fn ($sessionDetails) => $sessionDetails['$id'], $response['body']['sessions']); foreach ($allSessions as $sessionId) { if ($currentSessionId !== $sessionId) { $response = $this->client->call(Client::METHOD_GET, '/account/sessions/'.$sessionId, array_merge([ 'origin' => 'http://localhost', 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $session, ])); $this->assertEquals(404, $response['headers']['status-code']); } } $response = $this->client->call(Client::METHOD_POST, '/account/sessions/email', array_merge([ 'origin' => 'http://localhost', 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ]), [ 'email' => $email, 'password' => $newPassword, ]); $this->assertEquals(201, $response['headers']['status-code']); /** * Test for FAILURE */ $response = $this->client->call(Client::METHOD_PATCH, '/account/password', array_merge([ 'origin' => 'http://localhost', 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ])); $this->assertEquals(401, $response['headers']['status-code']); $response = $this->client->call(Client::METHOD_PATCH, '/account/password', array_merge([ 'origin' => 'http://localhost', 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $session, ]), []); $this->assertEquals(400, $response['headers']['status-code']); /** * Existing user tries to update password by passing wrong old password -> SHOULD FAIL */ $response = $this->client->call(Client::METHOD_PATCH, '/account/password', array_merge([ 'origin' => 'http://localhost', 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $session, ]), [ 'password' => 'new-password', 'oldPassword' => $password, ]); $this->assertEquals(401, $response['headers']['status-code']); /** * Existing user tries to update password without passing old password -> SHOULD FAIL */ $response = $this->client->call(Client::METHOD_PATCH, '/account/password', array_merge([ 'origin' => 'http://localhost', 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $session, ]), [ 'password' => 'new-password' ]); $this->assertEquals(401, $response['headers']['status-code']); } public function testUpdateAccountEmail(): void { $data = $this->setupAccountWithUpdatedPassword(); $newEmail = uniqid() . 'new@localhost.test'; $session = $data['session']; /** * Test for SUCCESS */ $response = $this->client->call(Client::METHOD_PATCH, '/account/email', array_merge([ 'origin' => 'http://localhost', 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $session, ]), [ 'email' => $newEmail, 'password' => 'new-password', ]); $this->assertEquals(200, $response['headers']['status-code']); $this->assertIsArray($response['body']); $this->assertNotEmpty($response['body']); $this->assertNotEmpty($response['body']['$id']); $this->assertTrue((new DatetimeValidator())->isValid($response['body']['registration'])); $this->assertEquals($response['body']['email'], $newEmail); /** * Test for FAILURE */ $response = $this->client->call(Client::METHOD_PATCH, '/account/email', array_merge([ 'origin' => 'http://localhost', 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ])); $this->assertEquals(401, $response['headers']['status-code']); $response = $this->client->call(Client::METHOD_PATCH, '/account/email', array_merge([ 'origin' => 'http://localhost', 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $session, ]), []); $this->assertEquals(400, $response['headers']['status-code']); // Test if we can create a new account with the old email /** * Test for SUCCESS */ $response = $this->client->call(Client::METHOD_POST, '/account', array_merge([ 'origin' => 'http://localhost', 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-dev-key' => $this->getProject()['devKey'] ?? '' ]), [ 'userId' => ID::unique(), 'email' => $data['email'], 'password' => $data['password'], 'name' => $data['name'], ]); $this->assertEquals(201, $response['headers']['status-code']); $this->assertNotEmpty($response['body']); $this->assertNotEmpty($response['body']['$id']); $this->assertTrue((new DatetimeValidator())->isValid($response['body']['registration'])); $this->assertEquals($response['body']['email'], $data['email']); $this->assertEquals($response['body']['name'], $data['name']); } public function testUpdateAccountPrefs(): void { $data = $this->setupAccountWithUpdatedEmail(); $session = $data['session']; /** * Test for SUCCESS */ $response = $this->client->call(Client::METHOD_PATCH, '/account/prefs', array_merge([ 'origin' => 'http://localhost', 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $session, ]), [ 'prefs' => [ 'prefKey1' => 'prefValue1', 'prefKey2' => 'prefValue2', ] ]); $this->assertEquals(200, $response['headers']['status-code']); $this->assertIsArray($response['body']); $this->assertNotEmpty($response['body']); $this->assertEquals('prefValue1', $response['body']['prefs']['prefKey1']); $this->assertEquals('prefValue2', $response['body']['prefs']['prefKey2']); /** * Test for FAILURE */ $response = $this->client->call(Client::METHOD_PATCH, '/account/prefs', array_merge([ 'origin' => 'http://localhost', 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ])); $this->assertEquals(401, $response['headers']['status-code']); $response = $this->client->call(Client::METHOD_PATCH, '/account/prefs', array_merge([ 'origin' => 'http://localhost', 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $session, ]), [ 'prefs' => '{}' ]); $this->assertEquals(400, $response['headers']['status-code']); $response = $this->client->call(Client::METHOD_PATCH, '/account/prefs', array_merge([ 'origin' => 'http://localhost', 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $session, ]), [ 'prefs' => '[]' ]); $this->assertEquals(400, $response['headers']['status-code']); $response = $this->client->call(Client::METHOD_PATCH, '/account/prefs', array_merge([ 'origin' => 'http://localhost', 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $session, ]), [ 'prefs' => '{"test": "value"}' ]); $this->assertEquals(400, $response['headers']['status-code']); /** * Prefs size exceeded */ $prefsObject = ["longValue" => str_repeat("🍰", 100000)]; $response = $this->client->call(Client::METHOD_PATCH, '/account/prefs', array_merge([ 'origin' => 'http://localhost', 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $session, ]), [ 'prefs' => $prefsObject ]); $this->assertEquals(400, $response['headers']['status-code']); // Now let's test the same thing, but with normal symbol instead of multi-byte cake emoji $prefsObject = ["longValue" => str_repeat("-", 100000)]; $response = $this->client->call(Client::METHOD_PATCH, '/account/prefs', array_merge([ 'origin' => 'http://localhost', 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $session, ]), [ 'prefs' => $prefsObject ]); $this->assertEquals(400, $response['headers']['status-code']); } public function testCreateAccountVerification(): void { $data = $this->setupAccountWithUpdatedPrefs(); $email = $data['email']; $name = $data['name']; $session = $data['session']; /** * Test for SUCCESS */ $response = $this->client->call(Client::METHOD_POST, '/account/verification', array_merge([ 'origin' => 'http://localhost', 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $session, ]), [ 'url' => 'http://localhost/verification', ]); $this->assertEquals(201, $response['headers']['status-code']); $this->assertNotEmpty($response['body']['$id']); $this->assertEmpty($response['body']['secret']); $this->assertTrue((new DatetimeValidator())->isValid($response['body']['expire'])); $lastEmail = $this->getLastEmailByAddress($email); $this->assertNotEmpty($lastEmail, 'Email not found for address: ' . $email); $this->assertEquals($name, $lastEmail['to'][0]['name']); $this->assertEquals('Account Verification for ' . $this->getProject()['name'], $lastEmail['subject']); $this->assertStringContainsStringIgnoringCase('Verify your email to activate your ' . $this->getProject()['name'] . ' account.', $lastEmail['text']); $tokens = $this->extractQueryParamsFromEmailLink($lastEmail['html']); $verification = $tokens['secret']; $expectedExpire = DateTime::formatTz($response['body']['expire']); $this->assertEquals($expectedExpire, $tokens['expire']); // Secret check $this->assertArrayHasKey('secret', $tokens); $this->assertNotEmpty($tokens['secret']); // User ID check $this->assertArrayHasKey('userId', $tokens); $this->assertNotEmpty($tokens['userId']); /** * Test for FAILURE */ $response = $this->client->call(Client::METHOD_POST, '/account/verification', array_merge([ 'origin' => 'http://localhost', 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $session, ]), [ 'url' => 'localhost/verification', ]); $this->assertEquals(400, $response['headers']['status-code']); $response = $this->client->call(Client::METHOD_POST, '/account/verification', array_merge([ 'origin' => 'http://localhost', 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $session, ]), [ 'url' => 'http://remotehost/verification', ]); $this->assertEquals(400, $response['headers']['status-code']); } public function testUpdateAccountVerification(): void { $data = $this->setupAccountWithVerification(); $id = $data['id']; $session = $data['session']; $verification = $data['verification']; /** * Test for SUCCESS */ $response = $this->client->call(Client::METHOD_PUT, '/account/verification', array_merge([ 'origin' => 'http://localhost', 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $session, ]), [ 'userId' => $id, 'secret' => $verification, ]); $this->assertEquals(200, $response['headers']['status-code']); /** * Test for FAILURE */ $response = $this->client->call(Client::METHOD_PUT, '/account/verification', array_merge([ 'origin' => 'http://localhost', 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $session, ]), [ 'userId' => ID::custom('ewewe'), 'secret' => $verification, ]); $this->assertEquals(404, $response['headers']['status-code']); $response = $this->client->call(Client::METHOD_PUT, '/account/verification', array_merge([ 'origin' => 'http://localhost', 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $session, ]), [ 'userId' => $id, 'secret' => 'sdasdasdasd', ]); $this->assertEquals(401, $response['headers']['status-code']); } public function testDeleteAccountSession(): void { $data = $this->setupAccountWithVerifiedEmail(); $email = $data['email']; $password = $data['password']; $session = $data['session']; /** * Test for SUCCESS */ $response = $this->client->call(Client::METHOD_POST, '/account/sessions/email', array_merge([ 'origin' => 'http://localhost', 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ]), [ 'email' => $email, 'password' => $password, ]); $sessionNewId = $response['body']['$id']; $sessionNew = $response['cookies']['a_session_' . $this->getProject()['$id']]; $this->assertEquals(201, $response['headers']['status-code']); $response = $this->client->call(Client::METHOD_GET, '/account', [ 'origin' => 'http://localhost', 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $sessionNew, ]); $this->assertEquals(200, $response['headers']['status-code']); $response = $this->client->call(Client::METHOD_DELETE, '/account/sessions/' . $sessionNewId, array_merge([ 'origin' => 'http://localhost', 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $sessionNew, ])); $this->assertEquals(204, $response['headers']['status-code']); $response = $this->client->call(Client::METHOD_GET, '/account', array_merge([ 'origin' => 'http://localhost', 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $session, ])); $this->assertEquals(200, $response['headers']['status-code']); /** * Test for FAILURE */ $response = $this->client->call(Client::METHOD_GET, '/account', [ 'origin' => 'http://localhost', 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $sessionNew, ]); $this->assertEquals(401, $response['headers']['status-code']); } public function testDeleteAccountSessionCurrent(): void { $data = $this->setupAccountWithVerifiedEmail(); $email = $data['email']; $password = $data['password']; /** * Test for SUCCESS */ $response = $this->client->call(Client::METHOD_POST, '/account/sessions/email', array_merge([ 'origin' => 'http://localhost', 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ]), [ 'email' => $email, 'password' => $password, ]); $sessionNew = $response['cookies']['a_session_' . $this->getProject()['$id']]; $this->assertEquals(201, $response['headers']['status-code']); $response = $this->client->call(Client::METHOD_GET, '/account', [ 'origin' => 'http://localhost', 'content-type' => 'application/json', 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $sessionNew, 'x-appwrite-project' => $this->getProject()['$id'], ]); $this->assertEquals(200, $response['headers']['status-code']); $response = $this->client->call(Client::METHOD_DELETE, '/account/sessions/current', [ 'origin' => 'http://localhost', 'content-type' => 'application/json', 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $sessionNew, 'x-appwrite-project' => $this->getProject()['$id'], ]); $this->assertEquals(204, $response['headers']['status-code']); /** * Test for FAILURE */ $response = $this->client->call(Client::METHOD_GET, '/account', [ 'origin' => 'http://localhost', 'content-type' => 'application/json', 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $sessionNew, 'x-appwrite-project' => $this->getProject()['$id'], ]); $this->assertEquals(401, $response['headers']['status-code']); } public function testDeleteAccountSessions(): void { $data = $this->setupAccountWithVerifiedEmail(); $session = $data['session']; /** * Test for SUCCESS */ $response = $this->client->call(Client::METHOD_DELETE, '/account/sessions', array_merge([ 'origin' => 'http://localhost', 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $session, ])); $this->assertEquals(204, $response['headers']['status-code']); /** * Test for FAILURE */ $response = $this->client->call(Client::METHOD_GET, '/account', array_merge([ 'origin' => 'http://localhost', 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ])); $this->assertEquals(401, $response['headers']['status-code']); } public function testCreateAccountRecovery(): void { $data = $this->setupAccountWithVerifiedEmail(); $email = $data['email']; $name = $data['name']; /** * Test for SUCCESS */ $response = $this->client->call(Client::METHOD_POST, '/account/recovery', array_merge([ 'origin' => 'http://localhost', 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ]), [ 'email' => $email, 'url' => 'http://localhost/recovery', ]); $this->assertEquals(201, $response['headers']['status-code']); $this->assertNotEmpty($response['body']['$id']); $this->assertEmpty($response['body']['secret']); $this->assertTrue((new DatetimeValidator())->isValid($response['body']['expire'])); $lastEmail = $this->getLastEmailByAddress($email, function ($email) { $this->assertStringContainsString('Password Reset', $email['subject']); }); $this->assertNotEmpty($lastEmail, 'Email not found for address: ' . $email); $this->assertEquals($name, $lastEmail['to'][0]['name']); $this->assertEquals('Password Reset for ' . $this->getProject()['name'], $lastEmail['subject']); $this->assertStringContainsStringIgnoringCase('Reset your ' . $this->getProject()['name'] . ' password using the link.', $lastEmail['text']); $tokens = $this->extractQueryParamsFromEmailLink($lastEmail['html']); // Secret check $this->assertArrayHasKey('secret', $tokens); $this->assertNotEmpty($tokens['secret']); $this->assertNotFalse($response['body']['secret']); // User ID check $this->assertArrayHasKey('userId', $tokens); $this->assertNotEmpty($tokens['userId']); $this->assertNotFalse($response['body']['userId']); // Expire check $this->assertArrayHasKey('expire', $tokens); $this->assertNotEmpty($tokens['expire']); $this->assertEquals( DateTime::formatTz($response['body']['expire']), $tokens['expire'] ); /** * Test for FAILURE */ $response = $this->client->call(Client::METHOD_POST, '/account/recovery', array_merge([ 'origin' => 'http://localhost', 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ]), [ 'email' => $email, 'url' => 'localhost/recovery', ]); $this->assertEquals(400, $response['headers']['status-code']); $response = $this->client->call(Client::METHOD_POST, '/account/recovery', array_merge([ 'origin' => 'http://localhost', 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ]), [ 'email' => $email, 'url' => 'http://remotehost/recovery', ]); $this->assertEquals(400, $response['headers']['status-code']); $response = $this->client->call(Client::METHOD_POST, '/account/recovery', array_merge([ 'origin' => 'http://localhost', 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ]), [ 'email' => 'not-found@localhost.test', 'url' => 'http://localhost/recovery', ]); $this->assertEquals(404, $response['headers']['status-code']); } #[Retry(count: 1)] public function testUpdateAccountRecovery(): void { $data = $this->setupAccountWithRecovery(); $id = $data['id']; $recovery = $data['recovery']; $newPassword = 'test-recovery'; /** * Test for SUCCESS */ $response = $this->client->call(Client::METHOD_PUT, '/account/recovery', array_merge([ 'origin' => 'http://localhost', 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ]), [ 'userId' => $id, 'secret' => $recovery, 'password' => $newPassword, ]); $this->assertEquals(200, $response['headers']['status-code']); /** * Test for FAILURE */ $response = $this->client->call(Client::METHOD_PUT, '/account/recovery', array_merge([ 'origin' => 'http://localhost', 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ]), [ 'userId' => ID::custom('ewewe'), 'secret' => $recovery, 'password' => $newPassword, ]); $this->assertEquals(404, $response['headers']['status-code']); $response = $this->client->call(Client::METHOD_PUT, '/account/recovery', array_merge([ 'origin' => 'http://localhost', 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ]), [ 'userId' => $id, 'secret' => 'sdasdasdasd', 'password' => $newPassword, ]); $this->assertEquals(401, $response['headers']['status-code']); } public function testSessionAlert(): void { $email = uniqid() . 'session-alert@appwrite.io'; $password = 'password123'; $name = 'Session Alert Tester'; // Enable session alerts $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $this->getProject()['$id'] . '/auth/session-alerts', array_merge([ 'origin' => 'http://localhost', 'content-type' => 'application/json', 'x-appwrite-project' => 'console', 'x-appwrite-response-format' => '1.9.1', 'cookie' => 'a_session_console=' . $this->getRoot()['session'], ]), [ 'alerts' => true, ]); $this->assertEquals(200, $response['headers']['status-code']); // Create a new account $response = $this->client->call(Client::METHOD_POST, '/account', array_merge([ 'origin' => 'http://localhost', 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-dev-key' => $this->getProject()['devKey'] ?? '' ]), [ 'userId' => ID::unique(), 'email' => $email, 'password' => $password, 'name' => $name, ]); $this->assertEquals(201, $response['headers']['status-code']); // Create first session for the new account $response = $this->client->call(Client::METHOD_POST, '/account/sessions/email', array_merge([ 'origin' => 'http://localhost', 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'user-agent' => 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36', ]), [ 'email' => $email, 'password' => $password, ]); $this->assertEquals(201, $response['headers']['status-code']); // Create second session for the new account $response = $this->client->call(Client::METHOD_POST, '/account/sessions/email', array_merge([ 'origin' => 'http://localhost', 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'user-agent' => 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36', ]), [ 'email' => $email, 'password' => $password, ]); // Check the alert email $lastEmail = $this->getLastEmailByAddress($email); $this->assertNotEmpty($lastEmail, 'Email not found for address: ' . $email); $this->assertStringContainsString('Security alert: new session', $lastEmail['subject']); $this->assertStringContainsString($response['body']['ip'], $lastEmail['text']); // IP Address $this->assertStringContainsString('Unknown', $lastEmail['text']); // Country $this->assertStringContainsString($response['body']['clientName'], $lastEmail['text']); // Client name $this->assertStringNotContainsStringIgnoringCase('Appwrite logo', $lastEmail['html']); // Verify no alert sent in OTP login $response = $this->client->call(Client::METHOD_POST, '/account/tokens/email', array_merge([ 'origin' => 'http://localhost', 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ]), [ 'userId' => ID::unique(), 'email' => 'otpuser3@appwrite.io' ]); $this->assertEquals($response['headers']['status-code'], 201); $this->assertNotEmpty($response['body']['$id']); $this->assertNotEmpty($response['body']['$createdAt']); $this->assertNotEmpty($response['body']['userId']); $this->assertNotEmpty($response['body']['expire']); $this->assertEmpty($response['body']['secret']); $this->assertEmpty($response['body']['phrase']); $this->assertStringContainsStringIgnoringCase('New login detected on '. $this->getProject()['name'], $lastEmail['text']); $userId = $response['body']['userId']; $lastEmail = $this->getLastEmailByAddress('otpuser3@appwrite.io'); $this->assertNotEmpty($lastEmail, 'Email not found for address: otpuser3@appwrite.io'); $this->assertEquals('OTP for ' . $this->getProject()['name'] . ' Login', $lastEmail['subject']); // Find 6 concurrent digits in email text - OTP preg_match_all("/\b\d{6}\b/", $lastEmail['text'], $matches); $code = $matches[0][0] ?? ''; $this->assertNotEmpty($code); $response = $this->client->call(Client::METHOD_POST, '/account/sessions/token', array_merge([ 'origin' => 'http://localhost', 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ]), [ 'userId' => $userId, 'secret' => $code ]); $this->assertEquals(201, $response['headers']['status-code']); $this->assertEquals($userId, $response['body']['userId']); $this->assertNotEmpty($response['body']['$id']); $this->assertNotEmpty($response['body']['expire']); $this->assertEmpty($response['body']['secret']); $lastEmailId = $lastEmail['id']; $lastEmail = $this->getLastEmailByAddress('otpuser3@appwrite.io'); $this->assertEquals($lastEmailId, $lastEmail['id']); } public function testCreateOAuth2AccountSession(): void { // Just ensure we have a session set up $this->setupAccountWithSession(); $provider = 'mock'; $appId = '1'; $secret = '123456'; /** * Test for SUCCESS */ $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $this->getProject()['$id'] . '/oauth2', array_merge([ 'origin' => 'http://localhost', 'content-type' => 'application/json', 'x-appwrite-project' => 'console', 'cookie' => 'a_session_console=' . $this->getRoot()['session'], ]), [ 'provider' => $provider, 'appId' => $appId, 'secret' => $secret, 'enabled' => true, ]); $this->assertEquals(200, $response['headers']['status-code']); $response = $this->client->call(Client::METHOD_GET, '/account/sessions/oauth2/' . $provider, array_merge([ 'origin' => 'http://localhost', 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ]), [ 'success' => 'http://localhost/v1/mock/tests/general/oauth2/success', 'failure' => 'http://localhost/v1/mock/tests/general/oauth2/failure', ], followRedirects: false); $this->assertEquals(301, $response['headers']['status-code']); $this->assertStringStartsWith('http://localhost/v1/mock/tests/general/oauth2', $response['headers']['location']); $oauthClient = new Client(); $oauthClient->setEndpoint(''); $response = $oauthClient->call(Client::METHOD_GET, $response['headers']['location'], followRedirects: false); $this->assertEquals(301, $response['headers']['status-code']); $this->assertStringStartsWith('http://appwrite:/v1/account/sessions/oauth2/callback/mock/' . $this->getProject()['$id'] . '?code=', $response['headers']['location']); $response = $oauthClient->call(Client::METHOD_GET, $response['headers']['location'], followRedirects: false); $this->assertEquals(301, $response['headers']['status-code']); $this->assertStringStartsWith('http://appwrite:/v1/account/sessions/oauth2/mock/redirect?code=', $response['headers']['location']); $response = $oauthClient->call(Client::METHOD_GET, $response['headers']['location'], followRedirects: false); $this->assertEquals(301, $response['headers']['status-code']); $this->assertArrayHasKey('a_session_' . $this->getProject()['$id'] . '_legacy', $response['cookies']); $this->assertArrayHasKey('a_session_' . $this->getProject()['$id'], $response['cookies']); $oauthUserCookie = $response['cookies']['a_session_' . $this->getProject()['$id']]; $this->assertNotEmpty($oauthUserCookie); $response = $oauthClient->call(Client::METHOD_GET, $response['headers']['location'], followRedirects: false); $this->assertEquals(200, $response['headers']['status-code']); $this->assertEquals('success', $response['body']['result']); // Ensure user is authenticated $response = $this->client->call(Client::METHOD_GET, '/account', [ 'x-appwrite-project' => $this->getProject()['$id'], 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $oauthUserCookie, ]); $this->assertEquals(200, $response['headers']['status-code']); $this->assertEquals('useroauth@localhost.test', $response['body']['email']); $oauthUserId = $response['body']['$id']; $this->assertNotEmpty($oauthUserId); // Ensure session looks as expected $response = $this->client->call(Client::METHOD_GET, '/account/sessions/current', [ 'x-appwrite-project' => $this->getProject()['$id'], 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $oauthUserCookie, ]); $this->assertEquals(200, $response['headers']['status-code']); $this->assertEquals($oauthUserId, $response['body']['userId']); $this->assertEquals('mock', $response['body']['provider']); // Same sign-in again, but this time with oauth2 token flow $response = $this->client->call(Client::METHOD_GET, '/account/tokens/oauth2/' . $provider, array_merge([ 'origin' => 'http://localhost', 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ]), [ 'success' => 'http://localhost/v1/mock/tests/general/oauth2/success', 'failure' => 'http://localhost/v1/mock/tests/general/oauth2/failure', ], followRedirects: false); $this->assertEquals(301, $response['headers']['status-code']); $this->assertStringStartsWith('http://localhost/v1/mock/tests/general/oauth2', $response['headers']['location']); $oauthClient = new Client(); $oauthClient->setEndpoint(''); $response = $oauthClient->call(Client::METHOD_GET, $response['headers']['location'], followRedirects: false); $this->assertEquals(301, $response['headers']['status-code']); $this->assertStringStartsWith('http://appwrite:/v1/account/sessions/oauth2/callback/mock/' . $this->getProject()['$id'] . '?code=', $response['headers']['location']); $response = $oauthClient->call(Client::METHOD_GET, $response['headers']['location'], followRedirects: false); $this->assertEquals(301, $response['headers']['status-code']); $this->assertStringStartsWith('http://appwrite:/v1/account/sessions/oauth2/mock/redirect?code=', $response['headers']['location']); $response = $oauthClient->call(Client::METHOD_GET, $response['headers']['location'], followRedirects: false); $this->assertEquals(301, $response['headers']['status-code']); $this->assertStringStartsWith('http://localhost/v1/mock/tests/general/oauth2/success?secret=', $response['headers']['location']); $oauthParamsString = \parse_url($response['headers']['location'], PHP_URL_QUERY); $oauthParams = []; \parse_str($oauthParamsString, $oauthParams); $this->assertNotEmpty($oauthParams['secret']); $this->assertNotEmpty($oauthParams['userId']); $response = $oauthClient->call(Client::METHOD_GET, $response['headers']['location'], followRedirects: false); $this->assertEquals(200, $response['headers']['status-code']); $this->assertEquals('success', $response['body']['result']); // Claim session $response = $this->client->call(Client::METHOD_POST, '/account/sessions/token', [ 'origin' => 'http://localhost', 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ], [ 'userId' => $oauthParams['userId'], 'secret' => $oauthParams['secret'], ]); $this->assertEquals(201, $response['headers']['status-code']); $this->assertEquals('mock', $response['body']['provider']); $this->assertArrayHasKey('a_session_' . $this->getProject()['$id'] . '_legacy', $response['cookies']); $this->assertArrayHasKey('a_session_' . $this->getProject()['$id'], $response['cookies']); $oauthUserCookie = $response['cookies']['a_session_' . $this->getProject()['$id']]; $this->assertNotEmpty($oauthUserCookie); $response = $this->client->call(Client::METHOD_GET, '/account', [ 'x-appwrite-project' => $this->getProject()['$id'], 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $oauthUserCookie, ]); $this->assertEquals(200, $response['headers']['status-code']); $this->assertEquals('useroauth@localhost.test', $response['body']['email']); $oauthUserId = $response['body']['$id']; $this->assertNotEmpty($oauthUserId); // Ensure session looks as expected $response = $this->client->call(Client::METHOD_GET, '/account/sessions/current', [ 'x-appwrite-project' => $this->getProject()['$id'], 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $oauthUserCookie, ]); $this->assertEquals(200, $response['headers']['status-code']); $this->assertEquals($oauthUserId, $response['body']['userId']); $this->assertEquals('mock', $response['body']['provider']); /** * Test for Failure when disabled */ $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $this->getProject()['$id'] . '/oauth2', array_merge([ 'origin' => 'http://localhost', 'content-type' => 'application/json', 'x-appwrite-project' => 'console', 'cookie' => 'a_session_console=' . $this->getRoot()['session'], ]), [ 'provider' => $provider, 'appId' => $appId, 'secret' => $secret, 'enabled' => false, ]); $this->assertEquals(200, $response['headers']['status-code']); $response = $this->client->call(Client::METHOD_GET, '/account/sessions/oauth2/' . $provider, array_merge([ 'origin' => 'http://localhost', 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ]), [ 'success' => 'http://localhost/v1/mock/tests/general/oauth2/success', 'failure' => 'http://localhost/v1/mock/tests/general/oauth2/failure', ]); $this->assertEquals(412, $response['headers']['status-code']); } public function testCreateOidcOAuth2Token(): void { $provider = 'oidc'; $appId = '1'; // Valid well-known configuration $secret = '{ "wellKnownEndpoint": "https://accounts.google.com/.well-known/openid-configuration", "authorizationEndpoint": "https://accounts.google.com/o/oauth2/v2/auth", "tokenEndpoint": "https://oauth2.googleapis.com/token", "userinfoEndpoint": "https://openidconnect.googleapis.com/v1/userinfo" }'; $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $this->getProject()['$id'] . '/oauth2', array_merge([ 'origin' => 'http://localhost', 'content-type' => 'application/json', 'x-appwrite-project' => 'console', 'cookie' => 'a_session_console=' . $this->getRoot()['session'], ]), [ 'provider' => $provider, 'appId' => $appId, 'secret' => $secret, 'enabled' => true, ]); $this->assertEquals(200, $response['headers']['status-code']); $response = $this->client->call(Client::METHOD_GET, '/account/tokens/oauth2/' . $provider, array_merge([ 'origin' => 'http://localhost', 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ]), [ 'provider' => $provider, 'success' => 'http://localhost/v1/mock/tests/general/oauth2/success', 'failure' => 'http://localhost/v1/mock/tests/general/oauth2/failure', ], true, false); $this->assertEquals(301, $response['headers']['status-code']); // Invalid well-known configuration $secret = '{}'; $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $this->getProject()['$id'] . '/oauth2', array_merge([ 'origin' => 'http://localhost', 'content-type' => 'application/json', 'x-appwrite-project' => 'console', 'cookie' => 'a_session_console=' . $this->getRoot()['session'], ]), [ 'provider' => $provider, 'appId' => $appId, 'secret' => $secret, 'enabled' => true, ]); $this->assertEquals(200, $response['headers']['status-code']); $response = $this->client->call(Client::METHOD_GET, '/account/tokens/oauth2/' . $provider, array_merge([ 'origin' => 'http://localhost', 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ]), [ 'provider' => $provider, 'success' => 'http://localhost/v1/mock/tests/general/oauth2/success', 'failure' => 'http://localhost/v1/mock/tests/general/oauth2/failure', ]); $this->assertEquals(500, $response['headers']['status-code']); // Clean up - disable the OIDC provider to avoid polluting other parallel tests $this->client->call(Client::METHOD_PATCH, '/projects/' . $this->getProject()['$id'] . '/oauth2', array_merge([ 'origin' => 'http://localhost', 'content-type' => 'application/json', 'x-appwrite-project' => 'console', 'cookie' => 'a_session_console=' . $this->getRoot()['session'], ]), [ 'provider' => $provider, 'enabled' => false, ]); } public function testBlockedAccount(): void { $email = uniqid() . 'user@localhost.test'; $password = 'password'; $name = 'User Name (blocked)'; /** * Test for SUCCESS */ $response = $this->client->call(Client::METHOD_POST, '/account', array_merge([ 'origin' => 'http://localhost', 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ]), [ 'userId' => ID::unique(), 'email' => $email, 'password' => $password, 'name' => $name, ]); $id = $response['body']['$id']; $this->assertEquals(201, $response['headers']['status-code']); $response = $this->client->call(Client::METHOD_POST, '/account/sessions/email', array_merge([ 'origin' => 'http://localhost', 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ]), [ 'email' => $email, 'password' => $password, ]); $this->assertEquals(201, $response['headers']['status-code']); $sessionId = $response['body']['$id']; $session = $response['cookies']['a_session_' . $this->getProject()['$id']]; $response = $this->client->call(Client::METHOD_GET, '/account', array_merge([ 'origin' => 'http://localhost', 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $session, ])); $this->assertEquals(200, $response['headers']['status-code']); $response = $this->client->call(Client::METHOD_PATCH, '/users/' . $id . '/status', [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'], ], [ 'status' => false, ]); $this->assertEquals(200, $response['headers']['status-code']); $response = $this->client->call(Client::METHOD_GET, '/account', array_merge([ 'origin' => 'http://localhost', 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $session, ])); $this->assertEquals(403, $response['headers']['status-code']); $response = $this->client->call(Client::METHOD_POST, '/account/sessions/email', array_merge([ 'origin' => 'http://localhost', 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ]), [ 'email' => $email, 'password' => $password, ]); $this->assertEquals(403, $response['headers']['status-code']); } public function testSelfBlockedAccount(): void { $email = uniqid() . 'user55@localhost.test'; $password = 'password'; $name = 'User Name (self blocked)'; /** * Test for SUCCESS */ $response = $this->client->call(Client::METHOD_POST, '/account', array_merge([ 'origin' => 'http://localhost', 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ]), [ 'userId' => ID::unique(), 'email' => $email, 'password' => $password, 'name' => $name, ]); $id = $response['body']['$id']; $this->assertEquals(201, $response['headers']['status-code']); $response = $this->client->call(Client::METHOD_POST, '/account/sessions/email', array_merge([ 'origin' => 'http://localhost', 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ]), [ 'email' => $email, 'password' => $password, ]); $this->assertEquals(201, $response['headers']['status-code']); $session = $response['cookies']['a_session_' . $this->getProject()['$id']]; $response = $this->client->call(Client::METHOD_GET, '/account', array_merge([ 'origin' => 'http://localhost', 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $session, ])); $this->assertEquals(200, $response['headers']['status-code']); $response = $this->client->call(Client::METHOD_PATCH, '/account/status', [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $session, ], [ 'status' => false, ]); $this->assertEquals(200, $response['headers']['status-code']); $this->assertStringContainsString('a_session_' . $this->getProject()['$id'] . '=deleted', $response['headers']['set-cookie']); $this->assertEquals('[]', $response['headers']['x-fallback-cookies']); $response = $this->client->call(Client::METHOD_GET, '/account', array_merge([ 'origin' => 'http://localhost', 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $session, ])); $this->assertEquals(403, $response['headers']['status-code']); $response = $this->client->call(Client::METHOD_POST, '/account/sessions/email', array_merge([ 'origin' => 'http://localhost', 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ]), [ 'email' => $email, 'password' => $password, ]); $this->assertEquals(403, $response['headers']['status-code']); } public function testCreateJWT(): void { $email = uniqid() . 'user@localhost.test'; $password = 'password'; $name = 'User Name (JWT)'; /** * Test for SUCCESS */ $response = $this->client->call(Client::METHOD_POST, '/account', array_merge([ 'origin' => 'http://localhost', 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ]), [ 'userId' => ID::unique(), 'email' => $email, 'password' => $password, 'name' => $name, ]); $id = $response['body']['$id']; $this->assertEquals(201, $response['headers']['status-code']); $response = $this->client->call(Client::METHOD_POST, '/account/sessions/email', array_merge([ 'origin' => 'http://localhost', 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ]), [ 'email' => $email, 'password' => $password, ]); $this->assertEquals(201, $response['headers']['status-code']); $sessionId = $response['body']['$id']; $session = $response['cookies']['a_session_' . $this->getProject()['$id']]; $response = $this->client->call(Client::METHOD_GET, '/account', array_merge([ 'origin' => 'http://localhost', 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $session, ])); $this->assertEquals(200, $response['headers']['status-code']); $response = $this->client->call(Client::METHOD_POST, '/account/jwt', array_merge([ 'origin' => 'http://localhost', 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $session, ])); $this->assertEquals(201, $response['headers']['status-code']); $this->assertEquals(119, $response['headers']['x-ratelimit-remaining']); $this->assertNotEmpty($response['body']['jwt']); $this->assertIsString($response['body']['jwt']); $jwt = $response['body']['jwt']; $response = $this->client->call(Client::METHOD_GET, '/account', array_merge([ 'origin' => 'http://localhost', 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-jwt' => 'wrong-token', ])); $this->assertEquals(401, $response['headers']['status-code']); $response = $this->client->call(Client::METHOD_GET, '/account', array_merge([ 'origin' => 'http://localhost', 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-jwt' => $jwt, ])); $this->assertEquals(200, $response['headers']['status-code']); $response = $this->client->call(Client::METHOD_DELETE, '/account/sessions/' . $sessionId, array_merge([ 'origin' => 'http://localhost', 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $session, ])); $this->assertEquals(204, $response['headers']['status-code']); $response = $this->client->call(Client::METHOD_GET, '/account', array_merge([ 'origin' => 'http://localhost', 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-jwt' => $jwt, ])); $this->assertEquals(401, $response['headers']['status-code']); // Test JWT with custom duration $response = $this->client->call(Client::METHOD_POST, '/account/sessions/email', array_merge([ 'origin' => 'http://localhost', 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ]), [ 'email' => $email, 'password' => $password, ]); $this->assertEquals(201, $response['headers']['status-code']); $session = $response['cookies']['a_session_' . $this->getProject()['$id']]; $response = $this->client->call(Client::METHOD_POST, '/account/jwt', array_merge([ 'origin' => 'http://localhost', 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $session, ]), [ 'duration' => 5 ]); $this->assertEquals(201, $response['headers']['status-code']); $this->assertNotEmpty($response['body']['jwt']); $jwt = $response['body']['jwt']; // Ensure JWT works before expiration $response = $this->client->call(Client::METHOD_GET, '/account', array_merge([ 'origin' => 'http://localhost', 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-jwt' => $jwt, ])); $this->assertEquals(200, $response['headers']['status-code']); // Wait for JWT to expire \sleep(6); // Ensure JWT no longer works after expiration $response = $this->client->call(Client::METHOD_GET, '/account', array_merge([ 'origin' => 'http://localhost', 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-jwt' => $jwt, ])); $this->assertEquals(401, $response['headers']['status-code']); } public function testCreateAnonymousAccount(): void { /** * Test for SUCCESS */ $response = $this->client->call(Client::METHOD_POST, '/account/sessions/anonymous', [ 'origin' => 'http://localhost', 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ]); $this->assertEquals(201, $response['headers']['status-code']); $this->assertIsArray($response['body']); $this->assertNotEmpty($response['body']); $this->assertNotEmpty($response['body']['$id']); $this->assertEmpty($response['body']['secret']); $session = $response['cookies']['a_session_' . $this->getProject()['$id']]; \usleep(1000 * 30); // wait for 30ms to let the shutdown update accessedAt $apiKey = $this->getProject()['apiKey']; $userId = $response['body']['userId']; $response = $this->client->call(Client::METHOD_GET, '/users/' . $userId, array_merge([ 'origin' => 'http://localhost', 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $apiKey, ])); $this->assertEquals(200, $response['headers']['status-code']); $this->assertArrayHasKey('accessedAt', $response['body']); $this->assertNotEmpty($response['body']['accessedAt']); /** * Test for FAILURE */ $response = $this->client->call(Client::METHOD_POST, '/account/sessions/anonymous', [ 'origin' => 'http://localhost', 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $session, ]); $this->assertEquals(401, $response['headers']['status-code']); } public function testCreateAnonymousAccountVerification(): void { $session = $this->createAnonymousSession(); $response = $this->client->call(Client::METHOD_POST, '/account/verification', array_merge([ 'origin' => 'http://localhost', 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $session, ]), [ 'url' => 'http://localhost/verification', ]); $this->assertEquals(400, $response['body']['code']); $this->assertEquals('user_email_not_found', $response['body']['type']); } public function testUpdateAnonymousAccountPassword(): void { $session = $this->createAnonymousSession(); /** * Test for FAILURE */ $response = $this->client->call(Client::METHOD_PATCH, '/account/password', array_merge([ 'origin' => 'http://localhost', 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $session, ]), [ 'oldPassword' => '', ]); $this->assertEquals(400, $response['headers']['status-code']); } public function testUpdateAnonymousAccountEmail(): void { $session = $this->createAnonymousSession(); $email = uniqid() . 'new@localhost.test'; /** * Test for FAILURE */ $response = $this->client->call(Client::METHOD_PATCH, '/account/email', array_merge([ 'origin' => 'http://localhost', 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $session, ]), [ 'email' => $email, 'password' => '', ]); $this->assertEquals(400, $response['headers']['status-code']); } public function testConvertAnonymousAccount(): void { $session = $this->createAnonymousSession(); $email = uniqid() . 'new@localhost.test'; $password = 'new-password'; /** * Test for FAILURE */ $response = $this->client->call(Client::METHOD_POST, '/account', array_merge([ 'origin' => 'http://localhost', 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ]), [ 'userId' => ID::unique(), 'email' => $email, 'password' => $password ]); $response = $this->client->call(Client::METHOD_PATCH, '/account/email', array_merge([ 'origin' => 'http://localhost', 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $session, ]), [ 'email' => $email, 'password' => $password, ]); $this->assertEquals(409, $response['headers']['status-code']); /** * Test for SUCCESS */ $email = uniqid() . 'new@localhost.test'; $response = $this->client->call(Client::METHOD_PATCH, '/account/email', array_merge([ 'origin' => 'http://localhost', 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $session, ]), [ 'email' => $email, 'password' => $password, ]); $this->assertEquals(200, $response['headers']['status-code']); $this->assertIsArray($response['body']); $this->assertNotEmpty($response['body']); $this->assertNotEmpty($response['body']['$id']); $this->assertTrue((new DatetimeValidator())->isValid($response['body']['registration'])); $this->assertEquals($response['body']['email'], $email); $response = $this->client->call(Client::METHOD_POST, '/account/sessions/email', array_merge([ 'origin' => 'http://localhost', 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ]), [ 'email' => $email, 'password' => $password, ]); $this->assertEquals(201, $response['headers']['status-code']); $response = $this->client->call(Client::METHOD_POST, '/account/verification', array_merge([ 'origin' => 'http://localhost', 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $session, ]), [ 'url' => 'http://localhost' ]); $this->assertEquals(201, $response['headers']['status-code']); } public function testConvertAnonymousAccountOAuth2(): void { // Clean up any existing user with the mock OAuth email to prevent // conflicts with parallel tests that also use the mock provider $this->deleteUserByEmail('useroauth@localhost.test'); $session = $this->createAnonymousSession(); $provider = 'mock'; $appId = '1'; $secret = '123456'; /** * Test for SUCCESS */ $response = $this->client->call(Client::METHOD_GET, '/account', array_merge([ 'origin' => 'http://localhost', 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $session, ])); $this->assertEquals(200, $response['headers']['status-code']); $userId = $response['body']['$id'] ?? ''; $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $this->getProject()['$id'] . '/oauth2', array_merge([ 'origin' => 'http://localhost', 'content-type' => 'application/json', 'x-appwrite-project' => 'console', 'cookie' => 'a_session_console=' . $this->getRoot()['session'], ]), [ 'provider' => $provider, 'appId' => $appId, 'secret' => $secret, 'enabled' => true, ]); $this->assertEquals(200, $response['headers']['status-code']); // Delete any user with the mock OAuth email right before the OAuth call // to minimize the race window with parallel tests $this->deleteUserByEmail('useroauth@localhost.test'); $response = $this->client->call(Client::METHOD_GET, '/account/sessions/oauth2/' . $provider, array_merge([ 'origin' => 'http://localhost', 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $session, ]), [ 'success' => 'http://localhost/v1/mock/tests/general/oauth2/success', 'failure' => 'http://localhost/v1/mock/tests/general/oauth2/failure', ]); $this->assertEquals(200, $response['headers']['status-code']); $sessionCookieKey = 'a_session_' . $this->getProject()['$id']; $this->assertArrayHasKey( $sessionCookieKey, $response['cookies'], "Failed asserting that session cookie '$sessionCookieKey' is set. Cookies: " . json_encode($response['cookies']) ); $session = $response['cookies'][$sessionCookieKey]; $response = $this->client->call(Client::METHOD_GET, '/account', array_merge([ 'origin' => 'http://localhost', 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $session, ])); $this->assertEquals(200, $response['headers']['status-code']); $this->assertEquals($response['body']['$id'], $userId); $this->assertEquals('User Name', $response['body']['name']); $this->assertEquals('useroauth@localhost.test', $response['body']['email']); // Since we only support one oauth user, let's also check updateSession here $response = $this->client->call(Client::METHOD_GET, '/account/sessions/current', array_merge([ 'origin' => 'http://localhost', 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $session, ])); $this->assertEquals(200, $response['headers']['status-code']); $this->assertEmpty($response['body']['secret']); $this->assertEquals('123456', $response['body']['providerAccessToken']); $this->assertEquals('tuvwxyz', $response['body']['providerRefreshToken']); $this->assertGreaterThan(DateTime::addSeconds(new \DateTime(), 14400 - 5), $response['body']['providerAccessTokenExpiry']); // 5 seconds allowed networking delay $initialExpiry = $response['body']['providerAccessTokenExpiry']; sleep(3); $response = $this->client->call(Client::METHOD_PATCH, '/account/sessions/current', array_merge([ 'origin' => 'http://localhost', 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $session, ])); $this->assertEquals(200, $response['headers']['status-code']); $this->assertEquals('123456', $response['body']['providerAccessToken']); $this->assertEquals('tuvwxyz', $response['body']['providerRefreshToken']); $this->assertNotEquals($initialExpiry, $response['body']['providerAccessTokenExpiry']); // Clean up - delete the user $response = $this->client->call(Client::METHOD_DELETE, '/users/' . $userId, array_merge([ 'origin' => 'http://localhost', 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'], ])); $this->assertEquals(204, $response['headers']['status-code']); } public function testOAuthUnverifiedEmailCannotLinkToExistingAccount(): void { $provider = 'mock-unverified'; $appId = '1'; $secret = '123456'; // First, create a user with the same email that the unverified OAuth will try to use $email = 'useroauthunverified@localhost.test'; $password = 'password'; // Clean up any existing user with this email from parallel tests $this->deleteUserByEmail($email); $response = $this->client->call(Client::METHOD_POST, '/account', [ 'origin' => 'http://localhost', 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ], [ 'userId' => ID::unique(), 'email' => $email, 'password' => $password, ]); $this->assertEquals(201, $response['headers']['status-code']); $existingUserId = $response['body']['$id']; // Enable the mock-unverified provider $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $this->getProject()['$id'] . '/oauth2', array_merge([ 'origin' => 'http://localhost', 'content-type' => 'application/json', 'x-appwrite-project' => 'console', 'cookie' => 'a_session_console=' . $this->getRoot()['session'], ]), [ 'provider' => $provider, 'appId' => $appId, 'secret' => $secret, 'enabled' => true, ]); $this->assertEquals(200, $response['headers']['status-code']); // Attempt OAuth login with unverified email - should fail because existing user has same email $response = $this->client->call(Client::METHOD_GET, '/account/sessions/oauth2/' . $provider, array_merge([ 'origin' => 'http://localhost', 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ]), [ 'success' => 'http://localhost/v1/mock/tests/general/oauth2/success', 'failure' => 'http://localhost/v1/mock/tests/general/oauth2/failure', ]); $this->assertEquals(400, $response['headers']['status-code']); $this->assertEquals('failure', $response['body']['result']); // Clean up - delete the user $response = $this->client->call(Client::METHOD_DELETE, '/users/' . $existingUserId, array_merge([ 'origin' => 'http://localhost', 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'], ])); $this->assertEquals(204, $response['headers']['status-code']); } public function testOAuthVerifiedEmailCanLinkToExistingAccount(): void { $provider = 'mock'; $appId = '1'; $secret = '123456'; $email = 'useroauth@localhost.test'; // Clean up any existing user with this email from parallel tests $this->deleteUserByEmail($email); // Create a user with the same email that the verified OAuth will try to use $response = $this->client->call(Client::METHOD_POST, '/account', [ 'origin' => 'http://localhost', 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ], [ 'userId' => ID::unique(), 'email' => $email, 'password' => 'password', ]); $this->assertEquals(201, $response['headers']['status-code']); $existingUserId = $response['body']['$id']; // Enable the mock provider $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $this->getProject()['$id'] . '/oauth2', array_merge([ 'origin' => 'http://localhost', 'content-type' => 'application/json', 'x-appwrite-project' => 'console', 'cookie' => 'a_session_console=' . $this->getRoot()['session'], ]), [ 'provider' => $provider, 'appId' => $appId, 'secret' => $secret, 'enabled' => true, ]); $this->assertEquals(200, $response['headers']['status-code']); // Attempt OAuth login with verified email - should succeed and link to existing account $response = $this->client->call(Client::METHOD_GET, '/account/sessions/oauth2/' . $provider, array_merge([ 'origin' => 'http://localhost', 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ]), [ 'success' => 'http://localhost/v1/mock/tests/general/oauth2/success', 'failure' => 'http://localhost/v1/mock/tests/general/oauth2/failure', ]); $this->assertEquals(200, $response['headers']['status-code']); $this->assertEquals('success', $response['body']['result']); // Verify the OAuth identity was linked to the existing user $sessionCookieKey = 'a_session_' . $this->getProject()['$id']; $session = $response['cookies'][$sessionCookieKey]; $response = $this->client->call(Client::METHOD_GET, '/account', array_merge([ 'origin' => 'http://localhost', 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $session, ])); $this->assertEquals(200, $response['headers']['status-code']); $this->assertEquals($existingUserId, $response['body']['$id']); $this->assertEquals($email, $response['body']['email']); // Clean up - delete the user $response = $this->client->call(Client::METHOD_DELETE, '/users/' . $existingUserId, array_merge([ 'origin' => 'http://localhost', 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'], ])); $this->assertEquals(204, $response['headers']['status-code']); } public function testGetSessionByID(): void { $session = $this->createAnonymousSession(); $response = $this->client->call(Client::METHOD_GET, '/account/sessions/current', array_merge([ 'origin' => 'http://localhost', 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $session, ])); $this->assertEquals(200, $response['headers']['status-code']); $this->assertEmpty($response['body']['secret']); $this->assertEquals('anonymous', $response['body']['provider']); $sessionID = $response['body']['$id']; $response = $this->client->call(Client::METHOD_GET, '/account/sessions/' . $sessionID, array_merge([ 'origin' => 'http://localhost', 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $session, ])); $this->assertEquals(200, $response['headers']['status-code']); $this->assertEmpty($response['body']['secret']); $this->assertEquals('anonymous', $response['body']['provider']); $response = $this->client->call(Client::METHOD_GET, '/account/sessions/97823askjdkasd80921371980', array_merge([ 'origin' => 'http://localhost', 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $session, ])); $this->assertEquals(404, $response['headers']['status-code']); } public function testUpdateAccountNameSearch(): void { $data = $this->setupAccountWithUpdatedName(); $id = $data['id']; $newName = 'Lorem'; /** * Test for SUCCESS */ $response = $this->client->call(Client::METHOD_GET, '/users', [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'], ], [ 'search' => $newName, ]); $this->assertEquals(200, $response['headers']['status-code']); $this->assertNotEmpty($response['body']); $this->assertNotEmpty($response['body']['users']); // In parallel execution, there may be more users with name 'Lorem' $this->assertGreaterThanOrEqual(1, count($response['body']['users'])); // Find our user in the results $foundUser = null; foreach ($response['body']['users'] as $user) { if ($user['$id'] === $id) { $foundUser = $user; break; } } $this->assertNotNull($foundUser, 'User should be found in search results'); $this->assertEquals($newName, $foundUser['name']); $response = $this->client->call(Client::METHOD_GET, '/users', [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'], ], [ 'search' => $id, ]); $this->assertEquals(200, $response['headers']['status-code']); $this->assertNotEmpty($response['body']); $this->assertNotEmpty($response['body']['users']); $this->assertCount(1, $response['body']['users']); $this->assertEquals($newName, $response['body']['users'][0]['name']); } public function testUpdateAccountEmailSearch(): void { $data = $this->setupAccountWithUpdatedEmail(); $id = $data['id']; $email = $data['email']; /** * Test for SUCCESS */ $response = $this->client->call(Client::METHOD_GET, '/users', [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'], ], [ 'search' => '"' . $email . '"', ]); $this->assertEquals(200, $response['headers']['status-code']); $this->assertNotEmpty($response['body']); $this->assertNotEmpty($response['body']['users']); $this->assertCount(1, $response['body']['users']); $this->assertEquals($response['body']['users'][0]['email'], $email); $response = $this->client->call(Client::METHOD_GET, '/users', [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'], ], [ 'search' => $id, ]); $this->assertEquals(200, $response['headers']['status-code']); $this->assertNotEmpty($response['body']); $this->assertNotEmpty($response['body']['users']); $this->assertCount(1, $response['body']['users']); $this->assertEquals($response['body']['users'][0]['email'], $email); } public function testCreatePhone(): void { $number = '+123456789'; /** * Test for SUCCESS */ $response = $this->client->call(Client::METHOD_POST, '/account/tokens/phone', array_merge([ 'origin' => 'http://localhost', 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ]), [ 'userId' => ID::unique(), 'phone' => $number, ]); $this->assertEquals(201, $response['headers']['status-code']); $this->assertNotEmpty($response['body']['$id']); $this->assertEmpty($response['body']['secret']); $this->assertTrue((new DatetimeValidator())->isValid($response['body']['expire'])); $userId = $response['body']['userId']; $smsRequest = $this->getLastRequestForProject( $this->getProject()['$id'], Scope::REQUEST_TYPE_SMS, [ 'header_X-Username' => 'username', 'header_X-Key' => 'password', 'method' => 'POST', ], probe: function (array $request) use ($number) { $this->assertEquals('Appwrite Mock Message Sender', $request['headers']['User-Agent'] ?? null); $this->assertEquals('username', $request['headers']['X-Username'] ?? null); $this->assertEquals('password', $request['headers']['X-Key'] ?? null); $this->assertEquals('POST', $request['method'] ?? null); $this->assertEquals('+123456789', $request['data']['from'] ?? null); $this->assertEquals($number, $request['data']['to'] ?? null); } ); /** * Test for FAILURE */ $response = $this->client->call(Client::METHOD_POST, '/account/tokens/phone', array_merge([ 'origin' => 'http://localhost', 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ]), [ 'userId' => ID::unique() ]); $this->assertEquals(400, $response['headers']['status-code']); } public function testCreateSessionWithPhone(): void { $data = $this->setupPhoneAccount(); $id = $data['id']; $token = explode(" ", $data['token'])[0]; $number = $data['number']; /** * Test for FAILURE */ $response = $this->client->call(Client::METHOD_PUT, '/account/sessions/phone', array_merge([ 'origin' => 'http://localhost', 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ]), [ 'userId' => ID::custom('ewewe'), 'secret' => $token, ]); $this->assertEquals(401, $response['headers']['status-code']); $response = $this->client->call(Client::METHOD_PUT, '/account/sessions/phone', array_merge([ 'origin' => 'http://localhost', 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ]), [ 'userId' => $id, 'secret' => 'sdasdasdasd', ]); $this->assertEquals(401, $response['headers']['status-code']); /** * Test for SUCCESS */ $response = $this->client->call(Client::METHOD_PUT, '/account/sessions/phone', array_merge([ 'origin' => 'http://localhost', 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ]), [ 'userId' => $id, 'secret' => $token, ]); $this->assertEquals(201, $response['headers']['status-code']); $this->assertIsArray($response['body']); $this->assertNotEmpty($response['body']); $this->assertNotEmpty($response['body']['$id']); $this->assertNotEmpty($response['body']['userId']); $session = $response['cookies']['a_session_' . $this->getProject()['$id']]; $response = $this->client->call(Client::METHOD_GET, '/account', array_merge([ 'origin' => 'http://localhost', 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $session, ])); $this->assertEquals(200, $response['headers']['status-code']); $this->assertNotEmpty($response['body']); $this->assertNotEmpty($response['body']['$id']); $this->assertEquals(true, (new DatetimeValidator())->isValid($response['body']['registration'])); $this->assertEquals($response['body']['phone'], $number); $this->assertTrue($response['body']['phoneVerification']); /** * Test for FAILURE */ $response = $this->client->call(Client::METHOD_PUT, '/account/sessions/phone', array_merge([ 'origin' => 'http://localhost', 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ]), [ 'userId' => $id, 'secret' => $token, ]); $this->assertEquals(401, $response['headers']['status-code']); } public function testConvertPhoneToPassword(): void { $data = $this->setupPhoneSession(); $session = $data['session']; $email = uniqid() . 'new@localhost.test'; $password = 'new-password'; $response = $this->client->call(Client::METHOD_PATCH, '/account/email', array_merge([ 'origin' => 'http://localhost', 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $session, ]), [ 'email' => $email, 'password' => $password, ]); $this->assertEquals(200, $response['headers']['status-code']); $this->assertIsArray($response['body']); $this->assertNotEmpty($response['body']); $this->assertNotEmpty($response['body']['$id']); $this->assertTrue((new DatetimeValidator())->isValid($response['body']['registration'])); $this->assertEquals($response['body']['email'], $email); $response = $this->client->call(Client::METHOD_POST, '/account/sessions/email', array_merge([ 'origin' => 'http://localhost', 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ]), [ 'email' => $email, 'password' => $password, ]); $this->assertEquals(201, $response['headers']['status-code']); } public function testUpdatePhone(): void { $data = $this->setupPhoneConvertedToPassword(); $newPhone = '+456' . substr(str_replace('.', '', microtime(true)) . getmypid() . random_int(100, 999), -8); $session = $data['session']; /** * Test for SUCCESS */ $response = $this->client->call(Client::METHOD_PATCH, '/account/phone', array_merge([ 'origin' => 'http://localhost', 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $session, ]), [ 'phone' => $newPhone, 'password' => 'new-password' ]); $this->assertEquals(200, $response['headers']['status-code']); $this->assertIsArray($response['body']); $this->assertNotEmpty($response['body']); $this->assertNotEmpty($response['body']['$id']); $this->assertTrue((new DatetimeValidator())->isValid($response['body']['registration'])); $this->assertEquals($response['body']['phone'], $newPhone); /** * Test for FAILURE */ $response = $this->client->call(Client::METHOD_PATCH, '/account/phone', array_merge([ 'origin' => 'http://localhost', 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ])); $this->assertEquals(401, $response['headers']['status-code']); $response = $this->client->call(Client::METHOD_PATCH, '/account/phone', array_merge([ 'origin' => 'http://localhost', 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $session, ]), []); $this->assertEquals(400, $response['headers']['status-code']); } public function testCreateSession(): void { $data = $this->setupPhoneUpdated(); $response = $this->client->call(Client::METHOD_POST, '/users/' . $data['id'] . '/tokens', [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'], ], [ 'expire' => 60 ]); $this->assertEquals(201, $response['headers']['status-code']); $userId = $response['body']['userId']; $secret = $response['body']['secret']; /** * Test for SUCCESS */ $response = $this->client->call(Client::METHOD_POST, '/account/sessions/token', [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ], [ 'userId' => $userId, 'secret' => $secret ]); $this->assertEquals(201, $response['headers']['status-code']); $this->assertEquals($data['id'], $response['body']['userId']); $this->assertNotEmpty($response['body']['$id']); $this->assertNotEmpty($response['body']['expire']); $this->assertEmpty($response['body']['secret']); $this->assertEquals('browser', $response['body']['clientType']); $this->assertEquals('CH', $response['body']['clientCode']); $this->assertEquals('Chrome', $response['body']['clientName']); // Forwarded User Agent with API Key $response = $this->client->call(Client::METHOD_POST, '/users/' . $data['id'] . '/tokens', [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'], ], [ 'expire' => 60 ]); $userId = $response['body']['userId']; $secret = $response['body']['secret']; $response = $this->client->call(Client::METHOD_POST, '/account/sessions/token', [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'], 'x-forwarded-user-agent' => 'Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Mobile Safari/537.36' ], [ 'userId' => $userId, 'secret' => $secret ]); $this->assertEquals('browser', $response['body']['clientType']); $this->assertEquals('CM', actual: $response['body']['clientCode']); $this->assertEquals('Chrome Mobile', $response['body']['clientName']); // Forwarded User Agent without API Key $response = $this->client->call(Client::METHOD_POST, '/users/' . $data['id'] . '/tokens', [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'], ], [ 'expire' => 60 ]); $userId = $response['body']['userId']; $secret = $response['body']['secret']; $response = $this->client->call(Client::METHOD_POST, '/account/sessions/token', [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-forwarded-user-agent' => 'Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Mobile Safari/537.36' ], [ 'userId' => $userId, 'secret' => $secret ]); $this->assertEquals('browser', $response['body']['clientType']); $this->assertEquals('CH', $response['body']['clientCode']); $this->assertEquals('Chrome', $response['body']['clientName']); /** * Test for FAILURE */ // Invalid userId $response = $this->client->call(Client::METHOD_POST, '/account/sessions/token', [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ], [ 'userId' => ID::custom('ewewe'), 'secret' => $secret, ]); $this->assertEquals(401, $response['headers']['status-code']); // Invalid secret $response = $this->client->call(Client::METHOD_POST, '/account/sessions/token', [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ], [ 'userId' => $userId, 'secret' => '123456', ]); $this->assertEquals(401, $response['headers']['status-code']); } public function testPhoneVerification(): void { $data = $this->setupPhoneUpdated(); $session = $data['session']; /** * Test for SUCCESS */ $response = $this->client->call(Client::METHOD_POST, '/account/verification/phone', array_merge([ 'origin' => 'http://localhost', 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $session, ])); $this->assertEquals(201, $response['headers']['status-code']); $this->assertNotEmpty($response['body']['$id']); $this->assertNotEmpty($response['body']['$createdAt']); $this->assertEmpty($response['body']['secret']); $this->assertTrue((new DatetimeValidator())->isValid($response['body']['expire'])); $tokenCreatedAt = $response['body']['$createdAt']; $phone = $data['phone'] ?? ''; $smsQuery = [ 'header_X-Username' => 'username', 'header_X-Key' => 'password', 'method' => 'POST', ]; $smsRequest = $this->getLastRequestForProject( $this->getProject()['$id'], Scope::REQUEST_TYPE_SMS, $smsQuery, probe: function (array $request) use ($tokenCreatedAt, $phone) { $this->assertArrayHasKey('data', $request); $this->assertArrayHasKey('time', $request); $this->assertArrayHasKey('message', $request['data'], "Last request missing message: " . \json_encode($request)); if (!empty($phone)) { $this->assertEquals($phone, $request['data']['to'] ?? null); } // Ensure we are not using token from last sms login $tokenRecievedAt = $request['time']; $this->assertGreaterThan($tokenCreatedAt, $tokenRecievedAt); } ); /** * Test for FAILURE */ // disable phone sessions $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $this->getProject()['$id'] . '/auth/phone', array_merge([ 'origin' => 'http://localhost', 'content-type' => 'application/json', 'x-appwrite-project' => 'console', 'x-appwrite-response-format' => '1.9.1', 'cookie' => 'a_session_console=' . $this->getRoot()['session'], ]), [ 'status' => false, ]); $this->assertEquals(200, $response['headers']['status-code']); $this->assertEquals(false, $response['body']['authPhone']); $response = $this->client->call(Client::METHOD_POST, '/account/verification/phone', array_merge([ 'origin' => 'http://localhost', 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $session, ])); $this->assertEquals(501, $response['headers']['status-code']); $this->assertEquals("Phone authentication is disabled for this project", $response['body']['message']); // Re-enable phone auth so other parallel tests are not affected $this->ensurePhoneAuthEnabled(); } public function testUpdatePhoneVerification(): void { $data = $this->setupPhoneVerification(); $id = $data['id']; $session = $data['session']; $secret = $data['token']; /** * Test for SUCCESS */ $response = $this->client->call(Client::METHOD_PUT, '/account/verification/phone', array_merge([ 'origin' => 'http://localhost', 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $session, ]), [ 'userId' => $id, 'secret' => $secret, ]); $this->assertEquals(200, $response['headers']['status-code']); /** * Test for FAILURE */ $response = $this->client->call(Client::METHOD_PUT, '/account/verification/phone', array_merge([ 'origin' => 'http://localhost', 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $session, ]), [ 'userId' => ID::custom('ewewe'), 'secret' => $secret, ]); $this->assertEquals(404, $response['headers']['status-code']); $response = $this->client->call(Client::METHOD_PUT, '/account/verification/phone', array_merge([ 'origin' => 'http://localhost', 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $session, ]), [ 'userId' => $id, 'secret' => '999999', ]); $this->assertEquals(401, $response['headers']['status-code']); } public function testCreateMagicUrl(): void { // Use uniqid for uniqueness in parallel test execution $email = 'magic-' . uniqid() . '-' . \time() . '@appwrite.io'; /** * Test for SUCCESS */ $response = $this->client->call(Client::METHOD_POST, '/account/tokens/magic-url', array_merge([ 'origin' => 'http://localhost', 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ]), [ 'userId' => ID::unique(), 'email' => $email, // 'url' => 'http://localhost/magiclogin', ]); $this->assertEquals(201, $response['headers']['status-code']); $this->assertNotEmpty($response['body']['$id']); $this->assertEmpty($response['body']['secret']); $this->assertEmpty($response['body']['phrase']); $this->assertTrue((new DatetimeValidator())->isValid($response['body']['expire'])); $userId = $response['body']['userId']; $lastEmail = $this->getLastEmailByAddress($email); $this->assertNotEmpty($lastEmail, 'Email not found for address: ' . $email); $this->assertEquals($this->getProject()['name'] . ' Login', $lastEmail['subject']); $this->assertStringContainsStringIgnoringCase('Sign in to '. $this->getProject()['name'] . ' with your secure link. Expires in 1 hour.', $lastEmail['text']); $this->assertStringNotContainsStringIgnoringCase('security phrase', $lastEmail['text']); $token = substr($lastEmail['text'], strpos($lastEmail['text'], '&secret=', 0) + 8, 64); $expireTime = strpos($lastEmail['text'], 'expire=' . urlencode($response['body']['expire']), 0); $this->assertNotFalse($expireTime); $secretTest = strpos($lastEmail['text'], 'secret=' . $response['body']['secret'], 0); $this->assertNotFalse($secretTest); $userIDTest = strpos($lastEmail['text'], 'userId=' . $response['body']['userId'], 0); $this->assertNotFalse($userIDTest); /** * Test for FAILURE */ $response = $this->client->call(Client::METHOD_POST, '/account/tokens/magic-url', array_merge([ 'origin' => 'http://localhost', 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ]), [ 'userId' => ID::unique(), 'email' => $email, 'url' => 'localhost/magiclogin', ]); $this->assertEquals(400, $response['headers']['status-code']); $response = $this->client->call(Client::METHOD_POST, '/account/tokens/magic-url', array_merge([ 'origin' => 'http://localhost', 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ]), [ 'userId' => ID::unique(), 'email' => $email, 'url' => 'http://remotehost/magiclogin', ]); $this->assertEquals(400, $response['headers']['status-code']); $response = $this->client->call(Client::METHOD_POST, '/account/tokens/magic-url', array_merge([ 'origin' => 'http://localhost', 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ]), [ 'email' => $email, ]); $this->assertEquals(400, $response['headers']['status-code']); $response = $this->client->call(Client::METHOD_POST, '/account/sessions/magic-url', array_merge([ 'origin' => 'http://localhost', 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ]), [ 'userId' => ID::unique(), 'email' => $email, 'phrase' => true ]); $this->assertEquals(201, $response['headers']['status-code']); $this->assertNotEmpty($response['body']['$id']); $this->assertNotEmpty($response['body']['phrase']); $phrase = $response['body']['phrase']; $lastEmail = $this->getLastEmailByAddress($email, function ($email) use ($phrase) { $this->assertStringContainsStringIgnoringCase($phrase, $email['text']); }); $this->assertNotEmpty($lastEmail, 'Email not found for address: ' . $email); $this->assertStringContainsStringIgnoringCase($phrase, $lastEmail['text']); } public function testCreateSessionWithMagicUrl(): void { $projectId = $this->getProject()['$id']; // Get a fresh magic URL token - the cached one may have been consumed by setupMagicUrlSession $email = \uniqid() . 'magicurl@localhost.test'; $tokenResponse = $this->client->call(Client::METHOD_POST, '/account/tokens/magic-url', array_merge([ 'origin' => 'http://localhost', 'content-type' => 'application/json', 'x-appwrite-project' => $projectId, ]), [ 'userId' => ID::unique(), 'email' => $email, ]); $this->assertEquals(201, $tokenResponse['headers']['status-code']); $id = $tokenResponse['body']['userId']; $lastEmail = $this->getLastEmailByAddress($email); $this->assertNotEmpty($lastEmail, 'Email not found for address: ' . $email); $token = substr($lastEmail['text'], strpos($lastEmail['text'], '&secret=', 0) + 8, 64); /** * Test for SUCCESS */ $response = $this->client->call(Client::METHOD_PUT, '/account/sessions/magic-url', array_merge([ 'origin' => 'http://localhost', 'content-type' => 'application/json', 'x-appwrite-project' => $projectId, ]), [ 'userId' => $id, 'secret' => $token, ]); $this->assertEquals(201, $response['headers']['status-code']); $this->assertIsArray($response['body']); $this->assertNotEmpty($response['body']); $this->assertNotEmpty($response['body']['$id']); $this->assertNotEmpty($response['body']['userId']); $this->assertEmpty($response['body']['secret']); $sessionId = $response['body']['$id']; $session = $response['cookies']['a_session_' . $this->getProject()['$id']]; $response = $this->client->call(Client::METHOD_GET, '/account', array_merge([ 'origin' => 'http://localhost', 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $session, ])); $this->assertEquals(200, $response['headers']['status-code']); $this->assertNotEmpty($response['body']); $this->assertNotEmpty($response['body']['$id']); $this->assertTrue((new DatetimeValidator())->isValid($response['body']['registration'])); $this->assertEquals($response['body']['email'], $email); $this->assertTrue($response['body']['emailVerification']); /** * Test for FAILURE */ $response = $this->client->call(Client::METHOD_PUT, '/account/sessions/magic-url', array_merge([ 'origin' => 'http://localhost', 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ]), [ 'userId' => ID::custom('ewewe'), 'secret' => $token, ]); $this->assertEquals(401, $response['headers']['status-code']); $response = $this->client->call(Client::METHOD_PUT, '/account/sessions/magic-url', array_merge([ 'origin' => 'http://localhost', 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ]), [ 'userId' => $id, 'secret' => 'sdasdasdasd', ]); $this->assertEquals(401, $response['headers']['status-code']); } public function testUpdateAccountPasswordWithMagicUrl(): void { $data = $this->setupMagicUrlSession(); $email = $data['email']; $session = $data['session']; /** * Test for SUCCESS */ $response = $this->client->call(Client::METHOD_PATCH, '/account/password', array_merge([ 'origin' => 'http://localhost', 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $session, ]), [ 'password' => 'new-password' ]); $this->assertEquals(200, $response['headers']['status-code']); $this->assertIsArray($response['body']); $this->assertNotEmpty($response['body']); $this->assertNotEmpty($response['body']['$id']); $this->assertTrue((new DatetimeValidator())->isValid($response['body']['registration'])); $this->assertEquals($response['body']['email'], $email); $response = $this->client->call(Client::METHOD_POST, '/account/sessions/email', array_merge([ 'origin' => 'http://localhost', 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ]), [ 'email' => $email, 'password' => 'new-password', ]); $this->assertEquals(201, $response['headers']['status-code']); $this->assertEmpty($response['body']['secret']); /** * Test for FAILURE */ $response = $this->client->call(Client::METHOD_PATCH, '/account/password', array_merge([ 'origin' => 'http://localhost', 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ])); $this->assertEquals(401, $response['headers']['status-code']); $response = $this->client->call(Client::METHOD_PATCH, '/account/password', array_merge([ 'origin' => 'http://localhost', 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $session, ]), []); $this->assertEquals(400, $response['headers']['status-code']); /** * Existing user tries to update password by passing wrong old password -> SHOULD FAIL */ $response = $this->client->call(Client::METHOD_PATCH, '/account/password', array_merge([ 'origin' => 'http://localhost', 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $session, ]), [ 'password' => 'new-password', 'oldPassword' => 'wrong-password', ]); $this->assertEquals(401, $response['headers']['status-code']); /** * Existing user tries to update password without passing old password -> SHOULD FAIL */ $response = $this->client->call(Client::METHOD_PATCH, '/account/password', array_merge([ 'origin' => 'http://localhost', 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $session, ]), [ 'password' => 'new-password' ]); $this->assertEquals(401, $response['headers']['status-code']); } public function testCreatePushTarget(): void { $response = $this->client->call(Client::METHOD_POST, '/account/targets/push', \array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'] ], $this->getHeaders()), [ 'targetId' => ID::unique(), 'identifier' => 'test-identifier', ]); $this->assertEquals(201, $response['headers']['status-code']); $this->assertNotEmpty($response['body']['$id']); $this->assertEquals('test-identifier', $response['body']['identifier']); } public function testUpdatePushTarget(): void { $response = $this->client->call(Client::METHOD_POST, '/account/targets/push', \array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ], $this->getHeaders()), [ 'targetId' => ID::unique(), 'identifier' => 'test-identifier-2', ]); $this->assertEquals(201, $response['headers']['status-code']); $this->assertNotEmpty($response['body']['$id']); $this->assertEquals('test-identifier-2', $response['body']['identifier']); $response = $this->client->call(Client::METHOD_PUT, '/account/targets/'. $response['body']['$id'] .'/push', \array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ], $this->getHeaders()), [ 'identifier' => 'test-identifier-updated', ]); $this->assertEquals(200, $response['headers']['status-code']); $this->assertEquals('test-identifier-updated', $response['body']['identifier']); $this->assertEquals(false, $response['body']['expired']); } public function testMFARecoveryCodeChallenge(): void { // Generate recovery codes using existing authenticated session $response = $this->client->call(Client::METHOD_POST, '/account/mfa/recovery-codes', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ], $this->getHeaders()), []); $this->assertEquals(201, $response['headers']['status-code']); $this->assertNotEmpty($response['body']['recoveryCodes']); $recoveryCodes = $response['body']['recoveryCodes']; $this->assertGreaterThan(0, count($recoveryCodes)); // Create recovery code challenge $challenge = $this->client->call(Client::METHOD_POST, '/account/mfa/challenge', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ], $this->getHeaders()), [ 'factor' => 'recoveryCode' ]); $this->assertEquals(201, $challenge['headers']['status-code']); $this->assertNotEmpty($challenge['body']['$id']); $challengeId = $challenge['body']['$id']; // Test SUCCESS: Verify with valid recovery code (this tests the bug fix) $verification = $this->client->call(Client::METHOD_PUT, '/account/mfa/challenge', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ], $this->getHeaders()), [ 'challengeId' => $challengeId, 'otp' => $recoveryCodes[0] ]); $this->assertEquals(200, $verification['headers']['status-code']); $this->assertArrayHasKey('factors', $verification['body']); $this->assertContains('recoveryCode', $verification['body']['factors']); // Test that the code was consumed (can't use again) $challenge2 = $this->client->call(Client::METHOD_POST, '/account/mfa/challenge', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ], $this->getHeaders()), [ 'factor' => 'recoveryCode' ]); $this->assertEquals(201, $challenge2['headers']['status-code']); $verification2 = $this->client->call(Client::METHOD_PUT, '/account/mfa/challenge', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ], $this->getHeaders()), [ 'challengeId' => $challenge2['body']['$id'], 'otp' => $recoveryCodes[0] // Same code should fail ]); $this->assertEquals(401, $verification2['headers']['status-code']); // Test FAILURE: Invalid recovery code $challenge3 = $this->client->call(Client::METHOD_POST, '/account/mfa/challenge', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ], $this->getHeaders()), [ 'factor' => 'recoveryCode' ]); $this->assertEquals(201, $challenge3['headers']['status-code']); $verification3 = $this->client->call(Client::METHOD_PUT, '/account/mfa/challenge', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ], $this->getHeaders()), [ 'challengeId' => $challenge3['body']['$id'], 'otp' => 'invalid-code-123' ]); $this->assertEquals(401, $verification3['headers']['status-code']); } public function testRefreshEmailPasswordSession(): void { $email = uniqid() . 'user@localhost.test'; $account = $this->client->call(Client::METHOD_POST, '/account', array_merge([ 'origin' => 'http://localhost', 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ]), [ 'userId' => ID::unique(), 'email' => $email, 'password' => 'password', ]); $this->assertEquals(201, $account['headers']['status-code']); $session = $this->client->call(Client::METHOD_POST, '/account/sessions/email', array_merge([ 'origin' => 'http://localhost', 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ]), [ 'email' => $email, 'password' => 'password', ]); $this->assertEquals(201, $session['headers']['status-code']); $this->assertNotEmpty($session['body']['$id']); $sessionId = $session['body']['$id']; $cookie = 'a_session_' . $this->getProject()['$id'] . '=' .$session['cookies']['a_session_' . $this->getProject()['$id']]; $session = $this->client->call(Client::METHOD_GET, '/account/sessions/current', array_merge([ 'origin' => 'http://localhost', 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'cookie' => $cookie, ])); $this->assertEquals(200, $session['headers']['status-code']); $this->assertNotEmpty($session['body']['expire']); $expiryBefore = $session['body']['expire']; \sleep(3); // Small delay to ensure expiry an expand $session = $this->client->call(Client::METHOD_PATCH, '/account/sessions/' . $sessionId, array_merge([ 'origin' => 'http://localhost', 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'cookie' => $cookie, ])); $this->assertEquals(200, $session['headers']['status-code']); $this->assertNotEmpty($session['body']['expire']); $expiryAfter = $session['body']['expire']; $this->assertGreaterThan(\strtotime($expiryBefore), \strtotime($expiryAfter)); $session = $this->client->call(Client::METHOD_GET, '/account/sessions/current', array_merge([ 'origin' => 'http://localhost', 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'cookie' => $cookie, ])); $this->assertEquals(200, $session['headers']['status-code']); $this->assertEquals(\strtotime($expiryAfter), \strtotime($session['body']['expire'])); } }