Compare commits

...
Author SHA1 Message Date
Matej Bačo db406b0a27 Fix tests 2026-04-13 15:08:20 +02:00
Matej Bačo 2585518e33 Fix bugs 2026-04-13 15:08:13 +02:00
Matej Bačo c9fceb870c Fix folder structure 2026-04-13 14:57:24 +02:00
Matej Bačo 9f1ec356d1 Formatting fix 2026-04-13 14:53:25 +02:00
Matej Bačo 28d285d5c5 Improved tests for webhook edge cases 2026-04-13 14:53:06 +02:00
Matej Bačo 3263133e5f Implement secure webhook interfaces 2026-04-13 14:50:06 +02:00
ArnabChatterjee20kandGitHub c24d724000 Merge pull request #11749 from appwrite/db-workers-memory
added reset in db worker for queue for realtime
2026-04-13 15:47:54 +05:30
ArnabChatterjee20kandGitHub 20e2f2284f Merge branch '1.9.x' into db-workers-memory 2026-04-13 13:43:14 +05:30
Matej BačoandGitHub 7a0d69c826 Merge pull request #11839 from appwrite/feat-services-protocols-apis
Feat: services protocols public apis
2026-04-13 09:38:17 +02:00
Matej Bačo feedec80f2 Merge branch '1.9.x' into feat-services-protocols-apis 2026-04-13 09:17:09 +02:00
Damodar LohaniandGitHub f4d40a1289 Merge pull request #11879 from appwrite/fix/graphql-batch-sent-reset
fix: reset response sent state between batched GraphQL queries
2026-04-13 11:35:22 +05:45
Damodar LohaniandGitHub 1e65f075e6 Merge branch '1.9.x' into fix/graphql-batch-sent-reset 2026-04-13 11:23:23 +05:45
Jake BarnbyandGitHub 68725d9262 Merge pull request #11860 from appwrite/fix-cache-fallback
(fix): cache fallback
2026-04-13 17:34:51 +12:00
Damodar LohaniandClaude Opus 4.6 5b805d686b fix: reset response sent state between batched GraphQL queries
utopia-php/http 0.34.20 added a guard that skips the action if
$response->isSent() is true. In batched GraphQL requests the resolver
reuses a single Response across all queries — after the first query's
action calls send(), subsequent queries hit the guard, their actions
are skipped, and stale/null payloads are returned.

Add Response::clearSent() to the Appwrite Response subclass (which can
access the protected $sent property from the parent) and call it in
Resolvers::resolve() before each execute(). This ensures each batched
query gets a fresh sent state while keeping the guard active for normal
request paths.

Also bumps utopia-php/http from 0.34.19 to 0.34.20 so CE CI tests
against the same version used by downstream consumers (cloud).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 05:32:04 +00:00
Chirag AggarwalandGitHub 584acafb1d Merge branch '1.9.x' into feat-services-protocols-apis 2026-04-13 10:45:42 +05:30
Chirag AggarwalandGitHub dce7856b77 Merge pull request #11848 from appwrite/fix/spec-generator-console-pr82 2026-04-13 10:40:47 +05:30
Chirag Aggarwal a6af609317 Remove scopes spec override, now fixed at source in #11839 2026-04-13 10:33:46 +05:30
Chirag Aggarwal 035f6244e1 Revert "fix: require scopes for project keys"
This reverts commit 8deafcaf4d52a59cc2e1b27c7a128e8b7843afa4.
2026-04-13 10:33:46 +05:30
Chirag Aggarwal 723cb1a488 fix: require scopes for project keys 2026-04-13 10:33:46 +05:30
Chirag Aggarwal 815209ebb0 fix: address sdk spec review feedback 2026-04-13 10:33:46 +05:30
Chirag Aggarwal 53c74582fc refactor: simplify request parameter spec overrides 2026-04-13 10:33:46 +05:30
Chirag Aggarwal 78bbe77580 fix: align project sdk spec generation 2026-04-13 10:33:45 +05:30
0c3871a681 fix: pass response to Http::execute() in GraphQL resolver (#11876)
Http::execute() now requires a Response parameter as of utopia-php/http
0.34.20. The GraphQL resolver was only passing route and request,
causing all GraphQL queries to fail with "Internal server error".

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 16:33:32 +12:00
Chirag AggarwalandGitHub b59eba4ec6 Merge pull request #11874 from appwrite/fix/11763-installer-compose-executor 2026-04-13 08:52:39 +05:30
Chirag Aggarwal cb4c97f2ee chore: remove installer compose regression test 2026-04-12 14:11:51 +05:30
Chirag Aggarwal 2ee2ea09a0 fix(installer): sync compose template executor image 2026-04-12 13:56:49 +05:30
Chirag AggarwalandGitHub 4ec84a147f Merge pull request #11861 from appwrite/fix-edge-pzj-rule-deployment-resource-type-optional
Make rule deploymentResourceType optional for non-deployment rules
2026-04-11 22:08:17 +05:30
Chirag AggarwalandGitHub e583de4650 Merge pull request #11858 from appwrite/fix-cve-2026-40194-phpseclib-bump
Bump phpseclib to 3.0.51 for CVE-2026-40194
2026-04-11 22:07:47 +05:30
Chirag Aggarwal 98af2a5eb3 fix: make rule deploymentResourceType optional 2026-04-11 22:05:01 +05:30
Matej Bačo 27fc8058b9 Fix failing tests 2026-04-11 14:19:05 +02:00
Matej Bačo c5bd8c712f Upgrade libs 2026-04-11 11:31:35 +02:00
Matej Bačo a1267b1bff Backwards compatibiltiy tests 2026-04-11 11:16:43 +02:00
Matej Bačo 18d17ea945 Webhook endpoints quality improvements 2026-04-11 11:00:56 +02:00
Matej Bačo fabd9559c4 Tests for backwards compatibility 2026-04-11 10:22:03 +02:00
Matej Bačo ec637d4417 Mark key scopes required 2026-04-11 10:19:14 +02:00
Chirag Aggarwal 4523e86b91 fix: bump phpseclib to 3.0.51 2026-04-11 09:01:42 +05:30
Matej Bačo d69726487e PR review fixes 2026-04-09 16:58:42 +02:00
Matej Bačo 5fccb8cc28 Improve tests 2026-04-09 16:57:44 +02:00
Matej Bačo 21a0d60c98 Fix tests 2026-04-09 16:13:54 +02:00
Matej Bačo 4eb8534294 Fix tests 2026-04-09 16:08:11 +02:00
Matej Bačo c95f905bce New services and protocols tests 2026-04-09 15:58:28 +02:00
Matej Bačo 0293da1e22 Improve test for backwards compatibility 2026-04-09 15:54:00 +02:00
Matej Bačo a4a0c4175d Implement new endpoints in /v1/project for services and protocols 2026-04-09 15:45:06 +02:00
Matej Bačo d3c73fbb49 Add endpoints to control protocols and services 2026-04-09 15:34:50 +02:00
Matej Bačo 8818187740 Introduce req&res filters for 1.9.1 2026-04-09 15:21:58 +02:00
Matej Bačo 75324b24fc Improve skill 2026-04-09 15:21:24 +02:00
Matej Bačo d6d118f4ab Bump version to 1.9.1 2026-04-09 15:19:58 +02:00
Matej Bačo e998739998 Add agent skill to increase patch version 2026-04-09 15:18:26 +02:00
ArnabChatterjee20k cf99269bc5 Add reset method to Realtime class for clearing event state
This new method resets the event state for long-lived worker processes by clearing subscribers, context, and user-related fields, ensuring no stale state affects subsequent triggers.
2026-04-01 18:08:45 +05:30
ArnabChatterjee20k 2a184288f4 removed throw 2026-04-01 18:05:56 +05:30
ArnabChatterjee20k 9237f387fc added reset in db worker for queue for realtime 2026-04-01 17:59:28 +05:30
53 changed files with 2319 additions and 440 deletions
@@ -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: <?php echo $organization; ?>/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
+4
View File
@@ -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.
+3 -3
View File
@@ -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 的本机主机上完成安装后,服务器可能需要几分钟才能启动。
+3 -3
View File
@@ -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.
@@ -9,8 +9,8 @@ return [
'key' => 'graphql',
'name' => 'GraphQL',
],
'realtime' => [
'key' => 'realtime',
'name' => 'Realtime',
'websocket' => [
'key' => 'websocket',
'name' => 'Websocket',
],
];
+4 -4
View File
@@ -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'],
],
+4 -186
View File
@@ -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')
+9
View File
@@ -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());
}
@@ -1419,6 +1427,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
+10 -9
View File
@@ -425,7 +425,7 @@ Http::init()
}
if (! empty($method)) {
$namespace = $method->getNamespace();
$namespace = \strtolower($method->getNamespace());
if (
array_key_exists($namespace, $project->getAttribute('services', []))
@@ -436,6 +436,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))) {
@@ -511,14 +520,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
*/
+1 -1
View File
@@ -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);
+2 -2
View File
@@ -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';
+4 -2
View File
@@ -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);
+1 -1
View File
@@ -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
Generated
+6 -6
View File
@@ -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",
+20
View File
@@ -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.
*
+2 -1
View File
@@ -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);
+1
View File
@@ -93,6 +93,7 @@ abstract class Migration
'1.8.0' => 'V23',
'1.8.1' => 'V23',
'1.9.0' => 'V24',
'1.9.1' => 'V24',
];
/**
@@ -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();
}
}
@@ -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<string>|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,
@@ -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<string>|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,
]);
@@ -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')
@@ -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')
@@ -0,0 +1,80 @@
<?php
namespace Appwrite\Platform\Modules\Project\Http\Project\Protocols\Status;
use Appwrite\Platform\Action;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\Method;
use Appwrite\SDK\Response as SDKResponse;
use Appwrite\Utopia\Response;
use Utopia\Config\Config;
use Utopia\Database\Database;
use Utopia\Database\Document;
use Utopia\Database\Validator\Authorization;
use Utopia\Platform\Scope\HTTP;
use Utopia\Validator\Boolean;
use Utopia\Validator\WhiteList;
class Update extends Action
{
use HTTP;
public static function getName()
{
return 'updateProjectProtocolStatus';
}
public function __construct()
{
$this
->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: <<<EOT
Update the status of a specific protocol. Use this endpoint to enable or disable a protocol in your project.
EOT,
auth: [AuthType::ADMIN, AuthType::KEY],
responses: [
new SDKResponse(
code: Response::STATUS_CODE_OK,
model: Response::MODEL_PROJECT,
)
],
))
->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);
}
}
@@ -0,0 +1,80 @@
<?php
namespace Appwrite\Platform\Modules\Project\Http\Project\Services\Status;
use Appwrite\Platform\Action;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\Method;
use Appwrite\SDK\Response as SDKResponse;
use Appwrite\Utopia\Response;
use Utopia\Config\Config;
use Utopia\Database\Database;
use Utopia\Database\Document;
use Utopia\Database\Validator\Authorization;
use Utopia\Platform\Scope\HTTP;
use Utopia\Validator\Boolean;
use Utopia\Validator\WhiteList;
class Update extends Action
{
use HTTP;
public static function getName()
{
return 'updateProjectServiceStatus';
}
public function __construct()
{
$this
->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: <<<EOT
Update the status of a specific service. Use this endpoint to enable or disable a service in your project.
EOT,
auth: [AuthType::ADMIN, AuthType::KEY],
responses: [
new SDKResponse(
code: Response::STATUS_CODE_OK,
model: Response::MODEL_PROJECT,
)
],
))
->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);
}
}
@@ -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());
@@ -21,6 +21,7 @@ use Utopia\Platform\Scope\HTTP;
use Utopia\Validator\ArrayList;
use Utopia\Validator\Boolean;
use Utopia\Validator\Multiple;
use Utopia\Validator\Nullable;
use Utopia\Validator\Text;
use Utopia\Validator\URL;
@@ -65,9 +66,10 @@ 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)
->param('secret', null, new Nullable(new Text(256, 8)), 'Webhook secret key. If not provided, a new key will be generated automatically. Key must be at least 8 characters long, and at max 256 characters.', optional: true)
->inject('response')
->inject('project')
->inject('queueForEvents')
@@ -85,9 +87,10 @@ class Create extends Action
string $name,
array $events,
bool $enabled,
bool $security,
string $httpUser,
string $httpPass,
bool $tls,
string $authUsername,
string $authPassword,
?string $secret,
Response $response,
Document $project,
QueueEvent $queueForEvents,
@@ -104,10 +107,10 @@ class Create extends Action
'name' => $name,
'events' => $events,
'url' => $url,
'security' => $security,
'httpUser' => $httpUser,
'httpPass' => $httpPass,
'signatureKey' => \bin2hex(\random_bytes(64)),
'security' => $tls,
'httpUser' => $authUsername,
'httpPass' => $authPassword,
'signatureKey' => $secret ?? \bin2hex(\random_bytes(64)),
'enabled' => $enabled,
]);
@@ -72,6 +72,8 @@ class Get extends Action
throw new Exception(Exception::WEBHOOK_NOT_FOUND);
}
$webhook->removeAttribute('signatureKey');
$response->dynamic($webhook, Response::MODEL_WEBHOOK);
}
}
@@ -1,6 +1,6 @@
<?php
namespace Appwrite\Platform\Modules\Webhooks\Http\Webhooks\Signature;
namespace Appwrite\Platform\Modules\Webhooks\Http\Webhooks\Secret;
use Appwrite\Event\Event as QueueEvent;
use Appwrite\Extend\Exception;
@@ -15,6 +15,8 @@ use Utopia\Database\Validator\Authorization;
use Utopia\Database\Validator\UID;
use Utopia\Platform\Action;
use Utopia\Platform\Scope\HTTP;
use Utopia\Validator\Nullable;
use Utopia\Validator\Text;
class Update extends Action
{
@@ -22,15 +24,15 @@ class Update extends Action
public static function getName()
{
return 'updateWebhookSignature';
return 'updateWebhookSecret';
}
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 +41,9 @@ class Update extends Action
->label('sdk', new Method(
namespace: 'webhooks',
group: null,
name: 'updateSignature',
name: 'updateSecret',
description: <<<EOT
Update the webhook signature key. This endpoint can be used to regenerate the signature key used to sign and validate payload deliveries for a specific webhook.
Update the webhook signing key. This endpoint can be used to regenerate the signing key used to sign and validate payload deliveries for a specific webhook.
EOT,
auth: [AuthType::ADMIN, AuthType::KEY],
responses: [
@@ -52,6 +54,7 @@ class Update extends Action
]
))
->param('webhookId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Webhook ID.', false, ['dbForPlatform'])
->param('secret', null, new Nullable(new Text(256, 8)), 'Webhook secret key. If not provided, a new key will be generated automatically. Key must be at least 8 characters long, and at max 256 characters.', optional: true)
->inject('response')
->inject('project')
->inject('queueForEvents')
@@ -62,6 +65,7 @@ class Update extends Action
public function action(
string $webhookId,
?string $secret,
Response $response,
Document $project,
QueueEvent $queueForEvents,
@@ -78,7 +82,7 @@ class Update extends Action
}
$updates = new Document([
'signatureKey' => \bin2hex(\random_bytes(64)),
'signatureKey' => $secret ?? \bin2hex(\random_bytes(64)),
]);
$webhook = $authorization->skip(fn () => $dbForPlatform->updateDocument('webhooks', $webhook->getId(), $updates));
@@ -63,9 +63,9 @@ class Update extends Action
->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,
]);
@@ -118,6 +118,8 @@ class Update extends Action
$queueForEvents->setParam('webhookId', $webhook->getId());
$webhook->removeAttribute('signatureKey');
$response->dynamic($webhook, Response::MODEL_WEBHOOK);
}
}
@@ -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);
@@ -111,6 +120,10 @@ class XList extends Action
throw new Exception(Exception::DATABASE_QUERY_ORDER_NULL, "The order attribute '{$e->getAttribute()}' had a null value. Cursor pagination requires all documents order attribute values are non-null.");
}
foreach ($webhooks as $webhook) {
$webhook->removeAttribute('signatureKey');
}
$response->dynamic(new Document([
'webhooks' => $webhooks,
'total' => $total,
@@ -6,7 +6,7 @@ use Appwrite\Platform\Modules\Webhooks\Http\Init;
use Appwrite\Platform\Modules\Webhooks\Http\Webhooks\Create as CreateWebhook;
use Appwrite\Platform\Modules\Webhooks\Http\Webhooks\Delete as DeleteWebhook;
use Appwrite\Platform\Modules\Webhooks\Http\Webhooks\Get as GetWebhook;
use Appwrite\Platform\Modules\Webhooks\Http\Webhooks\Signature\Update as UpdateWebhookSignature;
use Appwrite\Platform\Modules\Webhooks\Http\Webhooks\Secret\Update as UpdateWebhookSecret;
use Appwrite\Platform\Modules\Webhooks\Http\Webhooks\Update as UpdateWebhook;
use Appwrite\Platform\Modules\Webhooks\Http\Webhooks\XList as ListWebhooks;
use Utopia\Platform\Service;
@@ -26,6 +26,6 @@ class Http extends Service
$this->addAction(GetWebhook::getName(), new GetWebhook());
$this->addAction(DeleteWebhook::getName(), new DeleteWebhook());
$this->addAction(UpdateWebhook::getName(), new UpdateWebhook());
$this->addAction(UpdateWebhookSignature::getName(), new UpdateWebhookSignature());
$this->addAction(UpdateWebhookSecret::getName(), new UpdateWebhookSecret());
}
}
+42
View File
@@ -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;
}
@@ -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;
}
}
@@ -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;
}
@@ -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);
}
}
@@ -0,0 +1,93 @@
<?php
namespace Appwrite\Utopia\Request\Filters;
use Appwrite\Utopia\Request\Filter;
class V22 extends Filter
{
// Convert 1.9.0 params to 1.9.1
protected function parseUpdateProtocolStatus(array $content): array
{
if (isset($content['api'])) {
$content['protocolId'] = $content['api'];
unset($content['api']);
}
if (isset($content['status'])) {
$content['enabled'] = $content['status'];
unset($content['status']);
}
if (($content['protocolId'] ?? '') === 'realtime') {
$content['protocolId'] = 'websocket';
}
return $content;
}
protected function parseUpdateServiceStatus(array $content): array
{
if (isset($content['service'])) {
$content['serviceId'] = $content['service'];
unset($content['service']);
}
if (isset($content['status'])) {
$content['enabled'] = $content['status'];
unset($content['status']);
}
return $content;
}
protected function parseKeyScopes(array $content): array
{
if (!\is_array($content['scopes'] ?? null)) {
$content['scopes'] = [];
}
return $content;
}
protected function parseWebhook(array $content): array
{
if (isset($content['security'])) {
$content['tls'] = $content['security'];
unset($content['security']);
}
if (isset($content['httpUser'])) {
$content['authUsername'] = $content['httpUser'];
unset($content['httpUser']);
}
if (isset($content['httpPass'])) {
$content['authPassword'] = $content['httpPass'];
unset($content['httpPass']);
}
return $content;
}
public function parse(array $content, string $model): array
{
switch ($model) {
case 'project.updateServiceStatus':
$content = $this->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;
}
}
+11
View File
@@ -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.
*
@@ -0,0 +1,45 @@
<?php
namespace Appwrite\Utopia\Response\Filters;
use Appwrite\Utopia\Response;
use Appwrite\Utopia\Response\Filter;
// Convert 1.9.1 Data format to 1.9.0 format
class V22 extends Filter
{
public function parse(array $content, string $model): array
{
return match ($model) {
Response::MODEL_PROJECT => $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;
}
}
@@ -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')) {
+2 -1
View File
@@ -66,8 +66,9 @@ class Rule extends Model
])
->addRule('deploymentResourceType', [
'type' => self::TYPE_ENUM,
'required' => false,
'description' => 'Type of deployment. Possible values are "function", "site". Used if rule\'s type is "deployment".',
'default' => '',
'default' => null,
'example' => 'function',
'enum' => ['function', 'site'],
])
+24 -6
View File
@@ -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. Only returned on creation and secret rotation.',
'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
*
+2 -2
View File
@@ -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'],
];
}
+55 -3
View File
@@ -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(
@@ -0,0 +1,261 @@
<?php
namespace Tests\E2E\Services\Project;
use Tests\E2E\Client;
trait ProtocolsBase
{
protected static array $protocols = ['rest', 'graphql', 'websocket'];
// Success flow
public function testDisableProtocol(): void
{
foreach (self::$protocols as $protocol) {
$response = $this->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,
]);
}
}
@@ -0,0 +1,14 @@
<?php
namespace Tests\E2E\Services\Project;
use Tests\E2E\Scopes\ProjectCustom;
use Tests\E2E\Scopes\Scope;
use Tests\E2E\Scopes\SideConsole;
class ProtocolsConsoleClientTest extends Scope
{
use ProtocolsBase;
use ProjectCustom;
use SideConsole;
}
@@ -0,0 +1,14 @@
<?php
namespace Tests\E2E\Services\Project;
use Tests\E2E\Scopes\ProjectCustom;
use Tests\E2E\Scopes\Scope;
use Tests\E2E\Scopes\SideServer;
class ProtocolsCustomServerTest extends Scope
{
use ProtocolsBase;
use ProjectCustom;
use SideServer;
}
+259
View File
@@ -0,0 +1,259 @@
<?php
namespace Tests\E2E\Services\Project;
use Tests\E2E\Client;
trait ServicesBase
{
/**
* Optional services that can be toggled.
*/
protected static array $optionalServices = [
'account',
'avatars',
'databases',
'tablesdb',
'locale',
'health',
'project',
'storage',
'teams',
'users',
'vcs',
'sites',
'functions',
'proxy',
'migrations',
'messaging',
];
// Success flow
public function testDisableService(): void
{
foreach (self::$optionalServices as $service) {
$response = $this->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,
]);
}
}
@@ -0,0 +1,14 @@
<?php
namespace Tests\E2E\Services\Project;
use Tests\E2E\Scopes\ProjectCustom;
use Tests\E2E\Scopes\Scope;
use Tests\E2E\Scopes\SideConsole;
class ServicesConsoleClientTest extends Scope
{
use ServicesBase;
use ProjectCustom;
use SideConsole;
}
@@ -0,0 +1,14 @@
<?php
namespace Tests\E2E\Services\Project;
use Tests\E2E\Scopes\ProjectCustom;
use Tests\E2E\Scopes\Scope;
use Tests\E2E\Scopes\SideServer;
class ServicesCustomServerTest extends Scope
{
use ServicesBase;
use ProjectCustom;
use SideServer;
}
+5 -4
View File
@@ -90,16 +90,16 @@ trait ProjectsBase
'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']);
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,
@@ -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']);
+687 -68
View File
@@ -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 secret is not exposed via GET
$get = $this->getWebhook($webhookId);
$this->assertEquals(200, $get['headers']['status-code']);
$this->assertNotEquals($originalSignatureKey, $get['body']['signatureKey']);
$this->assertEmpty($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']);
@@ -708,6 +708,351 @@ trait WebhooksBase
$this->deleteWebhook($webhookId);
}
public function testSecretRotationZeroDowntime(): void
{
// Create webhook pointing to request-catcher so deliveries are captured
$webhook = $this->createWebhook(
ID::unique(),
'Rotation Test Webhook',
['users.*.create'],
null,
'http://request-catcher-webhook:5000/',
false,
null,
null
);
$this->assertEquals(201, $webhook['headers']['status-code']);
$webhookId = $webhook['body']['$id'];
$originalSecret = $webhook['body']['secret'];
$this->assertNotEmpty($originalSecret);
$this->assertEquals(128, \strlen($originalSecret));
// Step 1: Trigger user creation with the original auto-generated secret
$email1 = uniqid() . 'rotation1@localhost.test';
$user1 = $this->client->call(Client::METHOD_POST, '/users', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()), [
'userId' => ID::unique(),
'email' => $email1,
'password' => 'password',
'name' => 'Rotation User 1',
]);
$this->assertEquals(201, $user1['headers']['status-code']);
$userId1 = $user1['body']['$id'];
// Verify webhook delivery is signed with the original secret
$this->assertEventually(function () use ($userId1, $originalSecret) {
$delivery = $this->getLastRequest(function (array $request) use ($userId1) {
$this->assertStringContainsString(
"users.{$userId1}.create",
$request['headers']['X-Appwrite-Webhook-Events'] ?? ''
);
});
$this->assertNotEmpty($delivery);
$payload = json_encode($delivery['data']);
$url = $delivery['url'];
$signatureExpected = base64_encode(hash_hmac('sha1', $url . $payload, $originalSecret, true));
$this->assertEquals($signatureExpected, $delivery['headers']['X-Appwrite-Webhook-Signature']);
}, 15000, 500);
// Step 2: Rotate the secret to a known custom value
$newSecret = 'new-key-after-rotation';
$updated = $this->updateWebhookSecret($webhookId, $newSecret);
$this->assertEquals(200, $updated['headers']['status-code']);
$this->assertEquals($newSecret, $updated['body']['secret']);
$this->assertNotEquals($originalSecret, $updated['body']['secret']);
// Step 3: Trigger another user creation — should be signed with the new secret
$email2 = uniqid() . 'rotation2@localhost.test';
$user2 = $this->client->call(Client::METHOD_POST, '/users', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()), [
'userId' => ID::unique(),
'email' => $email2,
'password' => 'password',
'name' => 'Rotation User 2',
]);
$this->assertEquals(201, $user2['headers']['status-code']);
$userId2 = $user2['body']['$id'];
// Verify webhook delivery is signed with the new rotated secret
$this->assertEventually(function () use ($userId2, $newSecret) {
$delivery = $this->getLastRequest(function (array $request) use ($userId2) {
$this->assertStringContainsString(
"users.{$userId2}.create",
$request['headers']['X-Appwrite-Webhook-Events'] ?? ''
);
});
$this->assertNotEmpty($delivery);
$payload = json_encode($delivery['data']);
$url = $delivery['url'];
$signatureExpected = base64_encode(hash_hmac('sha1', $url . $payload, $newSecret, true));
$this->assertEquals($signatureExpected, $delivery['headers']['X-Appwrite-Webhook-Signature']);
}, 15000, 500);
// Cleanup
$this->deleteWebhook($webhookId);
}
public function testCreateWebhookWithCustomSecret(): void
{
$customSecret = 'custom-secret-key';
// Create webhook with a custom secret pointing to request-catcher
$webhook = $this->createWebhook(
ID::unique(),
'Custom Secret Webhook',
['users.*.create'],
null,
'http://request-catcher-webhook:5000/',
false,
null,
null,
$customSecret
);
$this->assertEquals(201, $webhook['headers']['status-code']);
$webhookId = $webhook['body']['$id'];
$this->assertEquals($customSecret, $webhook['body']['secret']);
// Trigger user creation to generate a webhook delivery
$email = uniqid() . 'customsecret@localhost.test';
$user = $this->client->call(Client::METHOD_POST, '/users', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()), [
'userId' => ID::unique(),
'email' => $email,
'password' => 'password',
'name' => 'Custom Secret User',
]);
$this->assertEquals(201, $user['headers']['status-code']);
$userId = $user['body']['$id'];
// Verify webhook delivery is signed with the custom secret
$this->assertEventually(function () use ($userId, $customSecret) {
$delivery = $this->getLastRequest(function (array $request) use ($userId) {
$this->assertStringContainsString(
"users.{$userId}.create",
$request['headers']['X-Appwrite-Webhook-Events'] ?? ''
);
});
$this->assertNotEmpty($delivery);
$payload = json_encode($delivery['data']);
$url = $delivery['url'];
$signatureExpected = base64_encode(hash_hmac('sha1', $url . $payload, $customSecret, true));
$this->assertEquals($signatureExpected, $delivery['headers']['X-Appwrite-Webhook-Signature']);
}, 15000, 500);
// Cleanup
$this->deleteWebhook($webhookId);
}
public function testCreateWebhookSecretMinLength(): void
{
// 7 chars — below minimum of 8
$webhook = $this->createWebhook(
ID::unique(),
'Short Secret Webhook',
['users.*.create'],
null,
'https://appwrite.io',
null,
null,
null,
'short12'
);
$this->assertEquals(400, $webhook['headers']['status-code']);
// 8 chars — exactly at minimum
$webhook = $this->createWebhook(
ID::unique(),
'Min Secret Webhook',
['users.*.create'],
null,
'https://appwrite.io',
null,
null,
null,
'exact8ch'
);
$this->assertEquals(201, $webhook['headers']['status-code']);
$this->assertEquals('exact8ch', $webhook['body']['secret']);
// Cleanup
$this->deleteWebhook($webhook['body']['$id']);
}
public function testCreateWebhookSecretMaxLength(): void
{
// 256 chars — exactly at maximum
$maxSecret = str_repeat('a', 256);
$webhook = $this->createWebhook(
ID::unique(),
'Max Secret Webhook',
['users.*.create'],
null,
'https://appwrite.io',
null,
null,
null,
$maxSecret
);
$this->assertEquals(201, $webhook['headers']['status-code']);
$this->assertEquals($maxSecret, $webhook['body']['secret']);
// Cleanup
$this->deleteWebhook($webhook['body']['$id']);
// 257 chars — above maximum
$tooLongSecret = str_repeat('a', 257);
$webhook = $this->createWebhook(
ID::unique(),
'Too Long Secret Webhook',
['users.*.create'],
null,
'https://appwrite.io',
null,
null,
null,
$tooLongSecret
);
$this->assertEquals(400, $webhook['headers']['status-code']);
}
public function testUpdateWebhookSecretMinLength(): void
{
$webhook = $this->createWebhook(
ID::unique(),
'Secret Min Update Webhook',
['users.*.create'],
null,
'https://appwrite.io',
null,
null,
null
);
$this->assertEquals(201, $webhook['headers']['status-code']);
$webhookId = $webhook['body']['$id'];
// 7 chars — below minimum of 8
$updated = $this->updateWebhookSecret($webhookId, 'short12');
$this->assertEquals(400, $updated['headers']['status-code']);
// 8 chars — exactly at minimum
$updated = $this->updateWebhookSecret($webhookId, 'exact8ch');
$this->assertEquals(200, $updated['headers']['status-code']);
$this->assertEquals('exact8ch', $updated['body']['secret']);
// Cleanup
$this->deleteWebhook($webhookId);
}
public function testUpdateWebhookSecretMaxLength(): void
{
$webhook = $this->createWebhook(
ID::unique(),
'Secret Max Update Webhook',
['users.*.create'],
null,
'https://appwrite.io',
null,
null,
null
);
$this->assertEquals(201, $webhook['headers']['status-code']);
$webhookId = $webhook['body']['$id'];
// 256 chars — exactly at maximum
$maxSecret = str_repeat('a', 256);
$updated = $this->updateWebhookSecret($webhookId, $maxSecret);
$this->assertEquals(200, $updated['headers']['status-code']);
$this->assertEquals($maxSecret, $updated['body']['secret']);
// 257 chars — above maximum
$tooLongSecret = str_repeat('a', 257);
$updated = $this->updateWebhookSecret($webhookId, $tooLongSecret);
$this->assertEquals(400, $updated['headers']['status-code']);
// Cleanup
$this->deleteWebhook($webhookId);
}
public function testWebhookSecretNotExposedInResponses(): void
{
// Create webhook — secret IS returned on creation
$webhook = $this->createWebhook(
ID::unique(),
'Secret Exposure Test',
['users.*.create'],
null,
'https://appwrite.io',
null,
null,
null,
'my-custom-secret'
);
$this->assertEquals(201, $webhook['headers']['status-code']);
$webhookId = $webhook['body']['$id'];
$this->assertEquals('my-custom-secret', $webhook['body']['secret']);
$this->assertArrayNotHasKey('signatureKey', $webhook['body']);
// Get webhook — secret must not be exposed
$get = $this->getWebhook($webhookId);
$this->assertEquals(200, $get['headers']['status-code']);
$this->assertEmpty($get['body']['secret']);
$this->assertArrayNotHasKey('signatureKey', $get['body']);
// List webhooks — secret must not be exposed
$list = $this->listWebhooks(null, true);
$this->assertEquals(200, $list['headers']['status-code']);
foreach ($list['body']['webhooks'] as $item) {
$this->assertEmpty($item['secret']);
$this->assertArrayNotHasKey('signatureKey', $item);
}
// Update webhook — secret must not be exposed
$updated = $this->updateWebhook(
$webhookId,
'Secret Exposure Test Updated',
['users.*.create'],
null,
'https://appwrite.io',
null,
null,
null
);
$this->assertEquals(200, $updated['headers']['status-code']);
$this->assertEmpty($updated['body']['secret']);
$this->assertArrayNotHasKey('signatureKey', $updated['body']);
// Update webhook secret — secret IS returned on rotation
$rotated = $this->updateWebhookSecret($webhookId, 'rotated-secret-key');
$this->assertEquals(200, $rotated['headers']['status-code']);
$this->assertEquals('rotated-secret-key', $rotated['body']['secret']);
$this->assertArrayNotHasKey('signatureKey', $rotated['body']);
// Cleanup
$this->deleteWebhook($webhookId);
}
// URL validation tests
public function testCreateWebhookWithPrivateDomain(): void
@@ -883,6 +1228,12 @@ trait WebhooksBase
{
$customId = 'my-custom-webhook-id';
// Clean up stale webhook from a previous run if it exists
$existing = $this->getWebhook($customId);
if ($existing['headers']['status-code'] === 200) {
$this->deleteWebhook($customId);
}
$webhook = $this->createWebhook(
$customId,
'Custom ID Webhook',
@@ -902,6 +1253,19 @@ trait WebhooksBase
$this->assertEquals(200, $get['headers']['status-code']);
$this->assertEquals($customId, $get['body']['$id']);
// Ensure duplicate creation fails
$duplicate = $this->createWebhook(
$customId,
'Duplicate Custom ID Webhook',
['users.*.create'],
null,
'https://appwrite.io',
null,
null,
null
);
$this->assertEquals(409, $duplicate['headers']['status-code']);
// Cleanup
$this->deleteWebhook($customId);
}
@@ -934,11 +1298,10 @@ 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->assertEmpty($get['body']['secret']);
$this->assertEquals(0, $get['body']['attempts']);
$this->assertEquals('', $get['body']['logs']);
@@ -1043,9 +1406,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 +1610,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 +1625,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 +1866,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->assertEmpty($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 +2142,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, ?string $secret = null): mixed
{
$params = [
'webhookId' => $webhookId,
@@ -1543,14 +2154,17 @@ 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;
}
if ($secret !== null) {
$params['secret'] = $secret;
}
$webhook = $this->client->call(Client::METHOD_POST, '/webhooks', array_merge([
@@ -1561,7 +2175,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 +2186,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,12 +2204,17 @@ trait WebhooksBase
return $webhook;
}
protected function updateWebhookSignature(string $webhookId): mixed
protected function updateWebhookSecret(string $webhookId, ?string $secret = null): mixed
{
$webhook = $this->client->call(Client::METHOD_PATCH, '/webhooks/' . $webhookId . '/signature', array_merge([
$params = [];
if ($secret !== null) {
$params['secret'] = $secret;
}
$webhook = $this->client->call(Client::METHOD_PATCH, '/webhooks/' . $webhookId . '/secret', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()));
], $this->getHeaders()), $params);
return $webhook;
}
@@ -0,0 +1,66 @@
<?php
namespace Tests\Unit\SDK\Specification;
use Appwrite\SDK\Specification\Format;
use PHPUnit\Framework\TestCase;
use Utopia\DI\Container;
class TestFormat extends Format
{
public function getName(): string
{
return 'test';
}
public function parse(): array
{
return [];
}
public function requestParameterConfig(string $service, string $method, string $param, bool $optional, bool $nullable, mixed $default): array
{
return $this->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'));
}
}