diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3a6ae039f0..aa6dbe2bc3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -394,7 +394,8 @@ jobs: Webhooks, VCS, Messaging, - Migrations + Migrations, + Project ] include: - service: Databases 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/account.php b/app/controllers/api/account.php index 6d33b45f0b..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 => SESSION_PROVIDER_OAUTH2, + TOKEN_TYPE_OAUTH2 => $oauthProvider, default => SESSION_PROVIDER_TOKEN, }; $session = new Document(array_merge( @@ -1899,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 diff --git a/app/controllers/api/project.php b/app/controllers/api/project.php index b79180c91c..054a7c8f0d 100644 --- a/app/controllers/api/project.php +++ b/app/controllers/api/project.php @@ -1,25 +1,15 @@ $total[METRIC_EMBEDDINGS_TEXT_TOTAL_ERROR] ?? 0, ]), 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/composer.json b/composer.json index 0ac1a7912c..65838a1615 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" }, @@ -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 8d325ceff2..8e469781f1 100644 --- a/composer.lock +++ b/composer.lock @@ -5216,22 +5216,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.*.*", @@ -5258,9 +5259,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", 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/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; diff --git a/src/Appwrite/Platform/Appwrite.php b/src/Appwrite/Platform/Appwrite.php index 77b9c4d1dd..06312d9cb2 100644 --- a/src/Appwrite/Platform/Appwrite.php +++ b/src/Appwrite/Platform/Appwrite.php @@ -9,6 +9,7 @@ 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; @@ -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 Project\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..acc39bb68d --- /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', '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..ac47ec3dbb --- /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', '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..61a943b618 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Variables/Update.php @@ -0,0 +1,121 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_PUT) + ->setHttpPath('/v1/project/variables/:variableId') + ->desc('Update project variable') + ->groups(['api', 'project']) + ->label('scope', 'project.write') + ->label('event', '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); + } + + $isSecretVariable = $variable->getAttribute('secret', false) === true; + if ($isSecretVariable && $secret === false) { + throw new Exception(Exception::VARIABLE_CANNOT_UNSET_SECRET); + } + + if (\is_null($key) && \is_null($value) && \is_null($secret)) { + throw new Exception(Exception::GENERAL_ARGUMENT_INVALID); + } + + $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 { + $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 + ])); + } + + $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..cd11fe68c6 --- /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/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'] ?? ''); diff --git a/src/Appwrite/Platform/Workers/Migrations.php b/src/Appwrite/Platform/Workers/Migrations.php index 75e1341d0a..d96a25351f 100644 --- a/src/Appwrite/Platform/Workers/Migrations.php +++ b/src/Appwrite/Platform/Workers/Migrations.php @@ -362,7 +362,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/src/Appwrite/Utopia/Request/Filters/V21.php b/src/Appwrite/Utopia/Request/Filters/V21.php index 74e1fcfaff..1fd6ba9dc4 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; + } } diff --git a/tests/e2e/Scopes/ProjectCustom.php b/tests/e2e/Scopes/ProjectCustom.php index b8b6f38643..b7037267c5 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' ], ]); 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 */ 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 acd8838374..1e8b1f5ad3 100644 --- a/tests/e2e/Services/Migrations/MigrationsBase.php +++ b/tests/e2e/Services/Migrations/MigrationsBase.php @@ -1188,7 +1188,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 new file mode 100644 index 0000000000..b1f8ed61b9 --- /dev/null +++ b/tests/e2e/Services/Project/VariablesBase.php @@ -0,0 +1,1102 @@ +createVariable( + ID::unique(), + 'APP_KEY', + 'my-secret-value', + ); + + $this->assertSame(201, $variable['headers']['status-code']); + $this->assertNotEmpty($variable['body']['$id']); + $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->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->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->assertSame(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->assertSame(201, $variable['headers']['status-code']); + $this->assertNotEmpty($variable['body']['$id']); + $this->assertSame('PUBLIC_KEY', $variable['body']['key']); + $this->assertSame(false, $variable['body']['secret']); + $this->assertIsBool($variable['body']['secret']); + $this->assertSame('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->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->assertSame(200, $get['headers']['status-code']); + $this->assertSame('', $get['body']['value']); + + // Cleanup + $this->deleteVariable($variable['body']['$id']); + } + + public function testCreateVariableWithoutAuthentication(): void + { + $response = $this->createVariable( + ID::unique(), + 'NO_AUTH_KEY', + 'no-auth-value', + null, + false + ); + + $this->assertSame(401, $response['headers']['status-code']); + } + + public function testCreateVariableInvalidId(): void + { + $variable = $this->createVariable( + '!invalid-id!', + 'INVALID_ID_KEY', + 'value', + ); + + $this->assertSame(400, $variable['headers']['status-code']); + } + + public function testCreateVariableMissingKey(): void + { + $response = $this->createVariable( + ID::unique(), + null, + 'some-value', + ); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testCreateVariableMissingValue(): void + { + $response = $this->createVariable( + ID::unique(), + 'MISSING_VALUE_KEY', + null, + ); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testCreateVariableDuplicateId(): void + { + $variableId = ID::unique(); + + $variable = $this->createVariable( + $variableId, + 'DUP_KEY_1', + 'value1', + ); + + $this->assertSame(201, $variable['headers']['status-code']); + + // Attempt to create with same ID + $duplicate = $this->createVariable( + $variableId, + 'DUP_KEY_2', + 'value2', + ); + + $this->assertSame(409, $duplicate['headers']['status-code']); + $this->assertSame('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', + ); + + $this->assertSame(201, $variable['headers']['status-code']); + $this->assertSame($customId, $variable['body']['$id']); + + // Verify via GET + $get = $this->getVariable($customId); + $this->assertSame(200, $get['headers']['status-code']); + $this->assertSame($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->assertSame(201, $variable['headers']['status-code']); + $variableId = $variable['body']['$id']; + + // Update key and value + $updated = $this->updateVariable($variableId, 'UPDATED_KEY', 'updated-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->assertSame(200, $get['headers']['status-code']); + $this->assertSame('UPDATED_KEY', $get['body']['key']); + $this->assertSame('updated-value', $get['body']['value']); + + // Cleanup + $this->deleteVariable($variableId); + } + + public function testUpdateVariableKey(): void + { + $variable = $this->createVariable( + ID::unique(), + 'KEY_BEFORE', + 'unchanged-value', + false + ); + + $this->assertSame(201, $variable['headers']['status-code']); + $variableId = $variable['body']['$id']; + + // Update only key + $updated = $this->updateVariable($variableId, 'KEY_AFTER'); + + $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); + } + + public function testUpdateVariableValue(): void + { + $variable = $this->createVariable( + ID::unique(), + 'UNCHANGED_KEY', + 'value-before', + false + ); + + $this->assertSame(201, $variable['headers']['status-code']); + $variableId = $variable['body']['$id']; + + // Update only value + $updated = $this->updateVariable($variableId, null, 'value-after'); + + $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); + } + + public function testUpdateVariableSetSecret(): void + { + $variable = $this->createVariable( + ID::unique(), + 'MAKE_SECRET_KEY', + 'some-value', + false + ); + + $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->assertSame(200, $updated['headers']['status-code']); + $this->assertSame(true, $updated['body']['secret']); + $this->assertSame('', $updated['body']['value']); + + // Cleanup + $this->deleteVariable($variableId); + } + + public function testUpdateVariableCannotUnsetSecret(): void + { + $variable = $this->createVariable( + ID::unique(), + 'UNSET_SECRET_KEY', + 'secret-value', + true + ); + + $this->assertSame(201, $variable['headers']['status-code']); + $variableId = $variable['body']['$id']; + + // Attempt to unset secret + $updated = $this->updateVariable($variableId, null, null, false); + + $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->assertSame(200, $get['headers']['status-code']); + $this->assertSame(true, $get['body']['secret']); + + // Cleanup + $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 should fail with 400 + $updated = $this->updateVariable($variableId); + + $this->assertSame(400, $updated['headers']['status-code']); + + // Cleanup + $this->deleteVariable($variableId); + } + + public function testUpdateVariableWithoutAuthentication(): void + { + $variable = $this->createVariable( + ID::unique(), + 'AUTH_UPDATE_KEY', + 'auth-value', + ); + + $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->assertSame(401, $response['headers']['status-code']); + + // Cleanup + $this->deleteVariable($variableId); + } + + public function testUpdateVariableNotFound(): void + { + $updated = $this->updateVariable('non-existent-id', 'NEW_KEY', 'new-value'); + + $this->assertSame(404, $updated['headers']['status-code']); + $this->assertSame('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->assertSame(201, $variable['headers']['status-code']); + $variableId = $variable['body']['$id']; + + $get = $this->getVariable($variableId); + + $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->assertSame(true, $dateValidator->isValid($get['body']['$createdAt'])); + $this->assertSame(true, $dateValidator->isValid($get['body']['$updatedAt'])); + + // Cleanup + $this->deleteVariable($variableId); + } + + public function testGetVariableNotFound(): void + { + $get = $this->getVariable('non-existent-id'); + + $this->assertSame(404, $get['headers']['status-code']); + $this->assertSame('variable_not_found', $get['body']['type']); + } + + public function testGetVariableWithoutAuthentication(): void + { + $variable = $this->createVariable( + ID::unique(), + 'AUTH_GET_KEY', + 'auth-get-value', + ); + + $this->assertSame(201, $variable['headers']['status-code']); + $variableId = $variable['body']['$id']; + + // Attempt GET without authentication + $response = $this->getVariable($variableId, false); + + $this->assertSame(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->assertSame(201, $variable1['headers']['status-code']); + + $variable2 = $this->createVariable( + ID::unique(), + 'LIST_KEY_BETA', + 'beta-value', + true + ); + $this->assertSame(201, $variable2['headers']['status-code']); + + $variable3 = $this->createVariable( + ID::unique(), + 'LIST_KEY_GAMMA', + 'gamma-value', + false + ); + $this->assertSame(201, $variable3['headers']['status-code']); + + // List all + $list = $this->listVariables(null, true); + + $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']); + + // 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', + ); + $this->assertSame(201, $variable1['headers']['status-code']); + + $variable2 = $this->createVariable( + ID::unique(), + 'LIMIT_KEY_2', + 'limit-value-2', + ); + $this->assertSame(201, $variable2['headers']['status-code']); + + // List with limit of 1 + $list = $this->listVariables([ + Query::limit(1)->toString(), + ], true); + + $this->assertSame(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', + ); + $this->assertSame(201, $variable1['headers']['status-code']); + + $variable2 = $this->createVariable( + ID::unique(), + 'OFFSET_KEY_2', + 'offset-value-2', + ); + $this->assertSame(201, $variable2['headers']['status-code']); + + // List all to get total + $listAll = $this->listVariables(null, true); + $this->assertSame(200, $listAll['headers']['status-code']); + $totalAll = \count($listAll['body']['variables']); + + // List with offset + $listOffset = $this->listVariables([ + Query::offset(1)->toString(), + ], true); + + $this->assertSame(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', + ); + $this->assertSame(201, $variable['headers']['status-code']); + + // List with total=false + $list = $this->listVariables(null, false); + + $this->assertSame(200, $list['headers']['status-code']); + $this->assertSame(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', + ); + $this->assertSame(201, $variable1['headers']['status-code']); + + $variable2 = $this->createVariable( + ID::unique(), + 'CURSOR_KEY_2', + 'cursor-value-2', + ); + $this->assertSame(201, $variable2['headers']['status-code']); + + // Get first page with limit 1 + $page1 = $this->listVariables([ + Query::limit(1)->toString(), + ], true); + + $this->assertSame(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->assertSame(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->listVariables(null, null, false); + + $this->assertSame(401, $response['headers']['status-code']); + } + + public function testListVariablesInvalidCursor(): void + { + $list = $this->listVariables([ + Query::cursorAfter(new Document(['$id' => 'non-existent-id']))->toString(), + ], true); + + $this->assertSame(400, $list['headers']['status-code']); + } + + // Delete variable tests + + public function testDeleteVariable(): void + { + $variable = $this->createVariable( + ID::unique(), + 'DELETE_KEY', + 'delete-value', + ); + + $this->assertSame(201, $variable['headers']['status-code']); + $variableId = $variable['body']['$id']; + + // Verify it exists + $get = $this->getVariable($variableId); + $this->assertSame(200, $get['headers']['status-code']); + + // Delete + $delete = $this->deleteVariable($variableId); + $this->assertSame(204, $delete['headers']['status-code']); + $this->assertEmpty($delete['body']); + + // Verify it no longer exists + $get = $this->getVariable($variableId); + $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->assertSame(404, $delete['headers']['status-code']); + $this->assertSame('variable_not_found', $delete['body']['type']); + } + + public function testDeleteVariableWithoutAuthentication(): void + { + $variable = $this->createVariable( + ID::unique(), + 'DELETE_AUTH_KEY', + 'delete-auth-value', + ); + + $this->assertSame(201, $variable['headers']['status-code']); + $variableId = $variable['body']['$id']; + + // Attempt DELETE without authentication + $response = $this->deleteVariable($variableId, false); + + $this->assertSame(401, $response['headers']['status-code']); + + // Verify it still exists + $get = $this->getVariable($variableId); + $this->assertSame(200, $get['headers']['status-code']); + + // Cleanup + $this->deleteVariable($variableId); + } + + public function testDeleteVariableRemovedFromList(): void + { + $variable = $this->createVariable( + ID::unique(), + 'DELETE_LIST_KEY', + 'delete-list-value', + ); + + $this->assertSame(201, $variable['headers']['status-code']); + $variableId = $variable['body']['$id']; + + // Get list count before delete + $listBefore = $this->listVariables(null, true); + $this->assertSame(200, $listBefore['headers']['status-code']); + $countBefore = $listBefore['body']['total']; + + // Delete + $delete = $this->deleteVariable($variableId); + $this->assertSame(204, $delete['headers']['status-code']); + + // Get list count after delete + $listAfter = $this->listVariables(null, true); + $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'); + $this->assertNotContains($variableId, $ids); + } + + public function testDeleteVariableDoubleDelete(): void + { + $variable = $this->createVariable( + ID::unique(), + 'DOUBLE_DELETE_KEY', + 'double-delete-value', + ); + + $this->assertSame(201, $variable['headers']['status-code']); + $variableId = $variable['body']['$id']; + + // First delete succeeds + $delete = $this->deleteVariable($variableId); + $this->assertSame(204, $delete['headers']['status-code']); + + // Second delete returns 404 + $delete = $this->deleteVariable($variableId); + $this->assertSame(404, $delete['headers']['status-code']); + $this->assertSame('variable_not_found', $delete['body']['type']); + } + + // Integration tests + + /** + * Test that project variables are available in function build and runtime. + */ + public function testProjectVariableInFunction(): void + { + $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->assertSame(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' => $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', + ]); + + $this->assertSame(201, $function['headers']['status-code']); + $functionId = $function['body']['$id']; + + // 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->assertSame(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->assertSame('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->assertSame($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' => $projectId, + 'x-appwrite-key' => $apiKey, + ]); + $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 + $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->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->assertSame('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); + } + + /** + * 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->assertSame(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->assertSame(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->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', [ + 'content-type' => 'multipart/form-data', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $apiKey, + ], [ + 'code' => $this->packageCode('sites', 'astro'), + 'activate' => 'true', + ]); + + $this->assertSame(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->assertSame('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->assertSame($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->assertSame(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->assertSame(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->assertSame(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, + ]; + + if ($key !== null) { + $params['key'] = $key; + } + + if ($value !== null) { + $params['value'] = $value; + } + + if ($secret !== null) { + $params['secret'] = $secret; + } + + $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_POST, '/project/variables', $headers, $params); + } + + protected function updateVariable(string $variableId, ?string $key = null, ?string $value = null, ?bool $secret = null, bool $authenticated = true): mixed + { + $params = []; + + if ($key !== null) { + $params['key'] = $key; + } + + if ($value !== null) { + $params['value'] = $value; + } + + if ($secret !== null) { + $params['secret'] = $secret; + } + + $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_PUT, '/project/variables/' . $variableId, $headers, $params); + } + + protected function getVariable(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_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 --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.'); + } + + return new CURLFile($tarPath, 'application/x-gzip', \basename($tarPath)); + } +} 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 @@ +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/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 diff --git a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php index ce051331ce..3f84529943 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), ]); @@ -4889,18 +4894,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'], @@ -4909,6 +4902,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); @@ -4958,6 +4964,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 +4979,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 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.');