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