diff --git a/.claude/skills/patch-release-checklist/SKILL.md b/.claude/skills/patch-release-checklist/SKILL.md new file mode 100644 index 0000000000..000fcd065a --- /dev/null +++ b/.claude/skills/patch-release-checklist/SKILL.md @@ -0,0 +1,29 @@ +# Patch Release Checklist for Appwrite + +When bumping a patch version (e.g., `1.9.0` -> `1.9.1`), follow this checklist. + +## Checklist + +### Bump console image + +Update the console Docker image tag in both files: +- [ ] `docker-compose.yml` -- update `image: appwrite/console:X.Y.Z` +- [ ] `app/views/install/compose.phtml` -- update `image: /console:X.Y.Z` + +### Bump Appwrite version + +- [ ] **`app/init/constants.php`** -- update `APP_VERSION_STABLE` to the new version (e.g., `'1.9.1'`). In same file, increment `APP_CACHE_BUSTER` by 1. +- [ ] **`README.md`** -- update the Docker image tag `appwrite/appwrite:X.Y.Z` in all 3 install code blocks (Unix, Windows CMD, PowerShell). +- [ ] **`README-CN.md`** -- same Docker image tag update in all 3 install code blocks. +- [ ] **`src/Appwrite/Migration/Migration.php`** -- add the new version to the `$versions` array, mapping it to a migration class. If new class exists, use that, otherwise use sle same class as previous version + +### Update CHANGES.md + +- [ ] Add a new `# Version X.Y.Z` section at the top of `CHANGES.md` with subsections: `### Notable changes`, `### Fixes`, `### Miscellaneous` + +## Final review + +- [ ] Ask user to review changes before commiting +- [ ] Ask user to update `CHANGES.md` with PRs +- [ ] Ask user to generate specs, if needed +- [ ] Ask user to add request and response filters, if needed diff --git a/AGENTS.md b/AGENTS.md index 4d11ff0ee3..4c5db871d7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -115,6 +115,10 @@ Common injections: `$response`, `$request`, `$dbForProject`, `$dbForPlatform`, ` - Never hardcode credentials -- use environment variables. - Code changes may require container restart. No central log location -- check relevant containers. +## Patch release process + +For bumping patch versions (e.g., `1.9.0` -> `1.9.1`), follow the checklist in `.claude/skills/patch-release-checklist/SKILL.md`. It covers the 4 files that must be updated, console image bumps, CHANGES.md updates, and common pitfalls to avoid. + ## Cross-repo context Appwrite is the base server for `appwrite/cloud`. Changes to the Action pattern, module structure, DI system, or response models affect cloud. The `feat-dedicated-db` feature spans cloud, edge, and console. diff --git a/README-CN.md b/README-CN.md index 212b5bb08d..2c7402f1ef 100644 --- a/README-CN.md +++ b/README-CN.md @@ -72,7 +72,7 @@ docker run -it --rm \ --volume /var/run/docker.sock:/var/run/docker.sock \ --volume "$(pwd)"/appwrite:/usr/src/code/appwrite:rw \ --entrypoint="install" \ - appwrite/appwrite:1.9.0 + appwrite/appwrite:1.9.1 ``` ### Windows @@ -84,7 +84,7 @@ docker run -it --rm ^ --volume //var/run/docker.sock:/var/run/docker.sock ^ --volume "%cd%"/appwrite:/usr/src/code/appwrite:rw ^ --entrypoint="install" ^ - appwrite/appwrite:1.9.0 + appwrite/appwrite:1.9.1 ``` #### PowerShell @@ -94,7 +94,7 @@ docker run -it --rm ` --volume /var/run/docker.sock:/var/run/docker.sock ` --volume ${pwd}/appwrite:/usr/src/code/appwrite:rw ` --entrypoint="install" ` - appwrite/appwrite:1.9.0 + appwrite/appwrite:1.9.1 ``` 运行后,可以在浏览器上访问 http://localhost 找到 Appwrite 控制台。在非 Linux 的本机主机上完成安装后,服务器可能需要几分钟才能启动。 diff --git a/README.md b/README.md index 88d527f060..31076ffa31 100644 --- a/README.md +++ b/README.md @@ -75,7 +75,7 @@ docker run -it --rm \ --volume /var/run/docker.sock:/var/run/docker.sock \ --volume "$(pwd)"/appwrite:/usr/src/code/appwrite:rw \ --entrypoint="install" \ - appwrite/appwrite:1.9.0 + appwrite/appwrite:1.9.1 ``` ### Windows @@ -88,7 +88,7 @@ docker run -it --rm ^ --volume //var/run/docker.sock:/var/run/docker.sock ^ --volume "%cd%"/appwrite:/usr/src/code/appwrite:rw ^ --entrypoint="install" ^ - appwrite/appwrite:1.9.0 + appwrite/appwrite:1.9.1 ``` #### PowerShell @@ -99,7 +99,7 @@ docker run -it --rm ` --volume /var/run/docker.sock:/var/run/docker.sock ` --volume ${pwd}/appwrite:/usr/src/code/appwrite:rw ` --entrypoint="install" ` - appwrite/appwrite:1.9.0 + appwrite/appwrite:1.9.1 ``` Once the Docker installation is complete, go to http://localhost to access the Appwrite console from your browser. Please note that on non-Linux native hosts, the server might take a few minutes to start after completing the installation. diff --git a/app/config/apis.php b/app/config/protocols.php similarity index 69% rename from app/config/apis.php rename to app/config/protocols.php index a625999682..bb12453712 100644 --- a/app/config/apis.php +++ b/app/config/protocols.php @@ -9,8 +9,8 @@ return [ 'key' => 'graphql', 'name' => 'GraphQL', ], - 'realtime' => [ - 'key' => 'realtime', - 'name' => 'Realtime', + 'websocket' => [ + 'key' => 'websocket', + 'name' => 'Websocket', ], ]; diff --git a/app/config/services.php b/app/config/services.php index a99501c530..548f659a81 100644 --- a/app/config/services.php +++ b/app/config/services.php @@ -137,7 +137,7 @@ return [ 'docs' => true, 'docsUrl' => '', 'tests' => false, - 'optional' => false, + 'optional' => true, 'icon' => '', 'platforms' => ['client', 'server', 'console'], ], @@ -193,7 +193,7 @@ return [ 'docs' => false, 'docsUrl' => '', 'tests' => false, - 'optional' => false, + 'optional' => true, 'icon' => '', 'platforms' => ['client', 'server', 'console'], ], @@ -235,7 +235,7 @@ return [ 'docs' => true, 'docsUrl' => 'https://appwrite.io/docs/proxy', 'tests' => false, - 'optional' => false, + 'optional' => true, 'icon' => '/images/services/proxy.png', 'platforms' => ['client', 'server', 'console'], ], @@ -291,7 +291,7 @@ return [ 'docs' => true, 'docsUrl' => 'https://appwrite.io/docs/migrations', 'tests' => true, - 'optional' => false, + 'optional' => true, 'icon' => '/images/services/migrations.png', 'platforms' => ['client', 'server', 'console'], ], diff --git a/app/controllers/api/projects.php b/app/controllers/api/projects.php index dac6ed456a..5b82e6c1a3 100644 --- a/app/controllers/api/projects.php +++ b/app/controllers/api/projects.php @@ -71,202 +71,20 @@ Http::get('/v1/projects/:projectId') $response->dynamic($project, Response::MODEL_PROJECT); }); -Http::patch('/v1/projects/:projectId/service') - ->desc('Update service status') - ->groups(['api', 'projects']) - ->label('scope', 'projects.write') - ->label('sdk', new Method( - namespace: 'projects', - group: 'projects', - name: 'updateServiceStatus', - description: '/docs/references/projects/update-service-status.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_PROJECT, - ) - ] - )) - ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform']) - ->param('service', '', new WhiteList(array_keys(array_filter(Config::getParam('services'), fn ($element) => $element['optional'])), true), 'Service name.') - ->param('status', null, new Boolean(), 'Service status.') - ->inject('response') - ->inject('dbForPlatform') - ->action(function (string $projectId, string $service, bool $status, Response $response, Database $dbForPlatform) { - - $project = $dbForPlatform->getDocument('projects', $projectId); - - if ($project->isEmpty()) { - throw new Exception(Exception::PROJECT_NOT_FOUND); - } - - $services = $project->getAttribute('services', []); - $services[$service] = $status; - - $project = $dbForPlatform->updateDocument('projects', $project->getId(), $project->setAttribute('services', $services)); - - $response->dynamic($project, Response::MODEL_PROJECT); - }); - Http::patch('/v1/projects/:projectId/service/all') ->desc('Update all service status') ->groups(['api', 'projects']) ->label('scope', 'projects.write') - ->label('sdk', new Method( - namespace: 'projects', - group: 'projects', - name: 'updateServiceStatusAll', - description: '/docs/references/projects/update-service-status-all.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_PROJECT, - ) - ] - )) - ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform']) - ->param('status', null, new Boolean(), 'Service status.') - ->inject('response') - ->inject('dbForPlatform') - ->action(function (string $projectId, bool $status, Response $response, Database $dbForPlatform) { - - $project = $dbForPlatform->getDocument('projects', $projectId); - - if ($project->isEmpty()) { - throw new Exception(Exception::PROJECT_NOT_FOUND); - } - - $allServices = array_keys(array_filter(Config::getParam('services'), fn ($element) => $element['optional'])); - - $services = []; - foreach ($allServices as $service) { - $services[$service] = $status; - } - - $project = $dbForPlatform->updateDocument('projects', $project->getId(), $project->setAttribute('services', $services)); - - $response->dynamic($project, Response::MODEL_PROJECT); - }); - -Http::patch('/v1/projects/:projectId/api') - ->desc('Update API status') - ->groups(['api', 'projects']) - ->label('scope', 'projects.write') - ->label('sdk', [ - new Method( - namespace: 'projects', - group: 'projects', - name: 'updateApiStatus', - description: '/docs/references/projects/update-api-status.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_PROJECT, - ) - ], - deprecated: new Deprecated( - since: '1.8.0', - replaceWith: 'projects.updateAPIStatus', - ), - public: false, - ), - new Method( - namespace: 'projects', - group: 'projects', - name: 'updateAPIStatus', - description: '/docs/references/projects/update-api-status.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_PROJECT, - ) - ] - ) - ]) - ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform']) - ->param('api', '', new WhiteList(array_keys(Config::getParam('apis')), true), 'API name.') - ->param('status', null, new Boolean(), 'API status.') - ->inject('response') - ->inject('dbForPlatform') - ->action(function (string $projectId, string $api, bool $status, Response $response, Database $dbForPlatform) { - - $project = $dbForPlatform->getDocument('projects', $projectId); - - if ($project->isEmpty()) { - throw new Exception(Exception::PROJECT_NOT_FOUND); - } - - $apis = $project->getAttribute('apis', []); - $apis[$api] = $status; - - $project = $dbForPlatform->updateDocument('projects', $project->getId(), $project->setAttribute('apis', $apis)); - - $response->dynamic($project, Response::MODEL_PROJECT); + ->action(function () { + throw new Exception(Exception::GENERAL_NOT_IMPLEMENTED, 'Bulk API no longer exists for services. Please change status individually.'); }); Http::patch('/v1/projects/:projectId/api/all') ->desc('Update all API status') ->groups(['api', 'projects']) ->label('scope', 'projects.write') - ->label('sdk', [ - new Method( - namespace: 'projects', - group: 'projects', - name: 'updateApiStatusAll', - description: '/docs/references/projects/update-api-status-all.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_PROJECT, - ) - ], - deprecated: new Deprecated( - since: '1.8.0', - replaceWith: 'projects.updateAPIStatusAll', - ), - public: false, - ), - new Method( - namespace: 'projects', - group: 'projects', - name: 'updateAPIStatusAll', - description: '/docs/references/projects/update-api-status-all.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_PROJECT, - ) - ] - ) - ]) - ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform']) - ->param('status', null, new Boolean(), 'API status.') - ->inject('response') - ->inject('dbForPlatform') - ->action(function (string $projectId, bool $status, Response $response, Database $dbForPlatform) { - - $project = $dbForPlatform->getDocument('projects', $projectId); - - if ($project->isEmpty()) { - throw new Exception(Exception::PROJECT_NOT_FOUND); - } - - $allApis = array_keys(Config::getParam('apis')); - - $apis = []; - foreach ($allApis as $api) { - $apis[$api] = $status; - } - - $project = $dbForPlatform->updateDocument('projects', $project->getId(), $project->setAttribute('apis', $apis)); - - $response->dynamic($project, Response::MODEL_PROJECT); + ->action(function () { + throw new Exception(Exception::GENERAL_NOT_IMPLEMENTED, 'Bulk API no longer exists for services. Please change status individually.'); }); Http::patch('/v1/projects/:projectId/oauth2') diff --git a/app/controllers/general.php b/app/controllers/general.php index c2ffd9db1f..b4f4a5c1d1 100644 --- a/app/controllers/general.php +++ b/app/controllers/general.php @@ -25,6 +25,7 @@ use Appwrite\Utopia\Request\Filters\V18 as RequestV18; use Appwrite\Utopia\Request\Filters\V19 as RequestV19; use Appwrite\Utopia\Request\Filters\V20 as RequestV20; use Appwrite\Utopia\Request\Filters\V21 as RequestV21; +use Appwrite\Utopia\Request\Filters\V22 as RequestV22; use Appwrite\Utopia\Response; use Appwrite\Utopia\Response\Filters\V16 as ResponseV16; use Appwrite\Utopia\Response\Filters\V17 as ResponseV17; @@ -32,6 +33,7 @@ use Appwrite\Utopia\Response\Filters\V18 as ResponseV18; use Appwrite\Utopia\Response\Filters\V19 as ResponseV19; use Appwrite\Utopia\Response\Filters\V20 as ResponseV20; use Appwrite\Utopia\Response\Filters\V21 as ResponseV21; +use Appwrite\Utopia\Response\Filters\V22 as ResponseV22; use Appwrite\Utopia\View; use Executor\Executor; use MaxMind\Db\Reader; @@ -892,6 +894,9 @@ Http::init() if (version_compare($requestFormat, '1.9.0', '<')) { $request->addFilter(new RequestV21()); } + if (version_compare($requestFormat, '1.9.1', '<')) { + $request->addFilter(new RequestV22()); + } } $localeParam = (string) $request->getParam('locale', $request->getHeader('x-appwrite-locale', '')); @@ -916,6 +921,9 @@ Http::init() */ $responseFormat = $request->getHeader('x-appwrite-response-format', System::getEnv('_APP_SYSTEM_RESPONSE_FORMAT', '')); if ($responseFormat) { + if (version_compare($responseFormat, '1.9.1', '<')) { + $response->addFilter(new ResponseV22()); + } if (version_compare($responseFormat, '1.9.0', '<')) { $response->addFilter(new ResponseV21()); } @@ -1420,6 +1428,7 @@ Http::error() case 402: // Error allowed publicly case 403: // Error allowed publicly case 404: // Error allowed publicly + case 405: // Error allowed publicly case 408: // Error allowed publicly case 409: // Error allowed publicly case 412: // Error allowed publicly diff --git a/app/controllers/shared/api.php b/app/controllers/shared/api.php index 0611983407..d3248b54ce 100644 --- a/app/controllers/shared/api.php +++ b/app/controllers/shared/api.php @@ -426,7 +426,7 @@ Http::init() } if (! empty($method)) { - $namespace = $method->getNamespace(); + $namespace = \strtolower($method->getNamespace()); if ( array_key_exists($namespace, $project->getAttribute('services', [])) @@ -437,6 +437,15 @@ Http::init() } } + // Step 8b: Check REST protocol status + if ( + array_key_exists('rest', $project->getAttribute('apis', [])) + && ! $project->getAttribute('apis', [])['rest'] + && ! ($user->isPrivileged($authorization->getRoles()) || $user->isApp($authorization->getRoles())) + ) { + throw new AppwriteException(AppwriteException::GENERAL_API_DISABLED); + } + // Step 9: Validate scope permissions $allowed = (array) $route->getLabel('scope', 'none'); if (empty(\array_intersect($allowed, $scopes))) { @@ -512,14 +521,6 @@ Http::init() default => '', }; - if ( - array_key_exists('rest', $project->getAttribute('apis', [])) - && ! $project->getAttribute('apis', [])['rest'] - && ! ($user->isPrivileged($authorization->getRoles()) || $user->isApp($authorization->getRoles())) - ) { - throw new AppwriteException(AppwriteException::GENERAL_API_DISABLED); - } - /* * Abuse Check */ diff --git a/app/init/configs.php b/app/init/configs.php index 35c8e3899d..360a7abc34 100644 --- a/app/init/configs.php +++ b/app/init/configs.php @@ -12,7 +12,7 @@ Config::load('runtimes-v2', __DIR__ . '/../config/runtimes-v2.php', $configAdapt Config::load('template-runtimes', __DIR__ . '/../config/template-runtimes.php', $configAdapter); Config::load('events', __DIR__ . '/../config/events.php', $configAdapter); Config::load('auth', __DIR__ . '/../config/auth.php', $configAdapter); -Config::load('apis', __DIR__ . '/../config/apis.php', $configAdapter); // List of APIs +Config::load('protocols', __DIR__ . '/../config/protocols.php', $configAdapter); Config::load('errors', __DIR__ . '/../config/errors.php', $configAdapter); Config::load('oAuthProviders', __DIR__ . '/../config/oAuthProviders.php', $configAdapter); Config::load('sdks', __DIR__ . '/../config/sdks.php', $configAdapter); diff --git a/app/init/constants.php b/app/init/constants.php index 3b907572ab..f2127cd666 100644 --- a/app/init/constants.php +++ b/app/init/constants.php @@ -46,8 +46,8 @@ const APP_PROJECT_ACCESS = 24 * 60 * 60; // 24 hours const APP_RESOURCE_TOKEN_ACCESS = 24 * 60 * 60; // 24 hours const APP_FILE_ACCESS = 24 * 60 * 60; // 24 hours const APP_CACHE_UPDATE = 24 * 60 * 60; // 24 hours -const APP_CACHE_BUSTER = 4321; -const APP_VERSION_STABLE = '1.9.0'; +const APP_CACHE_BUSTER = 4322; +const APP_VERSION_STABLE = '1.9.1'; const APP_DATABASE_ATTRIBUTE_EMAIL = 'email'; const APP_DATABASE_ATTRIBUTE_ENUM = 'enum'; const APP_DATABASE_ATTRIBUTE_IP = 'ip'; diff --git a/app/realtime.php b/app/realtime.php index e1a930d4ab..955832e93a 100644 --- a/app/realtime.php +++ b/app/realtime.php @@ -653,9 +653,11 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server, $user = $connectionContainer->get('user'); /** @var User $user */ $logUser = $user; + $apis = $project->getAttribute('apis', []); + // Websocket is what to check, but realtime is checked too for backwards compatibility + $websocketEnabled = $apis['websocket'] ?? $apis['realtime'] ?? true; if ( - array_key_exists('realtime', $project->getAttribute('apis', [])) - && !$project->getAttribute('apis', [])['realtime'] + !$websocketEnabled && !($user->isPrivileged($authorization->getRoles()) || $user->isApp($authorization->getRoles())) ) { throw new AppwriteException(AppwriteException::GENERAL_API_DISABLED); diff --git a/app/views/install/compose.phtml b/app/views/install/compose.phtml index 8d0ae55394..ef4d4a1fe4 100644 --- a/app/views/install/compose.phtml +++ b/app/views/install/compose.phtml @@ -993,7 +993,7 @@ $hostPath = rtrim($this->getParam('hostPath', ''), '/'); <<: *x-logging restart: unless-stopped stop_signal: SIGINT - image: openruntimes/executor:0.7.22 + image: openruntimes/executor:0.11.4 networks: - appwrite - runtimes diff --git a/composer.lock b/composer.lock index 3d7800a919..84febe60a8 100644 --- a/composer.lock +++ b/composer.lock @@ -2887,16 +2887,16 @@ }, { "name": "symfony/polyfill-mbstring", - "version": "v1.33.0", + "version": "v1.34.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-mbstring.git", - "reference": "6d857f4d76bd4b343eac26d6b539585d2bc56493" + "reference": "6a21eb99c6973357967f6ce3708cd55a6bec6315" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/6d857f4d76bd4b343eac26d6b539585d2bc56493", - "reference": "6d857f4d76bd4b343eac26d6b539585d2bc56493", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/6a21eb99c6973357967f6ce3708cd55a6bec6315", + "reference": "6a21eb99c6973357967f6ce3708cd55a6bec6315", "shasum": "" }, "require": { @@ -2948,7 +2948,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.33.0" + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.34.0" }, "funding": [ { @@ -2968,20 +2968,20 @@ "type": "tidelift" } ], - "time": "2024-12-23T08:48:59+00:00" + "time": "2026-04-10T17:25:58+00:00" }, { "name": "symfony/polyfill-php82", - "version": "v1.33.0", + "version": "v1.34.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php82.git", - "reference": "5d2ed36f7734637dacc025f179698031951b1692" + "reference": "34808efe3e68f69685796f7c253a2f1d8ea9df59" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php82/zipball/5d2ed36f7734637dacc025f179698031951b1692", - "reference": "5d2ed36f7734637dacc025f179698031951b1692", + "url": "https://api.github.com/repos/symfony/polyfill-php82/zipball/34808efe3e68f69685796f7c253a2f1d8ea9df59", + "reference": "34808efe3e68f69685796f7c253a2f1d8ea9df59", "shasum": "" }, "require": { @@ -3028,7 +3028,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php82/tree/v1.33.0" + "source": "https://github.com/symfony/polyfill-php82/tree/v1.34.0" }, "funding": [ { @@ -3048,20 +3048,20 @@ "type": "tidelift" } ], - "time": "2024-09-09T11:45:10+00:00" + "time": "2026-04-10T16:19:22+00:00" }, { "name": "symfony/polyfill-php83", - "version": "v1.33.0", + "version": "v1.34.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php83.git", - "reference": "17f6f9a6b1735c0f163024d959f700cfbc5155e5" + "reference": "3600c2cb22399e25bb226e4a135ce91eeb2a6149" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/17f6f9a6b1735c0f163024d959f700cfbc5155e5", - "reference": "17f6f9a6b1735c0f163024d959f700cfbc5155e5", + "url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/3600c2cb22399e25bb226e4a135ce91eeb2a6149", + "reference": "3600c2cb22399e25bb226e4a135ce91eeb2a6149", "shasum": "" }, "require": { @@ -3108,7 +3108,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php83/tree/v1.33.0" + "source": "https://github.com/symfony/polyfill-php83/tree/v1.34.0" }, "funding": [ { @@ -3128,20 +3128,20 @@ "type": "tidelift" } ], - "time": "2025-07-08T02:45:35+00:00" + "time": "2026-04-10T17:25:58+00:00" }, { "name": "symfony/polyfill-php85", - "version": "v1.33.0", + "version": "v1.34.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php85.git", - "reference": "d4e5fcd4ab3d998ab16c0db48e6cbb9a01993f91" + "reference": "2c408a6bb0313e6001a83628dc5506100474254e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php85/zipball/d4e5fcd4ab3d998ab16c0db48e6cbb9a01993f91", - "reference": "d4e5fcd4ab3d998ab16c0db48e6cbb9a01993f91", + "url": "https://api.github.com/repos/symfony/polyfill-php85/zipball/2c408a6bb0313e6001a83628dc5506100474254e", + "reference": "2c408a6bb0313e6001a83628dc5506100474254e", "shasum": "" }, "require": { @@ -3188,7 +3188,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php85/tree/v1.33.0" + "source": "https://github.com/symfony/polyfill-php85/tree/v1.34.0" }, "funding": [ { @@ -3208,7 +3208,7 @@ "type": "tidelift" } ], - "time": "2025-06-23T16:12:55+00:00" + "time": "2026-04-10T16:50:15+00:00" }, { "name": "symfony/service-contracts", @@ -3850,16 +3850,16 @@ }, { "name": "utopia-php/database", - "version": "5.3.19", + "version": "5.3.20", "source": { "type": "git", "url": "https://github.com/utopia-php/database.git", - "reference": "72ee1614c37e37c7fdd9d4dc87f1f7cdfa1ca691" + "reference": "fad8e6b93c4d08cc611e41a828df3bbe0d9cfa24" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/database/zipball/72ee1614c37e37c7fdd9d4dc87f1f7cdfa1ca691", - "reference": "72ee1614c37e37c7fdd9d4dc87f1f7cdfa1ca691", + "url": "https://api.github.com/repos/utopia-php/database/zipball/fad8e6b93c4d08cc611e41a828df3bbe0d9cfa24", + "reference": "fad8e6b93c4d08cc611e41a828df3bbe0d9cfa24", "shasum": "" }, "require": { @@ -3903,9 +3903,9 @@ ], "support": { "issues": "https://github.com/utopia-php/database/issues", - "source": "https://github.com/utopia-php/database/tree/5.3.19" + "source": "https://github.com/utopia-php/database/tree/5.3.20" }, - "time": "2026-03-31T15:52:08+00:00" + "time": "2026-04-10T08:27:41+00:00" }, { "name": "utopia-php/detector", @@ -4271,16 +4271,16 @@ }, { "name": "utopia-php/http", - "version": "0.34.19", + "version": "0.34.20", "source": { "type": "git", "url": "https://github.com/utopia-php/http.git", - "reference": "995c119f31866cacd42d63b1f922bf86eabb396c" + "reference": "d6b360d555022d16c16d40be51f86180364819f8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/http/zipball/995c119f31866cacd42d63b1f922bf86eabb396c", - "reference": "995c119f31866cacd42d63b1f922bf86eabb396c", + "url": "https://api.github.com/repos/utopia-php/http/zipball/d6b360d555022d16c16d40be51f86180364819f8", + "reference": "d6b360d555022d16c16d40be51f86180364819f8", "shasum": "" }, "require": { @@ -4319,9 +4319,9 @@ ], "support": { "issues": "https://github.com/utopia-php/http/issues", - "source": "https://github.com/utopia-php/http/tree/0.34.19" + "source": "https://github.com/utopia-php/http/tree/0.34.20" }, - "time": "2026-04-08T10:23:17+00:00" + "time": "2026-04-12T14:25:22+00:00" }, { "name": "utopia-php/image", @@ -5225,16 +5225,16 @@ }, { "name": "utopia-php/vcs", - "version": "3.1.0", + "version": "3.2.0", "source": { "type": "git", "url": "https://github.com/utopia-php/vcs.git", - "reference": "03b76ad5fd01bc50f809915bca6ff0745ea913af" + "reference": "44a84ab52b42fc12f812b4d7331286b519d39db3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/vcs/zipball/03b76ad5fd01bc50f809915bca6ff0745ea913af", - "reference": "03b76ad5fd01bc50f809915bca6ff0745ea913af", + "url": "https://api.github.com/repos/utopia-php/vcs/zipball/44a84ab52b42fc12f812b4d7331286b519d39db3", + "reference": "44a84ab52b42fc12f812b4d7331286b519d39db3", "shasum": "" }, "require": { @@ -5268,9 +5268,9 @@ ], "support": { "issues": "https://github.com/utopia-php/vcs/issues", - "source": "https://github.com/utopia-php/vcs/tree/3.1.0" + "source": "https://github.com/utopia-php/vcs/tree/3.2.0" }, - "time": "2026-03-24T08:49:14+00:00" + "time": "2026-04-08T16:00:31+00:00" }, { "name": "utopia-php/websocket", @@ -5448,16 +5448,16 @@ "packages-dev": [ { "name": "appwrite/sdk-generator", - "version": "1.17.7", + "version": "1.17.11", "source": { "type": "git", "url": "https://github.com/appwrite/sdk-generator.git", - "reference": "291471d04c3f0e7b9fcc46668a6255a4c0f2947e" + "reference": "c714ee52659ef5968b3372ff4da0e407140a6250" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/appwrite/sdk-generator/zipball/291471d04c3f0e7b9fcc46668a6255a4c0f2947e", - "reference": "291471d04c3f0e7b9fcc46668a6255a4c0f2947e", + "url": "https://api.github.com/repos/appwrite/sdk-generator/zipball/c714ee52659ef5968b3372ff4da0e407140a6250", + "reference": "c714ee52659ef5968b3372ff4da0e407140a6250", "shasum": "" }, "require": { @@ -5493,9 +5493,9 @@ "description": "Appwrite PHP library for generating API SDKs for multiple programming languages and platforms", "support": { "issues": "https://github.com/appwrite/sdk-generator/issues", - "source": "https://github.com/appwrite/sdk-generator/tree/1.17.7" + "source": "https://github.com/appwrite/sdk-generator/tree/1.17.11" }, - "time": "2026-04-08T08:51:05+00:00" + "time": "2026-04-11T02:42:32+00:00" }, { "name": "brianium/paratest", @@ -7764,16 +7764,16 @@ }, { "name": "symfony/polyfill-ctype", - "version": "v1.33.0", + "version": "v1.34.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-ctype.git", - "reference": "a3cc8b044a6ea513310cbd48ef7333b384945638" + "reference": "141046a8f9477948ff284fa65be2095baafb94f2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/a3cc8b044a6ea513310cbd48ef7333b384945638", - "reference": "a3cc8b044a6ea513310cbd48ef7333b384945638", + "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/141046a8f9477948ff284fa65be2095baafb94f2", + "reference": "141046a8f9477948ff284fa65be2095baafb94f2", "shasum": "" }, "require": { @@ -7823,7 +7823,7 @@ "portable" ], "support": { - "source": "https://github.com/symfony/polyfill-ctype/tree/v1.33.0" + "source": "https://github.com/symfony/polyfill-ctype/tree/v1.34.0" }, "funding": [ { @@ -7843,20 +7843,20 @@ "type": "tidelift" } ], - "time": "2024-09-09T11:45:10+00:00" + "time": "2026-04-10T16:19:22+00:00" }, { "name": "symfony/polyfill-intl-grapheme", - "version": "v1.33.0", + "version": "v1.34.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-grapheme.git", - "reference": "380872130d3a5dd3ace2f4010d95125fde5d5c70" + "reference": "ad1b7b9092976d6c948b8a187cec9faaea9ec1df" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/380872130d3a5dd3ace2f4010d95125fde5d5c70", - "reference": "380872130d3a5dd3ace2f4010d95125fde5d5c70", + "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/ad1b7b9092976d6c948b8a187cec9faaea9ec1df", + "reference": "ad1b7b9092976d6c948b8a187cec9faaea9ec1df", "shasum": "" }, "require": { @@ -7905,7 +7905,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.33.0" + "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.34.0" }, "funding": [ { @@ -7925,11 +7925,11 @@ "type": "tidelift" } ], - "time": "2025-06-27T09:58:17+00:00" + "time": "2026-04-10T16:19:22+00:00" }, { "name": "symfony/polyfill-intl-normalizer", - "version": "v1.33.0", + "version": "v1.34.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-normalizer.git", @@ -7990,7 +7990,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.33.0" + "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.34.0" }, "funding": [ { @@ -8014,7 +8014,7 @@ }, { "name": "symfony/polyfill-php81", - "version": "v1.33.0", + "version": "v1.34.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php81.git", @@ -8070,7 +8070,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php81/tree/v1.33.0" + "source": "https://github.com/symfony/polyfill-php81/tree/v1.34.0" }, "funding": [ { @@ -8449,5 +8449,5 @@ "platform-dev": { "ext-fileinfo": "*" }, - "plugin-api-version": "2.9.0" + "plugin-api-version": "2.6.0" } diff --git a/src/Appwrite/Event/Realtime.php b/src/Appwrite/Event/Realtime.php index 747fd786f9..f040d91468 100644 --- a/src/Appwrite/Event/Realtime.php +++ b/src/Appwrite/Event/Realtime.php @@ -61,6 +61,26 @@ class Realtime extends Event return $this->subscribers; } + /** + * Reset the event state for long-lived worker processes. + * + * `Event::reset()` clears params/sensitive/event/payload only. Realtime routing also + * depends on `context`, `subscribers`, and `project`/`user` fields, so we clear them too + * to prevent stale state from affecting subsequent triggers. + */ + public function reset(): self + { + parent::reset(); + + $this->subscribers = []; + $this->context = []; + $this->project = null; + $this->user = null; + $this->userId = null; + + return $this; + } + /** * Execute Event. * diff --git a/src/Appwrite/GraphQL/Resolvers.php b/src/Appwrite/GraphQL/Resolvers.php index 689724d9f1..65f8a64d68 100644 --- a/src/Appwrite/GraphQL/Resolvers.php +++ b/src/Appwrite/GraphQL/Resolvers.php @@ -262,11 +262,12 @@ class Resolvers $request = clone $request; $utopia->setResource('request', static fn () => $request); $response->setContentType(Response::CONTENT_TYPE_NULL); + $response->clearSent(); try { $route = $utopia->match($request, fresh: true); - $utopia->execute($route, $request); + $utopia->execute($route, $request, $response); } catch (\Throwable $e) { if ($beforeReject) { $e = $beforeReject($e); diff --git a/src/Appwrite/Migration/Migration.php b/src/Appwrite/Migration/Migration.php index e481eebf6e..a01031de9b 100644 --- a/src/Appwrite/Migration/Migration.php +++ b/src/Appwrite/Migration/Migration.php @@ -93,6 +93,7 @@ abstract class Migration '1.8.0' => 'V23', '1.8.1' => 'V23', '1.9.0' => 'V24', + '1.9.1' => 'V24', ]; /** diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/XList.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/XList.php index c35eebaea2..aeee280615 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/XList.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/XList.php @@ -145,7 +145,11 @@ class XList extends Action $documentsField = $this->getListCacheField($collection, $roles, $queries, self::LIST_CACHE_FIELD_DOCUMENTS); $documentsCacheHit = false; - $cachedDocuments = $dbForProject->getCache()->load($cacheKey, $ttl, $documentsField); + try { + $cachedDocuments = $dbForProject->getCache()->load($cacheKey, $ttl, $documentsField); + } catch (\Throwable) { + $cachedDocuments = null; + } if ($cachedDocuments !== null && $cachedDocuments !== false && @@ -157,21 +161,30 @@ class XList extends Action } else { $documents = $find(); - // Convert Document objects to arrays for caching $documentsArray = \array_map(function ($doc) { return $doc->getArrayCopy(); }, $documents); - $dbForProject->getCache()->save($cacheKey, $documentsArray, $documentsField); + try { + $dbForProject->getCache()->save($cacheKey, $documentsArray, $documentsField); + } catch (\Throwable) { + } } if ($includeTotal) { $totalField = $this->getListCacheField($collection, $roles, $queries, self::LIST_CACHE_FIELD_TOTAL); - $cachedTotal = $dbForProject->getCache()->load($cacheKey, $ttl, $totalField); + try { + $cachedTotal = $dbForProject->getCache()->load($cacheKey, $ttl, $totalField); + } catch (\Throwable) { + $cachedTotal = null; + } if ($cachedTotal !== null && $cachedTotal !== false) { $total = $cachedTotal; } else { $total = $dbForDatabases->count($collectionTableId, $queries, APP_LIMIT_COUNT); - $dbForProject->getCache()->save($cacheKey, $total, $totalField); + try { + $dbForProject->getCache()->save($cacheKey, $total, $totalField); + } catch (\Throwable) { + } } } else { $total = 0; diff --git a/src/Appwrite/Platform/Modules/Databases/Workers/Databases.php b/src/Appwrite/Platform/Modules/Databases/Workers/Databases.php index 66ed3e0eab..a50e8f8bdf 100644 --- a/src/Appwrite/Platform/Modules/Databases/Workers/Databases.php +++ b/src/Appwrite/Platform/Modules/Databases/Workers/Databases.php @@ -650,26 +650,30 @@ class Databases extends Action Document|null $attribute = null, Document|null $index = null, ): void { - $queueForRealtime - ->setProject($project) - ->setSubscribers(['console']) - ->setEvent($event) - ->setParam('databaseId', $database->getId()) - ->setParam('tableId', $collection->getId()) - ->setParam('collectionId', $collection->getId()); + try { + $queueForRealtime + ->setProject($project) + ->setSubscribers(['console']) + ->setEvent($event) + ->setParam('databaseId', $database->getId()) + ->setParam('tableId', $collection->getId()) + ->setParam('collectionId', $collection->getId()); - if (! empty($attribute)) { - $queueForRealtime - ->setParam('columnId', $attribute->getId()) - ->setParam('attributeId', $attribute->getId()) - ->setPayload($attribute->getArrayCopy()); - } - if (! empty($index)) { - $queueForRealtime - ->setParam('indexId', $index->getId()) - ->setPayload($index->getArrayCopy()); + if (! empty($attribute)) { + $queueForRealtime + ->setParam('columnId', $attribute->getId()) + ->setParam('attributeId', $attribute->getId()) + ->setPayload($attribute->getArrayCopy()); + } + if (! empty($index)) { + $queueForRealtime + ->setParam('indexId', $index->getId()) + ->setPayload($index->getArrayCopy()); + } + $queueForRealtime->trigger(); + } finally { + $queueForRealtime->reset(); } - $queueForRealtime->trigger(); } } diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Create.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Create.php index 59d2c1db49..236c091c31 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Create.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Create.php @@ -62,7 +62,7 @@ class Create extends Base )) ->param('keyId', '', fn (Database $dbForPlatform) => new CustomId(false, $dbForPlatform->getAdapter()->getMaxUIDLength()), 'Key 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, ['dbForPlatform']) ->param('name', null, new Text(128), 'Key name. Max length: 128 chars.') - ->param('scopes', null, new Nullable(new ArrayList(new WhiteList(array_keys(Config::getParam('projectScopes')), true), APP_LIMIT_ARRAY_PARAMS_SIZE)), 'Key scopes list. Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' scopes are allowed.') + ->param('scopes', [], new ArrayList(new WhiteList(array_keys(Config::getParam('projectScopes')), true), APP_LIMIT_ARRAY_PARAMS_SIZE), 'Key scopes list. Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' scopes are allowed.', optional: false) ->param('expire', null, new Nullable(new Datetime()), 'Expiration time in [ISO 8601](https://www.iso.org/iso-8601-date-and-time-format.html) format. Use null for unlimited expiration.', true) ->inject('response') ->inject('queueForEvents') @@ -72,13 +72,10 @@ class Create extends Base ->callback($this->action(...)); } - /** - * @param array|null $scopes - */ public function action( string $keyId, string $name, - ?array $scopes, + array $scopes, ?string $expire, Response $response, QueueEvent $queueForEvents, @@ -95,7 +92,7 @@ class Create extends Base 'resourceId' => $project->getId(), 'resourceType' => 'projects', 'name' => $name, - 'scopes' => $scopes ?? [], + 'scopes' => $scopes, 'expire' => $expire, 'sdks' => [], 'accessedAt' => null, diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Update.php index 8759faacc1..9193bdbfdf 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Keys/Update.php @@ -60,7 +60,7 @@ class Update extends Base )) ->param('keyId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Key ID.', false, ['dbForPlatform']) ->param('name', null, new Text(128), 'Key name. Max length: 128 chars.') - ->param('scopes', null, new Nullable(new ArrayList(new WhiteList(array_keys(Config::getParam('projectScopes')), true), APP_LIMIT_ARRAY_PARAMS_SIZE)), 'Key scopes list. Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' scopes are allowed.') + ->param('scopes', [], new ArrayList(new WhiteList(array_keys(Config::getParam('projectScopes')), true), APP_LIMIT_ARRAY_PARAMS_SIZE), 'Key scopes list. Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' scopes are allowed.', optional: false) ->param('expire', null, new Nullable(new Datetime()), 'Expiration time in [ISO 8601](https://www.iso.org/iso-8601-date-and-time-format.html) format. Use null for unlimited expiration.', true) ->inject('response') ->inject('queueForEvents') @@ -70,13 +70,10 @@ class Update extends Base ->callback($this->action(...)); } - /** - * @param array|null $scopes - */ public function action( string $keyId, string $name, - ?array $scopes, + array $scopes, ?string $expire, Response $response, QueueEvent $queueForEvents, @@ -92,7 +89,7 @@ class Update extends Base $updates = new Document([ 'name' => $name, - 'scopes' => $scopes ?? [], + 'scopes' => $scopes, 'expire' => $expire, ]); diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Web/Create.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Web/Create.php index 6794901c47..2fca0ace6c 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Web/Create.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Web/Create.php @@ -64,7 +64,7 @@ class Create extends Action )) ->param('platformId', '', fn (Database $dbForPlatform) => new CustomId(false, $dbForPlatform->getAdapter()->getMaxUIDLength()), 'Platform 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, ['dbForPlatform']) ->param('name', null, new Text(128), 'Platform name. Max length: 128 chars.') - ->param('hostname', '', new Hostname(), 'Platform web hostname. Max length: 256 chars.', optional: true) // Optional for backwards compatibility + ->param('hostname', '', new Hostname(), 'Platform web hostname. Max length: 256 chars.', optional: true, example: 'app.example.com') // Optional for backwards compatibility ->param('key', '', new Text(256), 'Deprecated: Package name for Android or bundle ID for iOS or macOS. Max length: 256 chars.', optional: true, deprecated: true) // Exists for backwards compatibility ->param('type', '', new Text(256), 'Deprecated: Platform type. Max length: 256 chars.', optional: true, deprecated: true) // Exists for backwards compatibility ->inject('request') diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Web/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Web/Update.php index 1e1f1b5ac1..62d209ea25 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Web/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Platforms/Web/Update.php @@ -56,7 +56,7 @@ class Update extends Action )) ->param('platformId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Platform ID.', false, ['dbForPlatform']) ->param('name', null, new Text(128), 'Platform name. Max length: 128 chars.') - ->param('hostname', '', new Hostname(), 'Platform web hostname. Max length: 256 chars.', optional: true) // Optional for backwards compatibility + ->param('hostname', '', new Hostname(), 'Platform web hostname. Max length: 256 chars.', optional: true, example: 'app.example.com') // Optional for backwards compatibility ->param('key', '', new Text(256), 'Package name for Android or bundle ID for iOS or macOS. Max length: 256 chars.', optional: true, deprecated: true) // Exists for backwards compatibility ->inject('response') ->inject('queueForEvents') diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Protocols/Status/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Protocols/Status/Update.php new file mode 100644 index 0000000000..1fa2df3566 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Protocols/Status/Update.php @@ -0,0 +1,80 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH) + ->setHttpPath('/v1/project/protocols/:protocolId/status') + ->httpAlias('/v1/projects/:projectId/api') + ->desc('Update project protocol status') + ->groups(['api', 'project']) + ->label('scope', 'project.write') + ->label('event', 'protocols.[protocol].update') + ->label('audits.event', 'project.protocols.[protocol].update') + ->label('audits.resource', 'project.protocols/{response.$id}') + ->label('sdk', new Method( + namespace: 'project', + group: null, + name: 'updateProtocolStatus', + description: <<param('protocolId', '', new WhiteList(array_keys(Config::getParam('protocols')), true), 'Protocol name. Can be one of: ' . \implode(', ', array_keys(Config::getParam('protocols')))) + ->param('enabled', null, new Boolean(), 'Protocol status.') + ->inject('response') + ->inject('dbForPlatform') + ->inject('project') + ->inject('authorization') + ->callback($this->action(...)); + } + + public function action( + string $protocolId, + bool $enabled, + Response $response, + Database $dbForPlatform, + Document $project, + Authorization $authorization + ): void { + $protocols = $project->getAttribute('apis', []); + $protocols[$protocolId] = $enabled; + + $project = $authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), new Document([ + 'apis' => $protocols, + ]))); + + $response->dynamic($project, Response::MODEL_PROJECT); + } +} diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Services/Status/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Services/Status/Update.php new file mode 100644 index 0000000000..35be32a604 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Services/Status/Update.php @@ -0,0 +1,80 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH) + ->setHttpPath('/v1/project/services/:serviceId/status') + ->httpAlias('/v1/projects/:projectId/service') + ->desc('Update project service status') + ->groups(['api', 'project']) + ->label('scope', 'project.write') + ->label('event', 'services.[service].update') + ->label('audits.event', 'project.services.[service].update') + ->label('audits.resource', 'project.services/{response.$id}') + ->label('sdk', new Method( + namespace: 'project', + group: null, + name: 'updateServiceStatus', + description: <<param('serviceId', '', new WhiteList(array_keys(array_filter(Config::getParam('services'), fn ($element) => $element['optional'])), true), 'Service name. Can be one of: '.\implode(', ', array_keys(array_filter(Config::getParam('services'), fn ($element) => $element['optional'])))) + ->param('enabled', null, new Boolean(), 'Service status.') + ->inject('response') + ->inject('dbForPlatform') + ->inject('project') + ->inject('authorization') + ->callback($this->action(...)); + } + + public function action( + string $serviceId, + bool $enabled, + Response $response, + Database $dbForPlatform, + Document $project, + Authorization $authorization + ): void { + $services = $project->getAttribute('services', []); + $services[$serviceId] = $enabled; + + $project = $authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), new Document([ + 'services' => $services, + ]))); + + $response->dynamic($project, Response::MODEL_PROJECT); + } +} diff --git a/src/Appwrite/Platform/Modules/Project/Services/Http.php b/src/Appwrite/Platform/Modules/Project/Services/Http.php index 9fd8366097..a2c94928e2 100644 --- a/src/Appwrite/Platform/Modules/Project/Services/Http.php +++ b/src/Appwrite/Platform/Modules/Project/Services/Http.php @@ -22,6 +22,8 @@ use Appwrite\Platform\Modules\Project\Http\Project\Platforms\Web\Update as Updat use Appwrite\Platform\Modules\Project\Http\Project\Platforms\Windows\Create as CreateWindowsPlatform; use Appwrite\Platform\Modules\Project\Http\Project\Platforms\Windows\Update as UpdateWindowsPlatform; use Appwrite\Platform\Modules\Project\Http\Project\Platforms\XList as ListPlatforms; +use Appwrite\Platform\Modules\Project\Http\Project\Protocols\Status\Update as UpdateProjectProtocolStatus; +use Appwrite\Platform\Modules\Project\Http\Project\Services\Status\Update as UpdateProjectServiceStatus; use Appwrite\Platform\Modules\Project\Http\Project\Variables\Create as CreateVariable; use Appwrite\Platform\Modules\Project\Http\Project\Variables\Delete as DeleteVariable; use Appwrite\Platform\Modules\Project\Http\Project\Variables\Get as GetVariable; @@ -40,6 +42,8 @@ class Http extends Service // Project $this->addAction(UpdateProjectLabels::getName(), new UpdateProjectLabels()); + $this->addAction(UpdateProjectProtocolStatus::getName(), new UpdateProjectProtocolStatus()); + $this->addAction(UpdateProjectServiceStatus::getName(), new UpdateProjectServiceStatus()); // Variables $this->addAction(CreateVariable::getName(), new CreateVariable()); diff --git a/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Create.php b/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Create.php index 3c716202af..e60810417c 100644 --- a/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Create.php +++ b/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Create.php @@ -65,9 +65,9 @@ class Create extends Action ->param('name', null, new Text(128), 'Webhook name. Max length: 128 chars.') ->param('events', null, new ArrayList(new Event(), APP_LIMIT_ARRAY_PARAMS_SIZE), 'Events list. Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' events are allowed.') ->param('enabled', true, new Boolean(), 'Enable or disable a webhook.', true) - ->param('security', false, new Boolean(), 'Certificate verification, false for disabled or true for enabled.', true) - ->param('httpUser', '', new Text(256), 'Webhook HTTP user. Max length: 256 chars.', true) - ->param('httpPass', '', new Text(256), 'Webhook HTTP password. Max length: 256 chars.', true) + ->param('tls', false, new Boolean(), 'Certificate verification, false for disabled or true for enabled.', true) + ->param('authUsername', '', new Text(256), 'Webhook HTTP user. Max length: 256 chars.', true) + ->param('authPassword', '', new Text(256), 'Webhook HTTP password. Max length: 256 chars.', true) ->inject('response') ->inject('project') ->inject('queueForEvents') @@ -85,9 +85,9 @@ class Create extends Action string $name, array $events, bool $enabled, - bool $security, - string $httpUser, - string $httpPass, + bool $tls, + string $authUsername, + string $authPassword, Response $response, Document $project, QueueEvent $queueForEvents, @@ -104,9 +104,9 @@ class Create extends Action 'name' => $name, 'events' => $events, 'url' => $url, - 'security' => $security, - 'httpUser' => $httpUser, - 'httpPass' => $httpPass, + 'security' => $tls, + 'httpUser' => $authUsername, + 'httpPass' => $authPassword, 'signatureKey' => \bin2hex(\random_bytes(64)), 'enabled' => $enabled, ]); diff --git a/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Signature/Update.php b/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Signature/Update.php index 51c5bfbaf9..e36fa5c2ce 100644 --- a/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Signature/Update.php +++ b/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/Signature/Update.php @@ -28,9 +28,9 @@ class Update extends Action public function __construct() { $this->setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH) - ->setHttpPath('/v1/webhooks/:webhookId/signature') + ->setHttpPath('/v1/webhooks/:webhookId/secret') ->httpAlias('/v1/projects/:projectId/webhooks/:webhookId/signature') - ->desc('Update webhook signature key') + ->desc('Update webhook secret key') ->groups(['api', 'webhooks']) ->label('scope', 'webhooks.write') ->label('event', 'webhooks.[webhookId].update') @@ -39,9 +39,9 @@ class Update extends Action ->label('sdk', new Method( namespace: 'webhooks', group: null, - name: 'updateSignature', + name: 'updateSecret', description: <<param('url', '', fn () => new Multiple([new URL(['http', 'https']), new PublicDomain()], Multiple::TYPE_STRING), 'Webhook URL.') ->param('events', null, new ArrayList(new Event(), APP_LIMIT_ARRAY_PARAMS_SIZE), 'Events list. Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' events are allowed.') ->param('enabled', true, new Boolean(), 'Enable or disable a webhook.', true) - ->param('security', false, new Boolean(), 'Certificate verification, false for disabled or true for enabled.', true) - ->param('httpUser', '', new Text(256), 'Webhook HTTP user. Max length: 256 chars.', true) - ->param('httpPass', '', new Text(256), 'Webhook HTTP password. Max length: 256 chars.', true) + ->param('tls', false, new Boolean(), 'Certificate verification, false for disabled or true for enabled.', true) + ->param('authUsername', '', new Text(256), 'Webhook HTTP user. Max length: 256 chars.', true) + ->param('authPassword', '', new Text(256), 'Webhook HTTP password. Max length: 256 chars.', true) ->inject('response') ->inject('project') ->inject('queueForEvents') @@ -80,9 +80,9 @@ class Update extends Action string $url, array $events, bool $enabled, - bool $security, - string $httpUser, - string $httpPass, + bool $tls, + string $authUsername, + string $authPassword, Response $response, Document $project, QueueEvent $queueForEvents, @@ -102,9 +102,9 @@ class Update extends Action 'name' => $name, 'events' => $events, 'url' => $url, - 'security' => $security, - 'httpUser' => $httpUser, - 'httpPass' => $httpPass, + 'security' => $tls, + 'httpUser' => $authUsername, + 'httpPass' => $authPassword, 'enabled' => $enabled, ]); diff --git a/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/XList.php b/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/XList.php index 2a4c4f9e59..763c0d339b 100644 --- a/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/XList.php +++ b/src/Appwrite/Platform/Modules/Webhooks/Http/Webhooks/XList.php @@ -78,6 +78,15 @@ class XList extends Action throw new Exception(Exception::GENERAL_QUERY_INVALID, $e->getMessage()); } + foreach ($queries as $query) { + $attribute = $query->getAttribute(); + if ($attribute === 'authUsername') { + $query->setAttribute('httpUser'); + } elseif ($attribute === 'tls') { + $query->setAttribute('security'); + } + } + $queries[] = Query::equal('projectInternalId', [$project->getSequence()]); $cursor = Query::getCursorQueries($queries, false); diff --git a/src/Appwrite/SDK/Specification/Format.php b/src/Appwrite/SDK/Specification/Format.php index 02fac12a7a..91b090a9f6 100644 --- a/src/Appwrite/SDK/Specification/Format.php +++ b/src/Appwrite/SDK/Specification/Format.php @@ -78,6 +78,18 @@ abstract class Format ], ]; + private const array REQUEST_PARAMETER_OVERRIDES = [ + [ + 'namespace' => 'project', + 'methods' => [ + 'createWebPlatform', + 'updateWebPlatform', + ], + 'parameter' => 'hostname', + 'required' => true, + ], + ]; + protected array $enumBlacklist = []; public function __construct(Container $container, array $services, array $routes, array $models, array $keys, int $authCount, string $platform) @@ -774,8 +786,38 @@ abstract class Format return $values; } + protected function getRequestParameterConfig(string $service, string $method, string $param, bool $optional, bool $nullable, mixed $default): array + { + $config = [ + 'required' => !$optional, + 'nullable' => $nullable, + ]; + + foreach (self::REQUEST_PARAMETER_OVERRIDES as $override) { + if ( + $override['namespace'] !== $service + || !\in_array($method, $override['methods'], true) + || $override['parameter'] !== $param + ) { + continue; + } + + $config['required'] = $override['required'] ?? $config['required']; + $config['nullable'] = $override['nullable'] ?? $config['nullable']; + break; + } + + $config['emitDefault'] = !$config['required'] && !\is_null($default); + + return $config; + } + public function getResponseEnumName(string $model, string $param): ?string { + if ($param === 'type' && \str_starts_with($model, 'platform') && $model !== 'platformList') { + return 'PlatformType'; + } + if ($param !== 'status') { return null; } diff --git a/src/Appwrite/SDK/Specification/Format/OpenAPI3.php b/src/Appwrite/SDK/Specification/Format/OpenAPI3.php index 7da48fc2ca..b611558826 100644 --- a/src/Appwrite/SDK/Specification/Format/OpenAPI3.php +++ b/src/Appwrite/SDK/Specification/Format/OpenAPI3.php @@ -381,14 +381,23 @@ class OpenAPI3 extends Format */ $validator = $this->getValidator($param); + $isNullable = $validator instanceof Nullable; + + $parameter = $this->getRequestParameterConfig( + $sdk->getNamespace() ?? '', + $methodName, + $name, + $param['optional'], + $isNullable, + $param['default'], + ); + $node = [ 'name' => $name, 'description' => $param['description'], - 'required' => !$param['optional'], + 'required' => $parameter['required'], ]; - $isNullable = $validator instanceof Nullable; - if ($isNullable) { /** @var Nullable $validator */ $validator = $validator->getValidator(); @@ -735,7 +744,7 @@ class OpenAPI3 extends Format break; } - if ($param['optional'] && !\is_null($param['default'])) { // Param has default value + if ($parameter['emitDefault']) { // Param has default value $node['schema']['default'] = $param['default']; } @@ -746,7 +755,7 @@ class OpenAPI3 extends Format $node['in'] = 'query'; $temp['parameters'][] = $node; } else { // Param is in payload - if (!$param['optional']) { + if ($node['required']) { $bodyRequired[] = $name; } @@ -783,7 +792,7 @@ class OpenAPI3 extends Format $body['content'][$consumes[0]]['schema']['properties'][$name]['x-global'] = true; } - if ($isNullable) { + if ($parameter['nullable']) { $body['content'][$consumes[0]]['schema']['properties'][$name]['x-nullable'] = true; } } diff --git a/src/Appwrite/SDK/Specification/Format/Swagger2.php b/src/Appwrite/SDK/Specification/Format/Swagger2.php index d95f99bb70..413239f000 100644 --- a/src/Appwrite/SDK/Specification/Format/Swagger2.php +++ b/src/Appwrite/SDK/Specification/Format/Swagger2.php @@ -383,14 +383,23 @@ class Swagger2 extends Format /** @var Validator $validator */ $validator = $this->getValidator($param); + $isNullable = $validator instanceof Nullable; + + $parameter = $this->getRequestParameterConfig( + $sdk->getNamespace() ?? '', + $methodName, + $name, + $param['optional'], + $isNullable, + $param['default'], + ); + $node = [ 'name' => $name, 'description' => $param['description'], - 'required' => !$param['optional'], + 'required' => $parameter['required'], ]; - $isNullable = $validator instanceof Nullable; - if ($isNullable) { /** @var Nullable $validator */ $validator = $validator->getValidator(); @@ -711,7 +720,7 @@ class Swagger2 extends Format break; } - if ($param['optional'] && !\is_null($param['default'])) { // Param has default value + if ($parameter['emitDefault']) { // Param has default value $node['default'] = $param['default']; } @@ -729,7 +738,7 @@ class Swagger2 extends Format continue; } - if (!$param['optional']) { + if ($node['required']) { $bodyRequired[] = $name; } @@ -755,7 +764,7 @@ class Swagger2 extends Format $body['schema']['properties'][$name]['x-global'] = true; } - if ($isNullable) { + if ($parameter['nullable']) { $body['schema']['properties'][$name]['x-nullable'] = true; } diff --git a/src/Appwrite/Utopia/Database/Validator/Queries/Webhooks.php b/src/Appwrite/Utopia/Database/Validator/Queries/Webhooks.php index fa20bf34ef..07e27f06cb 100644 --- a/src/Appwrite/Utopia/Database/Validator/Queries/Webhooks.php +++ b/src/Appwrite/Utopia/Database/Validator/Queries/Webhooks.php @@ -5,6 +5,28 @@ namespace Appwrite\Utopia\Database\Validator\Queries; class Webhooks extends Base { public const ALLOWED_ATTRIBUTES = [ + 'name', + 'url', + 'authUsername', + 'tls', + 'events', + 'enabled', + 'logs', + 'attempts', + ]; + + /** + * Map API attribute names to DB column names. + */ + private const ATTRIBUTE_ALIASES = [ + 'tls' => 'security', + 'authUsername' => 'httpUser', + ]; + + /** + * DB column names used for schema validation. + */ + private const DB_ATTRIBUTES = [ 'name', 'url', 'httpUser', @@ -21,6 +43,26 @@ class Webhooks extends Base */ public function __construct() { - parent::__construct('webhooks', self::ALLOWED_ATTRIBUTES); + parent::__construct('webhooks', self::DB_ATTRIBUTES); + } + + /** + * Convert API attribute names to DB column names in query strings before validation. + */ + public function isValid($value): bool + { + if (\is_array($value)) { + foreach ($value as &$queryString) { + if (!\is_string($queryString)) { + continue; + } + foreach (self::ATTRIBUTE_ALIASES as $alias => $dbName) { + $queryString = \str_replace('"' . $alias . '"', '"' . $dbName . '"', $queryString); + } + } + unset($queryString); + } + + return parent::isValid($value); } } diff --git a/src/Appwrite/Utopia/Request/Filters/V22.php b/src/Appwrite/Utopia/Request/Filters/V22.php new file mode 100644 index 0000000000..4f1e746775 --- /dev/null +++ b/src/Appwrite/Utopia/Request/Filters/V22.php @@ -0,0 +1,93 @@ +parseUpdateServiceStatus($content); + break; + case 'project.updateProtocolStatus': + $content = $this->parseUpdateProtocolStatus($content); + break; + case 'project.createKey': + case 'project.updateKey': + $content = $this->parseKeyScopes($content); + break; + case 'webhooks.create': + case 'webhooks.update': + $content = $this->parseWebhook($content); + break; + } + return $content; + } +} diff --git a/src/Appwrite/Utopia/Response.php b/src/Appwrite/Utopia/Response.php index 295348c665..5cd0e8366a 100644 --- a/src/Appwrite/Utopia/Response.php +++ b/src/Appwrite/Utopia/Response.php @@ -626,6 +626,17 @@ class Response extends SwooleResponse return $this->payload; } + /** + * Reset the sent flag so the response can be reused for another + * action execution (e.g. batched GraphQL queries that share one + * Response instance). + */ + public function clearSent(): static + { + $this->sent = false; + return $this; + } + /** * Function to add a response filter, the order of filters are first in - first out. * diff --git a/src/Appwrite/Utopia/Response/Filters/V22.php b/src/Appwrite/Utopia/Response/Filters/V22.php new file mode 100644 index 0000000000..4e295e43dd --- /dev/null +++ b/src/Appwrite/Utopia/Response/Filters/V22.php @@ -0,0 +1,45 @@ + $this->parseProject($content), + Response::MODEL_WEBHOOK => $this->parseWebhook($content), + Response::MODEL_WEBHOOK_LIST => $this->handleList($content, 'webhooks', fn ($item) => $this->parseWebhook($item)), + default => $content, + }; + } + + private function parseProject(array $content): array + { + foreach (['protocolStatusForRest', 'protocolStatusForGraphql', 'protocolStatusForWebsocket'] as $field) { + unset($content[$field]); + } + return $content; + } + + private function parseWebhook(array $content): array + { + $content['security'] = $content['tls'] ?? true; + unset($content['tls']); + + $content['httpUser'] = $content['authUsername'] ?? ''; + unset($content['authUsername']); + + $content['httpPass'] = $content['authPassword'] ?? ''; + unset($content['authPassword']); + + $content['signatureKey'] = $content['secret'] ?? ''; + unset($content['secret']); + + return $content; + } +} diff --git a/src/Appwrite/Utopia/Response/Model/Project.php b/src/Appwrite/Utopia/Response/Model/Project.php index 1ef73aa769..4cb038fc37 100644 --- a/src/Appwrite/Utopia/Response/Model/Project.php +++ b/src/Appwrite/Utopia/Response/Model/Project.php @@ -344,6 +344,22 @@ class Project extends Model ]) ; } + + $apis = Config::getParam('protocols', []); + + foreach ($apis as $api) { + $name = $api['name'] ?? ''; + $key = $api['key'] ?? ''; + + $this + ->addRule('protocolStatusFor' . ucfirst($key), [ + 'type' => self::TYPE_BOOLEAN, + 'description' => $name . ' protocol status', + 'example' => true, + 'default' => true, + ]) + ; + } } /** @@ -375,6 +391,7 @@ class Project extends Model { $this->expandSmtpFields($document); $this->expandServiceFields($document); + $this->expandApiFields($document); $this->expandAuthFields($document); $this->expandOAuthProviders($document); @@ -419,6 +436,22 @@ class Project extends Model } } + private function expandApiFields(Document $document): void + { + if (!$document->isSet('apis')) { + return; + } + + $values = $document->getAttribute('apis', []); + $apis = Config::getParam('protocols', []); + + foreach ($apis as $api) { + $key = $api['key'] ?? ''; + $value = $values[$key] ?? true; + $document->setAttribute('protocolStatusFor' . ucfirst($key), $value); + } + } + private function expandAuthFields(Document $document): void { if (!$document->isSet('auths')) { diff --git a/src/Appwrite/Utopia/Response/Model/Webhook.php b/src/Appwrite/Utopia/Response/Model/Webhook.php index 1ae8d5cb7b..c2a2c183b1 100644 --- a/src/Appwrite/Utopia/Response/Model/Webhook.php +++ b/src/Appwrite/Utopia/Response/Model/Webhook.php @@ -4,6 +4,7 @@ namespace Appwrite\Utopia\Response\Model; use Appwrite\Utopia\Response; use Appwrite\Utopia\Response\Model; +use Utopia\Database\Document; class Webhook extends Model { @@ -50,27 +51,27 @@ class Webhook extends Model ], 'array' => true, ]) - ->addRule('security', [ + ->addRule('tls', [ 'type' => self::TYPE_BOOLEAN, - 'description' => 'Indicated if SSL / TLS Certificate verification is enabled.', + 'description' => 'Indicates if SSL / TLS certificate verification is enabled.', 'default' => true, 'example' => true, ]) - ->addRule('httpUser', [ + ->addRule('authUsername', [ 'type' => self::TYPE_STRING, 'description' => 'HTTP basic authentication username.', 'default' => '', 'example' => 'username', ]) - ->addRule('httpPass', [ + ->addRule('authPassword', [ 'type' => self::TYPE_STRING, 'description' => 'HTTP basic authentication password.', 'default' => '', 'example' => 'password', ]) - ->addRule('signatureKey', [ + ->addRule('secret', [ 'type' => self::TYPE_STRING, - 'description' => 'Signature key which can be used to validated incoming', + 'description' => 'Signature key which can be used to validate incoming webhook payloads.', 'default' => '', 'example' => 'ad3d581ca230e2b7059c545e5a', ]) @@ -94,6 +95,23 @@ class Webhook extends Model ]); } + public function filter(Document $document): Document + { + $document->setAttribute('tls', $document->getAttribute('security')); + $document->removeAttribute('security'); + + $document->setAttribute('authUsername', $document->getAttribute('httpUser')); + $document->removeAttribute('httpUser'); + + $document->setAttribute('authPassword', $document->getAttribute('httpPass')); + $document->removeAttribute('httpPass'); + + $document->setAttribute('secret', $document->getAttribute('signatureKey')); + $document->removeAttribute('signatureKey'); + + return $document; + } + /** * Get Name * diff --git a/tests/e2e/Scopes/ProjectCustom.php b/tests/e2e/Scopes/ProjectCustom.php index 10641019f0..a62a1e8ba3 100644 --- a/tests/e2e/Scopes/ProjectCustom.php +++ b/tests/e2e/Scopes/ProjectCustom.php @@ -216,7 +216,7 @@ trait ProjectCustom 'users.*' ], 'url' => 'http://request-catcher-webhook:5000/', - 'security' => false, + 'tls' => false, ]); $this->assertEquals(201, $webhook['headers']['status-code']); @@ -243,7 +243,7 @@ trait ProjectCustom 'apiKey' => $key['body']['secret'], 'devKey' => $devKey['body']['secret'], 'webhookId' => $webhook['body']['$id'], - 'signatureKey' => $webhook['body']['signatureKey'], + 'signatureKey' => $webhook['body']['secret'], ]; } diff --git a/tests/e2e/Services/Project/KeysBase.php b/tests/e2e/Services/Project/KeysBase.php index fda5ef377f..505c7f6539 100644 --- a/tests/e2e/Services/Project/KeysBase.php +++ b/tests/e2e/Services/Project/KeysBase.php @@ -78,12 +78,12 @@ trait KeysBase $this->deleteKey($key['body']['$id']); } - public function testCreateKeyWithNullScopes(): void + public function testCreateKeyWithEmptyScopes(): void { $key = $this->createKey( ID::unique(), - 'Null Scopes Key', - null, + 'Empty Scopes Key', + [], ); $this->assertSame(201, $key['headers']['status-code']); @@ -93,6 +93,58 @@ trait KeysBase $this->deleteKey($key['body']['$id']); } + public function testCreateKeyWithNullScopesV22BackwardCompat(): void + { + $headers = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.0', + ]; + $headers = array_merge($headers, $this->getHeaders()); + + $key = $this->client->call(Client::METHOD_POST, '/project/keys', $headers, [ + 'keyId' => ID::unique(), + 'name' => 'V22 Compat Key', + 'scopes' => null, + ]); + + $this->assertSame(201, $key['headers']['status-code']); + $this->assertSame([], $key['body']['scopes']); + + // Cleanup + $this->deleteKey($key['body']['$id']); + } + + public function testUpdateKeyWithNullScopesV22BackwardCompat(): void + { + $key = $this->createKey( + ID::unique(), + 'V22 Update Compat Key', + ['users.read'], + ); + + $this->assertSame(201, $key['headers']['status-code']); + $keyId = $key['body']['$id']; + + $headers = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.0', + ]; + $headers = array_merge($headers, $this->getHeaders()); + + $updated = $this->client->call(Client::METHOD_PUT, '/project/keys/' . $keyId, $headers, [ + 'name' => 'V22 Update Compat Key', + 'scopes' => null, + ]); + + $this->assertSame(200, $updated['headers']['status-code']); + $this->assertSame([], $updated['body']['scopes']); + + // Cleanup + $this->deleteKey($keyId); + } + public function testCreateKeyWithoutAuthentication(): void { $response = $this->createKey( diff --git a/tests/e2e/Services/Project/ProtocolsBase.php b/tests/e2e/Services/Project/ProtocolsBase.php new file mode 100644 index 0000000000..0187fc8463 --- /dev/null +++ b/tests/e2e/Services/Project/ProtocolsBase.php @@ -0,0 +1,261 @@ +updateProtocolStatus($protocol, false); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertNotEmpty($response['body']['$id']); + $this->assertSame(false, $response['body']['protocolStatusFor' . ucfirst($protocol)]); + } + + // Cleanup + foreach (self::$protocols as $protocol) { + $this->updateProtocolStatus($protocol, true); + } + } + + public function testEnableProtocol(): void + { + // Disable first + foreach (self::$protocols as $protocol) { + $this->updateProtocolStatus($protocol, false); + } + + // Re-enable + foreach (self::$protocols as $protocol) { + $response = $this->updateProtocolStatus($protocol, true); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertNotEmpty($response['body']['$id']); + $this->assertSame(true, $response['body']['protocolStatusFor' . ucfirst($protocol)]); + } + } + + public function testDisableProtocolIdempotent(): void + { + $first = $this->updateProtocolStatus('rest', false); + $this->assertSame(200, $first['headers']['status-code']); + $this->assertSame(false, $first['body']['protocolStatusForRest']); + + $second = $this->updateProtocolStatus('rest', false); + $this->assertSame(200, $second['headers']['status-code']); + $this->assertSame(false, $second['body']['protocolStatusForRest']); + + // Cleanup + $this->updateProtocolStatus('rest', true); + } + + public function testEnableProtocolIdempotent(): void + { + $first = $this->updateProtocolStatus('rest', true); + $this->assertSame(200, $first['headers']['status-code']); + $this->assertSame(true, $first['body']['protocolStatusForRest']); + + $second = $this->updateProtocolStatus('rest', true); + $this->assertSame(200, $second['headers']['status-code']); + $this->assertSame(true, $second['body']['protocolStatusForRest']); + } + + public function testDisabledRestBlocksClientRequest(): void + { + $this->updateProtocolStatus('rest', false); + + $response = $this->client->call(Client::METHOD_GET, '/locale/countries', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]); + + $this->assertSame(403, $response['headers']['status-code']); + $this->assertSame('general_api_disabled', $response['body']['type']); + + // Cleanup + $this->updateProtocolStatus('rest', true); + } + + public function testEnabledRestAllowsClientRequest(): void + { + $this->updateProtocolStatus('rest', false); + $this->updateProtocolStatus('rest', true); + + $response = $this->client->call(Client::METHOD_GET, '/locale/countries', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]); + + $this->assertSame(200, $response['headers']['status-code']); + } + + public function testDisabledGraphqlBlocksClientRequest(): void + { + $this->updateProtocolStatus('graphql', false); + + $response = $this->client->call(Client::METHOD_POST, '/graphql', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], [ + 'query' => '{ localeListCountries { total } }', + ]); + + $this->assertSame(403, $response['headers']['status-code']); + $this->assertSame('general_api_disabled', $response['body']['type']); + + // Cleanup + $this->updateProtocolStatus('graphql', true); + } + + public function testDisableOneProtocolDoesNotAffectOther(): void + { + $this->updateProtocolStatus('graphql', false); + + // REST should still work + $response = $this->client->call(Client::METHOD_GET, '/locale/countries', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]); + + $this->assertSame(200, $response['headers']['status-code']); + + // Cleanup + $this->updateProtocolStatus('graphql', true); + } + + public function testDisabledRestBlocksAllServiceEndpoints(): void + { + $endpoints = [ + 'account' => '/account', + 'teams' => '/teams', + 'databases' => '/databases', + 'storage' => '/storage/buckets', + 'functions' => '/functions', + 'sites' => '/sites', + 'locale' => '/locale', + 'health' => '/health', + 'users' => '/users', + 'messaging' => '/messaging/providers', + 'migrations' => '/migrations', + ]; + + $this->updateProtocolStatus('rest', false); + + foreach ($endpoints as $service => $path) { + $response = $this->client->call(Client::METHOD_GET, $path, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]); + + $this->assertSame(403, $response['headers']['status-code'], 'Disabled REST protocol should block ' . $service . ' endpoint (got ' . $response['headers']['status-code'] . ')'); + $this->assertSame('general_api_disabled', $response['body']['type'], 'Disabled REST protocol should return general_api_disabled for ' . $service); + } + + // Cleanup + $this->updateProtocolStatus('rest', true); + } + + public function testReenabledRestAllowsAllServiceEndpoints(): void + { + $endpoints = [ + 'teams' => '/teams', + 'databases' => '/databases', + 'functions' => '/functions', + 'locale' => '/locale', + ]; + + $this->updateProtocolStatus('rest', false); + $this->updateProtocolStatus('rest', true); + + foreach ($endpoints as $service => $path) { + $response = $this->client->call(Client::METHOD_GET, $path, array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $this->assertNotEquals(403, $response['headers']['status-code'], 'Re-enabled REST protocol should not block ' . $service . ' endpoint'); + } + } + + public function testDisabledGraphqlBlocksMutationRequest(): void + { + $this->updateProtocolStatus('graphql', false); + + $response = $this->client->call(Client::METHOD_POST, '/graphql', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], [ + 'query' => 'mutation { teamsCreate(teamId: "unique()", name: "Test") { _id } }', + ]); + + $this->assertSame(403, $response['headers']['status-code']); + $this->assertSame('general_api_disabled', $response['body']['type']); + + // Cleanup + $this->updateProtocolStatus('graphql', true); + } + + public function testResponseModel(): void + { + $response = $this->updateProtocolStatus('rest', false); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertArrayHasKey('$id', $response['body']); + $this->assertArrayHasKey('name', $response['body']); + $this->assertArrayHasKey('protocolStatusForRest', $response['body']); + $this->assertArrayHasKey('protocolStatusForGraphql', $response['body']); + $this->assertArrayHasKey('protocolStatusForWebsocket', $response['body']); + + // Cleanup + $this->updateProtocolStatus('rest', true); + } + + // Failure flow + + public function testUpdateProtocolWithoutAuthentication(): void + { + $response = $this->updateProtocolStatus('rest', false, false); + + $this->assertSame(401, $response['headers']['status-code']); + } + + public function testUpdateProtocolInvalidProtocolId(): void + { + $response = $this->updateProtocolStatus('invalid', false); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdateProtocolEmptyProtocolId(): void + { + $response = $this->updateProtocolStatus('', false); + + $this->assertSame(404, $response['headers']['status-code']); + } + + // Helpers + + protected function updateProtocolStatus(string $protocolId, bool $enabled, 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_PATCH, '/project/protocols/' . $protocolId . '/status', $headers, [ + 'enabled' => $enabled, + ]); + } +} diff --git a/tests/e2e/Services/Project/ProtocolsConsoleClientTest.php b/tests/e2e/Services/Project/ProtocolsConsoleClientTest.php new file mode 100644 index 0000000000..b2cec9a438 --- /dev/null +++ b/tests/e2e/Services/Project/ProtocolsConsoleClientTest.php @@ -0,0 +1,14 @@ +updateServiceStatus($service, false); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertNotEmpty($response['body']['$id']); + $this->assertSame(false, $response['body']['serviceStatusFor' . ucfirst($service)]); + } + + // Cleanup + foreach (self::$optionalServices as $service) { + $this->updateServiceStatus($service, true); + } + } + + public function testEnableService(): void + { + // Disable first + foreach (self::$optionalServices as $service) { + $this->updateServiceStatus($service, false); + } + + // Re-enable + foreach (self::$optionalServices as $service) { + $response = $this->updateServiceStatus($service, true); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertNotEmpty($response['body']['$id']); + $this->assertSame(true, $response['body']['serviceStatusFor' . ucfirst($service)]); + } + } + + public function testDisableServiceIdempotent(): void + { + $first = $this->updateServiceStatus('teams', false); + $this->assertSame(200, $first['headers']['status-code']); + $this->assertSame(false, $first['body']['serviceStatusForTeams']); + + $second = $this->updateServiceStatus('teams', false); + $this->assertSame(200, $second['headers']['status-code']); + $this->assertSame(false, $second['body']['serviceStatusForTeams']); + + // Cleanup + $this->updateServiceStatus('teams', true); + } + + public function testEnableServiceIdempotent(): void + { + $first = $this->updateServiceStatus('teams', true); + $this->assertSame(200, $first['headers']['status-code']); + $this->assertSame(true, $first['body']['serviceStatusForTeams']); + + $second = $this->updateServiceStatus('teams', true); + $this->assertSame(200, $second['headers']['status-code']); + $this->assertSame(true, $second['body']['serviceStatusForTeams']); + } + + public function testDisabledServiceBlocksClientRequest(): void + { + $this->updateServiceStatus('teams', false); + + $response = $this->client->call(Client::METHOD_GET, '/teams', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]); + + $this->assertSame(403, $response['headers']['status-code']); + $this->assertSame('general_service_disabled', $response['body']['type']); + + // Cleanup + $this->updateServiceStatus('teams', true); + } + + public function testEnabledServiceAllowsClientRequest(): void + { + $this->updateServiceStatus('teams', false); + $this->updateServiceStatus('teams', true); + + $response = $this->client->call(Client::METHOD_GET, '/teams', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $this->assertSame(200, $response['headers']['status-code']); + } + + public function testDisableOneServiceDoesNotAffectOther(): void + { + $this->updateServiceStatus('teams', false); + + $response = $this->client->call(Client::METHOD_GET, '/functions', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $this->assertSame(200, $response['headers']['status-code']); + + // Cleanup + $this->updateServiceStatus('teams', true); + } + + public function testEachDisabledServiceBlocksItsEndpoint(): void + { + $serviceEndpoints = [ + 'account' => ['method' => Client::METHOD_GET, 'path' => '/account'], + 'avatars' => ['method' => Client::METHOD_GET, 'path' => '/avatars/initials'], + 'databases' => ['method' => Client::METHOD_GET, 'path' => '/databases'], + 'tablesdb' => ['method' => Client::METHOD_GET, 'path' => '/tablesdb'], + 'locale' => ['method' => Client::METHOD_GET, 'path' => '/locale'], + 'health' => ['method' => Client::METHOD_GET, 'path' => '/health'], + 'project' => ['method' => Client::METHOD_GET, 'path' => '/project/platforms'], + 'storage' => ['method' => Client::METHOD_GET, 'path' => '/storage/buckets'], + 'teams' => ['method' => Client::METHOD_GET, 'path' => '/teams'], + 'users' => ['method' => Client::METHOD_GET, 'path' => '/users'], + 'vcs' => ['method' => Client::METHOD_GET, 'path' => '/vcs/installations'], + 'sites' => ['method' => Client::METHOD_GET, 'path' => '/sites'], + 'functions' => ['method' => Client::METHOD_GET, 'path' => '/functions'], + 'proxy' => ['method' => Client::METHOD_GET, 'path' => '/proxy/rules'], + 'migrations' => ['method' => Client::METHOD_GET, 'path' => '/migrations'], + 'messaging' => ['method' => Client::METHOD_GET, 'path' => '/messaging/providers'], + ]; + + foreach ($serviceEndpoints as $service => $endpoint) { + $this->updateServiceStatus($service, false); + + $response = $this->client->call($endpoint['method'], $endpoint['path'], [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]); + + $this->assertSame(403, $response['headers']['status-code'], 'Service ' . $service . ' should block requests when disabled (got ' . $response['headers']['status-code'] . ')'); + $this->assertSame('general_service_disabled', $response['body']['type'], 'Service ' . $service . ' should return general_service_disabled error type'); + + // Cleanup + $this->updateServiceStatus($service, true); + } + } + + public function testEachReenabledServiceAllowsRequest(): void + { + $serviceEndpoints = [ + 'account' => ['method' => Client::METHOD_GET, 'path' => '/account'], + 'avatars' => ['method' => Client::METHOD_GET, 'path' => '/avatars/initials'], + 'databases' => ['method' => Client::METHOD_GET, 'path' => '/databases'], + 'tablesdb' => ['method' => Client::METHOD_GET, 'path' => '/tablesdb'], + 'locale' => ['method' => Client::METHOD_GET, 'path' => '/locale'], + 'health' => ['method' => Client::METHOD_GET, 'path' => '/health'], + 'project' => ['method' => Client::METHOD_GET, 'path' => '/project/platforms'], + 'storage' => ['method' => Client::METHOD_GET, 'path' => '/storage/buckets'], + 'teams' => ['method' => Client::METHOD_GET, 'path' => '/teams'], + 'users' => ['method' => Client::METHOD_GET, 'path' => '/users'], + 'vcs' => ['method' => Client::METHOD_GET, 'path' => '/vcs/installations'], + 'sites' => ['method' => Client::METHOD_GET, 'path' => '/sites'], + 'functions' => ['method' => Client::METHOD_GET, 'path' => '/functions'], + 'proxy' => ['method' => Client::METHOD_GET, 'path' => '/proxy/rules'], + 'migrations' => ['method' => Client::METHOD_GET, 'path' => '/migrations'], + 'messaging' => ['method' => Client::METHOD_GET, 'path' => '/messaging/providers'], + ]; + + foreach ($serviceEndpoints as $service => $endpoint) { + $this->updateServiceStatus($service, false); + $this->updateServiceStatus($service, true); + + $response = $this->client->call($endpoint['method'], $endpoint['path'], array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $this->assertNotEquals(403, $response['headers']['status-code'], 'Service ' . $service . ' should allow requests after re-enabling'); + } + } + + public function testResponseModel(): void + { + $response = $this->updateServiceStatus('teams', false); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertArrayHasKey('$id', $response['body']); + $this->assertArrayHasKey('name', $response['body']); + $this->assertArrayHasKey('serviceStatusForTeams', $response['body']); + + // Cleanup + $this->updateServiceStatus('teams', true); + } + + // Failure flow + + public function testUpdateServiceWithoutAuthentication(): void + { + $response = $this->updateServiceStatus('teams', false, false); + + $this->assertSame(401, $response['headers']['status-code']); + } + + public function testUpdateServiceInvalidServiceId(): void + { + $response = $this->updateServiceStatus('invalid', false); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdateServiceEmptyServiceId(): void + { + $response = $this->updateServiceStatus('', false); + + $this->assertSame(404, $response['headers']['status-code']); + } + + // Helpers + + protected function updateServiceStatus(string $serviceId, bool $enabled, 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_PATCH, '/project/services/' . $serviceId . '/status', $headers, [ + 'enabled' => $enabled, + ]); + } +} diff --git a/tests/e2e/Services/Project/ServicesConsoleClientTest.php b/tests/e2e/Services/Project/ServicesConsoleClientTest.php new file mode 100644 index 0000000000..b5660607f4 --- /dev/null +++ b/tests/e2e/Services/Project/ServicesConsoleClientTest.php @@ -0,0 +1,14 @@ + 'Webhook Test', 'events' => ['users.*.create', 'users.*.update.email'], 'url' => 'https://appwrite.io', - 'security' => true, - 'httpUser' => 'username', - 'httpPass' => 'password', + 'tls' => true, + 'authUsername' => 'username', + 'authPassword' => 'password', ]); $this->assertEquals(201, $response['headers']['status-code']); self::$cachedProjectWithWebhook = array_merge($projectData, [ 'webhookId' => $response['body']['$id'], - 'signatureKey' => $response['body']['signatureKey'] + 'signatureKey' => $response['body']['secret'] ]); return self::$cachedProjectWithWebhook; @@ -388,6 +388,7 @@ trait ProjectsBase $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/service', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.0', 'cookie' => 'a_session_console=' . $this->getRoot()['session'], ]), [ 'service' => $key, diff --git a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php index e0f94b64cc..7b9848e38f 100644 --- a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php +++ b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php @@ -2497,49 +2497,222 @@ class ProjectsConsoleClientTest extends Scope $id = $project['body']['$id']; + // Bulk disable should no longer work $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/service/all', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.0', 'cookie' => 'a_session_console=' . $this->getRoot()['session'], ]), [ 'status' => false, ]); - $this->assertEquals(200, $response['headers']['status-code']); - $this->assertNotEmpty($response['body']['$id']); - - $response = $this->client->call(Client::METHOD_GET, '/projects/' . $id, array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'cookie' => 'a_session_console=' . $this->getRoot()['session'], - ])); - - $this->assertEquals(200, $response['headers']['status-code']); - $this->assertNotEmpty($response['body']['$id']); - - $matches = []; - $pattern = '/serviceStatusFor.*/'; - - foreach ($response['body'] as $key => $value) { - if (\preg_match($pattern, $key)) { - $matches[$key] = $value; - } - } - - foreach ($matches as $value) { - $this->assertFalse($value); - } + $this->assertEquals(405, $response['headers']['status-code']); + $this->assertEquals('general_not_implemented', $response['body']['type']); + // Bulk enable should no longer work $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/service/all', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.0', 'cookie' => 'a_session_console=' . $this->getRoot()['session'], ]), [ 'status' => true, ]); + $this->assertEquals(405, $response['headers']['status-code']); + $this->assertEquals('general_not_implemented', $response['body']['type']); + } + + public function testUpdateProjectApisAll(): void + { + $team = $this->client->call(Client::METHOD_POST, '/teams', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'cookie' => 'a_session_console=' . $this->getRoot()['session'], + ]), [ + 'teamId' => ID::unique(), + 'name' => 'Project Test', + ]); + + $this->assertEquals(201, $team['headers']['status-code']); + $this->assertNotEmpty($team['body']['$id']); + + $project = $this->client->call(Client::METHOD_POST, '/projects', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'cookie' => 'a_session_console=' . $this->getRoot()['session'], + ]), [ + 'projectId' => ID::unique(), + 'name' => 'Project Test', + 'teamId' => $team['body']['$id'], + 'region' => System::getEnv('_APP_REGION', 'default') + ]); + + $this->assertEquals(201, $project['headers']['status-code']); + $this->assertNotEmpty($project['body']['$id']); + + $id = $project['body']['$id']; + + // Bulk disable should no longer work + $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/api/all', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.0', + 'cookie' => 'a_session_console=' . $this->getRoot()['session'], + ]), [ + 'status' => false, + ]); + + $this->assertEquals(405, $response['headers']['status-code']); + $this->assertEquals('general_not_implemented', $response['body']['type']); + + // Bulk enable should no longer work + $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/api/all', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.0', + 'cookie' => 'a_session_console=' . $this->getRoot()['session'], + ]), [ + 'status' => true, + ]); + + $this->assertEquals(405, $response['headers']['status-code']); + $this->assertEquals('general_not_implemented', $response['body']['type']); + } + + public function testUpdateProjectApiStatus(): void + { + $team = $this->client->call(Client::METHOD_POST, '/teams', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'cookie' => 'a_session_console=' . $this->getRoot()['session'], + ]), [ + 'teamId' => ID::unique(), + 'name' => 'Project Test', + ]); + + $this->assertEquals(201, $team['headers']['status-code']); + $this->assertNotEmpty($team['body']['$id']); + + $project = $this->client->call(Client::METHOD_POST, '/projects', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'cookie' => 'a_session_console=' . $this->getRoot()['session'], + ]), [ + 'projectId' => ID::unique(), + 'name' => 'Project Test', + 'teamId' => $team['body']['$id'], + 'region' => System::getEnv('_APP_REGION', 'default') + ]); + + $this->assertEquals(201, $project['headers']['status-code']); + $this->assertNotEmpty($project['body']['$id']); + + $id = $project['body']['$id']; + $protocols = ['rest', 'graphql', 'websocket']; + + /** + * Test for Disabled using old format (api + status) + */ + foreach ($protocols as $key) { + + $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/api', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.0', + 'cookie' => 'a_session_console=' . $this->getRoot()['session'], + ]), [ + 'api' => $key, + 'status' => false, + ]); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertNotEmpty($response['body']['$id']); + + $response = $this->client->call(Client::METHOD_GET, '/projects/' . $id, array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'cookie' => 'a_session_console=' . $this->getRoot()['session'], + ])); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertNotEmpty($response['body']['$id']); + $this->assertEquals(false, $response['body']['protocolStatusFor' . ucfirst($key)]); + } + + /** + * Test for Enabled using old format (api + status) + */ + foreach ($protocols as $key) { + + $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/api', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.0', + 'cookie' => 'a_session_console=' . $this->getRoot()['session'], + ]), [ + 'api' => $key, + 'status' => true, + ]); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertNotEmpty($response['body']['$id']); + + $response = $this->client->call(Client::METHOD_GET, '/projects/' . $id, array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'cookie' => 'a_session_console=' . $this->getRoot()['session'], + ])); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertNotEmpty($response['body']['$id']); + $this->assertEquals(true, $response['body']['protocolStatusFor' . ucfirst($key)]); + } + } + + public function testUpdateProjectApiStatusRealtimeBackwardsCompat(): void + { + $team = $this->client->call(Client::METHOD_POST, '/teams', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'cookie' => 'a_session_console=' . $this->getRoot()['session'], + ]), [ + 'teamId' => ID::unique(), + 'name' => 'Project Test', + ]); + + $this->assertEquals(201, $team['headers']['status-code']); + + $project = $this->client->call(Client::METHOD_POST, '/projects', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'cookie' => 'a_session_console=' . $this->getRoot()['session'], + ]), [ + 'projectId' => ID::unique(), + 'name' => 'Project Test', + 'teamId' => $team['body']['$id'], + 'region' => System::getEnv('_APP_REGION', 'default') + ]); + + $this->assertEquals(201, $project['headers']['status-code']); + + $id = $project['body']['$id']; + + /** + * Test that "realtime" gets renamed to "websocket" via request filter + */ + $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/api', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.0', + 'cookie' => 'a_session_console=' . $this->getRoot()['session'], + ]), [ + 'api' => 'realtime', + 'status' => false, + ]); + $this->assertEquals(200, $response['headers']['status-code']); - $this->assertNotEmpty($response['body']['$id']); $response = $this->client->call(Client::METHOD_GET, '/projects/' . $id, array_merge([ 'content-type' => 'application/json', @@ -2548,17 +2721,29 @@ class ProjectsConsoleClientTest extends Scope ])); $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals(false, $response['body']['protocolStatusForWebsocket']); - $matches = []; - foreach ($response['body'] as $key => $value) { - if (\preg_match($pattern, $key)) { - $matches[$key] = $value; - } - } + // Re-enable via old "realtime" name + $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/api', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.0', + 'cookie' => 'a_session_console=' . $this->getRoot()['session'], + ]), [ + 'api' => 'realtime', + 'status' => true, + ]); - foreach ($matches as $value) { - $this->assertTrue($value); - } + $this->assertEquals(200, $response['headers']['status-code']); + + $response = $this->client->call(Client::METHOD_GET, '/projects/' . $id, array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'cookie' => 'a_session_console=' . $this->getRoot()['session'], + ])); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals(true, $response['body']['protocolStatusForWebsocket']); } public function testUpdateProjectServiceStatusAdmin(): array @@ -2604,6 +2789,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/service', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.0', 'cookie' => 'a_session_console=' . $this->getRoot()['session'], ]), [ 'service' => $key, @@ -2649,6 +2835,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/service/', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.0', ], $this->getHeaders()), [ 'service' => $key, 'status' => true, @@ -2678,6 +2865,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/service', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.0', 'cookie' => 'a_session_console=' . $this->getRoot()['session'], ]), [ 'service' => $key, @@ -2725,6 +2913,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/service/', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.0', ], $this->getHeaders()), [ 'service' => $service, 'status' => true, @@ -2752,6 +2941,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/service', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.0', 'cookie' => 'a_session_console=' . $this->getRoot()['session'], ]), [ 'service' => $key, @@ -2841,6 +3031,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/service/', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.0', ], $this->getHeaders()), [ 'service' => $service, 'status' => true, @@ -2862,9 +3053,9 @@ class ProjectsConsoleClientTest extends Scope 'name' => 'Webhook Test', 'events' => ['users.*.create', 'users.*.update.email'], 'url' => 'https://appwrite.io', - 'security' => true, - 'httpUser' => 'username', - 'httpPass' => 'password', + 'tls' => true, + 'authUsername' => 'username', + 'authPassword' => 'password', ]); $this->assertEquals(201, $response['headers']['status-code']); @@ -2873,9 +3064,9 @@ class ProjectsConsoleClientTest extends Scope $this->assertContains('users.*.update.email', $response['body']['events']); $this->assertCount(2, $response['body']['events']); $this->assertEquals('https://appwrite.io', $response['body']['url']); - $this->assertIsBool($response['body']['security']); - $this->assertEquals(true, $response['body']['security']); - $this->assertEquals('username', $response['body']['httpUser']); + $this->assertIsBool($response['body']['tls']); + $this->assertEquals(true, $response['body']['tls']); + $this->assertEquals('username', $response['body']['authUsername']); /** * Test for FAILURE @@ -2889,9 +3080,9 @@ class ProjectsConsoleClientTest extends Scope 'name' => 'Webhook Test', 'events' => ['account.unknown', 'users.*.update.email'], 'url' => 'https://appwrite.io', - 'security' => true, - 'httpUser' => 'username', - 'httpPass' => 'password', + 'tls' => true, + 'authUsername' => 'username', + 'authPassword' => 'password', ]); $this->assertEquals(400, $response['headers']['status-code']); @@ -2949,8 +3140,8 @@ class ProjectsConsoleClientTest extends Scope $this->assertContains('users.*.update.email', $response['body']['events']); $this->assertCount(2, $response['body']['events']); $this->assertEquals('https://appwrite.io', $response['body']['url']); - $this->assertEquals('username', $response['body']['httpUser']); - $this->assertEquals('password', $response['body']['httpPass']); + $this->assertEquals('username', $response['body']['authUsername']); + $this->assertEquals('password', $response['body']['authPassword']); /** * Test for FAILURE @@ -2978,7 +3169,7 @@ class ProjectsConsoleClientTest extends Scope 'name' => 'Webhook Test Update', 'events' => ['users.*.delete', 'users.*.sessions.*.delete', 'buckets.*.files.*.create'], 'url' => 'https://appwrite.io/new', - 'security' => false, + 'tls' => false, ]); $this->assertEquals(200, $response['headers']['status-code']); @@ -2990,10 +3181,10 @@ class ProjectsConsoleClientTest extends Scope $this->assertContains('buckets.*.files.*.create', $response['body']['events']); $this->assertCount(3, $response['body']['events']); $this->assertEquals('https://appwrite.io/new', $response['body']['url']); - $this->assertIsBool($response['body']['security']); - $this->assertEquals(false, $response['body']['security']); - $this->assertEquals('', $response['body']['httpUser']); - $this->assertEquals('', $response['body']['httpPass']); + $this->assertIsBool($response['body']['tls']); + $this->assertEquals(false, $response['body']['tls']); + $this->assertEquals('', $response['body']['authUsername']); + $this->assertEquals('', $response['body']['authPassword']); $response = $this->client->call(Client::METHOD_GET, '/webhooks/' . $webhookId, array_merge([ 'content-type' => 'application/json', @@ -3010,10 +3201,10 @@ class ProjectsConsoleClientTest extends Scope $this->assertContains('buckets.*.files.*.create', $response['body']['events']); $this->assertCount(3, $response['body']['events']); $this->assertEquals('https://appwrite.io/new', $response['body']['url']); - $this->assertIsBool($response['body']['security']); - $this->assertEquals(false, $response['body']['security']); - $this->assertEquals('', $response['body']['httpUser']); - $this->assertEquals('', $response['body']['httpPass']); + $this->assertIsBool($response['body']['tls']); + $this->assertEquals(false, $response['body']['tls']); + $this->assertEquals('', $response['body']['authUsername']); + $this->assertEquals('', $response['body']['authPassword']); /** * Test for FAILURE @@ -3026,7 +3217,7 @@ class ProjectsConsoleClientTest extends Scope 'name' => 'Webhook Test Update', 'events' => ['users.*.delete', 'users.*.sessions.*.delete', 'buckets.*.files.*.unknown'], 'url' => 'https://appwrite.io/new', - 'security' => false, + 'tls' => false, ]); $this->assertEquals(400, $response['headers']['status-code']); @@ -3039,7 +3230,7 @@ class ProjectsConsoleClientTest extends Scope 'name' => 'Webhook Test Update', 'events' => ['users.*.delete', 'users.*.sessions.*.delete', 'buckets.*.files.*.create'], 'url' => 'appwrite.io/new', - 'security' => false, + 'tls' => false, ]); $this->assertEquals(400, $response['headers']['status-code']); @@ -3064,15 +3255,15 @@ class ProjectsConsoleClientTest extends Scope $webhookId = $data['webhookId']; $signatureKey = $data['signatureKey']; - $response = $this->client->call(Client::METHOD_PATCH, '/webhooks/' . $webhookId . '/signature', array_merge([ + $response = $this->client->call(Client::METHOD_PATCH, '/webhooks/' . $webhookId . '/secret', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $id, 'x-appwrite-mode' => 'admin' ], $this->getHeaders())); $this->assertEquals(200, $response['headers']['status-code']); - $this->assertNotEmpty($response['body']['signatureKey']); - $this->assertNotEquals($signatureKey, $response['body']['signatureKey']); + $this->assertNotEmpty($response['body']['secret']); + $this->assertNotEquals($signatureKey, $response['body']['secret']); } public function testDeleteProjectWebhook(): void @@ -3091,9 +3282,9 @@ class ProjectsConsoleClientTest extends Scope 'name' => 'Webhook To Delete', 'events' => ['users.*.create'], 'url' => 'https://appwrite.io', - 'security' => true, - 'httpUser' => 'username', - 'httpPass' => 'password', + 'tls' => true, + 'authUsername' => 'username', + 'authPassword' => 'password', ]); $this->assertEquals(201, $response['headers']['status-code']); diff --git a/tests/e2e/Services/Webhooks/WebhooksBase.php b/tests/e2e/Services/Webhooks/WebhooksBase.php index 7ad701b564..82181aa858 100644 --- a/tests/e2e/Services/Webhooks/WebhooksBase.php +++ b/tests/e2e/Services/Webhooks/WebhooksBase.php @@ -35,11 +35,11 @@ trait WebhooksBase $this->assertContains('users.*.create', $webhook['body']['events']); $this->assertCount(1, $webhook['body']['events']); $this->assertEquals(true, $webhook['body']['enabled']); - $this->assertEquals(false, $webhook['body']['security']); - $this->assertEquals('', $webhook['body']['httpUser']); - $this->assertEquals('', $webhook['body']['httpPass']); - $this->assertNotEmpty($webhook['body']['signatureKey']); - $this->assertEquals(128, \strlen($webhook['body']['signatureKey'])); + $this->assertEquals(false, $webhook['body']['tls']); + $this->assertEquals('', $webhook['body']['authUsername']); + $this->assertEquals('', $webhook['body']['authPassword']); + $this->assertNotEmpty($webhook['body']['secret']); + $this->assertEquals(128, \strlen($webhook['body']['secret'])); $this->assertEquals(0, $webhook['body']['attempts']); $this->assertEquals('', $webhook['body']['logs']); @@ -63,11 +63,11 @@ trait WebhooksBase $this->deleteWebhook($webhook['body']['$id']); } - public function testCreateWebhookWithSecurity(): void + public function testCreateWebhookWithTls(): void { $webhook = $this->createWebhook( ID::unique(), - 'Webhook With Security', + 'Webhook With TLS', ['users.*.create'], null, 'https://appwrite.io', @@ -78,8 +78,8 @@ trait WebhooksBase $this->assertEquals(201, $webhook['headers']['status-code']); $this->assertNotEmpty($webhook['body']['$id']); - $this->assertEquals(true, $webhook['body']['security']); - $this->assertIsBool($webhook['body']['security']); + $this->assertEquals(true, $webhook['body']['tls']); + $this->assertIsBool($webhook['body']['tls']); // Cleanup $this->deleteWebhook($webhook['body']['$id']); @@ -100,14 +100,14 @@ trait WebhooksBase $this->assertEquals(201, $webhook['headers']['status-code']); $this->assertNotEmpty($webhook['body']['$id']); - $this->assertEquals('username', $webhook['body']['httpUser']); - $this->assertEquals('password', $webhook['body']['httpPass']); - $this->assertEquals(true, $webhook['body']['security']); + $this->assertEquals('username', $webhook['body']['authUsername']); + $this->assertEquals('password', $webhook['body']['authPassword']); + $this->assertEquals(true, $webhook['body']['tls']); // Verify via GET $get = $this->getWebhook($webhook['body']['$id']); $this->assertEquals(200, $get['headers']['status-code']); - $this->assertEquals('username', $get['body']['httpUser']); + $this->assertEquals('username', $get['body']['authUsername']); // Cleanup $this->deleteWebhook($webhook['body']['$id']); @@ -331,11 +331,11 @@ trait WebhooksBase $this->deleteWebhook($webhookId); } - public function testUpdateWebhookWithSecurity(): void + public function testUpdateWebhookWithTls(): void { $webhook = $this->createWebhook( ID::unique(), - 'Security Webhook', + 'TLS Webhook', ['users.*.create'], null, 'https://appwrite.io', @@ -345,7 +345,7 @@ trait WebhooksBase ); $this->assertEquals(201, $webhook['headers']['status-code']); - $this->assertEquals(false, $webhook['body']['security']); + $this->assertEquals(false, $webhook['body']['tls']); $webhookId = $webhook['body']['$id']; // Update to enable security @@ -361,8 +361,8 @@ trait WebhooksBase ); $this->assertEquals(200, $updated['headers']['status-code']); - $this->assertEquals(true, $updated['body']['security']); - $this->assertIsBool($updated['body']['security']); + $this->assertEquals(true, $updated['body']['tls']); + $this->assertIsBool($updated['body']['tls']); // Cleanup $this->deleteWebhook($webhookId); @@ -382,8 +382,8 @@ trait WebhooksBase ); $this->assertEquals(201, $webhook['headers']['status-code']); - $this->assertEquals('', $webhook['body']['httpUser']); - $this->assertEquals('', $webhook['body']['httpPass']); + $this->assertEquals('', $webhook['body']['authUsername']); + $this->assertEquals('', $webhook['body']['authPassword']); $webhookId = $webhook['body']['$id']; // Update with HTTP auth credentials @@ -399,13 +399,13 @@ trait WebhooksBase ); $this->assertEquals(200, $updated['headers']['status-code']); - $this->assertEquals('newuser', $updated['body']['httpUser']); - $this->assertEquals('newpass', $updated['body']['httpPass']); + $this->assertEquals('newuser', $updated['body']['authUsername']); + $this->assertEquals('newpass', $updated['body']['authPassword']); // Verify via GET $get = $this->getWebhook($webhookId); $this->assertEquals(200, $get['headers']['status-code']); - $this->assertEquals('newuser', $get['body']['httpUser']); + $this->assertEquals('newuser', $get['body']['authUsername']); // Cleanup $this->deleteWebhook($webhookId); @@ -657,19 +657,19 @@ trait WebhooksBase $this->assertContains('buckets.*.files.*.create', $updated['body']['events']); $this->assertCount(3, $updated['body']['events']); $this->assertEquals('https://appwrite.io/updated', $updated['body']['url']); - $this->assertEquals(true, $updated['body']['security']); - $this->assertEquals('user', $updated['body']['httpUser']); - $this->assertEquals('pass', $updated['body']['httpPass']); + $this->assertEquals(true, $updated['body']['tls']); + $this->assertEquals('user', $updated['body']['authUsername']); + $this->assertEquals('pass', $updated['body']['authPassword']); // Cleanup $this->deleteWebhook($webhookId); } - public function testUpdateWebhookSignature(): void + public function testUpdateWebhookSecret(): void { $webhook = $this->createWebhook( ID::unique(), - 'Signature Webhook', + 'Secret Webhook', ['users.*.create'], null, 'https://appwrite.io', @@ -680,27 +680,27 @@ trait WebhooksBase $this->assertEquals(201, $webhook['headers']['status-code']); $webhookId = $webhook['body']['$id']; - $originalSignatureKey = $webhook['body']['signatureKey']; + $originalSecret = $webhook['body']['secret']; - $this->assertNotEmpty($originalSignatureKey); - $this->assertEquals(128, \strlen($originalSignatureKey)); + $this->assertNotEmpty($originalSecret); + $this->assertEquals(128, \strlen($originalSecret)); - // Update signature - $updated = $this->updateWebhookSignature($webhookId); + // Update secret + $updated = $this->updateWebhookSecret($webhookId); $this->assertEquals(200, $updated['headers']['status-code']); $this->assertEquals($webhookId, $updated['body']['$id']); - $this->assertNotEmpty($updated['body']['signatureKey']); - $this->assertEquals(128, \strlen($updated['body']['signatureKey'])); - $this->assertNotEquals($originalSignatureKey, $updated['body']['signatureKey']); + $this->assertNotEmpty($updated['body']['secret']); + $this->assertEquals(128, \strlen($updated['body']['secret'])); + $this->assertNotEquals($originalSecret, $updated['body']['secret']); - // Verify new signature persisted via GET + // Verify new secret persisted via GET $get = $this->getWebhook($webhookId); $this->assertEquals(200, $get['headers']['status-code']); - $this->assertNotEquals($originalSignatureKey, $get['body']['signatureKey']); + $this->assertNotEquals($originalSecret, $get['body']['secret']); - // Test signature update on non-existent webhook - $notFound = $this->updateWebhookSignature('non-existent-id'); + // Test secret update on non-existent webhook + $notFound = $this->updateWebhookSecret('non-existent-id'); $this->assertEquals(404, $notFound['headers']['status-code']); $this->assertEquals('webhook_not_found', $notFound['body']['type']); @@ -934,11 +934,11 @@ trait WebhooksBase $this->assertContains('users.*.update.email', $get['body']['events']); $this->assertCount(2, $get['body']['events']); $this->assertEquals(true, $get['body']['enabled']); - $this->assertEquals(true, $get['body']['security']); - $this->assertEquals('myuser', $get['body']['httpUser']); - $this->assertEquals('mypass', $get['body']['httpPass']); - $this->assertNotEmpty($get['body']['signatureKey']); - $this->assertEquals(128, \strlen($get['body']['signatureKey'])); + $this->assertEquals(true, $get['body']['tls']); + $this->assertEquals('myuser', $get['body']['authUsername']); + $this->assertEquals('mypass', $get['body']['authPassword']); + $this->assertNotEmpty($get['body']['secret']); + $this->assertEquals(128, \strlen($get['body']['secret'])); $this->assertEquals(0, $get['body']['attempts']); $this->assertEquals('', $get['body']['logs']); @@ -1043,9 +1043,9 @@ trait WebhooksBase $this->assertArrayHasKey('name', $webhook); $this->assertArrayHasKey('url', $webhook); $this->assertArrayHasKey('events', $webhook); - $this->assertArrayHasKey('security', $webhook); + $this->assertArrayHasKey('tls', $webhook); $this->assertArrayHasKey('enabled', $webhook); - $this->assertArrayHasKey('signatureKey', $webhook); + $this->assertArrayHasKey('secret', $webhook); $this->assertArrayHasKey('attempts', $webhook); $this->assertArrayHasKey('logs', $webhook); } @@ -1247,11 +1247,11 @@ trait WebhooksBase $this->deleteWebhook($webhook['body']['$id']); } - public function testListWebhooksFilterBySecurity(): void + public function testListWebhooksFilterByTls(): void { $webhook = $this->createWebhook( ID::unique(), - 'Security Filter Webhook', + 'TLS Filter Webhook', ['users.*.create'], null, 'https://appwrite.io/sec', @@ -1262,13 +1262,13 @@ trait WebhooksBase $this->assertEquals(201, $webhook['headers']['status-code']); $list = $this->listWebhooks([ - Query::equal('security', [true])->toString(), + Query::equal('tls', [true])->toString(), ], true); $this->assertEquals(200, $list['headers']['status-code']); $this->assertGreaterThanOrEqual(1, $list['body']['total']); foreach ($list['body']['webhooks'] as $w) { - $this->assertEquals(true, $w['security']); + $this->assertEquals(true, $w['tls']); } // Cleanup @@ -1503,6 +1503,254 @@ trait WebhooksBase $this->assertEquals('webhook_not_found', $delete['body']['type']); } + // ========================================================================= + // Backward compatibility tests (1.9.0 response format) + // ========================================================================= + + public function testCreateWebhookV22BackwardCompatRequest(): void + { + $headers = array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.0', + ], $this->getHeaders()); + + // Send old param names with 1.9.0 header + $webhook = $this->client->call(Client::METHOD_POST, '/webhooks', $headers, [ + 'webhookId' => ID::unique(), + 'name' => 'V22 Compat Create', + 'events' => ['users.*.create'], + 'url' => 'https://appwrite.io', + 'security' => true, + 'httpUser' => 'olduser', + 'httpPass' => 'oldpass', + ]); + + $this->assertEquals(201, $webhook['headers']['status-code']); + + // Response should use OLD field names + $this->assertArrayHasKey('security', $webhook['body']); + $this->assertArrayHasKey('httpUser', $webhook['body']); + $this->assertArrayHasKey('httpPass', $webhook['body']); + $this->assertArrayHasKey('signatureKey', $webhook['body']); + + // New field names should NOT be present + $this->assertArrayNotHasKey('tls', $webhook['body']); + $this->assertArrayNotHasKey('authUsername', $webhook['body']); + $this->assertArrayNotHasKey('authPassword', $webhook['body']); + $this->assertArrayNotHasKey('secret', $webhook['body']); + + // Values should be correct + $this->assertEquals(true, $webhook['body']['security']); + $this->assertEquals('olduser', $webhook['body']['httpUser']); + $this->assertEquals('oldpass', $webhook['body']['httpPass']); + $this->assertNotEmpty($webhook['body']['signatureKey']); + $this->assertEquals(128, \strlen($webhook['body']['signatureKey'])); + + // Cleanup + $this->deleteWebhook($webhook['body']['$id']); + } + + public function testUpdateWebhookV22BackwardCompatRequest(): void + { + $webhook = $this->createWebhook( + ID::unique(), + 'V22 Compat Update', + ['users.*.create'], + null, + 'https://appwrite.io', + null, + null, + null + ); + + $this->assertEquals(201, $webhook['headers']['status-code']); + $webhookId = $webhook['body']['$id']; + + $headers = array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.0', + ], $this->getHeaders()); + + // Update using old param names + $updated = $this->client->call(Client::METHOD_PUT, '/webhooks/' . $webhookId, $headers, [ + 'name' => 'V22 Compat Updated', + 'events' => ['users.*.create'], + 'url' => 'https://appwrite.io', + 'security' => true, + 'httpUser' => 'updateduser', + 'httpPass' => 'updatedpass', + ]); + + $this->assertEquals(200, $updated['headers']['status-code']); + + // Response should use OLD field names + $this->assertArrayHasKey('security', $updated['body']); + $this->assertArrayHasKey('httpUser', $updated['body']); + $this->assertArrayHasKey('httpPass', $updated['body']); + $this->assertArrayHasKey('signatureKey', $updated['body']); + + $this->assertArrayNotHasKey('tls', $updated['body']); + $this->assertArrayNotHasKey('authUsername', $updated['body']); + $this->assertArrayNotHasKey('authPassword', $updated['body']); + $this->assertArrayNotHasKey('secret', $updated['body']); + + $this->assertEquals(true, $updated['body']['security']); + $this->assertEquals('updateduser', $updated['body']['httpUser']); + $this->assertEquals('updatedpass', $updated['body']['httpPass']); + + // Cleanup + $this->deleteWebhook($webhookId); + } + + public function testGetWebhookV22BackwardCompatResponse(): void + { + $webhook = $this->createWebhook( + ID::unique(), + 'V22 Compat Get', + ['users.*.create'], + null, + 'https://appwrite.io', + true, + 'getuser', + 'getpass' + ); + + $this->assertEquals(201, $webhook['headers']['status-code']); + $webhookId = $webhook['body']['$id']; + + // GET with 1.9.0 header + $headers = array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.0', + ], $this->getHeaders()); + + $get = $this->client->call(Client::METHOD_GET, '/webhooks/' . $webhookId, $headers); + + $this->assertEquals(200, $get['headers']['status-code']); + + // Should have old field names + $this->assertArrayHasKey('security', $get['body']); + $this->assertArrayHasKey('httpUser', $get['body']); + $this->assertArrayHasKey('httpPass', $get['body']); + $this->assertArrayHasKey('signatureKey', $get['body']); + + $this->assertArrayNotHasKey('tls', $get['body']); + $this->assertArrayNotHasKey('authUsername', $get['body']); + $this->assertArrayNotHasKey('authPassword', $get['body']); + $this->assertArrayNotHasKey('secret', $get['body']); + + $this->assertEquals(true, $get['body']['security']); + $this->assertEquals('getuser', $get['body']['httpUser']); + $this->assertEquals('getpass', $get['body']['httpPass']); + $this->assertNotEmpty($get['body']['signatureKey']); + + // Cleanup + $this->deleteWebhook($webhookId); + } + + public function testListWebhooksV22BackwardCompatResponse(): void + { + $webhook = $this->createWebhook( + ID::unique(), + 'V22 Compat List', + ['users.*.create'], + null, + 'https://appwrite.io', + true, + 'listuser', + 'listpass' + ); + + $this->assertEquals(201, $webhook['headers']['status-code']); + $webhookId = $webhook['body']['$id']; + + // LIST with 1.9.0 header + $headers = array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.0', + ], $this->getHeaders()); + + $list = $this->client->call(Client::METHOD_GET, '/webhooks', $headers, [ + 'queries' => [ + Query::equal('name', ['V22 Compat List'])->toString(), + ], + 'total' => true, + ]); + + $this->assertEquals(200, $list['headers']['status-code']); + $this->assertEquals(1, $list['body']['total']); + $this->assertCount(1, $list['body']['webhooks']); + + $item = $list['body']['webhooks'][0]; + + // Each item should have old field names + $this->assertArrayHasKey('security', $item); + $this->assertArrayHasKey('httpUser', $item); + $this->assertArrayHasKey('httpPass', $item); + $this->assertArrayHasKey('signatureKey', $item); + + $this->assertArrayNotHasKey('tls', $item); + $this->assertArrayNotHasKey('authUsername', $item); + $this->assertArrayNotHasKey('authPassword', $item); + $this->assertArrayNotHasKey('secret', $item); + + $this->assertEquals(true, $item['security']); + $this->assertEquals('listuser', $item['httpUser']); + $this->assertEquals('listpass', $item['httpPass']); + + // Cleanup + $this->deleteWebhook($webhookId); + } + + public function testUpdateWebhookSecretV22BackwardCompatResponse(): void + { + $webhook = $this->createWebhook( + ID::unique(), + 'V22 Compat Secret', + ['users.*.create'], + null, + 'https://appwrite.io', + null, + null, + null + ); + + $this->assertEquals(201, $webhook['headers']['status-code']); + $webhookId = $webhook['body']['$id']; + + // Update secret with 1.9.0 header + $headers = array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.0', + ], $this->getHeaders()); + + $updated = $this->client->call(Client::METHOD_PATCH, '/webhooks/' . $webhookId . '/secret', $headers); + + $this->assertEquals(200, $updated['headers']['status-code']); + + // Response should use old field names + $this->assertArrayHasKey('signatureKey', $updated['body']); + $this->assertArrayHasKey('security', $updated['body']); + $this->assertArrayHasKey('httpUser', $updated['body']); + $this->assertArrayHasKey('httpPass', $updated['body']); + + $this->assertArrayNotHasKey('secret', $updated['body']); + $this->assertArrayNotHasKey('tls', $updated['body']); + $this->assertArrayNotHasKey('authUsername', $updated['body']); + $this->assertArrayNotHasKey('authPassword', $updated['body']); + + $this->assertNotEmpty($updated['body']['signatureKey']); + $this->assertEquals(128, \strlen($updated['body']['signatureKey'])); + + // Cleanup + $this->deleteWebhook($webhookId); + } + // Helpers /** @@ -1531,7 +1779,7 @@ trait WebhooksBase return $webhook; } - protected function createWebhook(string $webhookId, string $name, array $events, ?bool $enabled, ?string $url, ?bool $security, ?string $httpUser, ?string $httpPass): mixed + protected function createWebhook(string $webhookId, string $name, array $events, ?bool $enabled, ?string $url, ?bool $tls, ?string $authUsername, ?string $authPassword): mixed { $params = [ 'webhookId' => $webhookId, @@ -1543,14 +1791,14 @@ trait WebhooksBase if ($enabled !== null) { $params['enabled'] = $enabled; } - if ($security !== null) { - $params['security'] = $security; + if ($tls !== null) { + $params['tls'] = $tls; } - if ($httpUser !== null) { - $params['httpUser'] = $httpUser; + if ($authUsername !== null) { + $params['authUsername'] = $authUsername; } - if ($httpPass !== null) { - $params['httpPass'] = $httpPass; + if ($authPassword !== null) { + $params['authPassword'] = $authPassword; } $webhook = $this->client->call(Client::METHOD_POST, '/webhooks', array_merge([ @@ -1561,7 +1809,7 @@ trait WebhooksBase return $webhook; } - protected function updateWebhook(string $webhookId, string $name, array $events, ?bool $enabled, ?string $url, ?bool $security, ?string $httpUser, ?string $httpPass): mixed + protected function updateWebhook(string $webhookId, string $name, array $events, ?bool $enabled, ?string $url, ?bool $tls, ?string $authUsername, ?string $authPassword): mixed { $params = [ 'name' => $name, @@ -1572,14 +1820,14 @@ trait WebhooksBase if ($enabled !== null) { $params['enabled'] = $enabled; } - if ($security !== null) { - $params['security'] = $security; + if ($tls !== null) { + $params['tls'] = $tls; } - if ($httpUser !== null) { - $params['httpUser'] = $httpUser; + if ($authUsername !== null) { + $params['authUsername'] = $authUsername; } - if ($httpPass !== null) { - $params['httpPass'] = $httpPass; + if ($authPassword !== null) { + $params['authPassword'] = $authPassword; } $webhook = $this->client->call(Client::METHOD_PUT, '/webhooks/' . $webhookId, array_merge([ @@ -1590,9 +1838,9 @@ trait WebhooksBase return $webhook; } - protected function updateWebhookSignature(string $webhookId): mixed + protected function updateWebhookSecret(string $webhookId): mixed { - $webhook = $this->client->call(Client::METHOD_PATCH, '/webhooks/' . $webhookId . '/signature', array_merge([ + $webhook = $this->client->call(Client::METHOD_PATCH, '/webhooks/' . $webhookId . '/secret', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ], $this->getHeaders())); diff --git a/tests/unit/SDK/Specification/FormatTest.php b/tests/unit/SDK/Specification/FormatTest.php new file mode 100644 index 0000000000..f99b29bfe2 --- /dev/null +++ b/tests/unit/SDK/Specification/FormatTest.php @@ -0,0 +1,66 @@ +getRequestParameterConfig($service, $method, $param, $optional, $nullable, $default); + } +} + +class FormatTest extends TestCase +{ + private TestFormat $format; + + protected function setUp(): void + { + parent::setUp(); + + $this->format = new TestFormat(new Container(), [], [], [], [], 0, 'console'); + } + + public function testProjectRequestParameterOverrides(): void + { + $createWebPlatform = $this->format->requestParameterConfig('project', 'createWebPlatform', 'hostname', true, false, ''); + $updateWebPlatform = $this->format->requestParameterConfig('project', 'updateWebPlatform', 'hostname', true, false, ''); + $listPlatforms = $this->format->requestParameterConfig('project', 'listPlatforms', 'queries', true, false, []); + + $this->assertTrue($createWebPlatform['required']); + $this->assertFalse($createWebPlatform['emitDefault']); + $this->assertTrue($updateWebPlatform['required']); + $this->assertFalse($updateWebPlatform['emitDefault']); + $this->assertTrue($listPlatforms['emitDefault']); + } + + public function testProjectPlatformResponseTypeUsesSharedEnumName(): void + { + $this->assertSame('PlatformType', $this->format->getResponseEnumName('platformAndroid', 'type')); + $this->assertSame('PlatformType', $this->format->getResponseEnumName('platformWeb', 'type')); + $this->assertSame('PlatformType', $this->format->getResponseEnumName('platformApple', 'type')); + $this->assertSame('PlatformType', $this->format->getResponseEnumName('platformWindows', 'type')); + $this->assertSame('PlatformType', $this->format->getResponseEnumName('platformLinux', 'type')); + $this->assertNull($this->format->getResponseEnumName('platformList', 'type')); + } + + public function testExistingResponseEnumMappingsRemainUnchanged(): void + { + $this->assertSame('HealthCheckStatus', $this->format->getResponseEnumName('healthStatus', 'status')); + $this->assertNull($this->format->getResponseEnumName('key', 'name')); + } +}