Merge pull request #10624 from appwrite/feat-txn

Feat txn
This commit is contained in:
Jake Barnby 2025-10-10 02:38:21 +13:00 committed by GitHub
commit aa43902d32
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 542 additions and 56 deletions

View file

@ -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 nonpending transaction');
}

View file

@ -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 nonpending transaction');
}

View file

@ -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);
}

View file

@ -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);
}

View file

@ -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);
}

View file

@ -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);
}

View file

@ -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),

View file

@ -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 nonpending 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) . ')');
}
}
}
}

View file

@ -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);
}

View file

@ -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(...));
}
}

View file

@ -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']);
}
}