From e624040e5758ebdc4dcb87e03b13e10c662df8eb Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Wed, 29 Apr 2026 13:49:57 +0530 Subject: [PATCH] Refactor presence API methods for clarity and consistency. Updated method names to include 'Presence' suffix for better identification. Enhanced presence state logic to support unique index-based upserts and improved test coverage for presence functionalities, including custom permissions and expiry handling. --- src/Appwrite/Databases/PresenceState.php | 8 +- .../Modules/Presences/HTTP/Delete.php | 2 +- .../Platform/Modules/Presences/HTTP/Get.php | 2 +- .../Modules/Presences/HTTP/Update.php | 2 +- .../Modules/Presences/HTTP/Upsert.php | 2 +- .../Platform/Modules/Presences/HTTP/XList.php | 2 +- tests/e2e/Services/Presence/PresenceBase.php | 313 +++++++++++++++++- .../Services/Presence/PresenceExpiryTest.php | 11 +- .../Presence/PresenceRealtimeClientTest.php | 21 +- 9 files changed, 334 insertions(+), 29 deletions(-) diff --git a/src/Appwrite/Databases/PresenceState.php b/src/Appwrite/Databases/PresenceState.php index 6568a095d6..455cd27f72 100644 --- a/src/Appwrite/Databases/PresenceState.php +++ b/src/Appwrite/Databases/PresenceState.php @@ -66,6 +66,9 @@ class PresenceState try { if ($this->getSupportForUniqueIndexBasedUpsert()) { + $presenceCreated = $dbForProject->findOne('presenceLogs', [Query::equal('userId', [$userId])])->isEmpty(); + $presence = $dbForProject->upsertDocument('presenceLogs', $presenceDocument); + } else { $presence = $this->transactionalUpsertForUser( $dbForProject, $presenceDocument, @@ -73,9 +76,6 @@ class PresenceState $userId, $presenceCreated ); - } else { - $presenceCreated = $dbForProject->findOne('presenceLogs', [Query::equal('userId', [$userId])])->isEmpty(); - $presence = $dbForProject->upsertDocument('presenceLogs', $presenceDocument); } if ($presenceCreated && $onPresenceCreated !== null) { @@ -129,7 +129,7 @@ class PresenceState private function getSupportForUniqueIndexBasedUpsert(): bool { $adapter = \strtolower(System::getEnv('_APP_DB_ADAPTER', 'mariadb')); - return \in_array($adapter, ['mongodb', 'postgres', 'postgresql'], true); + return !\in_array($adapter, ['mongodb', 'postgres', 'postgresql'], true); } private function assertPermissionsAgainstAuthorization(array $permissions, Authorization $authorization): void diff --git a/src/Appwrite/Platform/Modules/Presences/HTTP/Delete.php b/src/Appwrite/Platform/Modules/Presences/HTTP/Delete.php index 7b9bd51205..203ec6497f 100644 --- a/src/Appwrite/Platform/Modules/Presences/HTTP/Delete.php +++ b/src/Appwrite/Platform/Modules/Presences/HTTP/Delete.php @@ -41,7 +41,7 @@ class Delete extends Base ->label('sdk', new Method( namespace: 'presences', group: 'presences', - name: 'delete', + name: 'deletePresence', description: 'Delete a presence log by its unique ID.', auth: [AuthType::ADMIN, AuthType::KEY, AuthType::SESSION, AuthType::JWT], responses: [ diff --git a/src/Appwrite/Platform/Modules/Presences/HTTP/Get.php b/src/Appwrite/Platform/Modules/Presences/HTTP/Get.php index 88316bd3ce..2fe6974f6f 100644 --- a/src/Appwrite/Platform/Modules/Presences/HTTP/Get.php +++ b/src/Appwrite/Platform/Modules/Presences/HTTP/Get.php @@ -33,7 +33,7 @@ class Get extends Base ->label('sdk', new Method( namespace: 'presences', group: 'presences', - name: 'get', + name: 'getPresence', description: 'Get a presence log by its unique ID.', auth: [AuthType::ADMIN, AuthType::KEY, AuthType::SESSION, AuthType::JWT], responses: [ diff --git a/src/Appwrite/Platform/Modules/Presences/HTTP/Update.php b/src/Appwrite/Platform/Modules/Presences/HTTP/Update.php index f9151a6f7e..9675c8f6da 100644 --- a/src/Appwrite/Platform/Modules/Presences/HTTP/Update.php +++ b/src/Appwrite/Platform/Modules/Presences/HTTP/Update.php @@ -45,7 +45,7 @@ class Update extends PresenceAction ->label('sdk', new Method( namespace: 'presences', group: 'presences', - name: 'update', + name: 'updatePresence', description: 'Update a presence log by its unique ID.', auth: [AuthType::ADMIN, AuthType::KEY, AuthType::SESSION, AuthType::JWT], responses: [ diff --git a/src/Appwrite/Platform/Modules/Presences/HTTP/Upsert.php b/src/Appwrite/Platform/Modules/Presences/HTTP/Upsert.php index 1622319c56..00ef174bd7 100644 --- a/src/Appwrite/Platform/Modules/Presences/HTTP/Upsert.php +++ b/src/Appwrite/Platform/Modules/Presences/HTTP/Upsert.php @@ -49,7 +49,7 @@ class Upsert extends PresenceAction ->label('sdk', new Method( namespace: 'presences', group: 'presences', - name: 'upsert', + name: 'upsertPresence', description: 'Create or update a presence log by its unique ID.', auth: [AuthType::ADMIN, AuthType::KEY, AuthType::SESSION, AuthType::JWT], responses: [ diff --git a/src/Appwrite/Platform/Modules/Presences/HTTP/XList.php b/src/Appwrite/Platform/Modules/Presences/HTTP/XList.php index e6f6711008..e4e6eaeff7 100644 --- a/src/Appwrite/Platform/Modules/Presences/HTTP/XList.php +++ b/src/Appwrite/Platform/Modules/Presences/HTTP/XList.php @@ -43,7 +43,7 @@ class XList extends Base ->label('sdk', new Method( namespace: 'presences', group: 'presences', - name: 'list', + name: 'listPresences', description: 'List presence logs.', auth: [AuthType::ADMIN, AuthType::KEY, AuthType::SESSION, AuthType::JWT], responses: [ diff --git a/tests/e2e/Services/Presence/PresenceBase.php b/tests/e2e/Services/Presence/PresenceBase.php index c432e534cf..0de2808333 100644 --- a/tests/e2e/Services/Presence/PresenceBase.php +++ b/tests/e2e/Services/Presence/PresenceBase.php @@ -4,6 +4,8 @@ namespace Tests\E2E\Services\Presence; use Tests\E2E\Client; use Utopia\Database\Helpers\ID; +use Utopia\Database\Helpers\Permission; +use Utopia\Database\Helpers\Role; use Utopia\Database\Query; trait PresenceBase @@ -27,6 +29,19 @@ trait PresenceBase return self::$presenceApiKeyCache[$projectId]; } + /** + * Server-side helper: ensure presences requests use a presence-scoped API key. + */ + protected function getPresenceServerHeaders(): array + { + $headers = $this->getHeaders(false); + + // Override the project API key added by `SideServer` with a presence-scoped key. + $headers['x-appwrite-key'] = $this->getPresenceApiKey(); + + return $headers; + } + protected function setupPresence(array $overrides = []): array { $projectId = $this->getProject()['$id']; @@ -93,6 +108,8 @@ trait PresenceBase public function testUpsertAndGetPresence(): void { if ($this->getSide() === 'client') { + $userId = $this->getUser()['$id']; + $upsert = $this->client->call( Client::METHOD_PUT, '/presences/' . ID::unique(), @@ -106,7 +123,24 @@ trait PresenceBase ] ); - $this->assertEquals(401, $upsert['headers']['status-code']); + $this->assertEquals(200, $upsert['headers']['status-code']); + $this->assertNotEmpty($upsert['body']['$id']); + $this->assertEquals($userId, $upsert['body']['userId']); + + $get = $this->client->call( + Client::METHOD_GET, + '/presences/' . $upsert['body']['$id'], + \array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders(false)) + ); + + $this->assertEquals(200, $get['headers']['status-code']); + $this->assertEquals($upsert['body']['$id'], $get['body']['$id']); + $this->assertEquals($userId, $get['body']['userId']); + $this->assertArrayHasKey('expiry', $get['body']); + return; } @@ -118,7 +152,7 @@ trait PresenceBase \array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders(false)) + ], $this->getPresenceServerHeaders()) ); $this->assertEquals(200, $get['headers']['status-code']); @@ -130,16 +164,78 @@ trait PresenceBase public function testListPresences(): void { if ($this->getSide() === 'client') { + $upsert = $this->client->call( + Client::METHOD_PUT, + '/presences/' . ID::unique(), + \array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders(false)), + [ + 'status' => 'online', + 'metadata' => ['device' => 'web'], + ] + ); + + $this->assertEquals(200, $upsert['headers']['status-code']); + $this->assertNotEmpty($upsert['body']['$id']); + $this->assertArrayHasKey('userId', $upsert['body']); + $list = $this->client->call( Client::METHOD_GET, '/presences', \array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders(false)) + ], $this->getHeaders(false)), + [ + 'queries' => [ + Query::equal('userId', [$upsert['body']['userId']])->toString(), + ], + ] ); - $this->assertEquals(401, $list['headers']['status-code']); + $this->assertEquals(200, $list['headers']['status-code']); + $this->assertArrayHasKey('total', $list['body']); + $this->assertArrayHasKey('presences', $list['body']); + $this->assertIsArray($list['body']['presences']); + $this->assertGreaterThanOrEqual(1, $list['body']['total']); + + // Client sessions must not be able to list presences belonging to a different user. + $projectId = $this->getProject()['$id']; + $originalUser = $this->getUser(); + $otherUserId = $this->getUser(true)['$id']; + + // Important: don't let `getUser(true)` overwrite the cached user/session for the rest + // of this test run. We only need the other user's ID. + self::$user[$projectId] = $originalUser; + + // Seed another presence for the other user (setup via API key, not the client session). + $this->setupPresence([ + 'userId' => $otherUserId, + 'status' => 'online', + 'metadata' => ['device' => 'other-user'], + ]); + + $otherList = $this->client->call( + Client::METHOD_GET, + '/presences', + \array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders(false)), + [ + 'queries' => [ + Query::equal('userId', [$otherUserId])->toString(), + ], + ] + ); + + $this->assertEquals(200, $otherList['headers']['status-code']); + $this->assertArrayHasKey('total', $otherList['body']); + $this->assertArrayHasKey('presences', $otherList['body']); + $this->assertSame([], $otherList['body']['presences']); + $this->assertEquals(0, $otherList['body']['total']); return; } @@ -151,7 +247,7 @@ trait PresenceBase \array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders(false)), + ], $this->getPresenceServerHeaders()), [ 'queries' => [ Query::equal('userId', [$presence['userId']])->toString(), @@ -166,12 +262,179 @@ trait PresenceBase $this->assertGreaterThanOrEqual(1, $list['body']['total']); } + public function testClientPresenceCustomPermissionsForOtherUser(): void + { + if ($this->getSide() !== 'client') { + $this->expectNotToPerformAssertions(); + return; + } + + $projectId = $this->getProject()['$id']; + $user1 = $this->getUser(); + $headersUser1 = $this->getHeaders(false); + + $user2 = $this->getUser(true); + + // Avoid overwriting the cached user for the rest of the test run. + self::$user[$projectId] = $user1; + + $headersUser2 = $this->getHeaders(false); + $headersUser2['cookie'] = 'a_session_' . $projectId . '=' . $user2['session']; + + $permissionsForUser2 = [ + Permission::read(Role::user($user2['$id'])), + Permission::update(Role::user($user2['$id'])), + Permission::delete(Role::user($user2['$id'])), + Permission::write(Role::user($user2['$id'])), + ]; + + $permissionsForUser1 = [ + Permission::read(Role::user($user1['$id'])), + Permission::update(Role::user($user1['$id'])), + Permission::delete(Role::user($user1['$id'])), + Permission::write(Role::user($user1['$id'])), + ]; + + // Create a presence for user1 using a presence-scoped API key so we can set ACLs. + $presenceAllow = $this->client->call( + Client::METHOD_PUT, + '/presences/' . ID::unique(), + \array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getPresenceApiKey(), + ]), + [ + 'userId' => $user1['$id'], + 'status' => 'online', + 'metadata' => ['case' => 'allow'], + // Owner always retains full permissions; user2 additionally gets access. + 'permissions' => \array_merge($permissionsForUser1, $permissionsForUser2), + ] + ); + + $this->assertEquals(200, $presenceAllow['headers']['status-code']); + $presenceIdAllow = $presenceAllow['body']['$id']; + + // user2 can read + $get = $this->client->call( + Client::METHOD_GET, + '/presences/' . $presenceIdAllow, + \array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $headersUser2) + ); + $this->assertEquals(200, $get['headers']['status-code']); + + // user2 can update + $patch = $this->client->call( + Client::METHOD_PATCH, + '/presences/' . $presenceIdAllow, + \array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $headersUser2), + [ + 'status' => 'busy', + 'metadata' => ['case' => 'allow-update'], + ] + ); + $this->assertEquals(200, $patch['headers']['status-code']); + $this->assertEquals('busy', $patch['body']['status']); + + // user2 can delete + $delete = $this->client->call( + Client::METHOD_DELETE, + '/presences/' . $presenceIdAllow, + \array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $headersUser2) + ); + $this->assertEquals(204, $delete['headers']['status-code']); + + // Create another presence for user1 without granting any special permissions to user2. + $presenceDeny = $this->client->call( + Client::METHOD_PUT, + '/presences/' . ID::unique(), + \array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getPresenceApiKey(), + ]), + [ + 'userId' => $user1['$id'], + 'status' => 'online', + 'metadata' => ['case' => 'deny'], + // Only the owner has permissions; user2 should not be able to access this document. + 'permissions' => $permissionsForUser1, + ] + ); + + $this->assertEquals(200, $presenceDeny['headers']['status-code']); + $presenceIdDeny = $presenceDeny['body']['$id']; + + // user2 cannot read + $getDeny = $this->client->call( + Client::METHOD_GET, + '/presences/' . $presenceIdDeny, + \array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $headersUser2) + ); + // When read permission is missing, the document should be treated as not found. + $this->assertEquals(404, $getDeny['headers']['status-code']); + + // user2 cannot update + $patchDeny = $this->client->call( + Client::METHOD_PATCH, + '/presences/' . $presenceIdDeny, + \array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $headersUser2), + [ + 'status' => 'busy', + ] + ); + $this->assertEquals(404, $patchDeny['headers']['status-code']); + + // user2 cannot delete + $deleteDeny = $this->client->call( + Client::METHOD_DELETE, + '/presences/' . $presenceIdDeny, + \array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $headersUser2) + ); + $this->assertEquals(404, $deleteDeny['headers']['status-code']); + } + public function testUpdatePresenceSparseFields(): void { if ($this->getSide() === 'client') { + $upsert = $this->client->call( + Client::METHOD_PUT, + '/presences/' . ID::unique(), + \array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders(false)), + [ + 'status' => 'away', + 'metadata' => ['source' => 'setup'], + ] + ); + + $this->assertEquals(200, $upsert['headers']['status-code']); + $presenceId = $upsert['body']['$id']; + $update = $this->client->call( Client::METHOD_PATCH, - '/presences/' . ID::unique(), + '/presences/' . $presenceId, \array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], @@ -182,7 +445,10 @@ trait PresenceBase ] ); - $this->assertEquals(401, $update['headers']['status-code']); + $this->assertEquals(200, $update['headers']['status-code']); + $this->assertEquals('busy', $update['body']['status']); + $this->assertEquals(['source' => 'update'], $update['body']['metadata']); + return; } @@ -206,7 +472,7 @@ trait PresenceBase \array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders(false)), + ], $this->getPresenceServerHeaders()), $payload ); @@ -218,16 +484,33 @@ trait PresenceBase public function testDeletePresence(): void { if ($this->getSide() === 'client') { + $upsert = $this->client->call( + Client::METHOD_PUT, + '/presences/' . ID::unique(), + \array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders(false)), + [ + 'status' => 'temp-delete', + 'metadata' => ['cleanup' => true], + ] + ); + + $this->assertEquals(200, $upsert['headers']['status-code']); + $presenceId = $upsert['body']['$id']; + $delete = $this->client->call( Client::METHOD_DELETE, - '/presences/' . ID::unique(), + '/presences/' . $presenceId, \array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ], $this->getHeaders(false)) ); - $this->assertEquals(401, $delete['headers']['status-code']); + $this->assertEquals(204, $delete['headers']['status-code']); + return; } @@ -242,7 +525,7 @@ trait PresenceBase \array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders(false)) + ], $this->getPresenceServerHeaders()) ); $this->assertEquals(204, $delete['headers']['status-code']); @@ -263,7 +546,7 @@ trait PresenceBase ] ); - $this->assertEquals(401, $response['headers']['status-code']); + $this->assertEquals(404, $response['headers']['status-code']); return; } @@ -281,7 +564,7 @@ trait PresenceBase \array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders(false)), + ], $this->getPresenceServerHeaders()), $payload ); @@ -324,7 +607,7 @@ trait PresenceBase \array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders(false)), + ], $this->getPresenceServerHeaders()), [ 'status' => 'online', ] @@ -345,7 +628,7 @@ trait PresenceBase $headers = \array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $projectId, - ], $this->getHeaders(false)); + ], $this->getPresenceServerHeaders()); $firstUpsert = $this->client->call( Client::METHOD_PUT, diff --git a/tests/e2e/Services/Presence/PresenceExpiryTest.php b/tests/e2e/Services/Presence/PresenceExpiryTest.php index 3d86045a41..8ef9b0a803 100644 --- a/tests/e2e/Services/Presence/PresenceExpiryTest.php +++ b/tests/e2e/Services/Presence/PresenceExpiryTest.php @@ -7,6 +7,7 @@ use Tests\E2E\Scopes\ProjectCustom; use Tests\E2E\Scopes\Scope; use Tests\E2E\Scopes\SideServer; use Utopia\Console; +use Utopia\Database\DateTime; use Utopia\Database\Helpers\ID; class PresenceExpiryTest extends Scope @@ -25,8 +26,8 @@ class PresenceExpiryTest extends Scope } self::$presenceApiKeyCache[$projectId] = $this->getNewKey([ - 'users.read', - 'users.write', + 'presence.read', + 'presence.write', ]); return self::$presenceApiKeyCache[$projectId]; @@ -36,7 +37,8 @@ class PresenceExpiryTest extends Scope { $projectId = $this->getProject()['$id']; $userId = $this->getUser()['$id']; - $expiredAt = \gmdate('Y-m-d\TH:i:s.v\Z', \time() - 120); + // Must match the format used by the maintenance worker query. + $expiredAt = DateTime::format((new \DateTime())->modify('-120 seconds')); $createServer = $this->client->call( Client::METHOD_PUT, @@ -81,6 +83,7 @@ class PresenceExpiryTest extends Scope $code = Console::execute('docker exec appwrite maintenance --type=trigger', '', $stdout, $stderr); $this->assertSame(0, $code, "Maintenance command failed with code $code: $stderr ($stdout)"); + // Maintenance + delete workers are asynchronous; give extra time to observe cleanup. $this->assertEventually(function () use ($presenceIdServer, $projectId) { $getServer = $this->client->call( Client::METHOD_GET, @@ -93,6 +96,6 @@ class PresenceExpiryTest extends Scope ); $this->assertEquals(404, $getServer['headers']['status-code']); - }); + }, 30000, 1000); } } diff --git a/tests/e2e/Services/Presence/PresenceRealtimeClientTest.php b/tests/e2e/Services/Presence/PresenceRealtimeClientTest.php index 2a36a9ea6b..b2f4c08f24 100644 --- a/tests/e2e/Services/Presence/PresenceRealtimeClientTest.php +++ b/tests/e2e/Services/Presence/PresenceRealtimeClientTest.php @@ -18,6 +18,8 @@ class PresenceRealtimeClientTest extends Scope use ProjectCustom; use SideClient; + private static array $presenceApiKeyCache = []; + private function bootstrapIsolatedProject(): array { $project = $this->getProject(true); @@ -37,10 +39,27 @@ class PresenceRealtimeClientTest extends Scope return [ 'content-type' => 'application/json', 'x-appwrite-project' => $project['$id'], - 'x-appwrite-key' => $project['apiKey'], + 'x-appwrite-key' => $this->getPresenceApiKey($project), ]; } + private function getPresenceApiKey(array $project): string + { + $projectId = $project['$id']; + + if (!empty(self::$presenceApiKeyCache[$projectId])) { + return self::$presenceApiKeyCache[$projectId]; + } + + // Realtime tests validate HTTP reads of presences; those endpoints require `presence.read`. + self::$presenceApiKeyCache[$projectId] = $this->getNewKey([ + 'presence.read', + 'presence.write', + ]); + + return self::$presenceApiKeyCache[$projectId]; + } + private function connectRealtimeAndSubscribe( array $project, array $headers,