Compare commits

...
Author SHA1 Message Date
shimon 552b6ab7ff Enhance function execution tracing by adding logging for function enqueueing and execution events across multiple components. This includes logging in Log, ScheduleFunctions, Executions, and Functions classes to capture relevant execution details for better monitoring and debugging. 2026-04-14 11:59:31 +03:00
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
54 changed files with 2029 additions and 417 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",
+15 -2
View File
@@ -5,6 +5,7 @@ namespace Appwrite\Bus\Listeners;
use Appwrite\Bus\Events\ExecutionCompleted;
use Appwrite\Event\Message\Execution as ExecutionMessage;
use Appwrite\Event\Publisher\Execution as ExecutionPublisher;
use Appwrite\Extend\TraceFunctionExecution;
use Utopia\Bus\Listener;
use Utopia\Database\Document;
@@ -30,9 +31,21 @@ class Log extends Listener
public function handle(ExecutionCompleted $event, ExecutionPublisher $publisherForExecutions): void
{
$project = new Document($event->project);
$execution = new Document($event->execution);
if ($execution->getAttribute('resourceType', '') === 'functions') {
TraceFunctionExecution::log('v1_executions_enqueue', [
'projectId' => $project->getId(),
'functionId' => $execution->getAttribute('resourceId', ''),
'executionId' => $execution->getId(),
'deploymentId' => $execution->getAttribute('deploymentId', ''),
'status' => $execution->getAttribute('status', ''),
]);
}
$publisherForExecutions->enqueue(new ExecutionMessage(
project: new Document($event->project),
execution: new Document($event->execution),
project: $project,
execution: $execution,
));
}
}
@@ -0,0 +1,74 @@
<?php
namespace Appwrite\Extend;
use Utopia\Console;
use Utopia\Database\DateTime;
use Utopia\System\System;
/**
* Opt-in trace logging for a single project+function pair via env (development / debugging).
* Set both _APP_TRACE_PROJECT_ID and _APP_TRACE_FUNCTION_ID to enable.
*/
class TraceFunctionExecution
{
private static ?string $cachedProjectId = null;
private static ?string $cachedFunctionId = null;
public static function isEnabled(): bool
{
return self::traceProjectId() !== '' && self::traceFunctionId() !== '';
}
private static function traceProjectId(): string
{
if (self::$cachedProjectId === null) {
self::$cachedProjectId = System::getEnv('_APP_TRACE_PROJECT_ID', '69ddf60f001255461d5c');
}
return self::$cachedProjectId;
}
private static function traceFunctionId(): string
{
if (self::$cachedFunctionId === null) {
self::$cachedFunctionId = System::getEnv('_APP_TRACE_FUNCTION_ID', '69ddf6500032ee9f5b0f');
}
return self::$cachedFunctionId;
}
public static function matches(?string $projectId, ?string $functionId): bool
{
if (!self::isEnabled()) {
return false;
}
return (string) $projectId === self::traceProjectId()
&& (string) $functionId === self::traceFunctionId();
}
/**
* @param array<string, mixed> $context Must include projectId and functionId for filtering.
*/
public static function log(string $station, array $context = []): void
{
if (!self::isEnabled()) {
return;
}
$projectId = $context['projectId'] ?? null;
$functionId = $context['functionId'] ?? null;
if (!self::matches($projectId, $functionId)) {
return;
}
$payload = \array_merge([
'station' => $station,
'time' => DateTime::now(),
], $context);
Console::log('[execution-trace] ' . \json_encode($payload, JSON_UNESCAPED_SLASHES));
}
}
+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',
];
/**
@@ -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());
@@ -65,9 +65,9 @@ class Create extends Action
->param('name', null, new Text(128), 'Webhook name. Max length: 128 chars.')
->param('events', null, new ArrayList(new Event(), APP_LIMIT_ARRAY_PARAMS_SIZE), 'Events list. Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' events are allowed.')
->param('enabled', true, new Boolean(), 'Enable or disable a webhook.', true)
->param('security', false, new Boolean(), 'Certificate verification, false for disabled or true for enabled.', true)
->param('httpUser', '', new Text(256), 'Webhook HTTP user. Max length: 256 chars.', true)
->param('httpPass', '', new Text(256), 'Webhook HTTP password. Max length: 256 chars.', true)
->param('tls', false, new Boolean(), 'Certificate verification, false for disabled or true for enabled.', true)
->param('authUsername', '', new Text(256), 'Webhook HTTP user. Max length: 256 chars.', true)
->param('authPassword', '', new Text(256), 'Webhook HTTP password. Max length: 256 chars.', true)
->inject('response')
->inject('project')
->inject('queueForEvents')
@@ -85,9 +85,9 @@ class Create extends Action
string $name,
array $events,
bool $enabled,
bool $security,
string $httpUser,
string $httpPass,
bool $tls,
string $authUsername,
string $authPassword,
Response $response,
Document $project,
QueueEvent $queueForEvents,
@@ -104,9 +104,9 @@ class Create extends Action
'name' => $name,
'events' => $events,
'url' => $url,
'security' => $security,
'httpUser' => $httpUser,
'httpPass' => $httpPass,
'security' => $tls,
'httpUser' => $authUsername,
'httpPass' => $authPassword,
'signatureKey' => \bin2hex(\random_bytes(64)),
'enabled' => $enabled,
]);
@@ -28,9 +28,9 @@ class Update extends Action
public function __construct()
{
$this->setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH)
->setHttpPath('/v1/webhooks/:webhookId/signature')
->setHttpPath('/v1/webhooks/:webhookId/secret')
->httpAlias('/v1/projects/:projectId/webhooks/:webhookId/signature')
->desc('Update webhook signature key')
->desc('Update webhook secret key')
->groups(['api', 'webhooks'])
->label('scope', 'webhooks.write')
->label('event', 'webhooks.[webhookId].update')
@@ -39,9 +39,9 @@ class Update extends Action
->label('sdk', new Method(
namespace: 'webhooks',
group: null,
name: 'updateSignature',
name: 'updateSecret',
description: <<<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: [
@@ -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,
]);
@@ -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);
@@ -3,6 +3,7 @@
namespace Appwrite\Platform\Tasks;
use Appwrite\Event\Func;
use Appwrite\Extend\TraceFunctionExecution;
use Cron\CronExpression;
use Utopia\Console;
use Utopia\Database\Database;
@@ -97,6 +98,16 @@ class ScheduleFunctions extends ScheduleBase
$queueForFunctions = new Func($this->publisherFunctions);
$projectDoc = $schedule['project'];
$functionDoc = $schedule['resource'];
TraceFunctionExecution::log('v1_functions_enqueue', [
'projectId' => $projectDoc->getId(),
'functionId' => $functionDoc->getId(),
'scheduleId' => $schedule['$id'] ?? '',
'schedule' => $schedule['schedule'] ?? '',
]);
$queueForFunctions
->setType('schedule')
->setFunction($schedule['resource'])
@@ -3,6 +3,7 @@
namespace Appwrite\Platform\Workers;
use Appwrite\Event\Message\Execution;
use Appwrite\Extend\TraceFunctionExecution;
use Exception;
use Utopia\Database\Database;
use Utopia\Platform\Action;
@@ -39,6 +40,14 @@ class Executions extends Action
throw new Exception('Missing execution');
}
TraceFunctionExecution::log('executions_worker_upsert', [
'projectId' => $executionMessage->project->getId(),
'functionId' => $execution->getAttribute('resourceId', ''),
'executionId' => $execution->getId(),
'deploymentId' => $execution->getAttribute('deploymentId', ''),
'resourceType' => $execution->getAttribute('resourceType', ''),
]);
$dbForProject->upsertDocument('executions', $execution);
}
}
@@ -9,6 +9,7 @@ use Appwrite\Event\Func;
use Appwrite\Event\Realtime;
use Appwrite\Event\Webhook;
use Appwrite\Extend\Exception as AppwriteException;
use Appwrite\Extend\TraceFunctionExecution;
use Appwrite\Utopia\Response\Model\Execution;
use Executor\Executor;
use Utopia\Bus\Bus;
@@ -115,6 +116,17 @@ class Functions extends Action
$log->addTag('projectId', $project->getId());
$log->addTag('type', $type);
if (empty($events) && !$function->isEmpty()) {
TraceFunctionExecution::log('functions_worker_dequeue', [
'projectId' => $project->getId(),
'functionId' => $function->getId(),
'payloadType' => $type,
'queuePid' => $message->getPid(),
'queueName' => $message->getQueue(),
'messageTimestamp' => $message->getTimestamp(),
]);
}
if (!empty($events)) {
$limit = 100;
$sum = 100;
@@ -304,6 +316,15 @@ class Functions extends Action
'duration' => 0.0,
]);
TraceFunctionExecution::log('functions_worker_before_execution_completed_bus_fail', [
'projectId' => $project->getId(),
'functionId' => $function->getId(),
'executionId' => $execution->getId(),
'deploymentId' => $execution->getAttribute('deploymentId', ''),
'trigger' => $trigger,
'status' => $execution->getAttribute('status', ''),
]);
$bus->dispatch(new ExecutionCompleted(
execution: $execution->getArrayCopy(),
project: $project->getArrayCopy(),
@@ -522,6 +543,13 @@ class Functions extends Action
$source = $deployment->getAttribute('buildPath', '');
$extension = str_ends_with($source, '.tar') ? 'tar' : 'tar.gz';
$command = $version === 'v2' ? '' : "cp /tmp/code.$extension /mnt/code/code.$extension && nohup helpers/start.sh \"$command\"";
TraceFunctionExecution::log('functions_worker_before_executor', [
'projectId' => $project->getId(),
'functionId' => $functionId,
'executionId' => $executionId,
'deploymentId' => $deployment->getId(),
'trigger' => $trigger,
]);
$executionResponse = $executor->createExecution(
projectId: $project->getId(),
deploymentId: $deploymentId,
@@ -594,6 +622,14 @@ class Functions extends Action
$errorCode = $th->getCode();
} finally {
/** Persist final execution status and record usage */
TraceFunctionExecution::log('functions_worker_before_execution_completed_bus', [
'projectId' => $project->getId(),
'functionId' => $functionId,
'executionId' => $execution->getId(),
'deploymentId' => $execution->getAttribute('deploymentId', ''),
'status' => $execution->getAttribute('status', ''),
'trigger' => $trigger,
]);
$bus->dispatch(new ExecutionCompleted(
execution: $execution->getArrayCopy(),
project: $project->getArrayCopy(),
+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.',
'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']);
+315 -67
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 new secret persisted via GET
$get = $this->getWebhook($webhookId);
$this->assertEquals(200, $get['headers']['status-code']);
$this->assertNotEquals($originalSignatureKey, $get['body']['signatureKey']);
$this->assertNotEquals($originalSecret, $get['body']['secret']);
// Test signature update on non-existent webhook
$notFound = $this->updateWebhookSignature('non-existent-id');
// Test secret update on non-existent webhook
$notFound = $this->updateWebhookSecret('non-existent-id');
$this->assertEquals(404, $notFound['headers']['status-code']);
$this->assertEquals('webhook_not_found', $notFound['body']['type']);
@@ -934,11 +934,11 @@ trait WebhooksBase
$this->assertContains('users.*.update.email', $get['body']['events']);
$this->assertCount(2, $get['body']['events']);
$this->assertEquals(true, $get['body']['enabled']);
$this->assertEquals(true, $get['body']['security']);
$this->assertEquals('myuser', $get['body']['httpUser']);
$this->assertEquals('mypass', $get['body']['httpPass']);
$this->assertNotEmpty($get['body']['signatureKey']);
$this->assertEquals(128, \strlen($get['body']['signatureKey']));
$this->assertEquals(true, $get['body']['tls']);
$this->assertEquals('myuser', $get['body']['authUsername']);
$this->assertEquals('mypass', $get['body']['authPassword']);
$this->assertNotEmpty($get['body']['secret']);
$this->assertEquals(128, \strlen($get['body']['secret']));
$this->assertEquals(0, $get['body']['attempts']);
$this->assertEquals('', $get['body']['logs']);
@@ -1043,9 +1043,9 @@ trait WebhooksBase
$this->assertArrayHasKey('name', $webhook);
$this->assertArrayHasKey('url', $webhook);
$this->assertArrayHasKey('events', $webhook);
$this->assertArrayHasKey('security', $webhook);
$this->assertArrayHasKey('tls', $webhook);
$this->assertArrayHasKey('enabled', $webhook);
$this->assertArrayHasKey('signatureKey', $webhook);
$this->assertArrayHasKey('secret', $webhook);
$this->assertArrayHasKey('attempts', $webhook);
$this->assertArrayHasKey('logs', $webhook);
}
@@ -1247,11 +1247,11 @@ trait WebhooksBase
$this->deleteWebhook($webhook['body']['$id']);
}
public function testListWebhooksFilterBySecurity(): void
public function testListWebhooksFilterByTls(): void
{
$webhook = $this->createWebhook(
ID::unique(),
'Security Filter Webhook',
'TLS Filter Webhook',
['users.*.create'],
null,
'https://appwrite.io/sec',
@@ -1262,13 +1262,13 @@ trait WebhooksBase
$this->assertEquals(201, $webhook['headers']['status-code']);
$list = $this->listWebhooks([
Query::equal('security', [true])->toString(),
Query::equal('tls', [true])->toString(),
], true);
$this->assertEquals(200, $list['headers']['status-code']);
$this->assertGreaterThanOrEqual(1, $list['body']['total']);
foreach ($list['body']['webhooks'] as $w) {
$this->assertEquals(true, $w['security']);
$this->assertEquals(true, $w['tls']);
}
// Cleanup
@@ -1503,6 +1503,254 @@ trait WebhooksBase
$this->assertEquals('webhook_not_found', $delete['body']['type']);
}
// =========================================================================
// Backward compatibility tests (1.9.0 response format)
// =========================================================================
public function testCreateWebhookV22BackwardCompatRequest(): void
{
$headers = array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-response-format' => '1.9.0',
], $this->getHeaders());
// Send old param names with 1.9.0 header
$webhook = $this->client->call(Client::METHOD_POST, '/webhooks', $headers, [
'webhookId' => ID::unique(),
'name' => 'V22 Compat Create',
'events' => ['users.*.create'],
'url' => 'https://appwrite.io',
'security' => true,
'httpUser' => 'olduser',
'httpPass' => 'oldpass',
]);
$this->assertEquals(201, $webhook['headers']['status-code']);
// Response should use OLD field names
$this->assertArrayHasKey('security', $webhook['body']);
$this->assertArrayHasKey('httpUser', $webhook['body']);
$this->assertArrayHasKey('httpPass', $webhook['body']);
$this->assertArrayHasKey('signatureKey', $webhook['body']);
// New field names should NOT be present
$this->assertArrayNotHasKey('tls', $webhook['body']);
$this->assertArrayNotHasKey('authUsername', $webhook['body']);
$this->assertArrayNotHasKey('authPassword', $webhook['body']);
$this->assertArrayNotHasKey('secret', $webhook['body']);
// Values should be correct
$this->assertEquals(true, $webhook['body']['security']);
$this->assertEquals('olduser', $webhook['body']['httpUser']);
$this->assertEquals('oldpass', $webhook['body']['httpPass']);
$this->assertNotEmpty($webhook['body']['signatureKey']);
$this->assertEquals(128, \strlen($webhook['body']['signatureKey']));
// Cleanup
$this->deleteWebhook($webhook['body']['$id']);
}
public function testUpdateWebhookV22BackwardCompatRequest(): void
{
$webhook = $this->createWebhook(
ID::unique(),
'V22 Compat Update',
['users.*.create'],
null,
'https://appwrite.io',
null,
null,
null
);
$this->assertEquals(201, $webhook['headers']['status-code']);
$webhookId = $webhook['body']['$id'];
$headers = array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-response-format' => '1.9.0',
], $this->getHeaders());
// Update using old param names
$updated = $this->client->call(Client::METHOD_PUT, '/webhooks/' . $webhookId, $headers, [
'name' => 'V22 Compat Updated',
'events' => ['users.*.create'],
'url' => 'https://appwrite.io',
'security' => true,
'httpUser' => 'updateduser',
'httpPass' => 'updatedpass',
]);
$this->assertEquals(200, $updated['headers']['status-code']);
// Response should use OLD field names
$this->assertArrayHasKey('security', $updated['body']);
$this->assertArrayHasKey('httpUser', $updated['body']);
$this->assertArrayHasKey('httpPass', $updated['body']);
$this->assertArrayHasKey('signatureKey', $updated['body']);
$this->assertArrayNotHasKey('tls', $updated['body']);
$this->assertArrayNotHasKey('authUsername', $updated['body']);
$this->assertArrayNotHasKey('authPassword', $updated['body']);
$this->assertArrayNotHasKey('secret', $updated['body']);
$this->assertEquals(true, $updated['body']['security']);
$this->assertEquals('updateduser', $updated['body']['httpUser']);
$this->assertEquals('updatedpass', $updated['body']['httpPass']);
// Cleanup
$this->deleteWebhook($webhookId);
}
public function testGetWebhookV22BackwardCompatResponse(): void
{
$webhook = $this->createWebhook(
ID::unique(),
'V22 Compat Get',
['users.*.create'],
null,
'https://appwrite.io',
true,
'getuser',
'getpass'
);
$this->assertEquals(201, $webhook['headers']['status-code']);
$webhookId = $webhook['body']['$id'];
// GET with 1.9.0 header
$headers = array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-response-format' => '1.9.0',
], $this->getHeaders());
$get = $this->client->call(Client::METHOD_GET, '/webhooks/' . $webhookId, $headers);
$this->assertEquals(200, $get['headers']['status-code']);
// Should have old field names
$this->assertArrayHasKey('security', $get['body']);
$this->assertArrayHasKey('httpUser', $get['body']);
$this->assertArrayHasKey('httpPass', $get['body']);
$this->assertArrayHasKey('signatureKey', $get['body']);
$this->assertArrayNotHasKey('tls', $get['body']);
$this->assertArrayNotHasKey('authUsername', $get['body']);
$this->assertArrayNotHasKey('authPassword', $get['body']);
$this->assertArrayNotHasKey('secret', $get['body']);
$this->assertEquals(true, $get['body']['security']);
$this->assertEquals('getuser', $get['body']['httpUser']);
$this->assertEquals('getpass', $get['body']['httpPass']);
$this->assertNotEmpty($get['body']['signatureKey']);
// Cleanup
$this->deleteWebhook($webhookId);
}
public function testListWebhooksV22BackwardCompatResponse(): void
{
$webhook = $this->createWebhook(
ID::unique(),
'V22 Compat List',
['users.*.create'],
null,
'https://appwrite.io',
true,
'listuser',
'listpass'
);
$this->assertEquals(201, $webhook['headers']['status-code']);
$webhookId = $webhook['body']['$id'];
// LIST with 1.9.0 header
$headers = array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-response-format' => '1.9.0',
], $this->getHeaders());
$list = $this->client->call(Client::METHOD_GET, '/webhooks', $headers, [
'queries' => [
Query::equal('name', ['V22 Compat List'])->toString(),
],
'total' => true,
]);
$this->assertEquals(200, $list['headers']['status-code']);
$this->assertEquals(1, $list['body']['total']);
$this->assertCount(1, $list['body']['webhooks']);
$item = $list['body']['webhooks'][0];
// Each item should have old field names
$this->assertArrayHasKey('security', $item);
$this->assertArrayHasKey('httpUser', $item);
$this->assertArrayHasKey('httpPass', $item);
$this->assertArrayHasKey('signatureKey', $item);
$this->assertArrayNotHasKey('tls', $item);
$this->assertArrayNotHasKey('authUsername', $item);
$this->assertArrayNotHasKey('authPassword', $item);
$this->assertArrayNotHasKey('secret', $item);
$this->assertEquals(true, $item['security']);
$this->assertEquals('listuser', $item['httpUser']);
$this->assertEquals('listpass', $item['httpPass']);
// Cleanup
$this->deleteWebhook($webhookId);
}
public function testUpdateWebhookSecretV22BackwardCompatResponse(): void
{
$webhook = $this->createWebhook(
ID::unique(),
'V22 Compat Secret',
['users.*.create'],
null,
'https://appwrite.io',
null,
null,
null
);
$this->assertEquals(201, $webhook['headers']['status-code']);
$webhookId = $webhook['body']['$id'];
// Update secret with 1.9.0 header
$headers = array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-response-format' => '1.9.0',
], $this->getHeaders());
$updated = $this->client->call(Client::METHOD_PATCH, '/webhooks/' . $webhookId . '/secret', $headers);
$this->assertEquals(200, $updated['headers']['status-code']);
// Response should use old field names
$this->assertArrayHasKey('signatureKey', $updated['body']);
$this->assertArrayHasKey('security', $updated['body']);
$this->assertArrayHasKey('httpUser', $updated['body']);
$this->assertArrayHasKey('httpPass', $updated['body']);
$this->assertArrayNotHasKey('secret', $updated['body']);
$this->assertArrayNotHasKey('tls', $updated['body']);
$this->assertArrayNotHasKey('authUsername', $updated['body']);
$this->assertArrayNotHasKey('authPassword', $updated['body']);
$this->assertNotEmpty($updated['body']['signatureKey']);
$this->assertEquals(128, \strlen($updated['body']['signatureKey']));
// Cleanup
$this->deleteWebhook($webhookId);
}
// Helpers
/**
@@ -1531,7 +1779,7 @@ trait WebhooksBase
return $webhook;
}
protected function createWebhook(string $webhookId, string $name, array $events, ?bool $enabled, ?string $url, ?bool $security, ?string $httpUser, ?string $httpPass): mixed
protected function createWebhook(string $webhookId, string $name, array $events, ?bool $enabled, ?string $url, ?bool $tls, ?string $authUsername, ?string $authPassword): mixed
{
$params = [
'webhookId' => $webhookId,
@@ -1543,14 +1791,14 @@ trait WebhooksBase
if ($enabled !== null) {
$params['enabled'] = $enabled;
}
if ($security !== null) {
$params['security'] = $security;
if ($tls !== null) {
$params['tls'] = $tls;
}
if ($httpUser !== null) {
$params['httpUser'] = $httpUser;
if ($authUsername !== null) {
$params['authUsername'] = $authUsername;
}
if ($httpPass !== null) {
$params['httpPass'] = $httpPass;
if ($authPassword !== null) {
$params['authPassword'] = $authPassword;
}
$webhook = $this->client->call(Client::METHOD_POST, '/webhooks', array_merge([
@@ -1561,7 +1809,7 @@ trait WebhooksBase
return $webhook;
}
protected function updateWebhook(string $webhookId, string $name, array $events, ?bool $enabled, ?string $url, ?bool $security, ?string $httpUser, ?string $httpPass): mixed
protected function updateWebhook(string $webhookId, string $name, array $events, ?bool $enabled, ?string $url, ?bool $tls, ?string $authUsername, ?string $authPassword): mixed
{
$params = [
'name' => $name,
@@ -1572,14 +1820,14 @@ trait WebhooksBase
if ($enabled !== null) {
$params['enabled'] = $enabled;
}
if ($security !== null) {
$params['security'] = $security;
if ($tls !== null) {
$params['tls'] = $tls;
}
if ($httpUser !== null) {
$params['httpUser'] = $httpUser;
if ($authUsername !== null) {
$params['authUsername'] = $authUsername;
}
if ($httpPass !== null) {
$params['httpPass'] = $httpPass;
if ($authPassword !== null) {
$params['authPassword'] = $authPassword;
}
$webhook = $this->client->call(Client::METHOD_PUT, '/webhooks/' . $webhookId, array_merge([
@@ -1590,9 +1838,9 @@ trait WebhooksBase
return $webhook;
}
protected function updateWebhookSignature(string $webhookId): mixed
protected function updateWebhookSecret(string $webhookId): mixed
{
$webhook = $this->client->call(Client::METHOD_PATCH, '/webhooks/' . $webhookId . '/signature', array_merge([
$webhook = $this->client->call(Client::METHOD_PATCH, '/webhooks/' . $webhookId . '/secret', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()));
@@ -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'));
}
}