From 90f0282ce334b7351307711d4731ff20ab634700 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Mon, 16 Mar 2026 16:31:08 +0100 Subject: [PATCH 01/18] Implement oauth2 token flow tests --- .../Account/AccountCustomClientTest.php | 129 +++++++++++++++++- 1 file changed, 128 insertions(+), 1 deletion(-) diff --git a/tests/e2e/Services/Account/AccountCustomClientTest.php b/tests/e2e/Services/Account/AccountCustomClientTest.php index ea387cff6c..107dceaa5e 100644 --- a/tests/e2e/Services/Account/AccountCustomClientTest.php +++ b/tests/e2e/Services/Account/AccountCustomClientTest.php @@ -2182,11 +2182,138 @@ class AccountCustomClientTest extends Scope ]), [ 'success' => 'http://localhost/v1/mock/tests/general/oauth2/success', 'failure' => 'http://localhost/v1/mock/tests/general/oauth2/failure', - ]); + ], followRedirects: false); + + $this->assertEquals(301, $response['headers']['status-code']); + $this->assertStringStartsWith('http://localhost/v1/mock/tests/general/oauth2', $response['headers']['location']); + + $oauthClient = new Client(); + $oauthClient->setEndpoint(''); + $response = $oauthClient->call(Client::METHOD_GET, $response['headers']['location'], followRedirects: false); + + $this->assertEquals(301, $response['headers']['status-code']); + $this->assertStringStartsWith('http://appwrite:/v1/account/sessions/oauth2/callback/mock/' . $this->getProject()['$id'] . '?code=', $response['headers']['location']); + + $response = $oauthClient->call(Client::METHOD_GET, $response['headers']['location'], followRedirects: false); + + $this->assertEquals(301, $response['headers']['status-code']); + $this->assertStringStartsWith('http://appwrite:/v1/account/sessions/oauth2/mock/redirect?code=', $response['headers']['location']); + + $response = $oauthClient->call(Client::METHOD_GET, $response['headers']['location'], followRedirects: false); + + $this->assertEquals(301, $response['headers']['status-code']); + + $this->assertArrayHasKey('a_session_' . $this->getProject()['$id'] . '_legacy', $response['cookies']); + $this->assertArrayHasKey('a_session_' . $this->getProject()['$id'], $response['cookies']); + + $oauthUserCookie = $response['cookies']['a_session_' . $this->getProject()['$id']]; + $this->assertNotEmpty($oauthUserCookie); + + $response = $oauthClient->call(Client::METHOD_GET, $response['headers']['location'], followRedirects: false); $this->assertEquals(200, $response['headers']['status-code']); $this->assertEquals('success', $response['body']['result']); + // Ensure user is authenticated + $response = $this->client->call(Client::METHOD_GET, '/account', [ + 'x-appwrite-project' => $this->getProject()['$id'], + 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $oauthUserCookie, + ]); + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals('useroauth@localhost.test', $response['body']['email']); + + $oauthUserId = $response['body']['$id']; + $this->assertNotEmpty($oauthUserId); + + // Ensure session looks as expected + $response = $this->client->call(Client::METHOD_GET, '/account/sessions/current', [ + 'x-appwrite-project' => $this->getProject()['$id'], + 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $oauthUserCookie, + ]); + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals($oauthUserId, $response['body']['userId']); + $this->assertEquals('mock', $response['body']['provider']); + + // Same sign-in again, but this time with oauth2 token flow + $response = $this->client->call(Client::METHOD_GET, '/account/tokens/oauth2/' . $provider, array_merge([ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]), [ + 'success' => 'http://localhost/v1/mock/tests/general/oauth2/success', + 'failure' => 'http://localhost/v1/mock/tests/general/oauth2/failure', + ], followRedirects: false); + + $this->assertEquals(301, $response['headers']['status-code']); + $this->assertStringStartsWith('http://localhost/v1/mock/tests/general/oauth2', $response['headers']['location']); + + $oauthClient = new Client(); + $oauthClient->setEndpoint(''); + $response = $oauthClient->call(Client::METHOD_GET, $response['headers']['location'], followRedirects: false); + + $this->assertEquals(301, $response['headers']['status-code']); + $this->assertStringStartsWith('http://appwrite:/v1/account/sessions/oauth2/callback/mock/' . $this->getProject()['$id'] . '?code=', $response['headers']['location']); + + $response = $oauthClient->call(Client::METHOD_GET, $response['headers']['location'], followRedirects: false); + + $this->assertEquals(301, $response['headers']['status-code']); + $this->assertStringStartsWith('http://appwrite:/v1/account/sessions/oauth2/mock/redirect?code=', $response['headers']['location']); + + $response = $oauthClient->call(Client::METHOD_GET, $response['headers']['location'], followRedirects: false); + + $this->assertEquals(301, $response['headers']['status-code']); + $this->assertStringStartsWith('http://localhost/v1/mock/tests/general/oauth2/success?secret=', $response['headers']['location']); + + $oauthParamsString = \parse_url($response['headers']['location'], PHP_URL_QUERY); + $oauthParams = []; + \parse_str($oauthParamsString, $oauthParams); + + $this->assertNotEmpty($oauthParams['secret']); + $this->assertNotEmpty($oauthParams['userId']); + + $response = $oauthClient->call(Client::METHOD_GET, $response['headers']['location'], followRedirects: false); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals('success', $response['body']['result']); + + // Claim session + $response = $this->client->call(Client::METHOD_POST, '/account/sessions/token', [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], [ + 'userId' => $oauthParams['userId'], + 'secret' => $oauthParams['secret'], + ]); + + $this->assertEquals(201, $response['headers']['status-code']); + $this->assertEquals('mock', $response['body']['provider']); + + $this->assertArrayHasKey('a_session_' . $this->getProject()['$id'] . '_legacy', $response['cookies']); + $this->assertArrayHasKey('a_session_' . $this->getProject()['$id'], $response['cookies']); + + $oauthUserCookie = $response['cookies']['a_session_' . $this->getProject()['$id']]; + $this->assertNotEmpty($oauthUserCookie); + + $response = $this->client->call(Client::METHOD_GET, '/account', [ + 'x-appwrite-project' => $this->getProject()['$id'], + 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $oauthUserCookie, + ]); + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals('useroauth@localhost.test', $response['body']['email']); + + $oauthUserId = $response['body']['$id']; + $this->assertNotEmpty($oauthUserId); + + // Ensure session looks as expected + $response = $this->client->call(Client::METHOD_GET, '/account/sessions/current', [ + 'x-appwrite-project' => $this->getProject()['$id'], + 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $oauthUserCookie, + ]); + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals($oauthUserId, $response['body']['userId']); + $this->assertEquals('mock', $response['body']['provider']); + /** * Test for Failure when disabled */ From afd8d8a02018201731840d474876b91a617c237b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Mon, 16 Mar 2026 16:57:35 +0100 Subject: [PATCH 02/18] Implement a fix to oauth missing provider --- app/config/collections/common.php | 11 +++++++++++ app/controllers/api/account.php | 3 ++- composer.json | 4 ++-- 3 files changed, 15 insertions(+), 3 deletions(-) diff --git a/app/config/collections/common.php b/app/config/collections/common.php index 1845ef8a42..d0cfc5f4a4 100644 --- a/app/config/collections/common.php +++ b/app/config/collections/common.php @@ -575,6 +575,17 @@ return [ 'default' => null, 'array' => false, 'filters' => [], + ], + [ + '$id' => ID::custom('provider'), + 'type' => Database::VAR_STRING, + 'format' => '', + 'size' => 128, + 'signed' => true, + 'required' => false, + 'default' => null, + 'array' => false, + 'filters' => [], ] ], 'indexes' => [ diff --git a/app/controllers/api/account.php b/app/controllers/api/account.php index a780bfdac3..b50047d1b2 100644 --- a/app/controllers/api/account.php +++ b/app/controllers/api/account.php @@ -245,7 +245,7 @@ $createSession = function (string $userId, string $secret, Request $request, Res TOKEN_TYPE_INVITE => SESSION_PROVIDER_EMAIL, TOKEN_TYPE_MAGIC_URL => SESSION_PROVIDER_MAGIC_URL, TOKEN_TYPE_PHONE => SESSION_PROVIDER_PHONE, - TOKEN_TYPE_OAUTH2 => SESSION_PROVIDER_OAUTH2, + TOKEN_TYPE_OAUTH2 => $verifiedToken->getAttribute('provider', SESSION_PROVIDER_OAUTH2), default => SESSION_PROVIDER_TOKEN, }; $session = new Document(array_merge( @@ -1878,6 +1878,7 @@ Http::get('/v1/account/sessions/oauth2/:provider/redirect') 'userId' => $user->getId(), 'userInternalId' => $user->getSequence(), 'type' => TOKEN_TYPE_OAUTH2, + 'provider' => $provider, 'secret' => $proofForTokenOAuth2->hash($secret), // One way hash encryption to protect DB leak 'expire' => $expire, 'userAgent' => $request->getUserAgent('UNKNOWN'), diff --git a/composer.json b/composer.json index 06ee153574..19e6a83e51 100644 --- a/composer.json +++ b/composer.json @@ -13,9 +13,9 @@ "test": "vendor/bin/phpunit", "lint": "vendor/bin/pint --test --config pint.json", "format": "vendor/bin/pint --config pint.json", - "analyze": "./vendor/bin/phpstan analyse -c phpstan.neon --memory-limit=1G", + "analyze": "./vendor/bin/phpstan analyse -c phpstan.neon --memory-limit=1G", "bench": "vendor/bin/phpbench run --report=benchmark", - "check": "./vendor/bin/phpstan analyse -c phpstan.neon", + "check": "./vendor/bin/phpstan analyse -c phpstan.neon --memory-limit=1G", "installer:clean": "php src/Appwrite/Platform/Installer/Server.php --clean", "installer:dev": "docker compose build && composer installer:clean && php src/Appwrite/Platform/Installer/Server.php --docker" }, From ba94bff8d47092d0d2b24bf686d05b46353b9bb8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Wed, 18 Mar 2026 14:48:31 +0100 Subject: [PATCH 03/18] Public project variables API --- app/config/roles.php | 2 + app/config/scopes/organization.php | 8 - app/config/scopes/project.php | 8 + app/controllers/api/project.php | 235 ------------------ src/Appwrite/Platform/Appwrite.php | 2 + .../Platform/Modules/Project/Http/Init.php | 32 +++ .../Project/Http/Project/Variables/Create.php | 108 ++++++++ .../Project/Http/Project/Variables/Delete.php | 88 +++++++ .../Project/Http/Project/Variables/Get.php | 67 +++++ .../Project/Http/Project/Variables/Update.php | 117 +++++++++ .../Project/Http/Project/Variables/XList.php | 116 +++++++++ .../Platform/Modules/Project/Module.php | 14 ++ .../Modules/Project/Services/Http.php | 29 +++ src/Appwrite/Platform/Workers/Migrations.php | 4 +- .../Database/Validator/Queries/Variables.php | 3 +- tests/e2e/Scopes/ProjectCustom.php | 2 + 16 files changed, 590 insertions(+), 245 deletions(-) create mode 100644 src/Appwrite/Platform/Modules/Project/Http/Init.php create mode 100644 src/Appwrite/Platform/Modules/Project/Http/Project/Variables/Create.php create mode 100644 src/Appwrite/Platform/Modules/Project/Http/Project/Variables/Delete.php create mode 100644 src/Appwrite/Platform/Modules/Project/Http/Project/Variables/Get.php create mode 100644 src/Appwrite/Platform/Modules/Project/Http/Project/Variables/Update.php create mode 100644 src/Appwrite/Platform/Modules/Project/Http/Project/Variables/XList.php create mode 100644 src/Appwrite/Platform/Modules/Project/Module.php create mode 100644 src/Appwrite/Platform/Modules/Project/Services/Http.php diff --git a/app/config/roles.php b/app/config/roles.php index 4473176c23..116e8ac932 100644 --- a/app/config/roles.php +++ b/app/config/roles.php @@ -62,6 +62,8 @@ $admins = [ 'devKeys.write', 'webhooks.read', 'webhooks.write', + 'project.read', + 'project.write', 'locale.read', 'avatars.read', 'health.read', diff --git a/app/config/scopes/organization.php b/app/config/scopes/organization.php index ca4160881d..8d85662652 100644 --- a/app/config/scopes/organization.php +++ b/app/config/scopes/organization.php @@ -31,12 +31,4 @@ return [ "description" => "Access to create, update, and delete project\'s development keys", ], - "webhooks.read" => [ - "description" => - "Access to read project\'s webhooks", - ], - "webhooks.write" => [ - "description" => - "Access to create, update, and delete project\'s webhooks", - ], ]; diff --git a/app/config/scopes/project.php b/app/config/scopes/project.php index 1f318b0376..f5d8461aff 100644 --- a/app/config/scopes/project.php +++ b/app/config/scopes/project.php @@ -180,4 +180,12 @@ return [ // List of publicly visible scopes "description" => "Access to create, update, and delete project\'s webhooks", ], + "project.read" => [ + "description" => + "Access to read project\'s information", + ], + "project.write" => [ + "description" => + "Access to update project\'s information", + ], ]; diff --git a/app/controllers/api/project.php b/app/controllers/api/project.php index d24519e3fb..8668a21e24 100644 --- a/app/controllers/api/project.php +++ b/app/controllers/api/project.php @@ -1,25 +1,15 @@ $total[METRIC_FILES_IMAGES_TRANSFORMED], ]), Response::MODEL_USAGE_PROJECT); }); - - -// Variables -Http::post('/v1/project/variables') - ->desc('Create variable') - ->groups(['api']) - ->label('scope', 'projects.write') - ->label('audits.event', 'variable.create') - ->label('sdk', new Method( - namespace: 'project', - group: null, - name: 'createVariable', - description: '/docs/references/project/create-variable.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_CREATED, - model: Response::MODEL_VARIABLE, - ) - ] - )) - ->param('key', null, new Text(Database::LENGTH_KEY), 'Variable key. Max length: ' . Database::LENGTH_KEY . ' chars.', false) - ->param('value', null, new Text(8192, 0), 'Variable value. Max length: 8192 chars.', false) - ->param('secret', true, new Boolean(), 'Secret variables can be updated or deleted, but only projects can read them during build and runtime.', true) - ->inject('project') - ->inject('response') - ->inject('dbForProject') - ->inject('dbForPlatform') - ->action(function (string $key, string $value, bool $secret, Document $project, Response $response, Database $dbForProject, Database $dbForPlatform) { - $variableId = ID::unique(); - - $variable = new Document([ - '$id' => $variableId, - '$permissions' => [ - Permission::read(Role::any()), - Permission::update(Role::any()), - Permission::delete(Role::any()), - ], - 'resourceInternalId' => '', - 'resourceId' => '', - 'resourceType' => 'project', - 'key' => $key, - 'value' => $value, - 'secret' => $secret, - 'search' => implode(' ', [$variableId, $key, 'project']), - ]); - - try { - $variable = $dbForProject->createDocument('variables', $variable); - } catch (DuplicateException $th) { - throw new Exception(Exception::VARIABLE_ALREADY_EXISTS); - } - - $functions = $dbForProject->find('functions', [ - Query::limit(APP_LIMIT_SUBQUERY) - ]); - - foreach ($functions as $function) { - $dbForProject->updateDocument('functions', $function->getId(), $function->setAttribute('live', false)); - } - - $response - ->setStatusCode(Response::STATUS_CODE_CREATED) - ->dynamic($variable, Response::MODEL_VARIABLE); - }); - -Http::get('/v1/project/variables') - ->desc('List variables') - ->groups(['api']) - ->label('scope', 'projects.read') - ->label('sdk', new Method( - namespace: 'project', - group: null, - name: 'listVariables', - description: '/docs/references/project/list-variables.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_VARIABLE_LIST, - ) - ] - )) - ->inject('response') - ->inject('dbForProject') - ->action(function (Response $response, Database $dbForProject) { - $variables = $dbForProject->find('variables', [ - Query::equal('resourceType', ['project']), - Query::limit(APP_LIMIT_SUBQUERY) - ]); - - $response->dynamic(new Document([ - 'variables' => $variables, - 'total' => \count($variables), - ]), Response::MODEL_VARIABLE_LIST); - }); - -Http::get('/v1/project/variables/:variableId') - ->desc('Get variable') - ->groups(['api']) - ->label('scope', 'projects.read') - ->label('sdk', new Method( - namespace: 'project', - group: null, - name: 'getVariable', - description: '/docs/references/project/get-variable.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_VARIABLE, - ) - ] - )) - ->param('variableId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Variable unique ID.', false, ['dbForProject']) - ->inject('response') - ->inject('project') - ->inject('dbForProject') - ->action(function (string $variableId, Response $response, Document $project, Database $dbForProject) { - $variable = $dbForProject->getDocument('variables', $variableId); - if ($variable === false || $variable->isEmpty() || $variable->getAttribute('resourceType') !== 'project') { - throw new Exception(Exception::VARIABLE_NOT_FOUND); - } - - $response->dynamic($variable, Response::MODEL_VARIABLE); - }); - -Http::put('/v1/project/variables/:variableId') - ->desc('Update variable') - ->groups(['api']) - ->label('scope', 'projects.write') - ->label('sdk', new Method( - namespace: 'project', - group: null, - name: 'updateVariable', - description: '/docs/references/project/update-variable.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_VARIABLE, - ) - ] - )) - ->param('variableId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Variable unique ID.', false, ['dbForProject']) - ->param('key', null, new Text(255), 'Variable key. Max length: 255 chars.', false) - ->param('value', null, new Nullable(new Text(8192, 0)), 'Variable value. Max length: 8192 chars.', true) - ->param('secret', null, new Nullable(new Boolean()), 'Secret variables can be updated or deleted, but only projects can read them during build and runtime.', true) - ->inject('project') - ->inject('response') - ->inject('dbForProject') - ->inject('dbForPlatform') - ->action(function (string $variableId, string $key, ?string $value, ?bool $secret, Document $project, Response $response, Database $dbForProject, Database $dbForPlatform) { - $variable = $dbForProject->getDocument('variables', $variableId); - if ($variable === false || $variable->isEmpty() || $variable->getAttribute('resourceType') !== 'project') { - throw new Exception(Exception::VARIABLE_NOT_FOUND); - } - - if ($variable->getAttribute('secret') === true && $secret === false) { - throw new Exception(Exception::VARIABLE_CANNOT_UNSET_SECRET); - } - - $variable - ->setAttribute('key', $key) - ->setAttribute('value', $value ?? $variable->getAttribute('value')) - ->setAttribute('secret', $secret ?? $variable->getAttribute('secret')) - ->setAttribute('search', implode(' ', [$variableId, $key, 'project'])); - - try { - $dbForProject->updateDocument('variables', $variable->getId(), $variable); - } catch (DuplicateException $th) { - throw new Exception(Exception::VARIABLE_ALREADY_EXISTS); - } - - $functions = $dbForProject->find('functions', [ - Query::limit(APP_LIMIT_SUBQUERY) - ]); - - foreach ($functions as $function) { - $dbForProject->updateDocument('functions', $function->getId(), $function->setAttribute('live', false)); - } - - $response->dynamic($variable, Response::MODEL_VARIABLE); - }); - -Http::delete('/v1/project/variables/:variableId') - ->desc('Delete variable') - ->groups(['api']) - ->label('scope', 'projects.write') - ->label('sdk', new Method( - namespace: 'project', - group: null, - name: 'deleteVariable', - description: '/docs/references/project/delete-variable.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_NOCONTENT, - model: Response::MODEL_NONE, - ) - ], - contentType: ContentType::NONE - )) - ->param('variableId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Variable unique ID.', false, ['dbForProject']) - ->inject('project') - ->inject('response') - ->inject('dbForProject') - ->action(function (string $variableId, Document $project, Response $response, Database $dbForProject) { - $variable = $dbForProject->getDocument('variables', $variableId); - if ($variable === false || $variable->isEmpty() || $variable->getAttribute('resourceType') !== 'project') { - throw new Exception(Exception::VARIABLE_NOT_FOUND); - } - - $dbForProject->deleteDocument('variables', $variable->getId()); - - $functions = $dbForProject->find('functions', [ - Query::limit(APP_LIMIT_SUBQUERY) - ]); - - foreach ($functions as $function) { - $dbForProject->updateDocument('functions', $function->getId(), $function->setAttribute('live', false)); - } - - $response->noContent(); - }); diff --git a/src/Appwrite/Platform/Appwrite.php b/src/Appwrite/Platform/Appwrite.php index 77b9c4d1dd..68dc40a894 100644 --- a/src/Appwrite/Platform/Appwrite.php +++ b/src/Appwrite/Platform/Appwrite.php @@ -15,6 +15,7 @@ use Appwrite\Platform\Modules\Sites; use Appwrite\Platform\Modules\Storage; use Appwrite\Platform\Modules\Teams; use Appwrite\Platform\Modules\Tokens; +use Appwrite\Platform\Modules\Variables; use Appwrite\Platform\Modules\VCS; use Appwrite\Platform\Modules\Webhooks; use Utopia\Platform\Platform; @@ -38,5 +39,6 @@ class Appwrite extends Platform $this->addModule(new Storage\Module()); $this->addModule(new VCS\Module()); $this->addModule(new Webhooks\Module()); + $this->addModule(new Variables\Module()); } } diff --git a/src/Appwrite/Platform/Modules/Project/Http/Init.php b/src/Appwrite/Platform/Modules/Project/Http/Init.php new file mode 100644 index 0000000000..ff191ade6c --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Init.php @@ -0,0 +1,32 @@ +setType(Action::TYPE_INIT) + ->groups(['project']) + ->inject('project') + ->callback(function (Document $project) { + if ($project->getId() === 'console') { + throw new Exception(Exception::GENERAL_ACCESS_FORBIDDEN); + } + + if ($project->isEmpty()) { + throw new Exception(Exception::PROJECT_NOT_FOUND); + } + }); + } +} diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Variables/Create.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Variables/Create.php new file mode 100644 index 0000000000..9f7aeb3ece --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Variables/Create.php @@ -0,0 +1,108 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_POST) + ->setHttpPath('/v1/project/variables') + ->desc('Create project variable') + ->groups(['api', 'project']) + ->label('scope', 'project.write') + ->label('event', 'project.variables.[variableId].create') + ->label('audits.event', 'project.variable.create') + ->label('audits.resource', 'project.variable/{response.$id}') + ->label('sdk', new Method( + namespace: 'project', + group: 'variables', + name: 'createVariable', + description: <<param('variableId', '', fn (Database $dbForProject) => new CustomId(false, $dbForProject->getAdapter()->getMaxUIDLength()), 'Variable ID. Choose a custom ID or generate a random ID with `ID.unique()`. Valid chars are a-z, A-Z, 0-9, period, hyphen, and underscore. Can\'t start with a special char. Max length is 36 chars.', false, ['dbForProject']) + ->param('key', null, new Text(Database::LENGTH_KEY), 'Variable key. Max length: ' . Database::LENGTH_KEY . ' chars.') + ->param('value', null, new Text(8192, 0), 'Variable value. Max length: 8192 chars.') + ->param('secret', true, new Boolean(), 'Secret variables can be updated or deleted, but only projects can read them during build and runtime.', true) + ->inject('response') + ->inject('queueForEvents') + ->inject('dbForProject') + ->callback($this->action(...)); + } + + public function action( + string $variableId, + string $key, + string $value, + bool $secret, + Response $response, + QueueEvent $queueForEvents, + Database $dbForProject, + ) { + $variableId = ($variableId == 'unique()') ? ID::unique() : $variableId; + + $variable = new Document([ + '$id' => $variableId, + '$permissions' => [], + 'resourceInternalId' => '', // Already in project DB anyway + 'resourceId' => '', // Already in project DB anyway + 'resourceType' => 'project', + 'key' => $key, + 'value' => $value, + 'secret' => $secret, + 'search' => implode(' ', [$variableId, $key, 'project']), + ]); + + try { + $variable = $dbForProject->createDocument('variables', $variable); + } catch (DuplicateException $th) { + throw new Exception(Exception::VARIABLE_ALREADY_EXISTS); + } + + foreach (['functions', 'sites'] as $collection) { + $dbForProject->updateDocuments($collection, new Document([ + 'live', 'false' + ])); + } + + $queueForEvents->setParam('variableId', $variable->getId()); + + $response + ->setStatusCode(Response::STATUS_CODE_CREATED) + ->dynamic($variable, Response::MODEL_VARIABLE); + } +} diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Variables/Delete.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Variables/Delete.php new file mode 100644 index 0000000000..2121703b5a --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Variables/Delete.php @@ -0,0 +1,88 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_DELETE) + ->setHttpPath('/v1/project/variables/:variableId') + ->desc('Delete project variable') + ->groups(['api', 'project']) + ->label('scope', 'project.write') + ->label('event', 'project.variables.[variableId].delete') + ->label('audits.event', 'project.variable.delete') + ->label('audits.resource', 'project.variable/{response.$id}') + ->label('sdk', new Method( + namespace: 'project', + group: 'variables', + name: 'deleteVariable', + description: <<param('variableId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Variable ID.', false, ['dbForProject']) + ->inject('response') + ->inject('dbForProject') + ->inject('queueForEvents') + ->callback($this->action(...)); + } + + public function action( + string $variableId, + Response $response, + Database $dbForProject, + Event $queueForEvents, + ) { + $variable = $dbForProject->getDocument('variables', $variableId); + + if ($variable->isEmpty() || $variable->getAttribute('resourceType', '') !== 'project') { + throw new Exception(Exception::VARIABLE_NOT_FOUND); + } + + if (!$dbForProject->deleteDocument('variables', $variable->getId())) { + throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Failed to remove document from DB'); + }; + + foreach (['functions', 'sites'] as $collection) { + $dbForProject->updateDocuments($collection, new Document([ + 'live', 'false' + ])); + } + + $queueForEvents->setParam('variableId', $variable->getId()); + + $response->noContent(); + } +} diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Variables/Get.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Variables/Get.php new file mode 100644 index 0000000000..6de51dacaf --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Variables/Get.php @@ -0,0 +1,67 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/project/variables/:variableId') + ->desc('Get project variable') + ->groups(['api', 'project']) + ->label('scope', 'project.read') + ->label('sdk', new Method( + namespace: 'project', + group: 'variables', + name: 'getVariable', + description: <<param('variableId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Variable ID.', false, ['dbForProject']) + ->inject('response') + ->inject('dbForProject') + ->callback($this->action(...)); + } + + public function action( + string $variableId, + Response $response, + Database $dbForProject, + ) { + $variable = $dbForProject->getDocument('variables', $variableId); + + if ($variable->isEmpty() || $variable->getAttribute('resourceType', '') !== 'project') { + throw new Exception(Exception::VARIABLE_NOT_FOUND); + } + + $response->dynamic($variable, Response::MODEL_VARIABLE); + } +} diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Variables/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Variables/Update.php new file mode 100644 index 0000000000..802abc88fe --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Variables/Update.php @@ -0,0 +1,117 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH) + ->setHttpPath('/v1/project/variables/:variableId') + ->desc('Update project variable') + ->groups(['api', 'project']) + ->label('scope', 'project.write') + ->label('event', 'project.variables.[variableId].update') + ->label('audits.event', 'project.variable.update') + ->label('audits.resource', 'project.variable/{response.$id}') + ->label('sdk', new Method( + namespace: 'project', + group: 'variables', + name: 'updateVariable', + description: <<param('variableId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Variable ID.', false, ['dbForProject']) + ->param('key', null, new Nullable(new Text(255, 0)), 'Variable key. Max length: 255 chars.', true) + ->param('value', null, new Nullable(new Text(8192, 0)), 'Variable value. Max length: 8192 chars.', true) + ->param('secret', null, new Nullable(new Boolean()), 'Secret variables can be updated or deleted, but only projects can read them during build and runtime.', true) + ->inject('response') + ->inject('queueForEvents') + ->inject('dbForProject') + ->callback($this->action(...)); + } + + public function action( + string $variableId, + ?string $key, + ?string $value, + ?bool $secret, + Response $response, + QueueEvent $queueForEvents, + Database $dbForProject, + ) { + $variable = $dbForProject->getDocument('variables', $variableId); + + if ($variable->isEmpty() || $variable->getAttribute('resourceType', '') !== 'project') { + throw new Exception(Exception::VARIABLE_NOT_FOUND); + } + + $isSeretVariable = $variable->getAttribute('secret', false) === true; + if ($isSeretVariable && $secret === false) { + throw new Exception(Exception::VARIABLE_CANNOT_UNSET_SECRET); + } + + $updates = new Document(); + + if (!\is_null($key)) { + $updates->setAttribute('key', $key); + $updates->setAttribute('search', implode(' ', [$variableId, $key, 'project'])); + } + + if (!\is_null($value)) { + $updates->setAttribute('value', $value); + } + + if (!\is_null($secret)) { + $updates->setAttribute('secret', $secret); + } + + try { + $dbForProject->updateDocument('variables', $variable->getId(), $updates); + } catch (Duplicate $th) { + throw new Exception(Exception::VARIABLE_ALREADY_EXISTS); + } + + foreach (['functions', 'sites'] as $collection) { + $dbForProject->updateDocuments($collection, new Document([ + 'live', 'false' + ])); + } + + $queueForEvents->setParam('variableId', $variable->getId()); + + $response->dynamic($variable, Response::MODEL_VARIABLE); + } +} diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Variables/XList.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Variables/XList.php new file mode 100644 index 0000000000..ac7ce4112c --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Variables/XList.php @@ -0,0 +1,116 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/project/variables') + ->desc('List project variables') + ->groups(['api', 'project']) + ->label('scope', 'project.read') + ->label('sdk', new Method( + namespace: 'project', + group: 'variables', + name: 'listVariables', + description: <<param('queries', [], new Variables(), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' queries are allowed, each ' . APP_LIMIT_ARRAY_ELEMENT_SIZE . ' characters long. You may filter on the following attributes: ' . implode(', ', Variables::ALLOWED_ATTRIBUTES), true) + ->param('total', true, new Boolean(true), 'When set to false, the total count returned will be 0 and will not be calculated.', true) + ->inject('project') + ->inject('response') + ->inject('dbForProject') + ->callback($this->action(...)); + } + + /** + * @param array $queries + */ + public function action( + array $queries, + bool $includeTotal, + Document $project, + Response $response, + Database $dbForProject, + ) { + try { + $queries = Query::parseQueries($queries); + } catch (QueryException $e) { + throw new Exception(Exception::GENERAL_QUERY_INVALID, $e->getMessage()); + } + + $queries[] = Query::equal('resourceType', ['project']); + + $cursor = Query::getCursorQueries($queries, false); + $cursor = \reset($cursor); + + if ($cursor !== false) { + $validator = new Cursor(); + if (!$validator->isValid($cursor)) { + throw new Exception(Exception::GENERAL_QUERY_INVALID, $validator->getDescription()); + } + + $variableId = $cursor->getValue(); + $cursorDocument = $dbForProject->findOne('variables', [ + Query::equal('$id', [$variableId]), + Query::equal('resourceType', ['project']), + ]); + + if ($cursorDocument->isEmpty()) { + throw new Exception(Exception::GENERAL_CURSOR_NOT_FOUND, "Variable '{$variableId}' for the 'cursor' value not found."); + } + + $cursor->setValue($cursorDocument); + } + + $filterQueries = Query::groupByType($queries)['filters']; + + try { + $variables = $dbForProject->find('variables', $queries); + $total = $includeTotal ? $dbForProject->count('variables', $filterQueries, APP_LIMIT_COUNT) : 0; + } catch (OrderException $e) { + throw new Exception(Exception::DATABASE_QUERY_ORDER_NULL, "The order attribute '{$e->getAttribute()}' had a null value. Cursor pagination requires all documents order attribute values are non-null."); + } + + $response->dynamic(new Document([ + 'variables' => $variables, + 'total' => $total, + ]), Response::MODEL_VARIABLE_LIST); + } +} diff --git a/src/Appwrite/Platform/Modules/Project/Module.php b/src/Appwrite/Platform/Modules/Project/Module.php new file mode 100644 index 0000000000..ab6f445853 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Module.php @@ -0,0 +1,14 @@ +addService('http', new Http()); + } +} diff --git a/src/Appwrite/Platform/Modules/Project/Services/Http.php b/src/Appwrite/Platform/Modules/Project/Services/Http.php new file mode 100644 index 0000000000..949fb2bcd9 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Services/Http.php @@ -0,0 +1,29 @@ +type = Service::TYPE_HTTP; + + // Hooks + $this->addAction(Init::getName(), new Init()); + + // Project + $this->addAction(CreateVariable::getName(), new CreateVariable()); + $this->addAction(ListVariables::getName(), new ListVariables()); + $this->addAction(GetVariable::getName(), new GetVariable()); + $this->addAction(DeleteVariable::getName(), new DeleteVariable()); + $this->addAction(UpdateVariable::getName(), new UpdateVariable()); + } +} diff --git a/src/Appwrite/Platform/Workers/Migrations.php b/src/Appwrite/Platform/Workers/Migrations.php index 25d5bfa027..9f5defc5b4 100644 --- a/src/Appwrite/Platform/Workers/Migrations.php +++ b/src/Appwrite/Platform/Workers/Migrations.php @@ -333,7 +333,9 @@ class Migrations extends Action 'targets.read', 'targets.write', 'webhooks.read', - 'webhooks.write' + 'webhooks.write', + 'project.read', + 'project.write' ] ]); diff --git a/src/Appwrite/Utopia/Database/Validator/Queries/Variables.php b/src/Appwrite/Utopia/Database/Validator/Queries/Variables.php index 5d7a5e5cee..222f571281 100644 --- a/src/Appwrite/Utopia/Database/Validator/Queries/Variables.php +++ b/src/Appwrite/Utopia/Database/Validator/Queries/Variables.php @@ -7,7 +7,8 @@ class Variables extends Base public const ALLOWED_ATTRIBUTES = [ 'key', 'resourceType', - 'resourceId' + 'resourceId', + 'secret', ]; /** diff --git a/tests/e2e/Scopes/ProjectCustom.php b/tests/e2e/Scopes/ProjectCustom.php index c80ef20193..1bb8c1250d 100644 --- a/tests/e2e/Scopes/ProjectCustom.php +++ b/tests/e2e/Scopes/ProjectCustom.php @@ -163,6 +163,8 @@ trait ProjectCustom 'tokens.write', 'webhooks.read', 'webhooks.write', + 'project.read', + 'project.write' ], ]); From 412d858c4952927f97f79521373afba065532db7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Wed, 18 Mar 2026 15:23:19 +0100 Subject: [PATCH 04/18] AI comments fixes --- src/Appwrite/Platform/Appwrite.php | 4 ++-- .../Modules/Project/Http/Project/Variables/Create.php | 4 ++-- .../Modules/Project/Http/Project/Variables/Delete.php | 2 +- .../Modules/Project/Http/Project/Variables/Update.php | 8 ++++---- .../Modules/Project/Http/Project/Variables/XList.php | 2 +- 5 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/Appwrite/Platform/Appwrite.php b/src/Appwrite/Platform/Appwrite.php index 68dc40a894..06312d9cb2 100644 --- a/src/Appwrite/Platform/Appwrite.php +++ b/src/Appwrite/Platform/Appwrite.php @@ -9,13 +9,13 @@ use Appwrite\Platform\Modules\Core; use Appwrite\Platform\Modules\Databases; use Appwrite\Platform\Modules\Functions; use Appwrite\Platform\Modules\Health; +use Appwrite\Platform\Modules\Project; use Appwrite\Platform\Modules\Projects; use Appwrite\Platform\Modules\Proxy; use Appwrite\Platform\Modules\Sites; use Appwrite\Platform\Modules\Storage; use Appwrite\Platform\Modules\Teams; use Appwrite\Platform\Modules\Tokens; -use Appwrite\Platform\Modules\Variables; use Appwrite\Platform\Modules\VCS; use Appwrite\Platform\Modules\Webhooks; use Utopia\Platform\Platform; @@ -39,6 +39,6 @@ class Appwrite extends Platform $this->addModule(new Storage\Module()); $this->addModule(new VCS\Module()); $this->addModule(new Webhooks\Module()); - $this->addModule(new Variables\Module()); + $this->addModule(new Project\Module()); } } diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Variables/Create.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Variables/Create.php index 9f7aeb3ece..a9209522e3 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Variables/Create.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Variables/Create.php @@ -44,7 +44,7 @@ class Create extends Base group: 'variables', name: 'createVariable', description: <<updateDocuments($collection, new Document([ - 'live', 'false' + 'live' => 'false' ])); } diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Variables/Delete.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Variables/Delete.php index 2121703b5a..51b8f05a8c 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Variables/Delete.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Variables/Delete.php @@ -77,7 +77,7 @@ class Delete extends Base foreach (['functions', 'sites'] as $collection) { $dbForProject->updateDocuments($collection, new Document([ - 'live', 'false' + 'live' => 'false' ])); } diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Variables/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Variables/Update.php index 802abc88fe..764acb2360 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Variables/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Variables/Update.php @@ -78,8 +78,8 @@ class Update extends Base throw new Exception(Exception::VARIABLE_NOT_FOUND); } - $isSeretVariable = $variable->getAttribute('secret', false) === true; - if ($isSeretVariable && $secret === false) { + $isSecretVariable = $variable->getAttribute('secret', false) === true; + if ($isSecretVariable && $secret === false) { throw new Exception(Exception::VARIABLE_CANNOT_UNSET_SECRET); } @@ -99,14 +99,14 @@ class Update extends Base } try { - $dbForProject->updateDocument('variables', $variable->getId(), $updates); + $variable = $dbForProject->updateDocument('variables', $variable->getId(), $updates); } catch (Duplicate $th) { throw new Exception(Exception::VARIABLE_ALREADY_EXISTS); } foreach (['functions', 'sites'] as $collection) { $dbForProject->updateDocuments($collection, new Document([ - 'live', 'false' + 'live' => 'false' ])); } diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Variables/XList.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Variables/XList.php index ac7ce4112c..cd11fe68c6 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Variables/XList.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Variables/XList.php @@ -41,7 +41,7 @@ class XList extends Base group: 'variables', name: 'listVariables', description: << Date: Wed, 18 Mar 2026 15:24:56 +0100 Subject: [PATCH 05/18] Remvoe breaking change --- .../Platform/Modules/Project/Http/Project/Variables/Update.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Variables/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Variables/Update.php index 764acb2360..44706df23a 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Variables/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Variables/Update.php @@ -30,7 +30,7 @@ class Update extends Base public function __construct() { - $this->setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH) + $this->setHttpMethod(Action::HTTP_REQUEST_METHOD_PUT) ->setHttpPath('/v1/project/variables/:variableId') ->desc('Update project variable') ->groups(['api', 'project']) From 84316fdfb50ffdc5d5567f85dd7c3bb813eb768b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Wed, 18 Mar 2026 15:41:43 +0100 Subject: [PATCH 06/18] Add project variable tests --- tests/e2e/Services/Project/VariablesBase.php | 831 ++++++++++++++++++ .../Project/VariablesConsoleClientTest.php | 14 + .../Project/VariablesCustomServerTest.php | 14 + 3 files changed, 859 insertions(+) create mode 100644 tests/e2e/Services/Project/VariablesBase.php create mode 100644 tests/e2e/Services/Project/VariablesConsoleClientTest.php create mode 100644 tests/e2e/Services/Project/VariablesCustomServerTest.php diff --git a/tests/e2e/Services/Project/VariablesBase.php b/tests/e2e/Services/Project/VariablesBase.php new file mode 100644 index 0000000000..a2ed0026ab --- /dev/null +++ b/tests/e2e/Services/Project/VariablesBase.php @@ -0,0 +1,831 @@ +createVariable( + ID::unique(), + 'APP_KEY', + 'my-secret-value', + null + ); + + $this->assertEquals(201, $variable['headers']['status-code']); + $this->assertNotEmpty($variable['body']['$id']); + $this->assertEquals('APP_KEY', $variable['body']['key']); + $this->assertEquals(true, $variable['body']['secret']); + $this->assertNull($variable['body']['value']); + $this->assertEquals('project', $variable['body']['resourceType']); + $this->assertEquals('', $variable['body']['resourceId']); + + $dateValidator = new DatetimeValidator(); + $this->assertEquals(true, $dateValidator->isValid($variable['body']['$createdAt'])); + $this->assertEquals(true, $dateValidator->isValid($variable['body']['$updatedAt'])); + + // Verify via GET + $get = $this->getVariable($variable['body']['$id']); + $this->assertEquals(200, $get['headers']['status-code']); + $this->assertEquals($variable['body']['$id'], $get['body']['$id']); + $this->assertEquals('APP_KEY', $get['body']['key']); + + // Verify via LIST + $list = $this->listVariables(null, true); + $this->assertEquals(200, $list['headers']['status-code']); + $this->assertGreaterThanOrEqual(1, $list['body']['total']); + $this->assertGreaterThanOrEqual(1, \count($list['body']['variables'])); + + // Cleanup + $this->deleteVariable($variable['body']['$id']); + } + + public function testCreateVariableNonSecret(): void + { + $variable = $this->createVariable( + ID::unique(), + 'PUBLIC_KEY', + 'public-value', + false + ); + + $this->assertEquals(201, $variable['headers']['status-code']); + $this->assertNotEmpty($variable['body']['$id']); + $this->assertEquals('PUBLIC_KEY', $variable['body']['key']); + $this->assertEquals(false, $variable['body']['secret']); + $this->assertIsBool($variable['body']['secret']); + $this->assertEquals('public-value', $variable['body']['value']); + + // Cleanup + $this->deleteVariable($variable['body']['$id']); + } + + public function testCreateVariableSecretValueHidden(): void + { + $variable = $this->createVariable( + ID::unique(), + 'SECRET_KEY', + 'hidden-value', + true + ); + + $this->assertEquals(201, $variable['headers']['status-code']); + $this->assertEquals(true, $variable['body']['secret']); + $this->assertNull($variable['body']['value']); + + // Verify value is also hidden on GET + $get = $this->getVariable($variable['body']['$id']); + $this->assertEquals(200, $get['headers']['status-code']); + $this->assertNull($get['body']['value']); + + // Cleanup + $this->deleteVariable($variable['body']['$id']); + } + + public function testCreateVariableWithoutAuthentication(): void + { + $response = $this->client->call(Client::METHOD_POST, '/project/variables', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], [ + 'variableId' => ID::unique(), + 'key' => 'NO_AUTH_KEY', + 'value' => 'no-auth-value', + ]); + + $this->assertEquals(401, $response['headers']['status-code']); + } + + public function testCreateVariableInvalidId(): void + { + $variable = $this->createVariable( + '!invalid-id!', + 'INVALID_ID_KEY', + 'value', + null + ); + + $this->assertEquals(400, $variable['headers']['status-code']); + } + + public function testCreateVariableMissingKey(): void + { + $response = $this->client->call(Client::METHOD_POST, '/project/variables', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'variableId' => ID::unique(), + 'value' => 'some-value', + ]); + + $this->assertEquals(400, $response['headers']['status-code']); + } + + public function testCreateVariableMissingValue(): void + { + $response = $this->client->call(Client::METHOD_POST, '/project/variables', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'variableId' => ID::unique(), + 'key' => 'MISSING_VALUE_KEY', + ]); + + $this->assertEquals(400, $response['headers']['status-code']); + } + + public function testCreateVariableDuplicateId(): void + { + $variableId = ID::unique(); + + $variable = $this->createVariable( + $variableId, + 'DUP_KEY_1', + 'value1', + null + ); + + $this->assertEquals(201, $variable['headers']['status-code']); + + // Attempt to create with same ID + $duplicate = $this->createVariable( + $variableId, + 'DUP_KEY_2', + 'value2', + null + ); + + $this->assertEquals(409, $duplicate['headers']['status-code']); + $this->assertEquals('variable_already_exists', $duplicate['body']['type']); + + // Cleanup + $this->deleteVariable($variableId); + } + + public function testCreateVariableCustomId(): void + { + $customId = 'my-custom-variable-id'; + + $variable = $this->createVariable( + $customId, + 'CUSTOM_ID_KEY', + 'custom-value', + null + ); + + $this->assertEquals(201, $variable['headers']['status-code']); + $this->assertEquals($customId, $variable['body']['$id']); + + // Verify via GET + $get = $this->getVariable($customId); + $this->assertEquals(200, $get['headers']['status-code']); + $this->assertEquals($customId, $get['body']['$id']); + + // Cleanup + $this->deleteVariable($customId); + } + + // Update variable tests + + public function testUpdateVariable(): void + { + $variable = $this->createVariable( + ID::unique(), + 'ORIGINAL_KEY', + 'original-value', + false + ); + + $this->assertEquals(201, $variable['headers']['status-code']); + $variableId = $variable['body']['$id']; + + // Update key and value + $updated = $this->updateVariable($variableId, 'UPDATED_KEY', 'updated-value', null); + + $this->assertEquals(200, $updated['headers']['status-code']); + $this->assertEquals($variableId, $updated['body']['$id']); + $this->assertEquals('UPDATED_KEY', $updated['body']['key']); + $this->assertEquals('updated-value', $updated['body']['value']); + + // Verify update persisted via GET + $get = $this->getVariable($variableId); + $this->assertEquals(200, $get['headers']['status-code']); + $this->assertEquals('UPDATED_KEY', $get['body']['key']); + $this->assertEquals('updated-value', $get['body']['value']); + + // Cleanup + $this->deleteVariable($variableId); + } + + public function testUpdateVariableKey(): void + { + $variable = $this->createVariable( + ID::unique(), + 'KEY_BEFORE', + 'unchanged-value', + false + ); + + $this->assertEquals(201, $variable['headers']['status-code']); + $variableId = $variable['body']['$id']; + + // Update only key + $updated = $this->updateVariable($variableId, 'KEY_AFTER', null, null); + + $this->assertEquals(200, $updated['headers']['status-code']); + $this->assertEquals('KEY_AFTER', $updated['body']['key']); + $this->assertEquals('unchanged-value', $updated['body']['value']); + + // Cleanup + $this->deleteVariable($variableId); + } + + public function testUpdateVariableValue(): void + { + $variable = $this->createVariable( + ID::unique(), + 'UNCHANGED_KEY', + 'value-before', + false + ); + + $this->assertEquals(201, $variable['headers']['status-code']); + $variableId = $variable['body']['$id']; + + // Update only value + $updated = $this->updateVariable($variableId, null, 'value-after', null); + + $this->assertEquals(200, $updated['headers']['status-code']); + $this->assertEquals('UNCHANGED_KEY', $updated['body']['key']); + $this->assertEquals('value-after', $updated['body']['value']); + + // Cleanup + $this->deleteVariable($variableId); + } + + public function testUpdateVariableSetSecret(): void + { + $variable = $this->createVariable( + ID::unique(), + 'MAKE_SECRET_KEY', + 'some-value', + false + ); + + $this->assertEquals(201, $variable['headers']['status-code']); + $this->assertEquals(false, $variable['body']['secret']); + $variableId = $variable['body']['$id']; + + // Update to secret + $updated = $this->updateVariable($variableId, null, null, true); + + $this->assertEquals(200, $updated['headers']['status-code']); + $this->assertEquals(true, $updated['body']['secret']); + $this->assertNull($updated['body']['value']); + + // Cleanup + $this->deleteVariable($variableId); + } + + public function testUpdateVariableCannotUnsetSecret(): void + { + $variable = $this->createVariable( + ID::unique(), + 'UNSET_SECRET_KEY', + 'secret-value', + true + ); + + $this->assertEquals(201, $variable['headers']['status-code']); + $variableId = $variable['body']['$id']; + + // Attempt to unset secret + $updated = $this->updateVariable($variableId, null, null, false); + + $this->assertEquals(400, $updated['headers']['status-code']); + $this->assertEquals('variable_cannot_unset_secret', $updated['body']['type']); + + // Verify variable is unchanged + $get = $this->getVariable($variableId); + $this->assertEquals(200, $get['headers']['status-code']); + $this->assertEquals(true, $get['body']['secret']); + + // Cleanup + $this->deleteVariable($variableId); + } + + public function testUpdateVariableWithoutAuthentication(): void + { + $variable = $this->createVariable( + ID::unique(), + 'AUTH_UPDATE_KEY', + 'auth-value', + null + ); + + $this->assertEquals(201, $variable['headers']['status-code']); + $variableId = $variable['body']['$id']; + + // Attempt update without authentication + $response = $this->client->call(Client::METHOD_PUT, '/project/variables/' . $variableId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], [ + 'key' => 'UPDATED_KEY', + ]); + + $this->assertEquals(401, $response['headers']['status-code']); + + // Cleanup + $this->deleteVariable($variableId); + } + + public function testUpdateVariableNotFound(): void + { + $updated = $this->updateVariable('non-existent-id', 'NEW_KEY', 'new-value', null); + + $this->assertEquals(404, $updated['headers']['status-code']); + $this->assertEquals('variable_not_found', $updated['body']['type']); + } + + // Get variable tests + + public function testGetVariable(): void + { + $variable = $this->createVariable( + ID::unique(), + 'GET_TEST_KEY', + 'get-test-value', + false + ); + + $this->assertEquals(201, $variable['headers']['status-code']); + $variableId = $variable['body']['$id']; + + $get = $this->getVariable($variableId); + + $this->assertEquals(200, $get['headers']['status-code']); + $this->assertEquals($variableId, $get['body']['$id']); + $this->assertEquals('GET_TEST_KEY', $get['body']['key']); + $this->assertEquals('get-test-value', $get['body']['value']); + $this->assertEquals(false, $get['body']['secret']); + $this->assertEquals('project', $get['body']['resourceType']); + $this->assertEquals('', $get['body']['resourceId']); + + $dateValidator = new DatetimeValidator(); + $this->assertEquals(true, $dateValidator->isValid($get['body']['$createdAt'])); + $this->assertEquals(true, $dateValidator->isValid($get['body']['$updatedAt'])); + + // Cleanup + $this->deleteVariable($variableId); + } + + public function testGetVariableNotFound(): void + { + $get = $this->getVariable('non-existent-id'); + + $this->assertEquals(404, $get['headers']['status-code']); + $this->assertEquals('variable_not_found', $get['body']['type']); + } + + public function testGetVariableWithoutAuthentication(): void + { + $variable = $this->createVariable( + ID::unique(), + 'AUTH_GET_KEY', + 'auth-get-value', + null + ); + + $this->assertEquals(201, $variable['headers']['status-code']); + $variableId = $variable['body']['$id']; + + // Attempt GET without authentication + $response = $this->client->call(Client::METHOD_GET, '/project/variables/' . $variableId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]); + + $this->assertEquals(401, $response['headers']['status-code']); + + // Cleanup + $this->deleteVariable($variableId); + } + + // List variables tests + + public function testListVariables(): void + { + // Create multiple variables + $variable1 = $this->createVariable( + ID::unique(), + 'LIST_KEY_ALPHA', + 'alpha-value', + false + ); + $this->assertEquals(201, $variable1['headers']['status-code']); + + $variable2 = $this->createVariable( + ID::unique(), + 'LIST_KEY_BETA', + 'beta-value', + true + ); + $this->assertEquals(201, $variable2['headers']['status-code']); + + $variable3 = $this->createVariable( + ID::unique(), + 'LIST_KEY_GAMMA', + 'gamma-value', + false + ); + $this->assertEquals(201, $variable3['headers']['status-code']); + + // List all + $list = $this->listVariables(null, true); + + $this->assertEquals(200, $list['headers']['status-code']); + $this->assertGreaterThanOrEqual(3, $list['body']['total']); + $this->assertGreaterThanOrEqual(3, \count($list['body']['variables'])); + $this->assertIsArray($list['body']['variables']); + + // Verify structure of returned variables + foreach ($list['body']['variables'] as $variable) { + $this->assertArrayHasKey('$id', $variable); + $this->assertArrayHasKey('$createdAt', $variable); + $this->assertArrayHasKey('$updatedAt', $variable); + $this->assertArrayHasKey('key', $variable); + $this->assertArrayHasKey('value', $variable); + $this->assertArrayHasKey('secret', $variable); + $this->assertArrayHasKey('resourceType', $variable); + $this->assertArrayHasKey('resourceId', $variable); + } + + // Cleanup + $this->deleteVariable($variable1['body']['$id']); + $this->deleteVariable($variable2['body']['$id']); + $this->deleteVariable($variable3['body']['$id']); + } + + public function testListVariablesWithLimit(): void + { + $variable1 = $this->createVariable( + ID::unique(), + 'LIMIT_KEY_1', + 'limit-value-1', + null + ); + $this->assertEquals(201, $variable1['headers']['status-code']); + + $variable2 = $this->createVariable( + ID::unique(), + 'LIMIT_KEY_2', + 'limit-value-2', + null + ); + $this->assertEquals(201, $variable2['headers']['status-code']); + + // List with limit of 1 + $list = $this->listVariables([ + Query::limit(1)->toString(), + ], true); + + $this->assertEquals(200, $list['headers']['status-code']); + $this->assertCount(1, $list['body']['variables']); + $this->assertGreaterThanOrEqual(2, $list['body']['total']); + + // Cleanup + $this->deleteVariable($variable1['body']['$id']); + $this->deleteVariable($variable2['body']['$id']); + } + + public function testListVariablesWithOffset(): void + { + $variable1 = $this->createVariable( + ID::unique(), + 'OFFSET_KEY_1', + 'offset-value-1', + null + ); + $this->assertEquals(201, $variable1['headers']['status-code']); + + $variable2 = $this->createVariable( + ID::unique(), + 'OFFSET_KEY_2', + 'offset-value-2', + null + ); + $this->assertEquals(201, $variable2['headers']['status-code']); + + // List all to get total + $listAll = $this->listVariables(null, true); + $this->assertEquals(200, $listAll['headers']['status-code']); + $totalAll = \count($listAll['body']['variables']); + + // List with offset + $listOffset = $this->listVariables([ + Query::offset(1)->toString(), + ], true); + + $this->assertEquals(200, $listOffset['headers']['status-code']); + $this->assertCount($totalAll - 1, $listOffset['body']['variables']); + + // Cleanup + $this->deleteVariable($variable1['body']['$id']); + $this->deleteVariable($variable2['body']['$id']); + } + + public function testListVariablesWithoutTotal(): void + { + $variable = $this->createVariable( + ID::unique(), + 'NO_TOTAL_KEY', + 'no-total-value', + null + ); + $this->assertEquals(201, $variable['headers']['status-code']); + + // List with total=false + $list = $this->listVariables(null, false); + + $this->assertEquals(200, $list['headers']['status-code']); + $this->assertEquals(0, $list['body']['total']); + $this->assertGreaterThanOrEqual(1, \count($list['body']['variables'])); + + // Cleanup + $this->deleteVariable($variable['body']['$id']); + } + + public function testListVariablesCursorPagination(): void + { + $variable1 = $this->createVariable( + ID::unique(), + 'CURSOR_KEY_1', + 'cursor-value-1', + null + ); + $this->assertEquals(201, $variable1['headers']['status-code']); + + $variable2 = $this->createVariable( + ID::unique(), + 'CURSOR_KEY_2', + 'cursor-value-2', + null + ); + $this->assertEquals(201, $variable2['headers']['status-code']); + + // Get first page with limit 1 + $page1 = $this->listVariables([ + Query::limit(1)->toString(), + ], true); + + $this->assertEquals(200, $page1['headers']['status-code']); + $this->assertCount(1, $page1['body']['variables']); + $cursorId = $page1['body']['variables'][0]['$id']; + + // Get next page using cursor + $page2 = $this->listVariables([ + Query::limit(1)->toString(), + Query::cursorAfter(new Document(['$id' => $cursorId]))->toString(), + ], true); + + $this->assertEquals(200, $page2['headers']['status-code']); + $this->assertCount(1, $page2['body']['variables']); + $this->assertNotEquals($cursorId, $page2['body']['variables'][0]['$id']); + + // Cleanup + $this->deleteVariable($variable1['body']['$id']); + $this->deleteVariable($variable2['body']['$id']); + } + + public function testListVariablesWithoutAuthentication(): void + { + $response = $this->client->call(Client::METHOD_GET, '/project/variables', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]); + + $this->assertEquals(401, $response['headers']['status-code']); + } + + public function testListVariablesInvalidCursor(): void + { + $list = $this->listVariables([ + Query::cursorAfter(new Document(['$id' => 'non-existent-id']))->toString(), + ], true); + + $this->assertEquals(400, $list['headers']['status-code']); + } + + // Delete variable tests + + public function testDeleteVariable(): void + { + $variable = $this->createVariable( + ID::unique(), + 'DELETE_KEY', + 'delete-value', + null + ); + + $this->assertEquals(201, $variable['headers']['status-code']); + $variableId = $variable['body']['$id']; + + // Verify it exists + $get = $this->getVariable($variableId); + $this->assertEquals(200, $get['headers']['status-code']); + + // Delete + $delete = $this->deleteVariable($variableId); + $this->assertEquals(204, $delete['headers']['status-code']); + $this->assertEmpty($delete['body']); + + // Verify it no longer exists + $get = $this->getVariable($variableId); + $this->assertEquals(404, $get['headers']['status-code']); + $this->assertEquals('variable_not_found', $get['body']['type']); + } + + public function testDeleteVariableNotFound(): void + { + $delete = $this->deleteVariable('non-existent-id'); + + $this->assertEquals(404, $delete['headers']['status-code']); + $this->assertEquals('variable_not_found', $delete['body']['type']); + } + + public function testDeleteVariableWithoutAuthentication(): void + { + $variable = $this->createVariable( + ID::unique(), + 'DELETE_AUTH_KEY', + 'delete-auth-value', + null + ); + + $this->assertEquals(201, $variable['headers']['status-code']); + $variableId = $variable['body']['$id']; + + // Attempt DELETE without authentication + $response = $this->client->call(Client::METHOD_DELETE, '/project/variables/' . $variableId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]); + + $this->assertEquals(401, $response['headers']['status-code']); + + // Verify it still exists + $get = $this->getVariable($variableId); + $this->assertEquals(200, $get['headers']['status-code']); + + // Cleanup + $this->deleteVariable($variableId); + } + + public function testDeleteVariableRemovedFromList(): void + { + $variable = $this->createVariable( + ID::unique(), + 'DELETE_LIST_KEY', + 'delete-list-value', + null + ); + + $this->assertEquals(201, $variable['headers']['status-code']); + $variableId = $variable['body']['$id']; + + // Get list count before delete + $listBefore = $this->listVariables(null, true); + $this->assertEquals(200, $listBefore['headers']['status-code']); + $countBefore = $listBefore['body']['total']; + + // Delete + $delete = $this->deleteVariable($variableId); + $this->assertEquals(204, $delete['headers']['status-code']); + + // Get list count after delete + $listAfter = $this->listVariables(null, true); + $this->assertEquals(200, $listAfter['headers']['status-code']); + $this->assertEquals($countBefore - 1, $listAfter['body']['total']); + + // Verify the deleted variable is not in the list + $ids = \array_column($listAfter['body']['variables'], '$id'); + $this->assertNotContains($variableId, $ids); + } + + public function testDeleteVariableDoubleDelete(): void + { + $variable = $this->createVariable( + ID::unique(), + 'DOUBLE_DELETE_KEY', + 'double-delete-value', + null + ); + + $this->assertEquals(201, $variable['headers']['status-code']); + $variableId = $variable['body']['$id']; + + // First delete succeeds + $delete = $this->deleteVariable($variableId); + $this->assertEquals(204, $delete['headers']['status-code']); + + // Second delete returns 404 + $delete = $this->deleteVariable($variableId); + $this->assertEquals(404, $delete['headers']['status-code']); + $this->assertEquals('variable_not_found', $delete['body']['type']); + } + + // Helpers + + /** + * @param array|null $queries + */ + protected function listVariables(?array $queries, ?bool $total): mixed + { + $variables = $this->client->call(Client::METHOD_GET, '/project/variables', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'queries' => $queries, + 'total' => $total + ]); + + return $variables; + } + + protected function getVariable(string $variableId): mixed + { + $variable = $this->client->call(Client::METHOD_GET, '/project/variables/' . $variableId, array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + return $variable; + } + + protected function createVariable(string $variableId, string $key, string $value, ?bool $secret): mixed + { + $params = [ + 'variableId' => $variableId, + 'key' => $key, + 'value' => $value, + ]; + + if ($secret !== null) { + $params['secret'] = $secret; + } + + $variable = $this->client->call(Client::METHOD_POST, '/project/variables', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), $params); + + return $variable; + } + + protected function updateVariable(string $variableId, ?string $key, ?string $value, ?bool $secret): mixed + { + $params = []; + + if ($key !== null) { + $params['key'] = $key; + } + + if ($value !== null) { + $params['value'] = $value; + } + + if ($secret !== null) { + $params['secret'] = $secret; + } + + $variable = $this->client->call(Client::METHOD_PUT, '/project/variables/' . $variableId, array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), $params); + + return $variable; + } + + protected function deleteVariable(string $variableId): mixed + { + $variable = $this->client->call(Client::METHOD_DELETE, '/project/variables/' . $variableId, array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + return $variable; + } +} diff --git a/tests/e2e/Services/Project/VariablesConsoleClientTest.php b/tests/e2e/Services/Project/VariablesConsoleClientTest.php new file mode 100644 index 0000000000..b969dd49e7 --- /dev/null +++ b/tests/e2e/Services/Project/VariablesConsoleClientTest.php @@ -0,0 +1,14 @@ + Date: Wed, 18 Mar 2026 16:00:03 +0100 Subject: [PATCH 07/18] Improve E2E tests for sites&functions --- tests/e2e/Services/Project/VariablesBase.php | 434 +++++++++++++++---- 1 file changed, 342 insertions(+), 92 deletions(-) diff --git a/tests/e2e/Services/Project/VariablesBase.php b/tests/e2e/Services/Project/VariablesBase.php index a2ed0026ab..d84d075e1d 100644 --- a/tests/e2e/Services/Project/VariablesBase.php +++ b/tests/e2e/Services/Project/VariablesBase.php @@ -3,16 +3,23 @@ namespace Tests\E2E\Services\Project; use Appwrite\Tests\Async; +use Appwrite\Tests\Async\Exceptions\Critical; +use CURLFile; use Tests\E2E\Client; +use Utopia\Console; use Utopia\Database\Document; use Utopia\Database\Helpers\ID; use Utopia\Database\Query; use Utopia\Database\Validator\Datetime as DatetimeValidator; +use Utopia\System\System; trait VariablesBase { use Async; + protected string $stdout = ''; + protected string $stderr = ''; + // Create variable tests public function testCreateVariable(): void @@ -21,7 +28,6 @@ trait VariablesBase ID::unique(), 'APP_KEY', 'my-secret-value', - null ); $this->assertEquals(201, $variable['headers']['status-code']); @@ -96,14 +102,13 @@ trait VariablesBase public function testCreateVariableWithoutAuthentication(): void { - $response = $this->client->call(Client::METHOD_POST, '/project/variables', [ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ], [ - 'variableId' => ID::unique(), - 'key' => 'NO_AUTH_KEY', - 'value' => 'no-auth-value', - ]); + $response = $this->createVariable( + ID::unique(), + 'NO_AUTH_KEY', + 'no-auth-value', + null, + false + ); $this->assertEquals(401, $response['headers']['status-code']); } @@ -114,7 +119,6 @@ trait VariablesBase '!invalid-id!', 'INVALID_ID_KEY', 'value', - null ); $this->assertEquals(400, $variable['headers']['status-code']); @@ -122,26 +126,22 @@ trait VariablesBase public function testCreateVariableMissingKey(): void { - $response = $this->client->call(Client::METHOD_POST, '/project/variables', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders()), [ - 'variableId' => ID::unique(), - 'value' => 'some-value', - ]); + $response = $this->createVariable( + ID::unique(), + null, + 'some-value', + ); $this->assertEquals(400, $response['headers']['status-code']); } public function testCreateVariableMissingValue(): void { - $response = $this->client->call(Client::METHOD_POST, '/project/variables', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders()), [ - 'variableId' => ID::unique(), - 'key' => 'MISSING_VALUE_KEY', - ]); + $response = $this->createVariable( + ID::unique(), + 'MISSING_VALUE_KEY', + null, + ); $this->assertEquals(400, $response['headers']['status-code']); } @@ -154,7 +154,6 @@ trait VariablesBase $variableId, 'DUP_KEY_1', 'value1', - null ); $this->assertEquals(201, $variable['headers']['status-code']); @@ -164,7 +163,6 @@ trait VariablesBase $variableId, 'DUP_KEY_2', 'value2', - null ); $this->assertEquals(409, $duplicate['headers']['status-code']); @@ -182,7 +180,6 @@ trait VariablesBase $customId, 'CUSTOM_ID_KEY', 'custom-value', - null ); $this->assertEquals(201, $variable['headers']['status-code']); @@ -212,7 +209,7 @@ trait VariablesBase $variableId = $variable['body']['$id']; // Update key and value - $updated = $this->updateVariable($variableId, 'UPDATED_KEY', 'updated-value', null); + $updated = $this->updateVariable($variableId, 'UPDATED_KEY', 'updated-value'); $this->assertEquals(200, $updated['headers']['status-code']); $this->assertEquals($variableId, $updated['body']['$id']); @@ -242,7 +239,7 @@ trait VariablesBase $variableId = $variable['body']['$id']; // Update only key - $updated = $this->updateVariable($variableId, 'KEY_AFTER', null, null); + $updated = $this->updateVariable($variableId, 'KEY_AFTER'); $this->assertEquals(200, $updated['headers']['status-code']); $this->assertEquals('KEY_AFTER', $updated['body']['key']); @@ -265,7 +262,7 @@ trait VariablesBase $variableId = $variable['body']['$id']; // Update only value - $updated = $this->updateVariable($variableId, null, 'value-after', null); + $updated = $this->updateVariable($variableId, null, 'value-after'); $this->assertEquals(200, $updated['headers']['status-code']); $this->assertEquals('UNCHANGED_KEY', $updated['body']['key']); @@ -332,19 +329,13 @@ trait VariablesBase ID::unique(), 'AUTH_UPDATE_KEY', 'auth-value', - null ); $this->assertEquals(201, $variable['headers']['status-code']); $variableId = $variable['body']['$id']; // Attempt update without authentication - $response = $this->client->call(Client::METHOD_PUT, '/project/variables/' . $variableId, [ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ], [ - 'key' => 'UPDATED_KEY', - ]); + $response = $this->updateVariable($variableId, 'UPDATED_KEY', null, null, false); $this->assertEquals(401, $response['headers']['status-code']); @@ -354,7 +345,7 @@ trait VariablesBase public function testUpdateVariableNotFound(): void { - $updated = $this->updateVariable('non-existent-id', 'NEW_KEY', 'new-value', null); + $updated = $this->updateVariable('non-existent-id', 'NEW_KEY', 'new-value'); $this->assertEquals(404, $updated['headers']['status-code']); $this->assertEquals('variable_not_found', $updated['body']['type']); @@ -406,17 +397,13 @@ trait VariablesBase ID::unique(), 'AUTH_GET_KEY', 'auth-get-value', - null ); $this->assertEquals(201, $variable['headers']['status-code']); $variableId = $variable['body']['$id']; // Attempt GET without authentication - $response = $this->client->call(Client::METHOD_GET, '/project/variables/' . $variableId, [ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ]); + $response = $this->getVariable($variableId, false); $this->assertEquals(401, $response['headers']['status-code']); @@ -485,7 +472,6 @@ trait VariablesBase ID::unique(), 'LIMIT_KEY_1', 'limit-value-1', - null ); $this->assertEquals(201, $variable1['headers']['status-code']); @@ -493,7 +479,6 @@ trait VariablesBase ID::unique(), 'LIMIT_KEY_2', 'limit-value-2', - null ); $this->assertEquals(201, $variable2['headers']['status-code']); @@ -517,7 +502,6 @@ trait VariablesBase ID::unique(), 'OFFSET_KEY_1', 'offset-value-1', - null ); $this->assertEquals(201, $variable1['headers']['status-code']); @@ -525,7 +509,6 @@ trait VariablesBase ID::unique(), 'OFFSET_KEY_2', 'offset-value-2', - null ); $this->assertEquals(201, $variable2['headers']['status-code']); @@ -553,7 +536,6 @@ trait VariablesBase ID::unique(), 'NO_TOTAL_KEY', 'no-total-value', - null ); $this->assertEquals(201, $variable['headers']['status-code']); @@ -574,7 +556,6 @@ trait VariablesBase ID::unique(), 'CURSOR_KEY_1', 'cursor-value-1', - null ); $this->assertEquals(201, $variable1['headers']['status-code']); @@ -582,7 +563,6 @@ trait VariablesBase ID::unique(), 'CURSOR_KEY_2', 'cursor-value-2', - null ); $this->assertEquals(201, $variable2['headers']['status-code']); @@ -612,10 +592,7 @@ trait VariablesBase public function testListVariablesWithoutAuthentication(): void { - $response = $this->client->call(Client::METHOD_GET, '/project/variables', [ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ]); + $response = $this->listVariables(null, null, false); $this->assertEquals(401, $response['headers']['status-code']); } @@ -637,7 +614,6 @@ trait VariablesBase ID::unique(), 'DELETE_KEY', 'delete-value', - null ); $this->assertEquals(201, $variable['headers']['status-code']); @@ -672,17 +648,13 @@ trait VariablesBase ID::unique(), 'DELETE_AUTH_KEY', 'delete-auth-value', - null ); $this->assertEquals(201, $variable['headers']['status-code']); $variableId = $variable['body']['$id']; // Attempt DELETE without authentication - $response = $this->client->call(Client::METHOD_DELETE, '/project/variables/' . $variableId, [ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ]); + $response = $this->deleteVariable($variableId, false); $this->assertEquals(401, $response['headers']['status-code']); @@ -700,7 +672,6 @@ trait VariablesBase ID::unique(), 'DELETE_LIST_KEY', 'delete-list-value', - null ); $this->assertEquals(201, $variable['headers']['status-code']); @@ -731,7 +702,6 @@ trait VariablesBase ID::unique(), 'DOUBLE_DELETE_KEY', 'double-delete-value', - null ); $this->assertEquals(201, $variable['headers']['status-code']); @@ -747,55 +717,279 @@ trait VariablesBase $this->assertEquals('variable_not_found', $delete['body']['type']); } - // Helpers + // Integration tests /** - * @param array|null $queries + * Test that project variables are available in function build and runtime. */ - protected function listVariables(?array $queries, ?bool $total): mixed + public function testProjectVariableInFunction(): void { - $variables = $this->client->call(Client::METHOD_GET, '/project/variables', array_merge([ + $projectId = $this->getProject()['$id']; + $apiKey = $this->getProject()['apiKey']; + + // 1. Create a project variable + $variable = $this->createVariable( + ID::unique(), + 'GLOBAL_VARIABLE', + 'Project Variable Value', + false + ); + + $this->assertEquals(201, $variable['headers']['status-code']); + $variableId = $variable['body']['$id']; + + // 2. Create a function with build commands that echo the variable + $function = $this->client->call(Client::METHOD_POST, '/functions', [ 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders()), [ - 'queries' => $queries, - 'total' => $total + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $apiKey, + ], [ + 'functionId' => ID::unique(), + 'name' => 'Project Variable Test', + 'runtime' => 'node-22', + 'entrypoint' => 'index.js', + 'execute' => ['any'], + 'timeout' => 15, + 'commands' => 'echo $GLOBAL_VARIABLE', ]); - return $variables; - } + $this->assertEquals(201, $function['headers']['status-code']); + $functionId = $function['body']['$id']; - protected function getVariable(string $variableId): mixed - { - $variable = $this->client->call(Client::METHOD_GET, '/project/variables/' . $variableId, array_merge([ + // 3. Deploy the function (basic function reads GLOBAL_VARIABLE from env) + $deployment = $this->client->call(Client::METHOD_POST, '/functions/' . $functionId . '/deployments', [ + 'content-type' => 'multipart/form-data', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $apiKey, + ], [ + 'code' => $this->packageCode('functions', 'basic'), + 'activate' => true, + ]); + + $this->assertEquals(202, $deployment['headers']['status-code']); + $deploymentId = $deployment['body']['$id'] ?? ''; + + // 4. Wait for deployment to be ready and activated + $this->assertEventually(function () use ($projectId, $apiKey, $functionId, $deploymentId) { + $deployment = $this->client->call(Client::METHOD_GET, '/functions/' . $functionId . '/deployments/' . $deploymentId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $apiKey, + ]); + + $status = $deployment['body']['status'] ?? ''; + if ($status === 'failed') { + throw new Critical('Deployment build failed: ' . ($deployment['body']['buildLogs'] ?? 'no logs')); + } + + $this->assertEquals('ready', $status, 'Deployment status is not ready'); + }, 120000, 500); + + $this->assertEventually(function () use ($projectId, $apiKey, $functionId, $deploymentId) { + $function = $this->client->call(Client::METHOD_GET, '/functions/' . $functionId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $apiKey, + ]); + $this->assertEquals($deploymentId, $function['body']['deploymentId'] ?? ''); + }, 120000, 500); + + // 5. Verify the project variable was available during build + $deployment = $this->client->call(Client::METHOD_GET, '/functions/' . $functionId . '/deployments/' . $deploymentId, [ 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders())); + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $apiKey, + ]); + $this->assertEquals(200, $deployment['headers']['status-code']); + $this->assertStringContainsString('Project Variable Value', $deployment['body']['buildLogs']); - return $variable; + // 6. Execute the function and verify the project variable is in runtime output + $execution = $this->client->call(Client::METHOD_POST, '/functions/' . $functionId . '/executions', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders()), [ + 'async' => false, + ]); + + $this->assertEquals(201, $execution['headers']['status-code']); + $this->assertEquals('completed', $execution['body']['status']); + $this->assertEquals(200, $execution['body']['responseStatusCode']); + $output = json_decode($execution['body']['responseBody'], true); + $this->assertEquals('Project Variable Value', $output['GLOBAL_VARIABLE']); + + // Cleanup + $this->client->call(Client::METHOD_DELETE, '/functions/' . $functionId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $apiKey, + ]); + $this->deleteVariable($variableId); } - protected function createVariable(string $variableId, string $key, string $value, ?bool $secret): mixed + /** + * Test that project variables are available in site build and SSR runtime. + */ + public function testProjectVariableInSite(): void + { + $projectId = $this->getProject()['$id']; + $apiKey = $this->getProject()['apiKey']; + + // 1. Create a project variable + $variable = $this->createVariable( + ID::unique(), + 'name', + 'ProjectVarTest', + ); + + $this->assertEquals(201, $variable['headers']['status-code']); + $variableId = $variable['body']['$id']; + + // 2. Create a site + $site = $this->client->call(Client::METHOD_POST, '/sites', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $apiKey, + ], [ + 'siteId' => ID::unique(), + 'name' => 'Project Variable Astro Site', + 'framework' => 'astro', + 'adapter' => 'ssr', + 'buildRuntime' => 'node-22', + 'outputDirectory' => './dist', + 'buildCommand' => 'echo $name && npm run build', + 'installCommand' => 'npm ci', + 'fallbackFile' => '', + ]); + + $this->assertEquals(201, $site['headers']['status-code']); + $siteId = $site['body']['$id']; + + // 3. Setup domain for proxy access + $sitesDomain = \explode(',', System::getEnv('_APP_DOMAIN_SITES', ''))[0]; + $rule = $this->client->call(Client::METHOD_POST, '/proxy/rules/site', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders()), [ + 'domain' => ID::unique() . '.' . $sitesDomain, + 'siteId' => $siteId, + ]); + + $this->assertEquals(201, $rule['headers']['status-code']); + + // 4. Deploy the site (astro site reads import.meta.env.name) + $deployment = $this->client->call(Client::METHOD_POST, '/sites/' . $siteId . '/deployments', [ + 'content-type' => 'multipart/form-data', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $apiKey, + ], [ + 'code' => $this->packageCode('sites', 'astro'), + 'activate' => 'true', + ]); + + $this->assertEquals(202, $deployment['headers']['status-code']); + $deploymentId = $deployment['body']['$id'] ?? ''; + + // 5. Wait for deployment to be ready and activated + $this->assertEventually(function () use ($projectId, $apiKey, $siteId, $deploymentId) { + $deployment = $this->client->call(Client::METHOD_GET, '/sites/' . $siteId . '/deployments/' . $deploymentId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $apiKey, + ]); + + $status = $deployment['body']['status'] ?? ''; + if ($status === 'failed') { + throw new Critical('Site deployment failed: ' . json_encode($deployment['body'], JSON_PRETTY_PRINT)); + } + + $this->assertEquals('ready', $status, 'Deployment status is not ready'); + }, 120000, 500); + + $this->assertEventually(function () use ($projectId, $apiKey, $siteId, $deploymentId) { + $site = $this->client->call(Client::METHOD_GET, '/sites/' . $siteId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $apiKey, + ]); + $this->assertEquals($deploymentId, $site['body']['deploymentId'] ?? ''); + }, 120000, 500); + + // 6. Verify the project variable was available during build + $deployment = $this->client->call(Client::METHOD_GET, '/sites/' . $siteId . '/deployments/' . $deploymentId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $apiKey, + ]); + $this->assertEquals(200, $deployment['headers']['status-code']); + $this->assertStringContainsString('ProjectVarTest', $deployment['body']['buildLogs']); + + // 7. Get the domain and access the site + $rules = $this->client->call(Client::METHOD_GET, '/proxy/rules', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders()), [ + 'queries' => [ + Query::equal('deploymentResourceId', [$siteId])->toString(), + Query::equal('trigger', ['manual'])->toString(), + Query::equal('type', ['deployment'])->toString(), + ], + ]); + + $this->assertEquals(200, $rules['headers']['status-code']); + $this->assertGreaterThanOrEqual(1, \count($rules['body']['rules'])); + $domain = $rules['body']['rules'][0]['domain']; + + $proxyClient = new Client(); + $proxyClient->setEndpoint('http://' . $domain); + + $response = $proxyClient->call(Client::METHOD_GET, '/'); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertStringContainsString('Env variable is ProjectVarTest', $response['body']); + $this->assertStringNotContainsString('Variable not found', $response['body']); + + // Cleanup + $this->client->call(Client::METHOD_DELETE, '/sites/' . $siteId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $apiKey, + ]); + $this->deleteVariable($variableId); + } + + // Helpers + + protected function createVariable(string $variableId, ?string $key, ?string $value, ?bool $secret = null, bool $authenticated = true): mixed { $params = [ 'variableId' => $variableId, - 'key' => $key, - 'value' => $value, ]; + if ($key !== null) { + $params['key'] = $key; + } + + if ($value !== null) { + $params['value'] = $value; + } + if ($secret !== null) { $params['secret'] = $secret; } - $variable = $this->client->call(Client::METHOD_POST, '/project/variables', array_merge([ + $headers = [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders()), $params); + ]; - return $variable; + if ($authenticated) { + $headers = array_merge($headers, $this->getHeaders()); + } + + return $this->client->call(Client::METHOD_POST, '/project/variables', $headers, $params); } - protected function updateVariable(string $variableId, ?string $key, ?string $value, ?bool $secret): mixed + protected function updateVariable(string $variableId, ?string $key = null, ?string $value = null, ?bool $secret = null, bool $authenticated = true): mixed { $params = []; @@ -811,21 +1005,77 @@ trait VariablesBase $params['secret'] = $secret; } - $variable = $this->client->call(Client::METHOD_PUT, '/project/variables/' . $variableId, array_merge([ + $headers = [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders()), $params); + ]; - return $variable; + if ($authenticated) { + $headers = array_merge($headers, $this->getHeaders()); + } + + return $this->client->call(Client::METHOD_PUT, '/project/variables/' . $variableId, $headers, $params); } - protected function deleteVariable(string $variableId): mixed + protected function getVariable(string $variableId, bool $authenticated = true): mixed { - $variable = $this->client->call(Client::METHOD_DELETE, '/project/variables/' . $variableId, array_merge([ + $headers = [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders())); + ]; - return $variable; + if ($authenticated) { + $headers = array_merge($headers, $this->getHeaders()); + } + + return $this->client->call(Client::METHOD_GET, '/project/variables/' . $variableId, $headers); + } + + /** + * @param array|null $queries + */ + protected function listVariables(?array $queries, ?bool $total, bool $authenticated = true): mixed + { + $headers = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]; + + if ($authenticated) { + $headers = array_merge($headers, $this->getHeaders()); + } + + return $this->client->call(Client::METHOD_GET, '/project/variables', $headers, [ + 'queries' => $queries, + 'total' => $total, + ]); + } + + protected function deleteVariable(string $variableId, bool $authenticated = true): mixed + { + $headers = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]; + + if ($authenticated) { + $headers = array_merge($headers, $this->getHeaders()); + } + + return $this->client->call(Client::METHOD_DELETE, '/project/variables/' . $variableId, $headers); + } + + protected function packageCode(string $type, string $name): CURLFile + { + $folderPath = realpath(__DIR__ . '/../../../resources/' . $type) . "/$name"; + $tarPath = "$folderPath/code.tar.gz"; + + Console::execute("cd $folderPath && tar --exclude code.tar.gz -czf code.tar.gz .", '', $this->stdout, $this->stderr); + + if (filesize($tarPath) > 1024 * 1024 * 5) { + throw new \Exception('Code package is too large. Use the chunked upload method instead.'); + } + + return new CURLFile($tarPath, 'application/x-gzip', \basename($tarPath)); } } From 564f56e0f55f0552c249c9511a250ab62063df51 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Wed, 18 Mar 2026 16:12:47 +0100 Subject: [PATCH 08/18] Finalize tests --- .github/workflows/ci.yml | 3 +- phpunit.xml | 1 + .../Project/Http/Project/Variables/Create.php | 2 +- .../Project/Http/Project/Variables/Delete.php | 2 +- .../Project/Http/Project/Variables/Update.php | 2 +- .../e2e/Services/Functions/FunctionsBase.php | 2 +- .../Functions/FunctionsCustomServerTest.php | 2 +- tests/e2e/Services/GraphQL/Base.php | 2 +- .../Services/Migrations/MigrationsBase.php | 2 +- tests/e2e/Services/Project/VariablesBase.php | 270 +++++++++--------- .../WebhooksCustomServerTest.php | 4 +- tests/e2e/Services/Proxy/ProxyBase.php | 4 +- tests/e2e/Services/Sites/SitesBase.php | 2 +- 13 files changed, 150 insertions(+), 148 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fe2f61bfcf..8f4df90398 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -396,7 +396,8 @@ jobs: Webhooks, VCS, Messaging, - Migrations + Migrations, + Project ] steps: - name: Checkout repository diff --git a/phpunit.xml b/phpunit.xml index 9ccbaf47cc..9748c5a5c8 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -37,6 +37,7 @@ ./tests/e2e/Services/ProjectWebhooks ./tests/e2e/Services/Messaging ./tests/e2e/Services/Migrations + ./tests/e2e/Services/Project ./tests/e2e/Services/Functions/FunctionsBase.php ./tests/e2e/Services/Functions/FunctionsCustomServerTest.php ./tests/e2e/Services/Functions/FunctionsCustomClientTest.php diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Variables/Create.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Variables/Create.php index a9209522e3..18fbda5f07 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Variables/Create.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Variables/Create.php @@ -95,7 +95,7 @@ class Create extends Base foreach (['functions', 'sites'] as $collection) { $dbForProject->updateDocuments($collection, new Document([ - 'live' => 'false' + 'live' => false ])); } diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Variables/Delete.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Variables/Delete.php index 51b8f05a8c..ca544e71d9 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Variables/Delete.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Variables/Delete.php @@ -77,7 +77,7 @@ class Delete extends Base foreach (['functions', 'sites'] as $collection) { $dbForProject->updateDocuments($collection, new Document([ - 'live' => 'false' + 'live' => false ])); } diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Variables/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Variables/Update.php index 44706df23a..1e7633cf3c 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Variables/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Variables/Update.php @@ -106,7 +106,7 @@ class Update extends Base foreach (['functions', 'sites'] as $collection) { $dbForProject->updateDocuments($collection, new Document([ - 'live' => 'false' + 'live' => false ])); } diff --git a/tests/e2e/Services/Functions/FunctionsBase.php b/tests/e2e/Services/Functions/FunctionsBase.php index 77c9367c44..af426d5221 100644 --- a/tests/e2e/Services/Functions/FunctionsBase.php +++ b/tests/e2e/Services/Functions/FunctionsBase.php @@ -264,7 +264,7 @@ trait FunctionsBase $folderPath = realpath(__DIR__ . '/../../../resources/functions') . "/$function"; $tarPath = "$folderPath/code.tar.gz"; - Console::execute("cd $folderPath && tar --exclude code.tar.gz -czf code.tar.gz .", '', $this->stdout, $this->stderr); + Console::execute("cd $folderPath && tar --exclude code.tar.gz --exclude node_modules -czf code.tar.gz .", '', $this->stdout, $this->stderr); if (filesize($tarPath) > 1024 * 1024 * 5) { throw new \Exception('Code package is too large. Use the chunked upload method instead.'); diff --git a/tests/e2e/Services/Functions/FunctionsCustomServerTest.php b/tests/e2e/Services/Functions/FunctionsCustomServerTest.php index 508ddede4a..d0b2190f1c 100644 --- a/tests/e2e/Services/Functions/FunctionsCustomServerTest.php +++ b/tests/e2e/Services/Functions/FunctionsCustomServerTest.php @@ -991,7 +991,7 @@ class FunctionsCustomServerTest extends Scope */ $folder = 'large'; $code = realpath(__DIR__ . '/../../../resources/functions') . "/$folder/code.tar.gz"; - Console::execute('cd ' . realpath(__DIR__ . "/../../../resources/functions") . "/$folder && tar --exclude code.tar.gz -czf code.tar.gz .", '', $this->stdout, $this->stderr); + Console::execute('cd ' . realpath(__DIR__ . "/../../../resources/functions") . "/$folder && tar --exclude code.tar.gz --exclude node_modules -czf code.tar.gz .", '', $this->stdout, $this->stderr); $chunkSize = 5 * 1024 * 1024; $handle = @fopen($code, "rb"); diff --git a/tests/e2e/Services/GraphQL/Base.php b/tests/e2e/Services/GraphQL/Base.php index 3e2624f83c..c42679018e 100644 --- a/tests/e2e/Services/GraphQL/Base.php +++ b/tests/e2e/Services/GraphQL/Base.php @@ -3464,7 +3464,7 @@ trait Base $folderPath = realpath(__DIR__ . '/../../../resources/functions') . "/$function"; $tarPath = "$folderPath/code.tar.gz"; - Console::execute("cd $folderPath && tar --exclude code.tar.gz -czf code.tar.gz .", '', $this->stdout, $this->stderr); + Console::execute("cd $folderPath && tar --exclude code.tar.gz --exclude node_modules -czf code.tar.gz .", '', $this->stdout, $this->stderr); if (filesize($tarPath) > 1024 * 1024 * 5) { throw new \Exception('Code package is too large. Use the chunked upload method instead.'); diff --git a/tests/e2e/Services/Migrations/MigrationsBase.php b/tests/e2e/Services/Migrations/MigrationsBase.php index 0d992c472e..92d6417180 100644 --- a/tests/e2e/Services/Migrations/MigrationsBase.php +++ b/tests/e2e/Services/Migrations/MigrationsBase.php @@ -1165,7 +1165,7 @@ trait MigrationsBase $folderPath = realpath(__DIR__ . '/../../../resources/sites') . "/$site"; $tarPath = "$folderPath/code.tar.gz"; - Console::execute("cd $folderPath && tar --exclude code.tar.gz -czf code.tar.gz .", '', $stdout, $stderr); + Console::execute("cd $folderPath && tar --exclude code.tar.gz --exclude node_modules -czf code.tar.gz .", '', $stdout, $stderr); return new CURLFile($tarPath, 'application/x-gzip', \basename($tarPath)); } diff --git a/tests/e2e/Services/Project/VariablesBase.php b/tests/e2e/Services/Project/VariablesBase.php index d84d075e1d..c2237f9d3a 100644 --- a/tests/e2e/Services/Project/VariablesBase.php +++ b/tests/e2e/Services/Project/VariablesBase.php @@ -30,27 +30,27 @@ trait VariablesBase 'my-secret-value', ); - $this->assertEquals(201, $variable['headers']['status-code']); + $this->assertSame(201, $variable['headers']['status-code']); $this->assertNotEmpty($variable['body']['$id']); - $this->assertEquals('APP_KEY', $variable['body']['key']); - $this->assertEquals(true, $variable['body']['secret']); - $this->assertNull($variable['body']['value']); - $this->assertEquals('project', $variable['body']['resourceType']); - $this->assertEquals('', $variable['body']['resourceId']); + $this->assertSame('APP_KEY', $variable['body']['key']); + $this->assertSame(true, $variable['body']['secret']); + $this->assertSame('', $variable['body']['value']); + $this->assertSame('project', $variable['body']['resourceType']); + $this->assertSame('', $variable['body']['resourceId']); $dateValidator = new DatetimeValidator(); - $this->assertEquals(true, $dateValidator->isValid($variable['body']['$createdAt'])); - $this->assertEquals(true, $dateValidator->isValid($variable['body']['$updatedAt'])); + $this->assertSame(true, $dateValidator->isValid($variable['body']['$createdAt'])); + $this->assertSame(true, $dateValidator->isValid($variable['body']['$updatedAt'])); // Verify via GET $get = $this->getVariable($variable['body']['$id']); - $this->assertEquals(200, $get['headers']['status-code']); - $this->assertEquals($variable['body']['$id'], $get['body']['$id']); - $this->assertEquals('APP_KEY', $get['body']['key']); + $this->assertSame(200, $get['headers']['status-code']); + $this->assertSame($variable['body']['$id'], $get['body']['$id']); + $this->assertSame('APP_KEY', $get['body']['key']); // Verify via LIST $list = $this->listVariables(null, true); - $this->assertEquals(200, $list['headers']['status-code']); + $this->assertSame(200, $list['headers']['status-code']); $this->assertGreaterThanOrEqual(1, $list['body']['total']); $this->assertGreaterThanOrEqual(1, \count($list['body']['variables'])); @@ -67,12 +67,12 @@ trait VariablesBase false ); - $this->assertEquals(201, $variable['headers']['status-code']); + $this->assertSame(201, $variable['headers']['status-code']); $this->assertNotEmpty($variable['body']['$id']); - $this->assertEquals('PUBLIC_KEY', $variable['body']['key']); - $this->assertEquals(false, $variable['body']['secret']); + $this->assertSame('PUBLIC_KEY', $variable['body']['key']); + $this->assertSame(false, $variable['body']['secret']); $this->assertIsBool($variable['body']['secret']); - $this->assertEquals('public-value', $variable['body']['value']); + $this->assertSame('public-value', $variable['body']['value']); // Cleanup $this->deleteVariable($variable['body']['$id']); @@ -87,14 +87,14 @@ trait VariablesBase true ); - $this->assertEquals(201, $variable['headers']['status-code']); - $this->assertEquals(true, $variable['body']['secret']); - $this->assertNull($variable['body']['value']); + $this->assertSame(201, $variable['headers']['status-code']); + $this->assertSame(true, $variable['body']['secret']); + $this->assertSame('', $variable['body']['value']); // Verify value is also hidden on GET $get = $this->getVariable($variable['body']['$id']); - $this->assertEquals(200, $get['headers']['status-code']); - $this->assertNull($get['body']['value']); + $this->assertSame(200, $get['headers']['status-code']); + $this->assertSame('', $get['body']['value']); // Cleanup $this->deleteVariable($variable['body']['$id']); @@ -110,7 +110,7 @@ trait VariablesBase false ); - $this->assertEquals(401, $response['headers']['status-code']); + $this->assertSame(401, $response['headers']['status-code']); } public function testCreateVariableInvalidId(): void @@ -121,7 +121,7 @@ trait VariablesBase 'value', ); - $this->assertEquals(400, $variable['headers']['status-code']); + $this->assertSame(400, $variable['headers']['status-code']); } public function testCreateVariableMissingKey(): void @@ -132,7 +132,7 @@ trait VariablesBase 'some-value', ); - $this->assertEquals(400, $response['headers']['status-code']); + $this->assertSame(400, $response['headers']['status-code']); } public function testCreateVariableMissingValue(): void @@ -143,7 +143,7 @@ trait VariablesBase null, ); - $this->assertEquals(400, $response['headers']['status-code']); + $this->assertSame(400, $response['headers']['status-code']); } public function testCreateVariableDuplicateId(): void @@ -156,7 +156,7 @@ trait VariablesBase 'value1', ); - $this->assertEquals(201, $variable['headers']['status-code']); + $this->assertSame(201, $variable['headers']['status-code']); // Attempt to create with same ID $duplicate = $this->createVariable( @@ -165,8 +165,8 @@ trait VariablesBase 'value2', ); - $this->assertEquals(409, $duplicate['headers']['status-code']); - $this->assertEquals('variable_already_exists', $duplicate['body']['type']); + $this->assertSame(409, $duplicate['headers']['status-code']); + $this->assertSame('variable_already_exists', $duplicate['body']['type']); // Cleanup $this->deleteVariable($variableId); @@ -182,13 +182,13 @@ trait VariablesBase 'custom-value', ); - $this->assertEquals(201, $variable['headers']['status-code']); - $this->assertEquals($customId, $variable['body']['$id']); + $this->assertSame(201, $variable['headers']['status-code']); + $this->assertSame($customId, $variable['body']['$id']); // Verify via GET $get = $this->getVariable($customId); - $this->assertEquals(200, $get['headers']['status-code']); - $this->assertEquals($customId, $get['body']['$id']); + $this->assertSame(200, $get['headers']['status-code']); + $this->assertSame($customId, $get['body']['$id']); // Cleanup $this->deleteVariable($customId); @@ -205,22 +205,22 @@ trait VariablesBase false ); - $this->assertEquals(201, $variable['headers']['status-code']); + $this->assertSame(201, $variable['headers']['status-code']); $variableId = $variable['body']['$id']; // Update key and value $updated = $this->updateVariable($variableId, 'UPDATED_KEY', 'updated-value'); - $this->assertEquals(200, $updated['headers']['status-code']); - $this->assertEquals($variableId, $updated['body']['$id']); - $this->assertEquals('UPDATED_KEY', $updated['body']['key']); - $this->assertEquals('updated-value', $updated['body']['value']); + $this->assertSame(200, $updated['headers']['status-code']); + $this->assertSame($variableId, $updated['body']['$id']); + $this->assertSame('UPDATED_KEY', $updated['body']['key']); + $this->assertSame('updated-value', $updated['body']['value']); // Verify update persisted via GET $get = $this->getVariable($variableId); - $this->assertEquals(200, $get['headers']['status-code']); - $this->assertEquals('UPDATED_KEY', $get['body']['key']); - $this->assertEquals('updated-value', $get['body']['value']); + $this->assertSame(200, $get['headers']['status-code']); + $this->assertSame('UPDATED_KEY', $get['body']['key']); + $this->assertSame('updated-value', $get['body']['value']); // Cleanup $this->deleteVariable($variableId); @@ -235,15 +235,15 @@ trait VariablesBase false ); - $this->assertEquals(201, $variable['headers']['status-code']); + $this->assertSame(201, $variable['headers']['status-code']); $variableId = $variable['body']['$id']; // Update only key $updated = $this->updateVariable($variableId, 'KEY_AFTER'); - $this->assertEquals(200, $updated['headers']['status-code']); - $this->assertEquals('KEY_AFTER', $updated['body']['key']); - $this->assertEquals('unchanged-value', $updated['body']['value']); + $this->assertSame(200, $updated['headers']['status-code']); + $this->assertSame('KEY_AFTER', $updated['body']['key']); + $this->assertSame('unchanged-value', $updated['body']['value']); // Cleanup $this->deleteVariable($variableId); @@ -258,15 +258,15 @@ trait VariablesBase false ); - $this->assertEquals(201, $variable['headers']['status-code']); + $this->assertSame(201, $variable['headers']['status-code']); $variableId = $variable['body']['$id']; // Update only value $updated = $this->updateVariable($variableId, null, 'value-after'); - $this->assertEquals(200, $updated['headers']['status-code']); - $this->assertEquals('UNCHANGED_KEY', $updated['body']['key']); - $this->assertEquals('value-after', $updated['body']['value']); + $this->assertSame(200, $updated['headers']['status-code']); + $this->assertSame('UNCHANGED_KEY', $updated['body']['key']); + $this->assertSame('value-after', $updated['body']['value']); // Cleanup $this->deleteVariable($variableId); @@ -281,16 +281,16 @@ trait VariablesBase false ); - $this->assertEquals(201, $variable['headers']['status-code']); - $this->assertEquals(false, $variable['body']['secret']); + $this->assertSame(201, $variable['headers']['status-code']); + $this->assertSame(false, $variable['body']['secret']); $variableId = $variable['body']['$id']; // Update to secret $updated = $this->updateVariable($variableId, null, null, true); - $this->assertEquals(200, $updated['headers']['status-code']); - $this->assertEquals(true, $updated['body']['secret']); - $this->assertNull($updated['body']['value']); + $this->assertSame(200, $updated['headers']['status-code']); + $this->assertSame(true, $updated['body']['secret']); + $this->assertSame('', $updated['body']['value']); // Cleanup $this->deleteVariable($variableId); @@ -305,19 +305,19 @@ trait VariablesBase true ); - $this->assertEquals(201, $variable['headers']['status-code']); + $this->assertSame(201, $variable['headers']['status-code']); $variableId = $variable['body']['$id']; // Attempt to unset secret $updated = $this->updateVariable($variableId, null, null, false); - $this->assertEquals(400, $updated['headers']['status-code']); - $this->assertEquals('variable_cannot_unset_secret', $updated['body']['type']); + $this->assertSame(400, $updated['headers']['status-code']); + $this->assertSame('variable_cannot_unset_secret', $updated['body']['type']); // Verify variable is unchanged $get = $this->getVariable($variableId); - $this->assertEquals(200, $get['headers']['status-code']); - $this->assertEquals(true, $get['body']['secret']); + $this->assertSame(200, $get['headers']['status-code']); + $this->assertSame(true, $get['body']['secret']); // Cleanup $this->deleteVariable($variableId); @@ -331,13 +331,13 @@ trait VariablesBase 'auth-value', ); - $this->assertEquals(201, $variable['headers']['status-code']); + $this->assertSame(201, $variable['headers']['status-code']); $variableId = $variable['body']['$id']; // Attempt update without authentication $response = $this->updateVariable($variableId, 'UPDATED_KEY', null, null, false); - $this->assertEquals(401, $response['headers']['status-code']); + $this->assertSame(401, $response['headers']['status-code']); // Cleanup $this->deleteVariable($variableId); @@ -347,8 +347,8 @@ trait VariablesBase { $updated = $this->updateVariable('non-existent-id', 'NEW_KEY', 'new-value'); - $this->assertEquals(404, $updated['headers']['status-code']); - $this->assertEquals('variable_not_found', $updated['body']['type']); + $this->assertSame(404, $updated['headers']['status-code']); + $this->assertSame('variable_not_found', $updated['body']['type']); } // Get variable tests @@ -362,22 +362,22 @@ trait VariablesBase false ); - $this->assertEquals(201, $variable['headers']['status-code']); + $this->assertSame(201, $variable['headers']['status-code']); $variableId = $variable['body']['$id']; $get = $this->getVariable($variableId); - $this->assertEquals(200, $get['headers']['status-code']); - $this->assertEquals($variableId, $get['body']['$id']); - $this->assertEquals('GET_TEST_KEY', $get['body']['key']); - $this->assertEquals('get-test-value', $get['body']['value']); - $this->assertEquals(false, $get['body']['secret']); - $this->assertEquals('project', $get['body']['resourceType']); - $this->assertEquals('', $get['body']['resourceId']); + $this->assertSame(200, $get['headers']['status-code']); + $this->assertSame($variableId, $get['body']['$id']); + $this->assertSame('GET_TEST_KEY', $get['body']['key']); + $this->assertSame('get-test-value', $get['body']['value']); + $this->assertSame(false, $get['body']['secret']); + $this->assertSame('project', $get['body']['resourceType']); + $this->assertSame('', $get['body']['resourceId']); $dateValidator = new DatetimeValidator(); - $this->assertEquals(true, $dateValidator->isValid($get['body']['$createdAt'])); - $this->assertEquals(true, $dateValidator->isValid($get['body']['$updatedAt'])); + $this->assertSame(true, $dateValidator->isValid($get['body']['$createdAt'])); + $this->assertSame(true, $dateValidator->isValid($get['body']['$updatedAt'])); // Cleanup $this->deleteVariable($variableId); @@ -387,8 +387,8 @@ trait VariablesBase { $get = $this->getVariable('non-existent-id'); - $this->assertEquals(404, $get['headers']['status-code']); - $this->assertEquals('variable_not_found', $get['body']['type']); + $this->assertSame(404, $get['headers']['status-code']); + $this->assertSame('variable_not_found', $get['body']['type']); } public function testGetVariableWithoutAuthentication(): void @@ -399,13 +399,13 @@ trait VariablesBase 'auth-get-value', ); - $this->assertEquals(201, $variable['headers']['status-code']); + $this->assertSame(201, $variable['headers']['status-code']); $variableId = $variable['body']['$id']; // Attempt GET without authentication $response = $this->getVariable($variableId, false); - $this->assertEquals(401, $response['headers']['status-code']); + $this->assertSame(401, $response['headers']['status-code']); // Cleanup $this->deleteVariable($variableId); @@ -422,7 +422,7 @@ trait VariablesBase 'alpha-value', false ); - $this->assertEquals(201, $variable1['headers']['status-code']); + $this->assertSame(201, $variable1['headers']['status-code']); $variable2 = $this->createVariable( ID::unique(), @@ -430,7 +430,7 @@ trait VariablesBase 'beta-value', true ); - $this->assertEquals(201, $variable2['headers']['status-code']); + $this->assertSame(201, $variable2['headers']['status-code']); $variable3 = $this->createVariable( ID::unique(), @@ -438,12 +438,12 @@ trait VariablesBase 'gamma-value', false ); - $this->assertEquals(201, $variable3['headers']['status-code']); + $this->assertSame(201, $variable3['headers']['status-code']); // List all $list = $this->listVariables(null, true); - $this->assertEquals(200, $list['headers']['status-code']); + $this->assertSame(200, $list['headers']['status-code']); $this->assertGreaterThanOrEqual(3, $list['body']['total']); $this->assertGreaterThanOrEqual(3, \count($list['body']['variables'])); $this->assertIsArray($list['body']['variables']); @@ -473,21 +473,21 @@ trait VariablesBase 'LIMIT_KEY_1', 'limit-value-1', ); - $this->assertEquals(201, $variable1['headers']['status-code']); + $this->assertSame(201, $variable1['headers']['status-code']); $variable2 = $this->createVariable( ID::unique(), 'LIMIT_KEY_2', 'limit-value-2', ); - $this->assertEquals(201, $variable2['headers']['status-code']); + $this->assertSame(201, $variable2['headers']['status-code']); // List with limit of 1 $list = $this->listVariables([ Query::limit(1)->toString(), ], true); - $this->assertEquals(200, $list['headers']['status-code']); + $this->assertSame(200, $list['headers']['status-code']); $this->assertCount(1, $list['body']['variables']); $this->assertGreaterThanOrEqual(2, $list['body']['total']); @@ -503,18 +503,18 @@ trait VariablesBase 'OFFSET_KEY_1', 'offset-value-1', ); - $this->assertEquals(201, $variable1['headers']['status-code']); + $this->assertSame(201, $variable1['headers']['status-code']); $variable2 = $this->createVariable( ID::unique(), 'OFFSET_KEY_2', 'offset-value-2', ); - $this->assertEquals(201, $variable2['headers']['status-code']); + $this->assertSame(201, $variable2['headers']['status-code']); // List all to get total $listAll = $this->listVariables(null, true); - $this->assertEquals(200, $listAll['headers']['status-code']); + $this->assertSame(200, $listAll['headers']['status-code']); $totalAll = \count($listAll['body']['variables']); // List with offset @@ -522,7 +522,7 @@ trait VariablesBase Query::offset(1)->toString(), ], true); - $this->assertEquals(200, $listOffset['headers']['status-code']); + $this->assertSame(200, $listOffset['headers']['status-code']); $this->assertCount($totalAll - 1, $listOffset['body']['variables']); // Cleanup @@ -537,13 +537,13 @@ trait VariablesBase 'NO_TOTAL_KEY', 'no-total-value', ); - $this->assertEquals(201, $variable['headers']['status-code']); + $this->assertSame(201, $variable['headers']['status-code']); // List with total=false $list = $this->listVariables(null, false); - $this->assertEquals(200, $list['headers']['status-code']); - $this->assertEquals(0, $list['body']['total']); + $this->assertSame(200, $list['headers']['status-code']); + $this->assertSame(0, $list['body']['total']); $this->assertGreaterThanOrEqual(1, \count($list['body']['variables'])); // Cleanup @@ -557,21 +557,21 @@ trait VariablesBase 'CURSOR_KEY_1', 'cursor-value-1', ); - $this->assertEquals(201, $variable1['headers']['status-code']); + $this->assertSame(201, $variable1['headers']['status-code']); $variable2 = $this->createVariable( ID::unique(), 'CURSOR_KEY_2', 'cursor-value-2', ); - $this->assertEquals(201, $variable2['headers']['status-code']); + $this->assertSame(201, $variable2['headers']['status-code']); // Get first page with limit 1 $page1 = $this->listVariables([ Query::limit(1)->toString(), ], true); - $this->assertEquals(200, $page1['headers']['status-code']); + $this->assertSame(200, $page1['headers']['status-code']); $this->assertCount(1, $page1['body']['variables']); $cursorId = $page1['body']['variables'][0]['$id']; @@ -581,7 +581,7 @@ trait VariablesBase Query::cursorAfter(new Document(['$id' => $cursorId]))->toString(), ], true); - $this->assertEquals(200, $page2['headers']['status-code']); + $this->assertSame(200, $page2['headers']['status-code']); $this->assertCount(1, $page2['body']['variables']); $this->assertNotEquals($cursorId, $page2['body']['variables'][0]['$id']); @@ -594,7 +594,7 @@ trait VariablesBase { $response = $this->listVariables(null, null, false); - $this->assertEquals(401, $response['headers']['status-code']); + $this->assertSame(401, $response['headers']['status-code']); } public function testListVariablesInvalidCursor(): void @@ -603,7 +603,7 @@ trait VariablesBase Query::cursorAfter(new Document(['$id' => 'non-existent-id']))->toString(), ], true); - $this->assertEquals(400, $list['headers']['status-code']); + $this->assertSame(400, $list['headers']['status-code']); } // Delete variable tests @@ -616,30 +616,30 @@ trait VariablesBase 'delete-value', ); - $this->assertEquals(201, $variable['headers']['status-code']); + $this->assertSame(201, $variable['headers']['status-code']); $variableId = $variable['body']['$id']; // Verify it exists $get = $this->getVariable($variableId); - $this->assertEquals(200, $get['headers']['status-code']); + $this->assertSame(200, $get['headers']['status-code']); // Delete $delete = $this->deleteVariable($variableId); - $this->assertEquals(204, $delete['headers']['status-code']); + $this->assertSame(204, $delete['headers']['status-code']); $this->assertEmpty($delete['body']); // Verify it no longer exists $get = $this->getVariable($variableId); - $this->assertEquals(404, $get['headers']['status-code']); - $this->assertEquals('variable_not_found', $get['body']['type']); + $this->assertSame(404, $get['headers']['status-code']); + $this->assertSame('variable_not_found', $get['body']['type']); } public function testDeleteVariableNotFound(): void { $delete = $this->deleteVariable('non-existent-id'); - $this->assertEquals(404, $delete['headers']['status-code']); - $this->assertEquals('variable_not_found', $delete['body']['type']); + $this->assertSame(404, $delete['headers']['status-code']); + $this->assertSame('variable_not_found', $delete['body']['type']); } public function testDeleteVariableWithoutAuthentication(): void @@ -650,17 +650,17 @@ trait VariablesBase 'delete-auth-value', ); - $this->assertEquals(201, $variable['headers']['status-code']); + $this->assertSame(201, $variable['headers']['status-code']); $variableId = $variable['body']['$id']; // Attempt DELETE without authentication $response = $this->deleteVariable($variableId, false); - $this->assertEquals(401, $response['headers']['status-code']); + $this->assertSame(401, $response['headers']['status-code']); // Verify it still exists $get = $this->getVariable($variableId); - $this->assertEquals(200, $get['headers']['status-code']); + $this->assertSame(200, $get['headers']['status-code']); // Cleanup $this->deleteVariable($variableId); @@ -674,22 +674,22 @@ trait VariablesBase 'delete-list-value', ); - $this->assertEquals(201, $variable['headers']['status-code']); + $this->assertSame(201, $variable['headers']['status-code']); $variableId = $variable['body']['$id']; // Get list count before delete $listBefore = $this->listVariables(null, true); - $this->assertEquals(200, $listBefore['headers']['status-code']); + $this->assertSame(200, $listBefore['headers']['status-code']); $countBefore = $listBefore['body']['total']; // Delete $delete = $this->deleteVariable($variableId); - $this->assertEquals(204, $delete['headers']['status-code']); + $this->assertSame(204, $delete['headers']['status-code']); // Get list count after delete $listAfter = $this->listVariables(null, true); - $this->assertEquals(200, $listAfter['headers']['status-code']); - $this->assertEquals($countBefore - 1, $listAfter['body']['total']); + $this->assertSame(200, $listAfter['headers']['status-code']); + $this->assertSame($countBefore - 1, $listAfter['body']['total']); // Verify the deleted variable is not in the list $ids = \array_column($listAfter['body']['variables'], '$id'); @@ -704,17 +704,17 @@ trait VariablesBase 'double-delete-value', ); - $this->assertEquals(201, $variable['headers']['status-code']); + $this->assertSame(201, $variable['headers']['status-code']); $variableId = $variable['body']['$id']; // First delete succeeds $delete = $this->deleteVariable($variableId); - $this->assertEquals(204, $delete['headers']['status-code']); + $this->assertSame(204, $delete['headers']['status-code']); // Second delete returns 404 $delete = $this->deleteVariable($variableId); - $this->assertEquals(404, $delete['headers']['status-code']); - $this->assertEquals('variable_not_found', $delete['body']['type']); + $this->assertSame(404, $delete['headers']['status-code']); + $this->assertSame('variable_not_found', $delete['body']['type']); } // Integration tests @@ -735,7 +735,7 @@ trait VariablesBase false ); - $this->assertEquals(201, $variable['headers']['status-code']); + $this->assertSame(201, $variable['headers']['status-code']); $variableId = $variable['body']['$id']; // 2. Create a function with build commands that echo the variable @@ -753,7 +753,7 @@ trait VariablesBase 'commands' => 'echo $GLOBAL_VARIABLE', ]); - $this->assertEquals(201, $function['headers']['status-code']); + $this->assertSame(201, $function['headers']['status-code']); $functionId = $function['body']['$id']; // 3. Deploy the function (basic function reads GLOBAL_VARIABLE from env) @@ -766,7 +766,7 @@ trait VariablesBase 'activate' => true, ]); - $this->assertEquals(202, $deployment['headers']['status-code']); + $this->assertSame(202, $deployment['headers']['status-code']); $deploymentId = $deployment['body']['$id'] ?? ''; // 4. Wait for deployment to be ready and activated @@ -782,7 +782,7 @@ trait VariablesBase throw new Critical('Deployment build failed: ' . ($deployment['body']['buildLogs'] ?? 'no logs')); } - $this->assertEquals('ready', $status, 'Deployment status is not ready'); + $this->assertSame('ready', $status, 'Deployment status is not ready'); }, 120000, 500); $this->assertEventually(function () use ($projectId, $apiKey, $functionId, $deploymentId) { @@ -791,7 +791,7 @@ trait VariablesBase 'x-appwrite-project' => $projectId, 'x-appwrite-key' => $apiKey, ]); - $this->assertEquals($deploymentId, $function['body']['deploymentId'] ?? ''); + $this->assertSame($deploymentId, $function['body']['deploymentId'] ?? ''); }, 120000, 500); // 5. Verify the project variable was available during build @@ -800,7 +800,7 @@ trait VariablesBase 'x-appwrite-project' => $projectId, 'x-appwrite-key' => $apiKey, ]); - $this->assertEquals(200, $deployment['headers']['status-code']); + $this->assertSame(200, $deployment['headers']['status-code']); $this->assertStringContainsString('Project Variable Value', $deployment['body']['buildLogs']); // 6. Execute the function and verify the project variable is in runtime output @@ -811,11 +811,11 @@ trait VariablesBase 'async' => false, ]); - $this->assertEquals(201, $execution['headers']['status-code']); - $this->assertEquals('completed', $execution['body']['status']); - $this->assertEquals(200, $execution['body']['responseStatusCode']); + $this->assertSame(201, $execution['headers']['status-code']); + $this->assertSame('completed', $execution['body']['status']); + $this->assertSame(200, $execution['body']['responseStatusCode']); $output = json_decode($execution['body']['responseBody'], true); - $this->assertEquals('Project Variable Value', $output['GLOBAL_VARIABLE']); + $this->assertSame('Project Variable Value', $output['GLOBAL_VARIABLE']); // Cleanup $this->client->call(Client::METHOD_DELETE, '/functions/' . $functionId, [ @@ -841,7 +841,7 @@ trait VariablesBase 'ProjectVarTest', ); - $this->assertEquals(201, $variable['headers']['status-code']); + $this->assertSame(201, $variable['headers']['status-code']); $variableId = $variable['body']['$id']; // 2. Create a site @@ -861,7 +861,7 @@ trait VariablesBase 'fallbackFile' => '', ]); - $this->assertEquals(201, $site['headers']['status-code']); + $this->assertSame(201, $site['headers']['status-code']); $siteId = $site['body']['$id']; // 3. Setup domain for proxy access @@ -874,7 +874,7 @@ trait VariablesBase 'siteId' => $siteId, ]); - $this->assertEquals(201, $rule['headers']['status-code']); + $this->assertSame(201, $rule['headers']['status-code']); // 4. Deploy the site (astro site reads import.meta.env.name) $deployment = $this->client->call(Client::METHOD_POST, '/sites/' . $siteId . '/deployments', [ @@ -886,7 +886,7 @@ trait VariablesBase 'activate' => 'true', ]); - $this->assertEquals(202, $deployment['headers']['status-code']); + $this->assertSame(202, $deployment['headers']['status-code']); $deploymentId = $deployment['body']['$id'] ?? ''; // 5. Wait for deployment to be ready and activated @@ -902,7 +902,7 @@ trait VariablesBase throw new Critical('Site deployment failed: ' . json_encode($deployment['body'], JSON_PRETTY_PRINT)); } - $this->assertEquals('ready', $status, 'Deployment status is not ready'); + $this->assertSame('ready', $status, 'Deployment status is not ready'); }, 120000, 500); $this->assertEventually(function () use ($projectId, $apiKey, $siteId, $deploymentId) { @@ -911,7 +911,7 @@ trait VariablesBase 'x-appwrite-project' => $projectId, 'x-appwrite-key' => $apiKey, ]); - $this->assertEquals($deploymentId, $site['body']['deploymentId'] ?? ''); + $this->assertSame($deploymentId, $site['body']['deploymentId'] ?? ''); }, 120000, 500); // 6. Verify the project variable was available during build @@ -920,7 +920,7 @@ trait VariablesBase 'x-appwrite-project' => $projectId, 'x-appwrite-key' => $apiKey, ]); - $this->assertEquals(200, $deployment['headers']['status-code']); + $this->assertSame(200, $deployment['headers']['status-code']); $this->assertStringContainsString('ProjectVarTest', $deployment['body']['buildLogs']); // 7. Get the domain and access the site @@ -935,7 +935,7 @@ trait VariablesBase ], ]); - $this->assertEquals(200, $rules['headers']['status-code']); + $this->assertSame(200, $rules['headers']['status-code']); $this->assertGreaterThanOrEqual(1, \count($rules['body']['rules'])); $domain = $rules['body']['rules'][0]['domain']; @@ -944,7 +944,7 @@ trait VariablesBase $response = $proxyClient->call(Client::METHOD_GET, '/'); - $this->assertEquals(200, $response['headers']['status-code']); + $this->assertSame(200, $response['headers']['status-code']); $this->assertStringContainsString('Env variable is ProjectVarTest', $response['body']); $this->assertStringNotContainsString('Variable not found', $response['body']); @@ -1070,7 +1070,7 @@ trait VariablesBase $folderPath = realpath(__DIR__ . '/../../../resources/' . $type) . "/$name"; $tarPath = "$folderPath/code.tar.gz"; - Console::execute("cd $folderPath && tar --exclude code.tar.gz -czf code.tar.gz .", '', $this->stdout, $this->stderr); + Console::execute("cd $folderPath && tar --exclude code.tar.gz --exclude node_modules -czf code.tar.gz .", '', $this->stdout, $this->stderr); if (filesize($tarPath) > 1024 * 1024 * 5) { throw new \Exception('Code package is too large. Use the chunked upload method instead.'); diff --git a/tests/e2e/Services/ProjectWebhooks/WebhooksCustomServerTest.php b/tests/e2e/Services/ProjectWebhooks/WebhooksCustomServerTest.php index 6baebd4ee8..9085733b70 100644 --- a/tests/e2e/Services/ProjectWebhooks/WebhooksCustomServerTest.php +++ b/tests/e2e/Services/ProjectWebhooks/WebhooksCustomServerTest.php @@ -83,7 +83,7 @@ class WebhooksCustomServerTest extends Scope $stdout = ''; $folder = 'timeout'; $code = realpath(__DIR__ . '/../../../resources/functions') . "/{$folder}/code.tar.gz"; - Console::execute('cd ' . realpath(__DIR__ . "/../../../resources/functions") . "/{$folder} && tar --exclude code.tar.gz -czf code.tar.gz .", '', $stdout, $stderr); + Console::execute('cd ' . realpath(__DIR__ . "/../../../resources/functions") . "/{$folder} && tar --exclude code.tar.gz --exclude node_modules -czf code.tar.gz .", '', $stdout, $stderr); // Create variable first $this->client->call(Client::METHOD_POST, '/functions/' . $functionId . '/variables', array_merge([ @@ -734,7 +734,7 @@ class WebhooksCustomServerTest extends Scope $stdout = ''; $folder = 'timeout'; $code = realpath(__DIR__ . '/../../../resources/functions') . "/{$folder}/code.tar.gz"; - Console::execute('cd ' . realpath(__DIR__ . "/../../../resources/functions") . "/{$folder} && tar --exclude code.tar.gz -czf code.tar.gz .", '', $stdout, $stderr); + Console::execute('cd ' . realpath(__DIR__ . "/../../../resources/functions") . "/{$folder} && tar --exclude code.tar.gz --exclude node_modules -czf code.tar.gz .", '', $stdout, $stderr); $deployment = $this->client->call(Client::METHOD_POST, '/functions/' . $functionId . '/deployments', array_merge([ 'content-type' => 'multipart/form-data', diff --git a/tests/e2e/Services/Proxy/ProxyBase.php b/tests/e2e/Services/Proxy/ProxyBase.php index 81b11d1041..59a853bfc8 100644 --- a/tests/e2e/Services/Proxy/ProxyBase.php +++ b/tests/e2e/Services/Proxy/ProxyBase.php @@ -271,7 +271,7 @@ trait ProxyBase $folderPath = realpath(__DIR__ . '/../../../resources/sites') . "/$site"; $tarPath = "$folderPath/code.tar.gz"; - Console::execute("cd $folderPath && tar --exclude code.tar.gz -czf code.tar.gz .", '', $stdout, $stderr); + Console::execute("cd $folderPath && tar --exclude code.tar.gz --exclude node_modules -czf code.tar.gz .", '', $stdout, $stderr); if (filesize($tarPath) > 1024 * 1024 * 5) { throw new \Exception('Code package is too large. Use the chunked upload method instead.'); @@ -288,7 +288,7 @@ trait ProxyBase $folderPath = realpath(__DIR__ . '/../../../resources/functions') . "/$function"; $tarPath = "$folderPath/code.tar.gz"; - Console::execute("cd $folderPath && tar --exclude code.tar.gz -czf code.tar.gz .", '', $stdout, $stderr); + Console::execute("cd $folderPath && tar --exclude code.tar.gz --exclude node_modules -czf code.tar.gz .", '', $stdout, $stderr); if (filesize($tarPath) > 1024 * 1024 * 5) { throw new \Exception('Code package is too large. Use the chunked upload method instead.'); diff --git a/tests/e2e/Services/Sites/SitesBase.php b/tests/e2e/Services/Sites/SitesBase.php index b940dda742..c3377faad8 100644 --- a/tests/e2e/Services/Sites/SitesBase.php +++ b/tests/e2e/Services/Sites/SitesBase.php @@ -241,7 +241,7 @@ trait SitesBase $folderPath = realpath(__DIR__ . '/../../../resources/sites') . "/$site"; $tarPath = "$folderPath/code.tar.gz"; - Console::execute("cd $folderPath && tar --exclude code.tar.gz -czf code.tar.gz .", '', $this->stdout, $this->stderr); + Console::execute("cd $folderPath && tar --exclude code.tar.gz --exclude node_modules -czf code.tar.gz .", '', $this->stdout, $this->stderr); if (filesize($tarPath) > 1024 * 1024 * 5) { throw new \Exception('Code package is too large. Use the chunked upload method instead.'); From 33e3e5e63deff1c1e5b4eb5771d53ada6c1967e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Wed, 18 Mar 2026 16:17:04 +0100 Subject: [PATCH 09/18] Fix noop patch call scenario --- .../Project/Http/Project/Variables/Update.php | 5 +++ tests/e2e/Services/Project/VariablesBase.php | 32 +++++++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Variables/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Variables/Update.php index 1e7633cf3c..4fffa6fecc 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Variables/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Variables/Update.php @@ -83,6 +83,11 @@ class Update extends Base throw new Exception(Exception::VARIABLE_CANNOT_UNSET_SECRET); } + if (\is_null($key) && \is_null($value) && \is_null($secret)) { + $response->dynamic($variable, Response::MODEL_VARIABLE); + return; + } + $updates = new Document(); if (!\is_null($key)) { diff --git a/tests/e2e/Services/Project/VariablesBase.php b/tests/e2e/Services/Project/VariablesBase.php index c2237f9d3a..d2db66f436 100644 --- a/tests/e2e/Services/Project/VariablesBase.php +++ b/tests/e2e/Services/Project/VariablesBase.php @@ -323,6 +323,38 @@ trait VariablesBase $this->deleteVariable($variableId); } + public function testUpdateVariableNoOp(): void + { + $variable = $this->createVariable( + ID::unique(), + 'NOOP_KEY', + 'noop-value', + false + ); + + $this->assertSame(201, $variable['headers']['status-code']); + $variableId = $variable['body']['$id']; + + // Update with no parameters (no-op) + $updated = $this->updateVariable($variableId); + + $this->assertSame(200, $updated['headers']['status-code']); + $this->assertSame($variableId, $updated['body']['$id']); + $this->assertSame('NOOP_KEY', $updated['body']['key']); + $this->assertSame('noop-value', $updated['body']['value']); + $this->assertSame(false, $updated['body']['secret']); + + // Verify variable is unchanged via GET + $get = $this->getVariable($variableId); + $this->assertSame(200, $get['headers']['status-code']); + $this->assertSame('NOOP_KEY', $get['body']['key']); + $this->assertSame('noop-value', $get['body']['value']); + $this->assertSame(false, $get['body']['secret']); + + // Cleanup + $this->deleteVariable($variableId); + } + public function testUpdateVariableWithoutAuthentication(): void { $variable = $this->createVariable( From 4bfb958146649ee783be360568966e5168dd4be8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Wed, 18 Mar 2026 16:21:26 +0100 Subject: [PATCH 10/18] fix events --- .../Platform/Modules/Project/Http/Project/Variables/Create.php | 2 +- .../Platform/Modules/Project/Http/Project/Variables/Delete.php | 2 +- .../Platform/Modules/Project/Http/Project/Variables/Update.php | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Variables/Create.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Variables/Create.php index 18fbda5f07..acc39bb68d 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Variables/Create.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Variables/Create.php @@ -36,7 +36,7 @@ class Create extends Base ->desc('Create project variable') ->groups(['api', 'project']) ->label('scope', 'project.write') - ->label('event', 'project.variables.[variableId].create') + ->label('event', 'variables.[variableId].create') ->label('audits.event', 'project.variable.create') ->label('audits.resource', 'project.variable/{response.$id}') ->label('sdk', new Method( diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Variables/Delete.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Variables/Delete.php index ca544e71d9..ac47ec3dbb 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Variables/Delete.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Variables/Delete.php @@ -33,7 +33,7 @@ class Delete extends Base ->desc('Delete project variable') ->groups(['api', 'project']) ->label('scope', 'project.write') - ->label('event', 'project.variables.[variableId].delete') + ->label('event', 'variables.[variableId].delete') ->label('audits.event', 'project.variable.delete') ->label('audits.resource', 'project.variable/{response.$id}') ->label('sdk', new Method( diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Variables/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Variables/Update.php index 4fffa6fecc..f3bbb860ed 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Variables/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Variables/Update.php @@ -35,7 +35,7 @@ class Update extends Base ->desc('Update project variable') ->groups(['api', 'project']) ->label('scope', 'project.write') - ->label('event', 'project.variables.[variableId].update') + ->label('event', 'variables.[variableId].update') ->label('audits.event', 'project.variable.update') ->label('audits.resource', 'project.variable/{response.$id}') ->label('sdk', new Method( From 351efa6cf2676556496cd51495a652de640a4b8e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Thu, 19 Mar 2026 15:00:20 +0100 Subject: [PATCH 11/18] Fix backwards compatibility --- src/Appwrite/Utopia/Request/Filters/V21.php | 22 +++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/src/Appwrite/Utopia/Request/Filters/V21.php b/src/Appwrite/Utopia/Request/Filters/V21.php index d51ec28a1e..5aec272237 100644 --- a/src/Appwrite/Utopia/Request/Filters/V21.php +++ b/src/Appwrite/Utopia/Request/Filters/V21.php @@ -2,6 +2,7 @@ namespace Appwrite\Utopia\Request\Filters; +use Appwrite\Query; use Appwrite\Utopia\Request\Filter; class V21 extends Filter @@ -13,6 +14,12 @@ class V21 extends Filter case 'webhooks.create': $content = $this->fillWebhookid($content); break; + case 'project.createVariable': + $content = $this->fillVariableId($content); + break; + case 'project.listVariables': + $content = $this->preserveVariablesQueries($content); + break; case 'functions.createTemplateDeployment': case 'sites.createTemplateDeployment': $content = $this->convertVersionToTypeAndReference($content); @@ -57,4 +64,19 @@ class V21 extends Filter $content['webhookId'] = $content['webhookId'] ?? 'unique()'; return $content; } + + protected function fillVariableId(array $content): array + { + $content['variableId'] = $content['variableId'] ?? 'unique()'; + return $content; + } + + protected function preserveVariablesQueries(array $content): array + { + $content['queries'] = $content['queries'] ?? [ + Query::limit(APP_LIMIT_SUBQUERY) + ]; + + return $content; + } } From 1754d6cc812b51f1a28eedbefdc45d3b012fc5ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Thu, 19 Mar 2026 15:21:22 +0100 Subject: [PATCH 12/18] Fix failing tests --- tests/e2e/Services/Projects/ProjectsConsoleClientTest.php | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php index ce051331ce..9bbad6330a 100644 --- a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php +++ b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php @@ -4686,6 +4686,7 @@ class ProjectsConsoleClientTest extends Scope 'x-appwrite-project' => $data['projectId'], 'x-appwrite-mode' => 'admin', ], $this->getHeaders()), [ + 'variableId' => 'unique()', 'key' => 'APP_TEST_CREATE', 'value' => 'TESTINGVALUE', 'secret' => false @@ -4702,6 +4703,7 @@ class ProjectsConsoleClientTest extends Scope 'x-appwrite-project' => $data['projectId'], 'x-appwrite-mode' => 'admin', ], $this->getHeaders()), [ + 'variableId' => 'unique()', 'key' => 'APP_TEST_CREATE_1', 'value' => 'TESTINGVALUE_1', 'secret' => true @@ -4720,6 +4722,7 @@ class ProjectsConsoleClientTest extends Scope 'x-appwrite-project' => $data['projectId'], 'x-appwrite-mode' => 'admin', ], $this->getHeaders()), [ + 'variableId' => 'unique()', 'key' => 'APP_TEST_CREATE', 'value' => 'ANOTHERTESTINGVALUE' ]); @@ -4732,6 +4735,7 @@ class ProjectsConsoleClientTest extends Scope 'x-appwrite-project' => $data['projectId'], 'x-appwrite-mode' => 'admin', ], $this->getHeaders()), [ + 'variableId' => 'unique()', 'key' => str_repeat("A", 256), 'value' => 'TESTINGVALUE' ]); @@ -4744,6 +4748,7 @@ class ProjectsConsoleClientTest extends Scope 'x-appwrite-project' => $data['projectId'], 'x-appwrite-mode' => 'admin', ], $this->getHeaders()), [ + 'variableId' => 'unique()', 'key' => 'LONGKEY', 'value' => str_repeat("#", 8193), ]); @@ -4958,6 +4963,7 @@ class ProjectsConsoleClientTest extends Scope 'x-appwrite-project' => $projectData['projectId'], 'x-appwrite-mode' => 'admin', ], $this->getHeaders()), [ + 'variableId' => 'unique()', 'key' => 'APP_TEST_DELETE', 'value' => 'TESTINGVALUE', 'secret' => false @@ -4972,6 +4978,7 @@ class ProjectsConsoleClientTest extends Scope 'x-appwrite-project' => $projectData['projectId'], 'x-appwrite-mode' => 'admin', ], $this->getHeaders()), [ + 'variableId' => 'unique()', 'key' => 'APP_TEST_DELETE_1', 'value' => 'TESTINGVALUE_1', 'secret' => true From bdd3c2f9f5dd7d64efa4e14693be6e22665a2b50 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Thu, 19 Mar 2026 15:33:36 +0100 Subject: [PATCH 13/18] Fix failing tests --- tests/e2e/Services/Projects/ProjectsBase.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/e2e/Services/Projects/ProjectsBase.php b/tests/e2e/Services/Projects/ProjectsBase.php index 231ec302de..ced3a0e23d 100644 --- a/tests/e2e/Services/Projects/ProjectsBase.php +++ b/tests/e2e/Services/Projects/ProjectsBase.php @@ -274,6 +274,7 @@ trait ProjectsBase 'x-appwrite-project' => $projectData['projectId'], 'x-appwrite-mode' => 'admin', ], $this->getHeaders()), [ + 'variableId' => 'unique()', 'key' => 'APP_TEST', 'value' => 'TESTINGVALUE', 'secret' => false @@ -288,6 +289,7 @@ trait ProjectsBase 'x-appwrite-project' => $projectData['projectId'], 'x-appwrite-mode' => 'admin', ], $this->getHeaders()), [ + 'variableId' => 'unique()', 'key' => 'APP_TEST_1', 'value' => 'TESTINGVALUE_1', 'secret' => true From be76990bb65ab13b7a911a8a945ce6d3ddabb57d Mon Sep 17 00:00:00 2001 From: loks0n <22452787+loks0n@users.noreply.github.com> Date: Sun, 22 Mar 2026 18:50:03 +0100 Subject: [PATCH 14/18] fix: fail open realtime publishing --- src/Appwrite/Event/Realtime.php | 27 ++++++++++++++++----------- 1 file changed, 16 insertions(+), 11 deletions(-) diff --git a/src/Appwrite/Event/Realtime.php b/src/Appwrite/Event/Realtime.php index 419863191e..747fd786f9 100644 --- a/src/Appwrite/Event/Realtime.php +++ b/src/Appwrite/Event/Realtime.php @@ -4,6 +4,7 @@ namespace Appwrite\Event; use Appwrite\Messaging\Adapter; use Appwrite\Messaging\Adapter\Realtime as RealtimeAdapter; +use Utopia\Console; use Utopia\Database\Document; use Utopia\Database\Exception; @@ -96,17 +97,21 @@ class Realtime extends Event : [$target['projectId'] ?? $this->getProject()->getId()]; foreach ($projectIds as $projectId) { - $this->realtime->send( - projectId: $projectId, - payload: $this->getRealtimePayload(), - events: $allEvents, - channels: $target['channels'], - roles: $target['roles'], - options: [ - 'permissionsChanged' => $target['permissionsChanged'], - 'userId' => $this->getParam('userId') - ] - ); + try { + $this->realtime->send( + projectId: $projectId, + payload: $this->getRealtimePayload(), + events: $allEvents, + channels: $target['channels'], + roles: $target['roles'], + options: [ + 'permissionsChanged' => $target['permissionsChanged'], + 'userId' => $this->getParam('userId') + ] + ); + } catch (\Exception $e) { + Console::error('Realtime send failed: '.$e->getMessage()); + } } return true; From 682105c068b52385c6f777f6501deaf0b07f3ff2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Mon, 23 Mar 2026 11:52:40 +0100 Subject: [PATCH 15/18] Rework without schema changes --- app/config/collections/common.php | 11 ----------- app/controllers/api/account.php | 32 ++++++++++++++++++++++++++++--- 2 files changed, 29 insertions(+), 14 deletions(-) diff --git a/app/config/collections/common.php b/app/config/collections/common.php index 7d71fefd81..80bb717423 100644 --- a/app/config/collections/common.php +++ b/app/config/collections/common.php @@ -593,17 +593,6 @@ return [ 'default' => null, 'array' => false, 'filters' => [], - ], - [ - '$id' => ID::custom('provider'), - 'type' => Database::VAR_STRING, - 'format' => '', - 'size' => 128, - 'signed' => true, - 'required' => false, - 'default' => null, - 'array' => false, - 'filters' => [], ] ], 'indexes' => [ diff --git a/app/controllers/api/account.php b/app/controllers/api/account.php index 692851407f..3d7db8f457 100644 --- a/app/controllers/api/account.php +++ b/app/controllers/api/account.php @@ -209,6 +209,22 @@ function sendSessionAlert(Locale $locale, Document $user, Document $project, arr $createSession = function (string $userId, string $secret, Request $request, Response $response, User $user, Database $dbForProject, Document $project, array $platform, Locale $locale, Reader $geodb, Event $queueForEvents, Mail $queueForMails, Store $store, ProofsToken $proofForToken, ProofsCode $proofForCode, Authorization $authorization) { + // Attempt to decode secret as a JWT (used by OAuth2 token flow to carry provider info) + $oauthProvider = null; + try { + $jwtDecoder = new JWT(System::getEnv('_APP_OPENSSL_KEY_V1'), 'HS256', 60, 0); + $payload = $jwtDecoder->decode($secret); + + if (empty($payload['provider'])) { + throw new Exception(Exception::USER_INVALID_TOKEN); + } + + $oauthProvider = $payload['provider']; + $secret = $payload['secret']; + } catch (\Ahc\Jwt\JWTException) { + // Not a JWT — use secret as-is (non-OAuth flows) + } + /** @var Appwrite\Utopia\Database\Documents\User $userFromRequest */ $userFromRequest = $authorization->skip(fn () => $dbForProject->getDocument('users', $userId)); @@ -220,6 +236,12 @@ $createSession = function (string $userId, string $secret, Request $request, Res ?: $userFromRequest->tokenVerify(null, $secret, $proofForCode); if (!$verifiedToken) { + // Could mean invalid/expired JWT, or expired secret + throw new Exception(Exception::USER_INVALID_TOKEN); + } + + // OAuth2 tokens must have a provider from the JWT + if ($verifiedToken->getAttribute('type') === TOKEN_TYPE_OAUTH2 && $oauthProvider === null) { throw new Exception(Exception::USER_INVALID_TOKEN); } @@ -245,7 +267,7 @@ $createSession = function (string $userId, string $secret, Request $request, Res TOKEN_TYPE_INVITE => SESSION_PROVIDER_EMAIL, TOKEN_TYPE_MAGIC_URL => SESSION_PROVIDER_MAGIC_URL, TOKEN_TYPE_PHONE => SESSION_PROVIDER_PHONE, - TOKEN_TYPE_OAUTH2 => $verifiedToken->getAttribute('provider', SESSION_PROVIDER_OAUTH2), + TOKEN_TYPE_OAUTH2 => $oauthProvider, default => SESSION_PROVIDER_TOKEN, }; $session = new Document(array_merge( @@ -1878,7 +1900,6 @@ Http::get('/v1/account/sessions/oauth2/:provider/redirect') 'userId' => $user->getId(), 'userInternalId' => $user->getSequence(), 'type' => TOKEN_TYPE_OAUTH2, - 'provider' => $provider, 'secret' => $proofForTokenOAuth2->hash($secret), // One way hash encryption to protect DB leak 'expire' => $expire, 'userAgent' => $request->getUserAgent('UNKNOWN'), @@ -1900,7 +1921,12 @@ Http::get('/v1/account/sessions/oauth2/:provider/redirect') ->setParam('tokenId', $token->getId()) ; - $query['secret'] = $secret; + // Wrap secret in a JWT that also carries the provider name + $jwtEncoder = new JWT(System::getEnv('_APP_OPENSSL_KEY_V1'), 'HS256', 60, 0); + $query['secret'] = $jwtEncoder->encode([ + 'secret' => $secret, + 'provider' => $provider, + ]); $query['userId'] = $user->getId(); // If the `token` param is not set, we persist the session in a cookie From 7ab474b963b754a7261c68ee51a870ed00fb39c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Mon, 23 Mar 2026 12:33:08 +0100 Subject: [PATCH 16/18] Fix failing tests --- .../Platform/Modules/Project/Http/Project/Variables/Update.php | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Variables/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Variables/Update.php index f3bbb860ed..61a943b618 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Variables/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Variables/Update.php @@ -84,8 +84,7 @@ class Update extends Base } if (\is_null($key) && \is_null($value) && \is_null($secret)) { - $response->dynamic($variable, Response::MODEL_VARIABLE); - return; + throw new Exception(Exception::GENERAL_ARGUMENT_INVALID); } $updates = new Document(); From 0114e260f0528c3e4029e0ac8f78136364fcebc0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Mon, 23 Mar 2026 12:56:23 +0100 Subject: [PATCH 17/18] Fix tests --- tests/e2e/Services/Project/VariablesBase.php | 15 ++--------- .../Projects/ProjectsConsoleClientTest.php | 25 ++++++++++--------- 2 files changed, 15 insertions(+), 25 deletions(-) diff --git a/tests/e2e/Services/Project/VariablesBase.php b/tests/e2e/Services/Project/VariablesBase.php index d2db66f436..b1f8ed61b9 100644 --- a/tests/e2e/Services/Project/VariablesBase.php +++ b/tests/e2e/Services/Project/VariablesBase.php @@ -335,21 +335,10 @@ trait VariablesBase $this->assertSame(201, $variable['headers']['status-code']); $variableId = $variable['body']['$id']; - // Update with no parameters (no-op) + // Update with no parameters should fail with 400 $updated = $this->updateVariable($variableId); - $this->assertSame(200, $updated['headers']['status-code']); - $this->assertSame($variableId, $updated['body']['$id']); - $this->assertSame('NOOP_KEY', $updated['body']['key']); - $this->assertSame('noop-value', $updated['body']['value']); - $this->assertSame(false, $updated['body']['secret']); - - // Verify variable is unchanged via GET - $get = $this->getVariable($variableId); - $this->assertSame(200, $get['headers']['status-code']); - $this->assertSame('NOOP_KEY', $get['body']['key']); - $this->assertSame('noop-value', $get['body']['value']); - $this->assertSame(false, $get['body']['secret']); + $this->assertSame(400, $updated['headers']['status-code']); // Cleanup $this->deleteVariable($variableId); diff --git a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php index e97b92be2b..5a1008bc58 100644 --- a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php +++ b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php @@ -4787,18 +4787,6 @@ class ProjectsConsoleClientTest extends Scope $this->assertContains("APP_TEST_UPDATE", $variableKeys); $this->assertContains("APP_TEST_UPDATE_1", $variableKeys); - /** - * Test for FAILURE - */ - - $response = $this->client->call(Client::METHOD_PUT, '/project/variables/' . $data['variableId'], array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $data['projectId'], - 'x-appwrite-mode' => 'admin', - ], $this->getHeaders())); - - $this->assertEquals(400, $response['headers']['status-code']); - $response = $this->client->call(Client::METHOD_PUT, '/project/variables/' . $data['variableId'], array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $data['projectId'], @@ -4807,6 +4795,19 @@ class ProjectsConsoleClientTest extends Scope 'value' => 'TESTINGVALUEUPDATED_2' ]); + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertSame('TESTINGVALUEUPDATED_2', $response['body']['value']); + $this->assertSame('APP_TEST_UPDATE', $response['body']['key']); + + /** + * Test for FAILURE + */ + $response = $this->client->call(Client::METHOD_PUT, '/project/variables/' . $data['variableId'], array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $data['projectId'], + 'x-appwrite-mode' => 'admin', + ], $this->getHeaders())); + $this->assertEquals(400, $response['headers']['status-code']); $longKey = str_repeat("A", 256); From d53cad2b0f2183bfcee6ca0908c571329c5f7053 Mon Sep 17 00:00:00 2001 From: Hemachandar <132386067+hmacr@users.noreply.github.com> Date: Mon, 23 Mar 2026 22:18:35 +0530 Subject: [PATCH 18/18] perf: simplify repository authorized checks (#11616) * perf: simplify repository authorized checks * search repos --- composer.json | 2 +- composer.lock | 19 ++++++++++--------- .../Http/Installations/Repositories/Get.php | 16 +++++++++------- .../Http/Installations/Repositories/XList.php | 2 +- 4 files changed, 21 insertions(+), 18 deletions(-) diff --git a/composer.json b/composer.json index f4e21222ee..3963058b5f 100644 --- a/composer.json +++ b/composer.json @@ -84,7 +84,7 @@ "utopia-php/storage": "1.0.*", "utopia-php/system": "0.10.*", "utopia-php/telemetry": "0.2.*", - "utopia-php/vcs": "2.*", + "utopia-php/vcs": "3.*", "utopia-php/websocket": "1.0.*", "matomo/device-detector": "6.4.*", "dragonmantank/cron-expression": "3.4.*", diff --git a/composer.lock b/composer.lock index 50b317c811..dd936702ac 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "1404c8821e43b3fe92e06a8ed658ed26", + "content-hash": "c1ca5882bd2c5896cc2f171891da8133", "packages": [ { "name": "adhocore/jwt", @@ -5247,22 +5247,23 @@ }, { "name": "utopia-php/vcs", - "version": "2.0.2", + "version": "3.0.1", "source": { "type": "git", "url": "https://github.com/utopia-php/vcs.git", - "reference": "5769679308bad498f2777547d48ab332166c4c0b" + "reference": "0efe842d695acb4b184f5306a836169c771fbcea" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/vcs/zipball/5769679308bad498f2777547d48ab332166c4c0b", - "reference": "5769679308bad498f2777547d48ab332166c4c0b", + "url": "https://api.github.com/repos/utopia-php/vcs/zipball/0efe842d695acb4b184f5306a836169c771fbcea", + "reference": "0efe842d695acb4b184f5306a836169c771fbcea", "shasum": "" }, "require": { "adhocore/jwt": "^1.1", "php": ">=8.0", - "utopia-php/cache": "1.0.*" + "utopia-php/cache": "1.0.*", + "utopia-php/fetch": "0.5.*" }, "require-dev": { "laravel/pint": "1.*.*", @@ -5289,9 +5290,9 @@ ], "support": { "issues": "https://github.com/utopia-php/vcs/issues", - "source": "https://github.com/utopia-php/vcs/tree/2.0.2" + "source": "https://github.com/utopia-php/vcs/tree/3.0.1" }, - "time": "2026-03-13T15:25:16+00:00" + "time": "2026-03-23T15:58:31+00:00" }, { "name": "utopia-php/websocket", @@ -8489,5 +8490,5 @@ "platform-overrides": { "php": "8.3" }, - "plugin-api-version": "2.9.0" + "plugin-api-version": "2.6.0" } diff --git a/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/Get.php b/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/Get.php index 9e32ca8276..52b94cd525 100644 --- a/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/Get.php +++ b/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/Get.php @@ -85,14 +85,16 @@ class Get extends Action $repository = $github->getRepository($owner, $repositoryName); - $authorized = false; - try { - $installationRepository = $github->getInstallationRepository($repositoryName); - if (!empty($installationRepository)) { - $authorized = true; + $authorized = $github->hasAccessToAllRepositories(); + if (!$authorized) { + try { + $installationRepository = $github->getInstallationRepository($repositoryName); + if (!empty($installationRepository)) { + $authorized = true; + } + } catch (RepositoryNotFound $e) { + $authorized = false; } - } catch (RepositoryNotFound $e) { - $authorized = false; } $repository['id'] = \strval($repository['id']) ?? ''; diff --git a/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/XList.php b/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/XList.php index 53713f8407..ca2c812901 100644 --- a/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/XList.php +++ b/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/XList.php @@ -141,7 +141,7 @@ class XList extends Action $page = ($offset / $limit) + 1; $owner = $github->getOwnerName($providerInstallationId); - ['items' => $repos, 'total' => $total] = $github->searchRepositories($providerInstallationId, $owner, $page, $limit, $search); + ['items' => $repos, 'total' => $total] = $github->searchRepositories($owner, $page, $limit, $search); $repos = \array_map(function ($repo) use ($installation) { $repo['id'] = \strval($repo['id'] ?? '');