diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Attribute/Decrement.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Attribute/Decrement.php index da202b2f40..316d0f1edb 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Attribute/Decrement.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Attribute/Decrement.php @@ -2,6 +2,7 @@ namespace Appwrite\Platform\Modules\Databases\Http\Databases\Collections\Documents\Attribute; +use Appwrite\Auth\Auth; use Appwrite\Event\Event; use Appwrite\Event\StatsUsage; use Appwrite\Extend\Exception; @@ -88,6 +89,9 @@ class Decrement extends Action public function action(string $databaseId, string $collectionId, string $documentId, string $attribute, int|float $value, int|float|null $min, ?string $transactionId, UtopiaResponse $response, Database $dbForProject, Event $queueForEvents, StatsUsage $queueForStatsUsage, array $plan): void { + $isAPIKey = Auth::isAppUser(Authorization::getRoles()); + $isPrivilegedUser = Auth::isPrivilegedUser(Authorization::getRoles()); + $database = Authorization::skip(fn () => $dbForProject->getDocument('databases', $databaseId)); if ($database->isEmpty()) { throw new Exception(Exception::DATABASE_NOT_FOUND); @@ -100,7 +104,9 @@ class Decrement extends Action // Handle transaction staging if ($transactionId !== null) { - $transaction = $dbForProject->getDocument('transactions', $transactionId); + $transaction = ($isAPIKey || $isPrivilegedUser) + ? Authorization::skip(fn () => $dbForProject->getDocument('transactions', $transactionId)) + : $dbForProject->getDocument('transactions', $transactionId); if ($transaction->isEmpty() || $transaction->getAttribute('status', '') !== 'pending') { throw new Exception(Exception::GENERAL_BAD_REQUEST, 'Invalid or non‑pending transaction'); } diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Attribute/Increment.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Attribute/Increment.php index 543597612c..22fc1a8152 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Attribute/Increment.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Attribute/Increment.php @@ -2,6 +2,7 @@ namespace Appwrite\Platform\Modules\Databases\Http\Databases\Collections\Documents\Attribute; +use Appwrite\Auth\Auth; use Appwrite\Event\Event; use Appwrite\Event\StatsUsage; use Appwrite\Extend\Exception; @@ -88,6 +89,9 @@ class Increment extends Action public function action(string $databaseId, string $collectionId, string $documentId, string $attribute, int|float $value, int|float|null $max, ?string $transactionId, UtopiaResponse $response, Database $dbForProject, Event $queueForEvents, StatsUsage $queueForStatsUsage, array $plan): void { + $isAPIKey = Auth::isAppUser(Authorization::getRoles()); + $isPrivilegedUser = Auth::isPrivilegedUser(Authorization::getRoles()); + $database = Authorization::skip(fn () => $dbForProject->getDocument('databases', $databaseId)); if ($database->isEmpty()) { throw new Exception(Exception::DATABASE_NOT_FOUND); @@ -100,7 +104,9 @@ class Increment extends Action // Handle transaction staging if ($transactionId !== null) { - $transaction = $dbForProject->getDocument('transactions', $transactionId); + $transaction = ($isAPIKey || $isPrivilegedUser) + ? Authorization::skip(fn () => $dbForProject->getDocument('transactions', $transactionId)) + : $dbForProject->getDocument('transactions', $transactionId); if ($transaction->isEmpty() || $transaction->getAttribute('status', '') !== 'pending') { throw new Exception(Exception::GENERAL_BAD_REQUEST, 'Invalid or non‑pending transaction'); } diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Create.php index 41c50775a3..4254283432 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Create.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Create.php @@ -367,7 +367,9 @@ class Create extends Action // Handle transaction staging if ($transactionId !== null) { - $transaction = $dbForProject->getDocument('transactions', $transactionId); + $transaction = ($isAPIKey || $isPrivilegedUser) + ? Authorization::skip(fn () => $dbForProject->getDocument('transactions', $transactionId)) + : $dbForProject->getDocument('transactions', $transactionId); if ($transaction->isEmpty()) { throw new Exception(Exception::TRANSACTION_NOT_FOUND); } diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Delete.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Delete.php index 200bd4db72..a4cef59e5f 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Delete.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Delete.php @@ -129,7 +129,9 @@ class Delete extends Action // Handle transaction staging if ($transactionId !== null) { - $transaction = $dbForProject->getDocument('transactions', $transactionId); + $transaction = ($isAPIKey || $isPrivilegedUser) + ? Authorization::skip(fn () => $dbForProject->getDocument('transactions', $transactionId)) + : $dbForProject->getDocument('transactions', $transactionId); if ($transaction->isEmpty()) { throw new Exception(Exception::TRANSACTION_NOT_FOUND); } diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Update.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Update.php index 37adc1db70..a3a976c04a 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Update.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Update.php @@ -244,7 +244,9 @@ class Update extends Action // Handle transaction staging if ($transactionId !== null) { - $transaction = $dbForProject->getDocument('transactions', $transactionId); + $transaction = ($isAPIKey || $isPrivilegedUser) + ? Authorization::skip(fn () => $dbForProject->getDocument('transactions', $transactionId)) + : $dbForProject->getDocument('transactions', $transactionId); if ($transaction->isEmpty()) { throw new Exception(Exception::TRANSACTION_NOT_FOUND); } diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Upsert.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Upsert.php index 92dd1c03b7..91a35d74c2 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Upsert.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Upsert.php @@ -253,7 +253,9 @@ class Upsert extends Action // Handle transaction staging if ($transactionId !== null) { - $transaction = $dbForProject->getDocument('transactions', $transactionId); + $transaction = ($isAPIKey || $isPrivilegedUser) + ? Authorization::skip(fn () => $dbForProject->getDocument('transactions', $transactionId)) + : $dbForProject->getDocument('transactions', $transactionId); if ($transaction->isEmpty()) { throw new Exception(Exception::TRANSACTION_NOT_FOUND); } diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Transactions/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Transactions/Create.php index 744ad33540..c4c5bf8b51 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Transactions/Create.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Transactions/Create.php @@ -11,6 +11,7 @@ use Utopia\Database\Database; use Utopia\Database\DateTime; use Utopia\Database\Document; use Utopia\Database\Helpers\ID; +use Utopia\Database\Helpers\Permission; use Utopia\Database\Validator\Authorization; use Utopia\Swoole\Response as SwooleResponse; use Utopia\Validator\Range; @@ -53,13 +54,28 @@ class Create extends Action ->param('ttl', APP_DATABASE_TXN_TTL_DEFAULT, new Range(min: APP_DATABASE_TXN_TTL_MIN, max: APP_DATABASE_TXN_TTL_MAX), 'Seconds before the transaction expires.', true) ->inject('response') ->inject('dbForProject') + ->inject('user') ->callback($this->action(...)); } - public function action(int $ttl, UtopiaResponse $response, Database $dbForProject): void + public function action(int $ttl, UtopiaResponse $response, Database $dbForProject, Document $user): void { + $permissions = []; + if (!empty($user->getId())) { + $allowedPermissions = [ + Database::PERMISSION_READ, + Database::PERMISSION_UPDATE, + Database::PERMISSION_DELETE, + ]; + + foreach ($allowedPermissions as $permission) { + $permissions[] = (new Permission($permission, 'user', $user->getId()))->toString(); + } + } + $transaction = Authorization::skip(fn () => $dbForProject->createDocument('transactions', new Document([ '$id' => ID::unique(), + '$permissions' => $permissions, 'status' => 'pending', 'operations' => 0, 'expiresAt' => DateTime::addSeconds(new \DateTime(), $ttl), diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Transactions/Operations/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Transactions/Operations/Create.php index 460da671b0..8aa132d15d 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Transactions/Operations/Create.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Transactions/Operations/Create.php @@ -72,8 +72,17 @@ class Create extends Action throw new Exception(Exception::GENERAL_BAD_REQUEST, 'Operations array cannot be empty'); } - $transaction = Authorization::skip(fn () => $dbForProject->getDocument('transactions', $transactionId)); - if ($transaction->isEmpty() || $transaction->getAttribute('status', '') !== 'pending') { + $isAPIKey = Auth::isAppUser(Authorization::getRoles()); + $isPrivilegedUser = Auth::isPrivilegedUser(Authorization::getRoles()); + + // API keys and admins can read any transaction, regular users need permissions + $transaction = ($isAPIKey || $isPrivilegedUser) + ? Authorization::skip(fn () => $dbForProject->getDocument('transactions', $transactionId)) + : $dbForProject->getDocument('transactions', $transactionId); + if ($transaction->isEmpty()) { + throw new Exception(Exception::TRANSACTION_NOT_FOUND); + } + if ($transaction->getAttribute('status', '') !== 'pending') { throw new Exception(Exception::GENERAL_BAD_REQUEST, 'Invalid or non‑pending transaction'); } @@ -93,9 +102,6 @@ class Create extends Action ); } - $isAPIKey = Auth::isAppUser(Authorization::getRoles()); - $isPrivilegedUser = Auth::isPrivilegedUser(Authorization::getRoles()); - $databases = $collections = $staged = $dependants = []; foreach ($operations as $operation) { if (!$isAPIKey && !$isPrivilegedUser && \in_array($operation['action'], [ @@ -146,54 +152,58 @@ class Create extends Action } } - $permissionType = match ($operation['action']) { - 'create', 'bulkCreate' => Database::PERMISSION_CREATE, - 'update', 'bulkUpdate', 'increment', 'decrement' => Database::PERMISSION_UPDATE, - 'delete', 'bulkDelete' => Database::PERMISSION_DELETE, - 'upsert', 'bulkUpsert' => ($document && !$document->isEmpty()) ? Database::PERMISSION_UPDATE : Database::PERMISSION_CREATE, - default => throw new Exception(Exception::GENERAL_BAD_REQUEST, 'Invalid action: ' . $operation['action']) - }; + // Bulk operations skip permission validation entirely (API key/admin only, already checked above) + if (!\in_array($operation['action'], ['bulkCreate', 'bulkUpdate', 'bulkUpsert', 'bulkDelete'])) { + $permissionType = match ($operation['action']) { + 'create' => Database::PERMISSION_CREATE, + 'update', 'increment', 'decrement' => Database::PERMISSION_UPDATE, + 'delete' => Database::PERMISSION_DELETE, + 'upsert' => ($document && !$document->isEmpty()) ? Database::PERMISSION_UPDATE : Database::PERMISSION_CREATE, + default => throw new Exception(Exception::GENERAL_BAD_REQUEST, 'Invalid action: ' . $operation['action']) + }; - if (!$isAPIKey && !$isPrivilegedUser) { - $documentSecurity = $collection->getAttribute('documentSecurity', false); - $validator = new Authorization($permissionType); - $collectionValid = $validator->isValid($collection->getPermissionsByType($permissionType)); - $documentValid = false; - if ($document !== null && !$document->isEmpty() && $documentSecurity) { - if ($permissionType === Database::PERMISSION_UPDATE) { - $documentValid = $validator->isValid($document->getUpdate()); - } elseif ($permissionType === Database::PERMISSION_DELETE) { - $documentValid = $validator->isValid($document->getDelete()); + // For individual operations, enforce permissions unless using API key/admin + if (!$isAPIKey && !$isPrivilegedUser) { + $documentSecurity = $collection->getAttribute('documentSecurity', false); + $validator = new Authorization($permissionType); + $collectionValid = $validator->isValid($collection->getPermissionsByType($permissionType)); + $documentValid = false; + if ($document !== null && !$document->isEmpty() && $documentSecurity) { + if ($permissionType === Database::PERMISSION_UPDATE) { + $documentValid = $validator->isValid($document->getUpdate()); + } elseif ($permissionType === Database::PERMISSION_DELETE) { + $documentValid = $validator->isValid($document->getDelete()); + } } - } - if ($permissionType === Database::PERMISSION_CREATE || !$documentSecurity) { - if (!$collectionValid) { - throw new Exception(Exception::USER_UNAUTHORIZED); + if ($permissionType === Database::PERMISSION_CREATE || !$documentSecurity) { + if (!$collectionValid) { + throw new Exception(Exception::USER_UNAUTHORIZED); + } + } else { + if (!$collectionValid && !$documentValid) { + throw new Exception(Exception::USER_UNAUTHORIZED); + } } - } else { - if (!$collectionValid && !$documentValid) { - throw new Exception(Exception::USER_UNAUTHORIZED); - } - } - // Users can only set permissions for roles they have - if (isset($operation['data']['$permissions'])) { - $permissions = $operation['data']['$permissions']; - $roles = Authorization::getRoles(); - foreach (Database::PERMISSIONS as $type) { - foreach ($permissions as $permission) { - $permission = Permission::parse($permission); - if ($permission->getPermission() != $type) { - continue; - } - $role = (new Role( - $permission->getRole(), - $permission->getIdentifier(), - $permission->getDimension() - ))->toString(); - if (!Authorization::isRole($role)) { - throw new Exception(Exception::USER_UNAUTHORIZED, 'Permissions must be one of: (' . \implode(', ', $roles) . ')'); + // Users can only set permissions for roles they have + if (isset($operation['data']['$permissions'])) { + $permissions = $operation['data']['$permissions']; + $roles = Authorization::getRoles(); + foreach (Database::PERMISSIONS as $type) { + foreach ($permissions as $permission) { + $permission = Permission::parse($permission); + if ($permission->getPermission() != $type) { + continue; + } + $role = (new Role( + $permission->getRole(), + $permission->getIdentifier(), + $permission->getDimension() + ))->toString(); + if (!Authorization::isRole($role)) { + throw new Exception(Exception::USER_UNAUTHORIZED, 'Permissions must be one of: (' . \implode(', ', $roles) . ')'); + } } } } diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Transactions/Update.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Transactions/Update.php index 5d29bba34b..927adb9bc7 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Transactions/Update.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Transactions/Update.php @@ -2,6 +2,7 @@ namespace Appwrite\Platform\Modules\Databases\Http\Databases\Transactions; +use Appwrite\Auth\Auth; use Appwrite\Databases\TransactionState; use Appwrite\Event\Delete; use Appwrite\Event\Event; @@ -110,7 +111,12 @@ class Update extends Action throw new Exception(Exception::GENERAL_BAD_REQUEST, 'Cannot commit and rollback at the same time'); } - $transaction = Authorization::skip(fn () => $dbForProject->getDocument('transactions', $transactionId)); + $isAPIKey = Auth::isAppUser(Authorization::getRoles()); + $isPrivilegedUser = Auth::isPrivilegedUser(Authorization::getRoles()); + + $transaction = ($isAPIKey || $isPrivilegedUser) + ? Authorization::skip(fn () => $dbForProject->getDocument('transactions', $transactionId)) + : $dbForProject->getDocument('transactions', $transactionId); if ($transaction->isEmpty()) { throw new Exception(Exception::TRANSACTION_NOT_FOUND); } diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Transactions/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Transactions/Create.php index e6c24b3341..a375d47c3c 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Transactions/Create.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Transactions/Create.php @@ -49,6 +49,7 @@ class Create extends TransactionsCreate ->param('ttl', APP_DATABASE_TXN_TTL_DEFAULT, new Range(min: APP_DATABASE_TXN_TTL_MIN, max: APP_DATABASE_TXN_TTL_MAX), 'Seconds before the transaction expires.', true) ->inject('response') ->inject('dbForProject') + ->inject('user') ->callback($this->action(...)); } } diff --git a/tests/e2e/Services/Databases/TablesDB/Transactions/PermissionsBase.php b/tests/e2e/Services/Databases/TablesDB/Transactions/PermissionsBase.php index 9dac59e2ec..a1082256ee 100644 --- a/tests/e2e/Services/Databases/TablesDB/Transactions/PermissionsBase.php +++ b/tests/e2e/Services/Databases/TablesDB/Transactions/PermissionsBase.php @@ -780,4 +780,437 @@ trait PermissionsBase $this->assertEquals(200, $rollback['headers']['status-code']); } + + /** + * Test that one user cannot read another user's transaction + */ + public function testUserCannotReadAnotherUsersTransaction(): void + { + // Create user 1 (fresh) and their transaction + $user1 = $this->getUser(true); + $user1Headers = [ + 'origin' => 'http://localhost', + 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $user1['session'], + ]; + + $transaction1 = $this->client->call(Client::METHOD_POST, '/tablesdb/transactions', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $user1Headers)); + + $this->assertEquals(201, $transaction1['headers']['status-code']); + $transactionId1 = $transaction1['body']['$id']; + + // Create user 2 (fresh) + $user2 = $this->getUser(true); // Fresh user + $user2Headers = [ + 'origin' => 'http://localhost', + 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $user2['session'], + ]; + + // User 2 tries to read User 1's transaction - should fail + $readAttempt = $this->client->call(Client::METHOD_GET, '/tablesdb/transactions/' . $transactionId1, array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $user2Headers)); + + // This should fail with 404 Not Found (transaction doesn't exist for this user) + $this->assertEquals(404, $readAttempt['headers']['status-code']); + + // Verify User 1 can still read their own transaction + $readOwn = $this->client->call(Client::METHOD_GET, '/tablesdb/transactions/' . $transactionId1, array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $user1Headers)); + + $this->assertEquals(200, $readOwn['headers']['status-code']); + $this->assertEquals($transactionId1, $readOwn['body']['$id']); + } + + /** + * Test that one user cannot list another user's transactions + */ + public function testUserCannotListAnotherUsersTransactions(): void + { + // Create user 1 (fresh) with transactions + $user1 = $this->getUser(true); + $user1Headers = [ + 'origin' => 'http://localhost', + 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $user1['session'], + ]; + + $transaction1 = $this->client->call(Client::METHOD_POST, '/tablesdb/transactions', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $user1Headers)); + + $this->assertEquals(201, $transaction1['headers']['status-code']); + + $transaction2 = $this->client->call(Client::METHOD_POST, '/tablesdb/transactions', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $user1Headers)); + + $this->assertEquals(201, $transaction2['headers']['status-code']); + + // Create user 2 (fresh) with their own transaction + $user2 = $this->getUser(true); // Fresh user + $user2Headers = [ + 'origin' => 'http://localhost', + 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $user2['session'], + ]; + + $transaction3 = $this->client->call(Client::METHOD_POST, '/tablesdb/transactions', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $user2Headers)); + + $this->assertEquals(201, $transaction3['headers']['status-code']); + + // User 2 lists transactions - should only see their own + $listUser2 = $this->client->call(Client::METHOD_GET, '/tablesdb/transactions', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $user2Headers)); + + $this->assertEquals(200, $listUser2['headers']['status-code']); + $this->assertEquals(1, $listUser2['body']['total']); + $this->assertEquals($transaction3['body']['$id'], $listUser2['body']['transactions'][0]['$id']); + + // User 1 lists transactions - should only see their own (2 transactions) + $listUser1 = $this->client->call(Client::METHOD_GET, '/tablesdb/transactions', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $user1Headers)); + + $this->assertEquals(200, $listUser1['headers']['status-code']); + $this->assertEquals(2, $listUser1['body']['total']); + + // Verify neither of user1's transactions appear in user2's list + $user2TransactionIds = array_column($listUser2['body']['transactions'], '$id'); + $this->assertNotContains($transaction1['body']['$id'], $user2TransactionIds); + $this->assertNotContains($transaction2['body']['$id'], $user2TransactionIds); + } + + /** + * Test that one user cannot update another user's transaction + */ + public function testUserCannotUpdateAnotherUsersTransaction(): void + { + // Create user 1 (fresh) and their transaction + $user1 = $this->getUser(true); + $user1Headers = [ + 'origin' => 'http://localhost', + 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $user1['session'], + ]; + + $transaction1 = $this->client->call(Client::METHOD_POST, '/tablesdb/transactions', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $user1Headers)); + + $this->assertEquals(201, $transaction1['headers']['status-code']); + $transactionId1 = $transaction1['body']['$id']; + + // Create user 2 (fresh) + $user2 = $this->getUser(true); // Fresh user + $user2Headers = [ + 'origin' => 'http://localhost', + 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $user2['session'], + ]; + + // User 2 tries to commit User 1's transaction - should fail + $commitAttempt = $this->client->call(Client::METHOD_PATCH, '/tablesdb/transactions/' . $transactionId1, array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $user2Headers), [ + 'commit' => true, + ]); + + // This should fail with 404 Not Found + $this->assertEquals(404, $commitAttempt['headers']['status-code']); + + // User 2 tries to rollback User 1's transaction - should also fail + $rollbackAttempt = $this->client->call(Client::METHOD_PATCH, '/tablesdb/transactions/' . $transactionId1, array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $user2Headers), [ + 'rollback' => true, + ]); + + // This should also fail with 404 Not Found + $this->assertEquals(404, $rollbackAttempt['headers']['status-code']); + + // Verify User 1 can still commit their own transaction + $commitOwn = $this->client->call(Client::METHOD_PATCH, '/tablesdb/transactions/' . $transactionId1, array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $user1Headers), [ + 'commit' => true, + ]); + + $this->assertEquals(200, $commitOwn['headers']['status-code']); + } + + /** + * Test that one user cannot delete another user's transaction + */ + public function testUserCannotDeleteAnotherUsersTransaction(): void + { + // Create user 1 (fresh) and their transaction + $user1 = $this->getUser(true); + $user1Headers = [ + 'origin' => 'http://localhost', + 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $user1['session'], + ]; + + $transaction1 = $this->client->call(Client::METHOD_POST, '/tablesdb/transactions', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $user1Headers)); + + $this->assertEquals(201, $transaction1['headers']['status-code']); + $transactionId1 = $transaction1['body']['$id']; + + // Create user 2 (fresh) + $user2 = $this->getUser(true); // Fresh user + $user2Headers = [ + 'origin' => 'http://localhost', + 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $user2['session'], + ]; + + // User 2 tries to delete User 1's transaction - should fail + $deleteAttempt = $this->client->call(Client::METHOD_DELETE, '/tablesdb/transactions/' . $transactionId1, array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $user2Headers)); + + // This should fail with 404 Not Found + $this->assertEquals(404, $deleteAttempt['headers']['status-code']); + + // Verify User 1 can still access their transaction + $readOwn = $this->client->call(Client::METHOD_GET, '/tablesdb/transactions/' . $transactionId1, array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $user1Headers)); + + $this->assertEquals(200, $readOwn['headers']['status-code']); + + // User 1 can delete their own transaction + $deleteOwn = $this->client->call(Client::METHOD_DELETE, '/tablesdb/transactions/' . $transactionId1, array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $user1Headers)); + + $this->assertEquals(204, $deleteOwn['headers']['status-code']); + } + + /** + * Test that one user cannot add operations to another user's transaction + */ + public function testUserCannotAddOperationsToAnotherUsersTransaction(): void + { + // Create a collection for testing + $collection = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $this->permissionsDatabase . '/tables', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'tableId' => 'permTest11', + 'name' => 'Permission Test 11', + 'permissions' => [ + Permission::read(Role::any()), + Permission::create(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + 'rowSecurity' => false, + ]); + + $this->assertEquals(201, $collection['headers']['status-code']); + + $attribute = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $this->permissionsDatabase . '/tables/' . $collection['body']['$id'] . '/columns/string', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'key' => 'title', + 'size' => 255, + 'required' => true, + ]); + + $this->assertEquals(202, $attribute['headers']['status-code']); + sleep(2); + + // Create user 1 (fresh) and their transaction + $user1 = $this->getUser(true); + $user1Headers = [ + 'origin' => 'http://localhost', + 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $user1['session'], + ]; + + $transaction1 = $this->client->call(Client::METHOD_POST, '/tablesdb/transactions', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $user1Headers)); + + $this->assertEquals(201, $transaction1['headers']['status-code']); + $transactionId1 = $transaction1['body']['$id']; + + // Create user 2 (fresh) + $user2 = $this->getUser(true); // Fresh user + $user2Headers = [ + 'origin' => 'http://localhost', + 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $user2['session'], + ]; + + // User 2 tries to add operations to User 1's transaction - should fail + $operationAttempt = $this->client->call(Client::METHOD_POST, '/tablesdb/transactions/' . $transactionId1 . '/operations', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $user2Headers), [ + 'operations' => [[ + 'action' => 'create', + 'databaseId' => $this->permissionsDatabase, + 'tableId' => $collection['body']['$id'], + 'rowId' => 'maliciousDoc', + 'data' => ['title' => 'Malicious Document'], + ]] + ]); + + // This should fail with 404 Not Found + $this->assertEquals(404, $operationAttempt['headers']['status-code']); + + // Verify User 1 can still add operations to their own transaction + $operationOwn = $this->client->call(Client::METHOD_POST, '/tablesdb/transactions/' . $transactionId1 . '/operations', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $user1Headers), [ + 'operations' => [[ + 'action' => 'create', + 'databaseId' => $this->permissionsDatabase, + 'tableId' => $collection['body']['$id'], + 'rowId' => 'legitimateDoc', + 'data' => ['title' => 'Legitimate Document'], + ]] + ]); + + $this->assertEquals(201, $operationOwn['headers']['status-code']); + $this->assertEquals(1, $operationOwn['body']['operations']); + } + + /** + * Test that an authenticated user can successfully list their own transactions + */ + public function testAuthenticatedUserCanListTheirOwnTransactions(): void + { + // Create an authenticated user + $user = $this->getUser(); + $userHeaders = [ + 'origin' => 'http://localhost', + 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $user['session'], + ]; + + // Create multiple transactions for this user + $transactionIds = []; + for ($i = 0; $i < 3; $i++) { + $transaction = $this->client->call(Client::METHOD_POST, '/tablesdb/transactions', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $userHeaders)); + + $this->assertEquals(201, $transaction['headers']['status-code']); + $this->assertNotEmpty($transaction['body']['$id']); + $transactionIds[] = $transaction['body']['$id']; + } + + // List transactions + $list = $this->client->call(Client::METHOD_GET, '/tablesdb/transactions', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $userHeaders)); + + $this->assertEquals(200, $list['headers']['status-code']); + $this->assertGreaterThanOrEqual(3, $list['body']['total']); + $this->assertIsArray($list['body']['transactions']); + $this->assertGreaterThanOrEqual(3, count($list['body']['transactions'])); + + // Verify all created transactions are in the list + $listedIds = array_column($list['body']['transactions'], '$id'); + foreach ($transactionIds as $transactionId) { + $this->assertContains($transactionId, $listedIds); + } + + // Verify transaction structure + foreach ($list['body']['transactions'] as $transaction) { + $this->assertArrayHasKey('$id', $transaction); + $this->assertArrayHasKey('$createdAt', $transaction); + $this->assertArrayHasKey('$updatedAt', $transaction); + $this->assertArrayHasKey('status', $transaction); + $this->assertArrayHasKey('operations', $transaction); + } + } + + /** + * Test that an authenticated user can successfully delete their own transaction + */ + public function testAuthenticatedUserCanDeleteTheirOwnTransaction(): void + { + // Create an authenticated user + $user = $this->getUser(); + $userHeaders = [ + 'origin' => 'http://localhost', + 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $user['session'], + ]; + + // Create a transaction + $transaction = $this->client->call(Client::METHOD_POST, '/tablesdb/transactions', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $userHeaders)); + + $this->assertEquals(201, $transaction['headers']['status-code']); + $transactionId = $transaction['body']['$id']; + + // Verify transaction exists by reading it + $read = $this->client->call(Client::METHOD_GET, '/tablesdb/transactions/' . $transactionId, array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $userHeaders)); + + $this->assertEquals(200, $read['headers']['status-code']); + $this->assertEquals($transactionId, $read['body']['$id']); + + // Delete the transaction + $delete = $this->client->call(Client::METHOD_DELETE, '/tablesdb/transactions/' . $transactionId, array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $userHeaders)); + + $this->assertEquals(204, $delete['headers']['status-code']); + + // Verify transaction is deleted by trying to read it again + $readAfterDelete = $this->client->call(Client::METHOD_GET, '/tablesdb/transactions/' . $transactionId, array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $userHeaders)); + + $this->assertEquals(404, $readAfterDelete['headers']['status-code']); + + // Create another transaction and verify it can also be deleted + $transaction2 = $this->client->call(Client::METHOD_POST, '/tablesdb/transactions', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $userHeaders)); + + $this->assertEquals(201, $transaction2['headers']['status-code']); + $transactionId2 = $transaction2['body']['$id']; + + $delete2 = $this->client->call(Client::METHOD_DELETE, '/tablesdb/transactions/' . $transactionId2, array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $userHeaders)); + + $this->assertEquals(204, $delete2['headers']['status-code']); + } }