diff --git a/app/config/collections/common.php b/app/config/collections/common.php index 37fbcc8ca3..9c33d6e554 100644 --- a/app/config/collections/common.php +++ b/app/config/collections/common.php @@ -2755,4 +2755,146 @@ return [ ] ] ], + + // Naming it presenceLogs as later it might be only be used as a presence events table only and not for the actual presence + 'presenceLogs' => [ + '$collection' => ID::custom(Database::METADATA), + '$id' => ID::custom('presenceLogs'), + 'name' => 'Presence Logs', + 'attributes' => [ + [ + '$id' => ID::custom('userInternalId'), + 'type' => Database::VAR_ID, + 'format' => '', + 'size' => Database::LENGTH_KEY, + 'signed' => true, + 'required' => true, + 'default' => null, + 'array' => false, + 'filters' => [], + ], + [ + '$id' => ID::custom('userId'), + 'type' => Database::VAR_STRING, + 'format' => '', + 'size' => Database::LENGTH_KEY, + 'signed' => true, + 'required' => false, + 'default' => null, + 'array' => false, + 'filters' => [], + ], + [ + '$id' => ID::custom('expiresAt'), + 'type' => Database::VAR_DATETIME, + 'format' => '', + 'size' => 0, + 'signed' => false, + 'required' => false, + 'default' => null, + 'array' => false, + 'filters' => ['datetime'], + ], + [ + '$id' => ID::custom('status'), + 'type' => Database::VAR_STRING, + 'format' => '', + 'size' => Database::LENGTH_KEY, + 'signed' => true, + 'required' => false, + 'default' => null, + 'array' => false, + 'filters' => [], + ], + [ + '$id' => ID::custom('source'), + 'type' => Database::VAR_STRING, + 'format' => '', + 'size' => Database::LENGTH_KEY, + 'signed' => true, + 'required' => true, + 'array' => false, + 'filters' => [], + ], + [ + '$id' => ID::custom('hostname'), + 'type' => Database::VAR_STRING, + 'format' => '', + 'size' => Database::LENGTH_KEY, + 'signed' => true, + 'required' => false, + 'default' => null, + 'array' => false, + 'filters' => [], + ], + [ + '$id' => ID::custom('metadata'), + 'type' => Database::VAR_TEXT, + 'format' => '', + 'size' => 65535, + 'signed' => true, + 'required' => false, + 'default' => new \stdClass(), + 'array' => false, + 'filters' => ['json'], + ], + [ + '$id' => ID::custom('permissionsHash'), + 'type' => Database::VAR_STRING, + 'format' => '', + 'size' => 32, + 'signed' => true, + 'required' => false, + 'default' => null, + 'array' => false, + 'filters' => [], + ], + ], + 'indexes' => [ + [ + '$id' => ID::custom('_unique_userId'), + 'type' => Database::INDEX_UNIQUE, + 'attributes' => ['userId'], + 'lengths' => [Database::LENGTH_KEY], + 'orders' => [Database::ORDER_ASC] + ], + [ + '$id' => ID::custom('_key_userInternal'), + 'type' => Database::INDEX_KEY, + 'attributes' => ['userInternalId'], + 'orders' => [Database::ORDER_ASC] + ], + [ + '$id' => ID::custom('_key_expiresAt'), + 'type' => Database::INDEX_KEY, + 'attributes' => ['expiresAt'], + 'lengths' => [], + 'orders' => [Database::ORDER_ASC] + ], + [ + '$id' => ID::custom('_key_status'), + 'type' => Database::INDEX_KEY, + 'attributes' => ['status'], + 'lengths' => [Database::LENGTH_KEY], + 'orders' => [Database::ORDER_ASC] + ], + [ + '$id' => ID::custom('_key_source'), + 'type' => Database::INDEX_KEY, + 'attributes' => ['source'], + 'lengths' => [Database::LENGTH_KEY], + 'orders' => [Database::ORDER_ASC] + ], + [ + '$id' => ID::custom('_key_source_status'), + 'type' => Database::INDEX_KEY, + 'attributes' => ['source', 'status'] + ], + [ + '$id' => ID::custom('_key_permissionsHash'), + 'type' => Database::INDEX_KEY, + 'attributes' => ['permissionsHash'] + ] + ] + ], ]; diff --git a/app/config/collections/projects.php b/app/config/collections/projects.php index 120c9704ce..9ac5c562e3 100644 --- a/app/config/collections/projects.php +++ b/app/config/collections/projects.php @@ -2798,146 +2798,4 @@ return [ ], ], ], - - // Naming it presenceLogs as later it might be only be used as a presence events table only and not for the actual presence - 'presenceLogs' => [ - '$collection' => ID::custom(Database::METADATA), - '$id' => ID::custom('presenceLogs'), - 'name' => 'Presence Logs', - 'attributes' => [ - [ - '$id' => ID::custom('userInternalId'), - 'type' => Database::VAR_ID, - 'format' => '', - 'size' => Database::LENGTH_KEY, - 'signed' => true, - 'required' => true, - 'default' => null, - 'array' => false, - 'filters' => [], - ], - [ - '$id' => ID::custom('userId'), - 'type' => Database::VAR_STRING, - 'format' => '', - 'size' => Database::LENGTH_KEY, - 'signed' => true, - 'required' => false, - 'default' => null, - 'array' => false, - 'filters' => [], - ], - [ - '$id' => ID::custom('expiresAt'), - 'type' => Database::VAR_DATETIME, - 'format' => '', - 'size' => 0, - 'signed' => false, - 'required' => false, - 'default' => null, - 'array' => false, - 'filters' => ['datetime'], - ], - [ - '$id' => ID::custom('status'), - 'type' => Database::VAR_STRING, - 'format' => '', - 'size' => Database::LENGTH_KEY, - 'signed' => true, - 'required' => false, - 'default' => null, - 'array' => false, - 'filters' => [], - ], - [ - '$id' => ID::custom('source'), - 'type' => Database::VAR_STRING, - 'format' => '', - 'size' => Database::LENGTH_KEY, - 'signed' => true, - 'required' => true, - 'array' => false, - 'filters' => [], - ], - [ - '$id' => ID::custom('hostname'), - 'type' => Database::VAR_STRING, - 'format' => '', - 'size' => Database::LENGTH_KEY, - 'signed' => true, - 'required' => false, - 'default' => null, - 'array' => false, - 'filters' => [], - ], - [ - '$id' => ID::custom('metadata'), - 'type' => Database::VAR_TEXT, - 'format' => '', - 'size' => 65535, - 'signed' => true, - 'required' => false, - 'default' => new \stdClass(), - 'array' => false, - 'filters' => ['json'], - ], - [ - '$id' => ID::custom('permissionsHash'), - 'type' => Database::VAR_STRING, - 'format' => '', - 'size' => 32, - 'signed' => true, - 'required' => false, - 'default' => null, - 'array' => false, - 'filters' => [], - ], - ], - 'indexes' => [ - [ - '$id' => ID::custom('_unique_userId'), - 'type' => Database::INDEX_UNIQUE, - 'attributes' => ['userId'], - 'lengths' => [Database::LENGTH_KEY], - 'orders' => [Database::ORDER_ASC] - ], - [ - '$id' => ID::custom('_key_userInternal'), - 'type' => Database::INDEX_KEY, - 'attributes' => ['userInternalId'], - 'orders' => [Database::ORDER_ASC] - ], - [ - '$id' => ID::custom('_key_expiresAt'), - 'type' => Database::INDEX_KEY, - 'attributes' => ['expiresAt'], - 'lengths' => [], - 'orders' => [Database::ORDER_ASC] - ], - [ - '$id' => ID::custom('_key_status'), - 'type' => Database::INDEX_KEY, - 'attributes' => ['status'], - 'lengths' => [Database::LENGTH_KEY], - 'orders' => [Database::ORDER_ASC] - ], - [ - '$id' => ID::custom('_key_source'), - 'type' => Database::INDEX_KEY, - 'attributes' => ['source'], - 'lengths' => [Database::LENGTH_KEY], - 'orders' => [Database::ORDER_ASC] - ], - [ - '$id' => ID::custom('_key_source_status'), - 'type' => Database::INDEX_KEY, - 'attributes' => ['source', 'status'] - ], - [ - '$id' => ID::custom('_key_permissionsHash'), - 'type' => Database::INDEX_KEY, - 'attributes' => ['permissionsHash'] - ] - ] - ] ]; diff --git a/tests/e2e/Services/Presences/PresenceBase.php b/tests/e2e/Services/Presences/PresenceBase.php index 1c94ade61b..026d5c593a 100644 --- a/tests/e2e/Services/Presences/PresenceBase.php +++ b/tests/e2e/Services/Presences/PresenceBase.php @@ -127,7 +127,7 @@ trait PresenceBase public function testUpsertAndGetPresence(): void { - if ($this->getSide() === 'client') { + if ($this->getSide() === 'client' || $this->getSide() === 'console') { $userId = $this->getUser()['$id']; $upsert = $this->client->call( @@ -183,7 +183,7 @@ trait PresenceBase public function testListPresences(): void { - if ($this->getSide() === 'client') { + if ($this->getSide() === 'client' || $this->getSide() === 'console') { $upsert = $this->client->call( Client::METHOD_PUT, '/presences/' . ID::unique(), @@ -224,18 +224,36 @@ trait PresenceBase // 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']; + $otherUser = $this->getUser(true); + $otherUserId = $otherUser['$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. + // of this test run. 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'], - ]); + if ($projectId === 'console') { + // The console project has no API keys; seed via the other user's own session. + $this->client->call( + Client::METHOD_PUT, + '/presences/' . ID::unique(), + [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'cookie' => 'a_session_' . $projectId . '=' . $otherUser['session'], + ], + [ + 'status' => 'online', + 'metadata' => ['device' => 'other-user'], + ] + ); + } else { + // 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, @@ -284,6 +302,8 @@ trait PresenceBase public function testClientPresenceCustomPermissionsForOtherUser(): void { + // Requires API key to create two concurrent presences for the same user with + // different ACLs. Server-only — also skipped on console (which has no API keys). if ($this->getSide() !== 'client') { $this->expectNotToPerformAssertions(); return; @@ -431,7 +451,7 @@ trait PresenceBase public function testUpdatePresenceSparseFields(): void { - if ($this->getSide() === 'client') { + if ($this->getSide() === 'client' || $this->getSide() === 'console') { $upsert = $this->client->call( Client::METHOD_PUT, '/presences/' . ID::unique(), @@ -614,7 +634,7 @@ trait PresenceBase public function testDeletePresence(): void { - if ($this->getSide() === 'client') { + if ($this->getSide() === 'client' || $this->getSide() === 'console') { $upsert = $this->client->call( Client::METHOD_PUT, '/presences/' . ID::unique(), @@ -671,6 +691,14 @@ trait PresenceBase public function testUpdatePresencePurgeListCache(): void { + if ($this->getProject()['$id'] === 'console') { + // The console project shares dbForPlatform's cache with every other request, + // so parallel workers can wipe the list cache between calls and the hit/miss + // assertions become flaky. Skip on console. + $this->expectNotToPerformAssertions(); + return; + } + if ($this->getSide() === 'client') { $upsert = $this->client->call( Client::METHOD_PUT, @@ -743,6 +771,11 @@ trait PresenceBase public function testUpdatePresencePurgeOnlyListCache(): void { + if ($this->getProject()['$id'] === 'console') { + $this->expectNotToPerformAssertions(); + return; + } + if ($this->getSide() === 'client') { $upsert = $this->client->call( Client::METHOD_PUT, @@ -814,6 +847,11 @@ trait PresenceBase public function testDeletePresencePurgesListCache(): void { + if ($this->getProject()['$id'] === 'console') { + $this->expectNotToPerformAssertions(); + return; + } + if ($this->getSide() === 'client') { $upsert = $this->client->call( Client::METHOD_PUT, @@ -875,7 +913,7 @@ trait PresenceBase public function testUpdateNotFound(): void { - if ($this->getSide() === 'client') { + if ($this->getSide() === 'client' || $this->getSide() === 'console') { $response = $this->client->call( Client::METHOD_PATCH, '/presences/' . ID::unique(), @@ -938,7 +976,8 @@ trait PresenceBase public function testServerRequiresUserId(): void { - if ($this->getSide() === 'client') { + // Server-only behavior — also skipped on console (no API keys for the console project). + if ($this->getSide() === 'client' || $this->getSide() === 'console') { $this->expectNotToPerformAssertions(); return; } @@ -960,7 +999,8 @@ trait PresenceBase public function testUpsertSameUserMaintainsSinglePresence(): void { - if ($this->getSide() === 'client') { + // Server-only behavior — also skipped on console (no API keys for the console project). + if ($this->getSide() === 'client' || $this->getSide() === 'console') { $this->expectNotToPerformAssertions(); return; } @@ -1032,7 +1072,7 @@ trait PresenceBase */ public function testCrossUserUpsertDoesNotOverwriteForeignPresence(): void { - if ($this->getSide() !== 'client') { + if ($this->getSide() !== 'client' && $this->getSide() !== 'console') { $this->expectNotToPerformAssertions(); return; } @@ -1091,14 +1131,20 @@ trait PresenceBase // Verify User1's row is intact. Read via a presence-scoped API key to bypass // any read-permission ambiguity and inspect the persisted state directly. - $check = $this->client->call( - Client::METHOD_GET, - '/presences/' . $sharedPresenceId, - [ + // The console project has no API keys, so fall back to user1's own session — + // if the bug ever resurfaces and user2 overwrote the row, user1 would lose + // read permission and this GET would return 404, still surfacing the failure. + $checkHeaders = $projectId === 'console' + ? $headersUser1 + : [ 'content-type' => 'application/json', 'x-appwrite-project' => $projectId, 'x-appwrite-key' => $this->getPresenceApiKey(), - ] + ]; + $check = $this->client->call( + Client::METHOD_GET, + '/presences/' . $sharedPresenceId, + $checkHeaders ); $this->assertEquals(200, $check['headers']['status-code']); $this->assertEquals($user1['$id'], $check['body']['userId']); diff --git a/tests/e2e/Services/Presences/PresenceConsoleClientTest.php b/tests/e2e/Services/Presences/PresenceConsoleClientTest.php index c3c2233256..1f1c52a234 100644 --- a/tests/e2e/Services/Presences/PresenceConsoleClientTest.php +++ b/tests/e2e/Services/Presences/PresenceConsoleClientTest.php @@ -9,15 +9,38 @@ use Tests\E2E\Scopes\SideConsole; class PresenceConsoleClientTest extends Scope { - use ProjectCustom; - use SideConsole; + use PresenceBase; + use ProjectCustom { + getProject as getCustomProject; + } + use SideConsole { + getHeaders as getAdminHeaders; + } + + public function getProject(bool $fresh = false): array + { + return ['$id' => 'console']; + } + + // `x-appwrite-mode: admin` is forbidden for the console project, so authenticate + // as a console session user instead — `getUser()` signs them up against project=console. + public function getHeaders(bool $devKey = true): array + { + return [ + 'origin' => 'http://localhost', + 'cookie' => 'a_session_console=' . $this->getUser()['session'], + ]; + } public function testGetPresenceUsage(): void { + // Usage requires admin scope, which the console project rejects — run against a regular project. + $projectId = $this->getCustomProject()['$id']; + $response = $this->client->call(Client::METHOD_GET, '/presences/usage', array_merge([ 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders()), [ + 'x-appwrite-project' => $projectId, + ], $this->getAdminHeaders()), [ 'range' => '32h', ]); @@ -25,8 +48,8 @@ class PresenceConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_GET, '/presences/usage', array_merge([ 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders()), [ + 'x-appwrite-project' => $projectId, + ], $this->getAdminHeaders()), [ 'range' => '24h', ]);