Merge remote-tracking branch 'origin/1.9.x' into chore/remove-utopia-logger-sync

# Conflicts:
#	composer.lock
This commit is contained in:
Chirag Aggarwal
2026-05-04 10:16:23 +05:30
16 changed files with 187 additions and 126 deletions
+1 -1
View File
@@ -12,7 +12,7 @@ RUN composer install --ignore-platform-reqs --optimize-autoloader \
--no-plugins --no-scripts --prefer-dist \
`if [ "$TESTING" != "true" ]; then echo "--no-dev"; fi`
FROM appwrite/base:1.2.1 AS base
FROM appwrite/base:1.4.1 AS base
LABEL maintainer="team@appwrite.io"
+10
View File
@@ -215,6 +215,16 @@ return [
'description' => 'Access to create function executions',
'category' => 'Functions',
],
'execution.read' => [
'description' => 'Access to read function executions. This scope is deprecated for consistency purposes, and replaced by `executions.read`.',
'category' => 'Functions',
'deprecated' => true,
],
'execution.write' => [
'description' => 'Access to create function executions. This scope is deprecated for consistency purposes, and replaced by `executions.write`.',
'category' => 'Functions',
'deprecated' => true,
],
// Sites
'sites.read' => [
+3 -2
View File
@@ -79,12 +79,13 @@ return [
...getRuntimes($templateRuntimes['DENO'], 'deno cache src/main.ts', 'src/main.ts', 'deno/starter', $allowList),
...getRuntimes($templateRuntimes['BUN'], 'bun install', 'src/main.ts', 'bun/starter', $allowList),
...getRuntimes($templateRuntimes['RUBY'], 'bundle install', 'lib/main.rb', 'ruby/starter', $allowList),
...getRuntimes($templateRuntimes['RUST'], '', 'main.rs', 'rust/starter', $allowList),
],
'instructions' => 'For documentation and instructions check out <a target="_blank" rel="noopener noreferrer" class="link" href="https://github.com/appwrite/templates/tree/main/node/starter">file</a>.',
'instructions' => 'For documentation and instructions check out the <a target="_blank" rel="noopener noreferrer" class="link" href="https://github.com/appwrite/templates">templates repository</a>.',
'vcsProvider' => 'github',
'providerRepositoryId' => 'templates',
'providerOwner' => 'appwrite',
'providerVersion' => '0.2.*',
'providerVersion' => '0.3.*',
'variables' => [],
'scopes' => ['users.read']
],
+1 -1
View File
@@ -49,7 +49,7 @@
"ext-openssl": "*",
"ext-zlib": "*",
"ext-sockets": "*",
"appwrite/php-runtimes": "0.19.*",
"appwrite/php-runtimes": "0.20.*",
"appwrite/php-clamav": "2.0.*",
"utopia-php/abuse": "1.2.*",
"utopia-php/agents": "1.2.*",
Generated
+7 -7
View File
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "788e59ef0f1f12947b5c3700f905c635",
"content-hash": "34190862fe85c76d5325fd6b5dea8f90",
"packages": [
{
"name": "adhocore/jwt",
@@ -161,16 +161,16 @@
},
{
"name": "appwrite/php-runtimes",
"version": "0.19.5",
"version": "0.20.0",
"source": {
"type": "git",
"url": "https://github.com/appwrite/runtimes.git",
"reference": "aa2f7760cd0493c0880209b92df812c9386b3546"
"reference": "7d9b7f4eef5c0a142a60907b06de2219d025c5c3"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/appwrite/runtimes/zipball/aa2f7760cd0493c0880209b92df812c9386b3546",
"reference": "aa2f7760cd0493c0880209b92df812c9386b3546",
"url": "https://api.github.com/repos/appwrite/runtimes/zipball/7d9b7f4eef5c0a142a60907b06de2219d025c5c3",
"reference": "7d9b7f4eef5c0a142a60907b06de2219d025c5c3",
"shasum": ""
},
"require": {
@@ -210,9 +210,9 @@
],
"support": {
"issues": "https://github.com/appwrite/runtimes/issues",
"source": "https://github.com/appwrite/runtimes/tree/0.19.5"
"source": "https://github.com/appwrite/runtimes/tree/0.20.0"
},
"time": "2026-04-01T01:39:23+00:00"
"time": "2026-05-01T07:47:07+00:00"
},
{
"name": "brick/math",
+7
View File
@@ -37,6 +37,13 @@ class Authentik extends OAuth2
return 'authentik';
}
public function verifyCredentials(): void
{
if (empty($this->getAuthentikDomain())) {
throw new \Exception('Authentik endpoint is required.');
}
}
/**
* @return string
*/
+7
View File
@@ -37,6 +37,13 @@ class FusionAuth extends OAuth2
return 'fusionauth';
}
public function verifyCredentials(): void
{
if (empty($this->getFusionAuthDomain())) {
throw new \Exception('FusionAuth endpoint is required.');
}
}
/**
* @return string
*/
+11
View File
@@ -37,6 +37,17 @@ class Keycloak extends OAuth2
return 'keycloak';
}
public function verifyCredentials(): void
{
if (empty($this->getKeycloakDomain())) {
throw new \Exception('Keycloak endpoint is required.');
}
if (empty($this->getKeycloakRealm())) {
throw new \Exception('Keycloak realm name is required.');
}
}
/**
* @return string
*/
+9 -2
View File
@@ -36,6 +36,13 @@ class Microsoft extends OAuth2
return 'microsoft';
}
public function verifyCredentials(): void
{
if (empty($this->getTenantID())) {
throw new \Exception('Microsoft tenant is required.');
}
}
/**
* @return string
*/
@@ -201,7 +208,7 @@ class Microsoft extends OAuth2
}
/**
* Extracts the Tenant Id from the JSON stored in appSecret. Defaults to 'common' as a fallback
* Extracts the Tenant Id from the JSON stored in appSecret.
*
* @return string
*/
@@ -209,6 +216,6 @@ class Microsoft extends OAuth2
{
$secret = $this->getAppSecret();
return $secret['tenantID'] ?? 'common';
return $secret['tenantID'] ?? '';
}
}
@@ -105,7 +105,7 @@ class Update extends Base
))
->param(static::getClientIdParamName(), null, new Nullable(new Text(256, 0)), static::getClientIdDescription(), optional: true)
->param(static::getClientSecretParamName(), null, new Nullable(new Text(512, 0)), static::getClientSecretDescription(), optional: true)
->param('endpoint', '', new Text(256, 1), 'Domain of Authentik instance. For example: example.authentik.com', optional: false)
->param('endpoint', null, new Nullable(new Text(256, 0)), 'Domain of Authentik instance. For example: example.authentik.com', optional: true)
->param('enabled', null, new Nullable(new Boolean()), 'OAuth2 sign-in method status. Set to true to enable new session creation. Setting to true will trigger end-to-end credentials validation, and will throw if the credentials are invalid.', true)
->inject('response')
->inject('dbForPlatform')
@@ -138,7 +138,7 @@ class Update extends Base
public function handle(
?string $clientId,
?string $clientSecret,
string $endpoint,
?string $endpoint,
?bool $enabled,
Response $response,
Database $dbForPlatform,
@@ -151,7 +151,7 @@ class Update extends Base
// The secret is stored as JSON `{"clientSecret": "...", "authentikDomain": "..."}`
// to match the shape Authentik's OAuth2 adapter expects (getAuthentikDomain()).
// The `endpoint` param is required on every call, so it's always written.
// The `endpoint` param is optional; if omitted, the existing stored endpoint is preserved.
// `clientSecret` is optional; if omitted, the existing stored secret is preserved.
$storedRaw = $project->getAttribute('oAuthProviders', [])[$providerId . 'Secret'] ?? '';
$existing = [];
@@ -160,7 +160,7 @@ class Update extends Base
}
$encodedSecret = \json_encode([
'clientSecret' => $clientSecret ?? ($existing['clientSecret'] ?? ''),
'authentikDomain' => $endpoint,
'authentikDomain' => $endpoint ?? ($existing['authentikDomain'] ?? ''),
]);
$project = $this->persistCredentials($project, $dbForPlatform, $authorization, $clientId, $encodedSecret, $enabled);
@@ -105,7 +105,7 @@ class Update extends Base
))
->param(static::getClientIdParamName(), null, new Nullable(new Text(256, 0)), static::getClientIdDescription(), optional: true)
->param(static::getClientSecretParamName(), null, new Nullable(new Text(512, 0)), static::getClientSecretDescription(), optional: true)
->param('endpoint', '', new Text(256, 1), 'Domain of FusionAuth instance. For example: example.fusionauth.io', optional: false)
->param('endpoint', null, new Nullable(new Text(256, 0)), 'Domain of FusionAuth instance. For example: example.fusionauth.io', optional: true)
->param('enabled', null, new Nullable(new Boolean()), 'OAuth2 sign-in method status. Set to true to enable new session creation. Setting to true will trigger end-to-end credentials validation, and will throw if the credentials are invalid.', true)
->inject('response')
->inject('dbForPlatform')
@@ -138,7 +138,7 @@ class Update extends Base
public function handle(
?string $clientId,
?string $clientSecret,
string $endpoint,
?string $endpoint,
?bool $enabled,
Response $response,
Database $dbForPlatform,
@@ -151,7 +151,7 @@ class Update extends Base
// The secret is stored as JSON `{"clientSecret": "...", "fusionAuthDomain": "..."}`
// to match the shape FusionAuth's OAuth2 adapter expects (getFusionAuthDomain()).
// The `endpoint` param is required on every call, so it's always written.
// The `endpoint` param is optional; if omitted, the existing stored endpoint is preserved.
// `clientSecret` is optional; if omitted, the existing stored secret is preserved.
$storedRaw = $project->getAttribute('oAuthProviders', [])[$providerId . 'Secret'] ?? '';
$existing = [];
@@ -160,7 +160,7 @@ class Update extends Base
}
$encodedSecret = \json_encode([
'clientSecret' => $clientSecret ?? ($existing['clientSecret'] ?? ''),
'fusionAuthDomain' => $endpoint,
'fusionAuthDomain' => $endpoint ?? ($existing['fusionAuthDomain'] ?? ''),
]);
$project = $this->persistCredentials($project, $dbForPlatform, $authorization, $clientId, $encodedSecret, $enabled);
@@ -35,7 +35,7 @@ class Update extends Base
public static function getClientIdName(): string
{
return 'OAuth 2 app Client ID, or App ID';
return 'OAuth2 app Client ID, or App ID';
}
public static function getClientIdExample(): string
@@ -111,8 +111,8 @@ class Update extends Base
))
->param(static::getClientIdParamName(), null, new Nullable(new Text(256, 0)), static::getClientIdDescription(), optional: true)
->param(static::getClientSecretParamName(), null, new Nullable(new Text(512, 0)), static::getClientSecretDescription(), optional: true)
->param('endpoint', '', new Text(256, 1), 'Domain of Keycloak instance. For example: keycloak.example.com', optional: false)
->param('realmName', '', new Text(256, 1), 'Keycloak realm name. For example: appwrite-realm', optional: false)
->param('endpoint', null, new Nullable(new Text(256, 0)), 'Domain of Keycloak instance. For example: keycloak.example.com', optional: true)
->param('realmName', null, new Nullable(new Text(256, 0)), 'Keycloak realm name. For example: appwrite-realm', optional: true)
->param('enabled', null, new Nullable(new Boolean()), 'OAuth2 sign-in method status. Set to true to enable new session creation. Setting to true will trigger end-to-end credentials validation, and will throw if the credentials are invalid.', true)
->inject('response')
->inject('dbForPlatform')
@@ -147,8 +147,8 @@ class Update extends Base
public function handle(
?string $clientId,
?string $clientSecret,
string $endpoint,
string $realmName,
?string $endpoint,
?string $realmName,
?bool $enabled,
Response $response,
Database $dbForPlatform,
@@ -161,7 +161,7 @@ class Update extends Base
// The secret is stored as JSON `{"clientSecret": "...", "keycloakDomain": "...", "keycloakRealm": "..."}`
// to match the shape Keycloak's OAuth2 adapter expects (getKeycloakDomain(), getKeycloakRealm()).
// The `endpoint` and `realmName` params are required on every call, so they're always written.
// The `endpoint` and `realmName` params are optional; if omitted, existing stored values are preserved.
// `clientSecret` is optional; if omitted, the existing stored secret is preserved.
$storedRaw = $project->getAttribute('oAuthProviders', [])[$providerId . 'Secret'] ?? '';
$existing = [];
@@ -170,8 +170,8 @@ class Update extends Base
}
$encodedSecret = \json_encode([
'clientSecret' => $clientSecret ?? ($existing['clientSecret'] ?? ''),
'keycloakDomain' => $endpoint,
'keycloakRealm' => $realmName,
'keycloakDomain' => $endpoint ?? ($existing['keycloakDomain'] ?? ''),
'keycloakRealm' => $realmName ?? ($existing['keycloakRealm'] ?? ''),
]);
$project = $this->persistCredentials($project, $dbForPlatform, $authorization, $clientId, $encodedSecret, $enabled);
@@ -115,7 +115,7 @@ class Update extends Base
))
->param(static::getClientIdParamName(), null, new Nullable(new Text(256, 0)), static::getClientIdDescription(), optional: true)
->param(static::getClientSecretParamName(), null, new Nullable(new Text(512, 0)), static::getClientSecretDescription(), optional: true)
->param('tenant', '', new Text(256, 1), 'Microsoft Entra ID tenant identifier. Use \'common\', \'organizations\', \'consumers\' or a specific tenant ID. For example: common', optional: false)
->param('tenant', null, new Nullable(new Text(256, 0)), 'Microsoft Entra ID tenant identifier. Use \'common\', \'organizations\', \'consumers\' or a specific tenant ID. For example: common', true)
->param('enabled', null, new Nullable(new Boolean()), 'OAuth2 sign-in method status. Set to true to enable new session creation. Setting to true will trigger end-to-end credentials validation, and will throw if the credentials are invalid.', true)
->inject('response')
->inject('dbForPlatform')
@@ -148,7 +148,7 @@ class Update extends Base
public function handle(
?string $applicationId,
?string $applicationSecret,
string $tenant,
?string $tenant,
?bool $enabled,
Response $response,
Database $dbForPlatform,
@@ -161,7 +161,7 @@ class Update extends Base
// The secret is stored as JSON `{"clientSecret": "...", "tenantID": "..."}`
// to match the shape Microsoft's OAuth2 adapter expects (getTenantID()).
// The `tenant` param is required on every call, so it's always written.
// The `tenant` param is optional; if omitted, the existing stored tenant is preserved.
// `applicationSecret` is optional; if omitted, the existing stored secret is preserved.
$storedRaw = $project->getAttribute('oAuthProviders', [])[$providerId . 'Secret'] ?? '';
$existing = [];
@@ -170,7 +170,7 @@ class Update extends Base
}
$encodedSecret = \json_encode([
'clientSecret' => $applicationSecret ?? ($existing['clientSecret'] ?? ''),
'tenantID' => $tenant,
'tenantID' => $tenant ?? ($existing['tenantID'] ?? ''),
]);
$project = $this->persistCredentials($project, $dbForPlatform, $authorization, $applicationId, $encodedSecret, $enabled);
@@ -101,7 +101,7 @@ class ConsoleConsoleClientTest extends Scope
$this->assertCount(2, $github['parameters']);
$clientId = $github['parameters'][0];
$this->assertEquals('clientId', $clientId['$id']);
$this->assertEquals('OAuth 2 app Client ID, or App ID', $clientId['name']);
$this->assertEquals('OAuth2 app Client ID, or App ID', $clientId['name']);
$this->assertEquals('e4d87900000000540733', $clientId['example']);
$this->assertEquals('Example of wrong value: 370006', $clientId['hint']);
$clientSecret = $github['parameters'][1];
+110 -92
View File
@@ -872,30 +872,36 @@ trait OAuth2Base
}
// =========================================================================
// Update Authentik (clientId + clientSecret + REQUIRED endpoint)
// Update Authentik (clientId + clientSecret + optional endpoint)
// =========================================================================
public function testUpdateOAuth2AuthentikRequiresEndpoint(): void
public function testUpdateOAuth2AuthentikAllowsOmittedEndpointWhenDisabled(): void
{
// The `endpoint` param is required (Text(min=1)); omitting → 400.
$response = $this->updateOAuth2('authentik', [
'clientId' => 'whatever',
'clientSecret' => 'whatever',
'enabled' => false,
]);
$this->assertSame(400, $response['headers']['status-code']);
$this->assertSame('general_argument_invalid', $response['body']['type']);
$this->assertSame(200, $response['headers']['status-code']);
$this->assertSame('authentik', $response['body']['$id']);
// Cleanup
$this->updateOAuth2('authentik', [
'clientId' => '',
'clientSecret' => '',
'endpoint' => '',
'enabled' => false,
]);
}
public function testUpdateOAuth2AuthentikEmptyEndpointRejected(): void
public function testUpdateOAuth2AuthentikEmptyEndpointRejectedWhenEnabling(): void
{
// The `endpoint` validator is Text(min=1). Sending `''` must be
// rejected the same way as omitting — the validator should treat the
// empty-string degenerate case as a missing required field.
$response = $this->updateOAuth2('authentik', [
'clientId' => 'whatever',
'clientSecret' => 'whatever',
'endpoint' => '',
'enabled' => true,
]);
$this->assertSame(400, $response['headers']['status-code']);
@@ -920,15 +926,14 @@ trait OAuth2Base
$this->updateOAuth2('authentik', [
'clientId' => '',
'clientSecret' => '',
'endpoint' => 'cleanup.authentik.com',
'endpoint' => '',
'enabled' => false,
]);
}
public function testUpdateOAuth2AuthentikPartialPreservesSecret(): void
{
// Authentik's `endpoint` is required on every call, so we always
// re-send it. The `clientSecret` lives in the JSON blob and must
// The `clientSecret` and `endpoint` live in the JSON blob and must
// survive when omitted on a subsequent call that only changes clientId.
$this->updateOAuth2('authentik', [
'clientId' => 'authentik-merge-client',
@@ -939,27 +944,24 @@ trait OAuth2Base
$response = $this->updateOAuth2('authentik', [
'clientId' => 'authentik-rotated-client',
'endpoint' => 'merge.authentik.com',
]);
$this->assertSame(200, $response['headers']['status-code']);
$this->assertSame('authentik-rotated-client', $response['body']['clientId']);
$this->assertSame('merge.authentik.com', $response['body']['endpoint']);
// Confirm clientSecret survived the omitted-field merge by enabling
// — Authentik has no verifyCredentials() hook, so non-empty stored
// secret is enough. `endpoint` must be re-sent (required on enable too).
// without re-sending endpoint.
$enable = $this->updateOAuth2('authentik', [
'endpoint' => 'merge.authentik.com',
'enabled' => true,
]);
$this->assertSame(200, $enable['headers']['status-code']);
$this->assertTrue($enable['body']['enabled']);
// Cleanup — endpoint is required, use a placeholder.
// Cleanup
$this->updateOAuth2('authentik', [
'clientId' => '',
'clientSecret' => '',
'endpoint' => 'cleanup.authentik.com',
'endpoint' => '',
'enabled' => false,
]);
}
@@ -984,40 +986,46 @@ trait OAuth2Base
$this->assertSame('enable.authentik.com', $get['body']['endpoint']);
$this->assertSame('', $get['body']['clientSecret']);
// Cleanup — endpoint is required (Text(min=1)) so use a placeholder.
// Cleanup
$this->updateOAuth2('authentik', [
'clientId' => '',
'clientSecret' => '',
'endpoint' => 'cleanup.authentik.com',
'endpoint' => '',
'enabled' => false,
]);
}
// =========================================================================
// Update FusionAuth (clientId + clientSecret + REQUIRED endpoint)
// Update FusionAuth (clientId + clientSecret + optional endpoint)
// =========================================================================
public function testUpdateOAuth2FusionAuthRequiresEndpoint(): void
public function testUpdateOAuth2FusionAuthAllowsOmittedEndpointWhenDisabled(): void
{
// The `endpoint` param is required (Text(min=1)); omitting → 400.
$response = $this->updateOAuth2('fusionauth', [
'clientId' => 'whatever',
'clientSecret' => 'whatever',
'enabled' => false,
]);
$this->assertSame(400, $response['headers']['status-code']);
$this->assertSame('general_argument_invalid', $response['body']['type']);
$this->assertSame(200, $response['headers']['status-code']);
$this->assertSame('fusionauth', $response['body']['$id']);
// Cleanup
$this->updateOAuth2('fusionauth', [
'clientId' => '',
'clientSecret' => '',
'endpoint' => '',
'enabled' => false,
]);
}
public function testUpdateOAuth2FusionAuthEmptyEndpointRejected(): void
public function testUpdateOAuth2FusionAuthEmptyEndpointRejectedWhenEnabling(): void
{
// The `endpoint` validator is Text(min=1). Sending `''` must be
// rejected the same way as omitting — the validator should treat the
// empty-string degenerate case as a missing required field.
$response = $this->updateOAuth2('fusionauth', [
'clientId' => 'whatever',
'clientSecret' => 'whatever',
'endpoint' => '',
'enabled' => true,
]);
$this->assertSame(400, $response['headers']['status-code']);
@@ -1042,15 +1050,14 @@ trait OAuth2Base
$this->updateOAuth2('fusionauth', [
'clientId' => '',
'clientSecret' => '',
'endpoint' => 'cleanup.fusionauth.io',
'endpoint' => '',
'enabled' => false,
]);
}
public function testUpdateOAuth2FusionAuthPartialPreservesSecret(): void
{
// FusionAuth's `endpoint` is required on every call, so we always
// re-send it. The `clientSecret` lives in the JSON blob and must
// The `clientSecret` and `endpoint` live in the JSON blob and must
// survive when omitted on a subsequent call that only changes clientId.
$this->updateOAuth2('fusionauth', [
'clientId' => 'fusionauth-merge-client',
@@ -1061,27 +1068,24 @@ trait OAuth2Base
$response = $this->updateOAuth2('fusionauth', [
'clientId' => 'fusionauth-rotated-client',
'endpoint' => 'merge.fusionauth.io',
]);
$this->assertSame(200, $response['headers']['status-code']);
$this->assertSame('fusionauth-rotated-client', $response['body']['clientId']);
$this->assertSame('merge.fusionauth.io', $response['body']['endpoint']);
// Confirm clientSecret survived the omitted-field merge by enabling
// — FusionAuth has no verifyCredentials() hook, so non-empty stored
// secret is enough. `endpoint` must be re-sent (required on enable too).
// without re-sending endpoint.
$enable = $this->updateOAuth2('fusionauth', [
'endpoint' => 'merge.fusionauth.io',
'enabled' => true,
]);
$this->assertSame(200, $enable['headers']['status-code']);
$this->assertTrue($enable['body']['enabled']);
// Cleanup — endpoint is required, use a placeholder.
// Cleanup
$this->updateOAuth2('fusionauth', [
'clientId' => '',
'clientSecret' => '',
'endpoint' => 'cleanup.fusionauth.io',
'endpoint' => '',
'enabled' => false,
]);
}
@@ -1106,70 +1110,85 @@ trait OAuth2Base
$this->assertSame('enable.fusionauth.io', $get['body']['endpoint']);
$this->assertSame('', $get['body']['clientSecret']);
// Cleanup — endpoint is required (Text(min=1)) so use a placeholder.
// Cleanup
$this->updateOAuth2('fusionauth', [
'clientId' => '',
'clientSecret' => '',
'endpoint' => 'cleanup.fusionauth.io',
'endpoint' => '',
'enabled' => false,
]);
}
// =========================================================================
// Update Keycloak (clientId + clientSecret + REQUIRED endpoint + REQUIRED realmName)
// Update Keycloak (clientId + clientSecret + optional endpoint + optional realmName)
// =========================================================================
public function testUpdateOAuth2KeycloakRequiresEndpoint(): void
public function testUpdateOAuth2KeycloakAllowsOmittedEndpointWhenDisabled(): void
{
// The `endpoint` param is required (Text(min=1)); omitting → 400.
$response = $this->updateOAuth2('keycloak', [
'clientId' => 'whatever',
'clientSecret' => 'whatever',
'realmName' => 'appwrite-realm',
'enabled' => false,
]);
$this->assertSame(400, $response['headers']['status-code']);
$this->assertSame('general_argument_invalid', $response['body']['type']);
$this->assertSame(200, $response['headers']['status-code']);
$this->assertSame('keycloak', $response['body']['$id']);
// Cleanup
$this->updateOAuth2('keycloak', [
'clientId' => '',
'clientSecret' => '',
'endpoint' => '',
'realmName' => '',
'enabled' => false,
]);
}
public function testUpdateOAuth2KeycloakEmptyEndpointRejected(): void
public function testUpdateOAuth2KeycloakEmptyEndpointRejectedWhenEnabling(): void
{
// The `endpoint` validator is Text(min=1). Sending `''` must be
// rejected the same way as omitting — the validator should treat the
// empty-string degenerate case as a missing required field.
$response = $this->updateOAuth2('keycloak', [
'clientId' => 'whatever',
'clientSecret' => 'whatever',
'endpoint' => '',
'realmName' => 'appwrite-realm',
'enabled' => true,
]);
$this->assertSame(400, $response['headers']['status-code']);
$this->assertSame('general_argument_invalid', $response['body']['type']);
}
public function testUpdateOAuth2KeycloakRequiresRealmName(): void
public function testUpdateOAuth2KeycloakAllowsOmittedRealmNameWhenDisabled(): void
{
// The `realmName` param is required (Text(min=1)); omitting → 400.
$response = $this->updateOAuth2('keycloak', [
'clientId' => 'whatever',
'clientSecret' => 'whatever',
'endpoint' => 'keycloak.example.com',
'enabled' => false,
]);
$this->assertSame(400, $response['headers']['status-code']);
$this->assertSame('general_argument_invalid', $response['body']['type']);
$this->assertSame(200, $response['headers']['status-code']);
$this->assertSame('keycloak', $response['body']['$id']);
// Cleanup
$this->updateOAuth2('keycloak', [
'clientId' => '',
'clientSecret' => '',
'endpoint' => '',
'realmName' => '',
'enabled' => false,
]);
}
public function testUpdateOAuth2KeycloakEmptyRealmNameRejected(): void
public function testUpdateOAuth2KeycloakEmptyRealmNameRejectedWhenEnabling(): void
{
// The `realmName` validator is Text(min=1). Sending `''` must be
// rejected the same way as omitting.
$response = $this->updateOAuth2('keycloak', [
'clientId' => 'whatever',
'clientSecret' => 'whatever',
'endpoint' => 'keycloak.example.com',
'realmName' => '',
'enabled' => true,
]);
$this->assertSame(400, $response['headers']['status-code']);
@@ -1196,16 +1215,15 @@ trait OAuth2Base
$this->updateOAuth2('keycloak', [
'clientId' => '',
'clientSecret' => '',
'endpoint' => 'cleanup.keycloak.com',
'realmName' => 'cleanup-realm',
'endpoint' => '',
'realmName' => '',
'enabled' => false,
]);
}
public function testUpdateOAuth2KeycloakPartialPreservesSecret(): void
{
// Keycloak's `endpoint` and `realmName` are required on every call,
// so we always re-send them. The `clientSecret` lives in the JSON
// The `clientSecret`, `endpoint`, and `realmName` live in the JSON
// blob and must survive when omitted on a subsequent call that only
// changes clientId.
$this->updateOAuth2('keycloak', [
@@ -1218,8 +1236,6 @@ trait OAuth2Base
$response = $this->updateOAuth2('keycloak', [
'clientId' => 'keycloak-rotated-client',
'endpoint' => 'merge.keycloak.com',
'realmName' => 'merge-realm',
]);
$this->assertSame(200, $response['headers']['status-code']);
$this->assertSame('keycloak-rotated-client', $response['body']['clientId']);
@@ -1227,23 +1243,19 @@ trait OAuth2Base
$this->assertSame('merge-realm', $response['body']['realmName']);
// Confirm clientSecret survived the omitted-field merge by enabling
// — Keycloak has no verifyCredentials() hook, so non-empty stored
// secret is enough. `endpoint`/`realmName` must be re-sent (required
// on enable too).
// without re-sending endpoint or realmName.
$enable = $this->updateOAuth2('keycloak', [
'endpoint' => 'merge.keycloak.com',
'realmName' => 'merge-realm',
'enabled' => true,
]);
$this->assertSame(200, $enable['headers']['status-code']);
$this->assertTrue($enable['body']['enabled']);
// Cleanup — endpoint and realmName are required, use placeholders.
// Cleanup
$this->updateOAuth2('keycloak', [
'clientId' => '',
'clientSecret' => '',
'endpoint' => 'cleanup.keycloak.com',
'realmName' => 'cleanup-realm',
'endpoint' => '',
'realmName' => '',
'enabled' => false,
]);
}
@@ -1270,40 +1282,47 @@ trait OAuth2Base
$this->assertSame('enable-realm', $get['body']['realmName']);
$this->assertSame('', $get['body']['clientSecret']);
// Cleanup — endpoint and realmName are required (Text(min=1)) so use placeholders.
// Cleanup
$this->updateOAuth2('keycloak', [
'clientId' => '',
'clientSecret' => '',
'endpoint' => 'cleanup.keycloak.com',
'realmName' => 'cleanup-realm',
'endpoint' => '',
'realmName' => '',
'enabled' => false,
]);
}
// =========================================================================
// Update Microsoft (applicationId + applicationSecret + REQUIRED tenant)
// Update Microsoft (applicationId + applicationSecret + optional tenant)
// =========================================================================
public function testUpdateOAuth2MicrosoftRequiresTenant(): void
public function testUpdateOAuth2MicrosoftAllowsOmittedTenantWhenDisabled(): void
{
$response = $this->updateOAuth2('microsoft', [
'applicationId' => 'whatever',
'applicationSecret' => 'whatever',
'enabled' => false,
]);
$this->assertSame(400, $response['headers']['status-code']);
$this->assertSame('general_argument_invalid', $response['body']['type']);
$this->assertSame(200, $response['headers']['status-code']);
$this->assertSame('microsoft', $response['body']['$id']);
// Cleanup
$this->updateOAuth2('microsoft', [
'applicationId' => '',
'applicationSecret' => '',
'tenant' => '',
'enabled' => false,
]);
}
public function testUpdateOAuth2MicrosoftEmptyTenantRejected(): void
public function testUpdateOAuth2MicrosoftEmptyTenantRejectedWhenEnabling(): void
{
// The `tenant` validator is Text(min=1). Sending `''` must be rejected
// the same way as omitting — the validator should treat the empty
// string as a missing required field.
$response = $this->updateOAuth2('microsoft', [
'applicationId' => 'whatever',
'applicationSecret' => 'whatever',
'tenant' => '',
'enabled' => true,
]);
$this->assertSame(400, $response['headers']['status-code']);
@@ -1331,7 +1350,7 @@ trait OAuth2Base
$this->updateOAuth2('microsoft', [
'applicationId' => '',
'applicationSecret' => '',
'tenant' => 'common',
'tenant' => '',
'enabled' => false,
]);
}
@@ -1346,23 +1365,21 @@ trait OAuth2Base
'enabled' => false,
]);
// Patch with only `tenant` (it's required on every call) and a new
// applicationId, leaving applicationSecret omitted. The stored secret
// must not be wiped.
// Patch with only a new applicationId, leaving applicationSecret and
// tenant omitted. The stored JSON values must not be wiped.
$response = $this->updateOAuth2('microsoft', [
'applicationId' => 'updated-app-id',
'tenant' => 'organizations',
]);
$this->assertSame(200, $response['headers']['status-code']);
$this->assertSame('updated-app-id', $response['body']['applicationId']);
$this->assertSame('organizations', $response['body']['tenant']);
$this->assertSame('common', $response['body']['tenant']);
// Cleanup
$this->updateOAuth2('microsoft', [
'applicationId' => '',
'applicationSecret' => '',
'tenant' => 'common',
'tenant' => '',
'enabled' => false,
]);
}
@@ -1387,11 +1404,11 @@ trait OAuth2Base
$this->assertSame('common', $get['body']['tenant']);
$this->assertSame('', $get['body']['applicationSecret']);
// Cleanup — tenant is required (Text(min=1)) so use a placeholder.
// Cleanup
$this->updateOAuth2('microsoft', [
'applicationId' => '',
'applicationSecret' => '',
'tenant' => 'common',
'tenant' => '',
'enabled' => false,
]);
}
@@ -2401,8 +2418,9 @@ trait OAuth2Base
//
// Ensures each provider's Update endpoint is wired up correctly: routing,
// provider class, response model and `$id`. Custom-shaped providers
// (apple, auth0, authentik, gitlab, microsoft, oidc, okta, dropbox) and
// sandboxes (paypalSandbox, tradeshiftSandbox) have dedicated tests above.
// (apple, auth0, authentik, fusionauth, gitlab, keycloak, microsoft, oidc,
// okta, dropbox) and sandboxes (paypalSandbox, tradeshiftSandbox) have
// dedicated tests above.
// Github is excluded because its `verifyCredentials()` hook is exercised
// separately.
// =========================================================================