@@ -4160,178 +4160,4 @@ public function testMFARecoveryCodeChallenge(): void
41604160
41614161 $ this ->assertEquals (401 , $ verification3 ['headers ' ]['status-code ' ]);
41624162 }
4163-
4164- /**
4165- * Test that a new email/password session is immediately usable even when
4166- * a concurrent request re-populates the user cache between the cache purge
4167- * and session creation.
4168- *
4169- * Regression test for: purging the user cache BEFORE persisting the session
4170- * allows a concurrent request (from a different Swoole worker) to re-cache
4171- * a stale user document that lacks the new session, causing sessionVerify
4172- * to fail with 401 on subsequent requests using the new session.
4173- */
4174- public function testEmailPasswordSessionNotCorruptedByConcurrentRequests (): void
4175- {
4176- $ projectId = $ this ->getProject ()['$id ' ];
4177- $ endpoint = $ this ->client ->getEndpoint ();
4178-
4179- $ email = uniqid ('race_ ' , true ) . getmypid () . '@localhost.test ' ;
4180- $ password = 'password123! ' ;
4181-
4182- // Create user
4183- $ response = $ this ->client ->call (Client::METHOD_POST , '/account ' , [
4184- 'origin ' => 'http://localhost ' ,
4185- 'content-type ' => 'application/json ' ,
4186- 'x-appwrite-project ' => $ projectId ,
4187- ], [
4188- 'userId ' => ID ::unique (),
4189- 'email ' => $ email ,
4190- 'password ' => $ password ,
4191- 'name ' => 'Race Test User ' ,
4192- ]);
4193- $ this ->assertEquals (201 , $ response ['headers ' ]['status-code ' ]);
4194-
4195- // Login to get session A
4196- $ responseA = $ this ->client ->call (Client::METHOD_POST , '/account/sessions/email ' , [
4197- 'origin ' => 'http://localhost ' ,
4198- 'content-type ' => 'application/json ' ,
4199- 'x-appwrite-project ' => $ projectId ,
4200- ], [
4201- 'email ' => $ email ,
4202- 'password ' => $ password ,
4203- ]);
4204- $ this ->assertEquals (201 , $ responseA ['headers ' ]['status-code ' ]);
4205- $ sessionA = $ responseA ['cookies ' ]['a_session_ ' . $ projectId ];
4206-
4207- // Verify session A works
4208- $ verifyA = $ this ->client ->call (Client::METHOD_GET , '/account ' , [
4209- 'origin ' => 'http://localhost ' ,
4210- 'content-type ' => 'application/json ' ,
4211- 'x-appwrite-project ' => $ projectId ,
4212- 'cookie ' => 'a_session_ ' . $ projectId . '= ' . $ sessionA ,
4213- ]);
4214- $ this ->assertEquals (200 , $ verifyA ['headers ' ]['status-code ' ]);
4215-
4216- /**
4217- * Race condition scenario:
4218- * 1. Start login B via curl_multi (non-blocking)
4219- * 2. Drive the transfer for ~150ms so login B reaches purgeCachedDocument
4220- * (findOne ~15ms + Argon2 hash verify ~60ms + middleware overhead)
4221- * 3. THEN add GET requests to curl_multi - these hit different workers and
4222- * re-cache a stale user document (without session B) during the window
4223- * between purgeCachedDocument and createDocument
4224- * 4. After all complete, verify session B is usable
4225- */
4226- for ($ attempt = 0 ; $ attempt < 5 ; $ attempt ++) {
4227- $ loginCookies = [];
4228-
4229- $ multi = curl_multi_init ();
4230-
4231- // Start login B first (alone)
4232- $ loginHandle = curl_init ("{$ endpoint }/account/sessions/email " );
4233- curl_setopt_array ($ loginHandle , [
4234- CURLOPT_POST => true ,
4235- CURLOPT_RETURNTRANSFER => true ,
4236- CURLOPT_HTTPHEADER => [
4237- 'origin: http://localhost ' ,
4238- 'content-type: application/json ' ,
4239- "x-appwrite-project: {$ projectId }" ,
4240- ],
4241- CURLOPT_POSTFIELDS => \json_encode ([
4242- 'email ' => $ email ,
4243- 'password ' => $ password ,
4244- ]),
4245- CURLOPT_HEADERFUNCTION => function ($ curl , $ header ) use (&$ loginCookies ) {
4246- if (\stripos ($ header , 'set-cookie: ' ) === 0 ) {
4247- $ cookiePart = \trim (\substr ($ header , 11 ));
4248- $ eqPos = \strpos ($ cookiePart , '= ' );
4249- if ($ eqPos !== false ) {
4250- $ name = \substr ($ cookiePart , 0 , $ eqPos );
4251- $ rest = \substr ($ cookiePart , $ eqPos + 1 );
4252- $ semiPos = \strpos ($ rest , '; ' );
4253- $ loginCookies [$ name ] = $ semiPos !== false
4254- ? \substr ($ rest , 0 , $ semiPos )
4255- : $ rest ;
4256- }
4257- }
4258- return \strlen ($ header );
4259- },
4260- ]);
4261- curl_multi_add_handle ($ multi , $ loginHandle );
4262-
4263- // Drive the login transfer forward and wait for the server to start
4264- // processing the login (past hash verification + cache purge).
4265- $ deadline = \microtime (true ) + 0.15 ; // 150ms
4266- do {
4267- curl_multi_exec ($ multi , $ active );
4268- curl_multi_select ($ multi , 0.005 );
4269- } while (\microtime (true ) < $ deadline && $ active );
4270-
4271- // NOW add GET requests - they arrive after the cache purge
4272- // but before session creation (which is delayed by the usleep or I/O).
4273- $ getHandles = [];
4274- for ($ i = 0 ; $ i < 10 ; $ i ++) {
4275- $ gh = curl_init ("{$ endpoint }/account " );
4276- curl_setopt_array ($ gh , [
4277- CURLOPT_RETURNTRANSFER => true ,
4278- CURLOPT_HTTPHEADER => [
4279- 'origin: http://localhost ' ,
4280- 'content-type: application/json ' ,
4281- "x-appwrite-project: {$ projectId }" ,
4282- "cookie: a_session_ {$ projectId }= {$ sessionA }" ,
4283- ],
4284- ]);
4285- curl_multi_add_handle ($ multi , $ gh );
4286- $ getHandles [] = $ gh ;
4287- }
4288-
4289- // Drive all to completion
4290- do {
4291- $ status = curl_multi_exec ($ multi , $ active );
4292- if ($ active ) {
4293- curl_multi_select ($ multi , 0.05 );
4294- }
4295- } while ($ active && $ status === CURLM_OK );
4296-
4297- $ loginStatus = curl_getinfo ($ loginHandle , CURLINFO_HTTP_CODE );
4298-
4299- curl_multi_remove_handle ($ multi , $ loginHandle );
4300- curl_close ($ loginHandle );
4301- foreach ($ getHandles as $ gh ) {
4302- curl_multi_remove_handle ($ multi , $ gh );
4303- curl_close ($ gh );
4304- }
4305- curl_multi_close ($ multi );
4306-
4307- $ this ->assertEquals (201 , $ loginStatus , 'Login for session B should succeed ' );
4308-
4309- $ sessionBCookie = $ loginCookies ["a_session_ {$ projectId }" ] ?? null ;
4310- $ this ->assertNotNull ($ sessionBCookie , 'Session B cookie should be set ' );
4311-
4312- // THE CRITICAL CHECK: verify session B is usable immediately
4313- $ verifyB = $ this ->client ->call (Client::METHOD_GET , '/account ' , [
4314- 'origin ' => 'http://localhost ' ,
4315- 'content-type ' => 'application/json ' ,
4316- 'x-appwrite-project ' => $ projectId ,
4317- 'cookie ' => "a_session_ {$ projectId }= {$ sessionBCookie }" ,
4318- ]);
4319-
4320- $ this ->assertEquals (
4321- 200 ,
4322- $ verifyB ['headers ' ]['status-code ' ],
4323- 'Session B must be immediately usable after login. '
4324- . 'A 401 here means a stale user cache (without the new session) was served. '
4325- . 'The fix is to create the session document BEFORE purging the user cache. '
4326- );
4327-
4328- // Clean up session B for next iteration
4329- $ this ->client ->call (Client::METHOD_DELETE , '/account/sessions/current ' , [
4330- 'origin ' => 'http://localhost ' ,
4331- 'content-type ' => 'application/json ' ,
4332- 'x-appwrite-project ' => $ projectId ,
4333- 'cookie ' => "a_session_ {$ projectId }= {$ sessionBCookie }" ,
4334- ]);
4335- }
4336- }
43374163}
0 commit comments