Merge remote-tracking branch 'origin/feat-memory-adapter-tests' into feat-memory-adapter-tests

This commit is contained in:
Jake Barnby
2026-05-01 08:35:27 +12:00
22 changed files with 469 additions and 239 deletions
+2
View File
@@ -48,6 +48,8 @@ _APP_DB_USER=user
_APP_DB_PASS=password
_APP_DB_ROOT_PASS=rootsecretpassword
_APP_DB_SQLITE_PATH=/storage/sqlite/appwrite.db
_APP_DATABASE_SHARED_TABLES=
_APP_DATABASE_SHARED_NAMESPACE=
_APP_DB_ADAPTER_DOCUMENTSDB=mongodb
_APP_DB_HOST_DOCUMENTSDB=mongodb
_APP_DB_PORT_DOCUMENTSDB=27017
+20
View File
@@ -157,12 +157,19 @@ $container->set('getProjectDB', function (Group $pools, Database $dbForPlatform,
}
if (isset($databases[$dsn->getHost()])) {
/** @var array $collections */
$collections = Config::getParam('collections', []);
$projectCollections = $collections['projects'] ?? [];
$projectsGlobalCollections = array_keys($projectCollections);
$projectsGlobalCollections[] = 'audit';
$database = $databases[$dsn->getHost()];
$sharedTables = \explode(',', System::getEnv('_APP_DATABASE_SHARED_TABLES', ''));
if (\in_array($dsn->getHost(), $sharedTables)) {
$database
->setSharedTables(true)
->setGlobalCollections($projectsGlobalCollections)
->setTenant($project->getSequence())
->setNamespace($dsn->getParam('namespace'));
} else {
@@ -182,9 +189,16 @@ $container->set('getProjectDB', function (Group $pools, Database $dbForPlatform,
$sharedTables = \explode(',', System::getEnv('_APP_DATABASE_SHARED_TABLES', ''));
if (\in_array($dsn->getHost(), $sharedTables)) {
/** @var array $collections */
$collections = Config::getParam('collections', []);
$projectCollections = $collections['projects'] ?? [];
$projectsGlobalCollections = array_keys($projectCollections);
$projectsGlobalCollections[] = 'audit';
$database
->setSharedTables(true)
->setTenant($project->getSequence())
->setGlobalCollections($projectsGlobalCollections)
->setNamespace($dsn->getParam('namespace'));
} else {
$database
@@ -212,6 +226,11 @@ $container->set('getLogsDB', function (Group $pools, Cache $cache, Authorization
return $database;
}
/** @var array $collections */
$collections = Config::getParam('collections', []);
$logsCollections = $collections['logs'] ?? [];
$logsCollections = array_keys($logsCollections);
$adapter = new DatabasePool($pools->get('logs'));
$database = new Database($adapter, $cache);
@@ -220,6 +239,7 @@ $container->set('getLogsDB', function (Group $pools, Cache $cache, Authorization
->setAuthorization($authorization)
->setSharedTables(true)
->setNamespace('logsV1')
->setGlobalCollections($logsCollections)
->setTimeout(APP_DATABASE_TIMEOUT_MILLISECONDS_TASK)
->setMaxQueryValues(APP_DATABASE_QUERY_MAX_VALUES);
+10
View File
@@ -28,6 +28,16 @@
"emails.invitation.thanks": "Gracias.,",
"emails.invitation.buttonText": "Aceptar invitación a {{team}}",
"emails.invitation.signature": "El equipo de {{project}}",
"emails.sessionAlert.subject": "Alerta de seguridad: nueva sesión en tu cuenta de {{project}}",
"emails.sessionAlert.preview": "Nuevo inicio de sesión detectado en {{project}} a las {{time}} UTC.",
"emails.sessionAlert.hello": "Hola {{user}},",
"emails.sessionAlert.body": "Se ha creado una nueva sesión en tu cuenta de {{b}}{{project}}{{/b}}, {{b}}el {{date}} de {{year}} a las {{time}} UTC{{/b}}.\nEstos son los detalles de la nueva sesión:",
"emails.sessionAlert.listDevice": "Dispositivo: {{b}}{{device}}{{/b}}",
"emails.sessionAlert.listIpAddress": "Dirección IP: {{b}}{{ipAddress}}{{/b}}",
"emails.sessionAlert.listCountry": "País: {{b}}{{country}}{{/b}}",
"emails.sessionAlert.footer": "Si has sido tú, no tienes que hacer nada más.\nSi no has iniciado esta sesión o sospechas actividad no autorizada, protege tu cuenta.",
"emails.sessionAlert.thanks": "Gracias,",
"emails.sessionAlert.signature": "El equipo de {{project}}",
"locale.country.unknown": "Desconocido",
"countries.af": "Afganistán",
"countries.ao": "Angola",
+6
View File
@@ -159,10 +159,16 @@ $container->set('getLogsDB', function (Group $pools, Cache $cache, Authorization
$adapter = new DatabasePool($pools->get('logs'));
$database = new Database($adapter, $cache);
/** @var array $collections */
$collections = Config::getParam('collections', []);
$logsCollections = $collections['logs'] ?? [];
$logsCollections = array_keys($logsCollections);
$database
->setDatabase(APP_DATABASE)
->setAuthorization($authorization)
->setSharedTables(true)
->setGlobalCollections($logsCollections)
->setNamespace('logsV1')
->setTimeout(APP_DATABASE_TIMEOUT_MILLISECONDS_API)
->setMaxQueryValues(APP_DATABASE_QUERY_MAX_VALUES);
+28
View File
@@ -204,9 +204,16 @@ return function (Container $container): void {
$sharedTables = \explode(',', System::getEnv('_APP_DATABASE_SHARED_TABLES', ''));
if (\in_array($dsn->getHost(), $sharedTables)) {
/** @var array $collections */
$collections = Config::getParam('collections', []);
$projectCollections = $collections['projects'] ?? [];
$projectsGlobalCollections = array_keys($projectCollections);
$projectsGlobalCollections[] = 'audit';
$database
->setSharedTables(true)
->setTenant($project->getSequence())
->setGlobalCollections($projectsGlobalCollections)
->setNamespace($dsn->getParam('namespace'));
} else {
$database
@@ -223,6 +230,11 @@ return function (Container $container): void {
$adapter = null;
return function (?Document $project = null) use ($pools, $cache, $authorization, &$adapter) {
/** @var array $collections */
$collections = Config::getParam('collections', []);
$logsCollections = $collections['logs'] ?? [];
$logsCollections = array_keys($logsCollections);
$adapter ??= new DatabasePool($pools->get('logs'));
$database = new Database($adapter, $cache);
@@ -230,6 +242,7 @@ return function (Container $container): void {
->setDatabase(APP_DATABASE)
->setAuthorization($authorization)
->setSharedTables(true)
->setGlobalCollections($logsCollections)
->setNamespace('logsV1')
->setTimeout(APP_DATABASE_TIMEOUT_MILLISECONDS_API)
->setMaxQueryValues(APP_DATABASE_QUERY_MAX_VALUES);
@@ -690,8 +703,15 @@ return function (Container $container): void {
$sharedTables = \explode(',', System::getEnv('_APP_DATABASE_SHARED_TABLES', ''));
if (\in_array($dsn->getHost(), $sharedTables)) {
/** @var array $collections */
$collections = Config::getParam('collections', []);
$projectCollections = $collections['projects'] ?? [];
$projectsGlobalCollections = array_keys($projectCollections);
$projectsGlobalCollections[] = 'audit';
$database
->setSharedTables(true)
->setGlobalCollections($projectsGlobalCollections)
->setTenant($project->getSequence())
->setNamespace($dsn->getParam('namespace'));
} else {
@@ -1292,6 +1312,12 @@ return function (Container $container): void {
$database = new Database($adapter, $cache);
$sharedTables = \array_filter(\explode(',', System::getEnv('_APP_DATABASE_SHARED_TABLES', '')));
/** @var array $collections */
$collections = Config::getParam('collections', []);
$projectCollections = $collections['projects'] ?? [];
$projectsGlobalCollections = array_keys($projectCollections);
$projectsGlobalCollections[] = 'audit';
$database
->setDatabase(APP_DATABASE)
->setAuthorization($authorization)
@@ -1314,6 +1340,7 @@ return function (Container $container): void {
if (\in_array($databaseHost, $dbTypeSharedTables)) {
$database
->setSharedTables(true)
->setGlobalCollections($projectsGlobalCollections)
->setTenant($project->getSequence())
->setNamespace($databaseDSN->getParam('namespace'));
} else {
@@ -1325,6 +1352,7 @@ return function (Container $container): void {
} elseif (\in_array($dsn->getHost(), $sharedTables)) {
$database
->setSharedTables(true)
->setGlobalCollections($projectsGlobalCollections)
->setTenant($project->getSequence())
->setNamespace($dsn->getParam('namespace'));
} else {
+38
View File
@@ -14,6 +14,7 @@ use Appwrite\Utopia\Database\Documents\User;
use Utopia\Audit\Adapter\Database as AdapterDatabase;
use Utopia\Audit\Audit as UtopiaAudit;
use Utopia\Cache\Cache;
use Utopia\Config\Config;
use Utopia\Console;
use Utopia\Database\Adapter\Pool as DatabasePool;
use Utopia\Database\Database;
@@ -90,8 +91,15 @@ return function (Container $container): void {
$sharedTables = \explode(',', System::getEnv('_APP_DATABASE_SHARED_TABLES', ''));
if (\in_array($dsn->getHost(), $sharedTables)) {
/** @var array $collections */
$collections = Config::getParam('collections', []);
$projectCollections = $collections['projects'] ?? [];
$projectsGlobalCollections = array_keys($projectCollections);
$projectsGlobalCollections[] = 'audit';
$database
->setSharedTables(true)
->setGlobalCollections($projectsGlobalCollections)
->setTenant($project->getSequence())
->setNamespace($dsn->getParam('namespace'));
} else {
@@ -130,8 +138,15 @@ return function (Container $container): void {
$sharedTables = \explode(',', System::getEnv('_APP_DATABASE_SHARED_TABLES', ''));
if (\in_array($dsn->getHost(), $sharedTables)) {
/** @var array $collections */
$collections = Config::getParam('collections', []);
$projectCollections = $collections['projects'] ?? [];
$projectsGlobalCollections = array_keys($projectCollections);
$projectsGlobalCollections[] = 'audit';
$database
->setSharedTables(true)
->setGlobalCollections($projectsGlobalCollections)
->setTenant($project->getSequence())
->setNamespace($dsn->getParam('namespace'));
} else {
@@ -152,8 +167,15 @@ return function (Container $container): void {
$sharedTables = \explode(',', System::getEnv('_APP_DATABASE_SHARED_TABLES', ''));
if (\in_array($dsn->getHost(), $sharedTables)) {
/** @var array $collections */
$collections = Config::getParam('collections', []);
$projectCollections = $collections['projects'] ?? [];
$projectsGlobalCollections = array_keys($projectCollections);
$projectsGlobalCollections[] = 'audit';
$database
->setSharedTables(true)
->setGlobalCollections($projectsGlobalCollections)
->setTenant($project->getSequence())
->setNamespace($dsn->getParam('namespace'));
} else {
@@ -210,6 +232,14 @@ return function (Container $container): void {
$sharedTables = \array_filter(\explode(',', System::getEnv('_APP_DATABASE_SHARED_TABLES', '')));
/** @var array $collections */
$collections = Config::getParam('collections', []);
$projectCollections = $collections['projects'] ?? [];
$projectsGlobalCollections = array_keys($projectCollections);
$projectsGlobalCollections[] = 'audit';
$database->setGlobalCollections($projectsGlobalCollections);
// For separate pools (documentsdb/vectorsdb), check their own shared tables config.
// If not configured, use dedicated mode to avoid cross-engine tenant type mismatches.
if ($databaseHost !== $dsn->getHost()) {
@@ -222,6 +252,7 @@ return function (Container $container): void {
if (\in_array($databaseHost, $dbTypeSharedTables)) {
$database
->setSharedTables(true)
->setGlobalCollections($projectsGlobalCollections)
->setTenant($projectDocument->getSequence())
->setNamespace($databaseDSN->getParam('namespace'));
} else {
@@ -233,6 +264,7 @@ return function (Container $container): void {
} elseif (\in_array($dsn->getHost(), $sharedTables, true)) {
$database
->setSharedTables(true)
->setGlobalCollections($projectsGlobalCollections)
->setTenant($projectDocument->getSequence())
->setNamespace($dsn->getParam('namespace'));
} else {
@@ -257,6 +289,11 @@ return function (Container $container): void {
return $database;
}
/** @var array $collections */
$collections = Config::getParam('collections', []);
$logsCollections = $collections['logs'] ?? [];
$logsCollections = array_keys($logsCollections);
$adapter = new DatabasePool($pools->get('logs'));
$database = new Database($adapter, $cache);
@@ -264,6 +301,7 @@ return function (Container $container): void {
->setDatabase(APP_DATABASE)
->setAuthorization($authorization)
->setSharedTables(true)
->setGlobalCollections($logsCollections)
->setNamespace('logsV1')
->setTimeout(APP_DATABASE_TIMEOUT_MILLISECONDS_WORKER)
->setMaxQueryValues(APP_DATABASE_QUERY_MAX_VALUES_WORKER);
+6
View File
@@ -130,8 +130,14 @@ if (!function_exists('getProjectDB')) {
$sharedTables = \explode(',', System::getEnv('_APP_DATABASE_SHARED_TABLES', ''));
if (\in_array($dsn->getHost(), $sharedTables)) {
$collections = Config::getParam('collections', []);
$projectCollections = $collections['projects'] ?? [];
$projectsGlobalCollections = array_keys($projectCollections);
$projectsGlobalCollections[] = 'audit';
$database
->setSharedTables(true)
->setGlobalCollections($projectsGlobalCollections)
->setTenant($project->getSequence())
->setNamespace($dsn->getParam('namespace'));
} else {
Generated
+29 -29
View File
@@ -3351,16 +3351,16 @@
},
{
"name": "utopia-php/abuse",
"version": "1.2.2",
"version": "1.2.3",
"source": {
"type": "git",
"url": "https://github.com/utopia-php/abuse.git",
"reference": "20bee84fd14dbe81d50ecabf1ffd81cceca06152"
"reference": "53f4274939353522ba331f55bcff6e6011ffc56c"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/utopia-php/abuse/zipball/20bee84fd14dbe81d50ecabf1ffd81cceca06152",
"reference": "20bee84fd14dbe81d50ecabf1ffd81cceca06152",
"url": "https://api.github.com/repos/utopia-php/abuse/zipball/53f4274939353522ba331f55bcff6e6011ffc56c",
"reference": "53f4274939353522ba331f55bcff6e6011ffc56c",
"shasum": ""
},
"require": {
@@ -3397,9 +3397,9 @@
],
"support": {
"issues": "https://github.com/utopia-php/abuse/issues",
"source": "https://github.com/utopia-php/abuse/tree/1.2.2"
"source": "https://github.com/utopia-php/abuse/tree/1.2.3"
},
"time": "2026-02-02T10:43:10+00:00"
"time": "2026-04-29T11:19:08+00:00"
},
{
"name": "utopia-php/agents",
@@ -4094,16 +4094,16 @@
},
{
"name": "utopia-php/domains",
"version": "1.0.5",
"version": "1.0.6",
"source": {
"type": "git",
"url": "https://github.com/utopia-php/domains.git",
"reference": "0edf6bb2b07f30db849a267027077bf5abb994c6"
"reference": "c87ba0a1da4cbf75d2cff9d3ea0262b78f1d86f6"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/utopia-php/domains/zipball/0edf6bb2b07f30db849a267027077bf5abb994c6",
"reference": "0edf6bb2b07f30db849a267027077bf5abb994c6",
"url": "https://api.github.com/repos/utopia-php/domains/zipball/c87ba0a1da4cbf75d2cff9d3ea0262b78f1d86f6",
"reference": "c87ba0a1da4cbf75d2cff9d3ea0262b78f1d86f6",
"shasum": ""
},
"require": {
@@ -4150,9 +4150,9 @@
],
"support": {
"issues": "https://github.com/utopia-php/domains/issues",
"source": "https://github.com/utopia-php/domains/tree/1.0.5"
"source": "https://github.com/utopia-php/domains/tree/1.0.6"
},
"time": "2026-03-03T09:20:50+00:00"
"time": "2026-04-29T11:08:10+00:00"
},
{
"name": "utopia-php/dsn",
@@ -4562,16 +4562,16 @@
},
{
"name": "utopia-php/migration",
"version": "1.9.4",
"version": "1.9.5",
"source": {
"type": "git",
"url": "https://github.com/utopia-php/migration.git",
"reference": "969dc9477ea962f16da9254facdbd8944cf13477"
"reference": "952a4dfe232702f80e45c35129466a8d8cb4c599"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/utopia-php/migration/zipball/969dc9477ea962f16da9254facdbd8944cf13477",
"reference": "969dc9477ea962f16da9254facdbd8944cf13477",
"url": "https://api.github.com/repos/utopia-php/migration/zipball/952a4dfe232702f80e45c35129466a8d8cb4c599",
"reference": "952a4dfe232702f80e45c35129466a8d8cb4c599",
"shasum": ""
},
"require": {
@@ -4611,9 +4611,9 @@
],
"support": {
"issues": "https://github.com/utopia-php/migration/issues",
"source": "https://github.com/utopia-php/migration/tree/1.9.4"
"source": "https://github.com/utopia-php/migration/tree/1.9.5"
},
"time": "2026-04-27T12:42:51+00:00"
"time": "2026-04-29T11:19:13+00:00"
},
{
"name": "utopia-php/mongo",
@@ -5052,16 +5052,16 @@
},
{
"name": "utopia-php/storage",
"version": "2.0.0",
"version": "2.0.1",
"source": {
"type": "git",
"url": "https://github.com/utopia-php/storage.git",
"reference": "52d1f89a47165ef0d3deff63043cda182175adfb"
"reference": "8a2e3a86fd01aaed675884146665308c2122264e"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/utopia-php/storage/zipball/52d1f89a47165ef0d3deff63043cda182175adfb",
"reference": "52d1f89a47165ef0d3deff63043cda182175adfb",
"url": "https://api.github.com/repos/utopia-php/storage/zipball/8a2e3a86fd01aaed675884146665308c2122264e",
"reference": "8a2e3a86fd01aaed675884146665308c2122264e",
"shasum": ""
},
"require": {
@@ -5098,9 +5098,9 @@
],
"support": {
"issues": "https://github.com/utopia-php/storage/issues",
"source": "https://github.com/utopia-php/storage/tree/2.0.0"
"source": "https://github.com/utopia-php/storage/tree/2.0.1"
},
"time": "2026-04-27T11:39:32+00:00"
"time": "2026-04-29T09:05:48+00:00"
},
{
"name": "utopia-php/system",
@@ -6253,11 +6253,11 @@
},
{
"name": "phpstan/phpstan",
"version": "2.1.52",
"version": "2.1.54",
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/phpstan/phpstan/zipball/08a34f8db7ca4daabff74a474fe13c0e56e2b4e5",
"reference": "08a34f8db7ca4daabff74a474fe13c0e56e2b4e5",
"url": "https://api.github.com/repos/phpstan/phpstan/zipball/8be50c3992107dc837b17da4d140fbbdf9a5c5bd",
"reference": "8be50c3992107dc837b17da4d140fbbdf9a5c5bd",
"shasum": ""
},
"require": {
@@ -6302,7 +6302,7 @@
"type": "github"
}
],
"time": "2026-04-28T12:17:53+00:00"
"time": "2026-04-29T13:31:09+00:00"
},
{
"name": "phpunit/php-code-coverage",
@@ -8506,5 +8506,5 @@
"platform-dev": {
"ext-fileinfo": "*"
},
"plugin-api-version": "2.9.0"
"plugin-api-version": "2.6.0"
}
+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'] ?? '';
}
}
@@ -54,9 +54,9 @@ class XList extends Action
$actions = OAuth2Base::getProviderActions();
$providers = [];
foreach ($actions as $providerId => $updateClass) {
$config = $providersConfig[$providerId] ?? null;
if ($config === null) {
foreach ($providersConfig as $providerId => $config) {
$updateClass = $actions[$providerId] ?? null;
if ($updateClass === null) {
continue;
}
if (!($config['enabled'] ?? false)) {
@@ -146,13 +146,14 @@ class Update extends Base
{
$providerId = static::getProviderId();
$oAuthProviders = $project->getAttribute('oAuthProviders', []);
$storedSecret = $this->decodeStoredSecret($project);
return new Document([
'$id' => $providerId,
'enabled' => $oAuthProviders[$providerId . 'Enabled'] ?? false,
static::getClientIdParamName() => $oAuthProviders[$providerId . 'Appid'] ?? '',
'keyId' => '',
'teamId' => '',
'keyId' => $storedSecret['keyID'] ?? '',
'teamId' => $storedSecret['teamID'] ?? '',
'p8File' => '',
]);
}
@@ -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);
+144 -73
View File
@@ -633,6 +633,89 @@ class Deletes extends Action
$dsn = new DSN('mysql://' . $document->getAttribute('database', 'console'));
}
// Delete Platforms
try {
$this->deleteByGroup('platforms', [
Query::equal('projectInternalId', [$projectInternalId]),
Query::orderAsc()
], $dbForPlatform);
} catch (Throwable $th) {
Console::error('Failed to delete platforms: ' . $th->getMessage());
}
// Delete project and function rules
try {
$this->deleteByGroup('rules', [
Query::equal('projectInternalId', [$projectInternalId]),
Query::orderAsc()
], $dbForPlatform, function (Document $document) use ($dbForPlatform, $certificates) {
$this->deleteRule($dbForPlatform, $document, $certificates);
});
} catch (Throwable $th) {
Console::error('Failed to delete rules: ' . $th->getMessage());
}
// Delete Keys
try {
$this->deleteByGroup('keys', [
Query::equal('resourceType', ['projects']),
Query::equal('resourceInternalId', [$projectInternalId]),
Query::orderAsc()
], $dbForPlatform);
} catch (Throwable $th) {
Console::error('Failed to delete keys: ' . $th->getMessage());
}
// Delete Webhooks
try {
$this->deleteByGroup('webhooks', [
Query::equal('projectInternalId', [$projectInternalId]),
Query::orderAsc()
], $dbForPlatform);
} catch (Throwable $th) {
Console::error('Failed to delete webhooks: ' . $th->getMessage());
}
// Delete VCS Installations
try {
$this->deleteByGroup('installations', [
Query::equal('projectInternalId', [$projectInternalId]),
Query::orderAsc()
], $dbForPlatform);
} catch (Throwable $th) {
Console::error('Failed to delete installations: ' . $th->getMessage());
}
// Delete VCS Repositories
try {
$this->deleteByGroup('repositories', [
Query::equal('projectInternalId', [$projectInternalId]),
Query::orderAsc()
], $dbForPlatform);
} catch (Throwable $th) {
Console::error('Failed to delete repositories: ' . $th->getMessage());
}
// Delete VCS comments
try {
$this->deleteByGroup('vcsComments', [
Query::equal('projectInternalId', [$projectInternalId]),
Query::orderAsc()
], $dbForPlatform);
} catch (Throwable $th) {
Console::error('Failed to delete VCS comments: ' . $th->getMessage());
}
// Delete Schedules
try {
$this->deleteByGroup('schedules', [
Query::equal('projectId', [$projectId]),
Query::orderAsc()
], $dbForPlatform);
} catch (Throwable $th) {
Console::error('Failed to delete schedules: ' . $th->getMessage());
}
/**
* @var Database $dbForProject
*/
@@ -685,75 +768,35 @@ class Deletes extends Action
};
batch(array_map(
fn ($databaseDoc) => fn () => $this->cleanDatabase(
$databaseDoc,
$executionActionPerDatabase,
$projectTables,
$projectCollectionIds
),
fn ($databaseDoc) => function () use ($databaseDoc, $executionActionPerDatabase, $projectTables, $projectCollectionIds) {
try {
$this->cleanDatabase(
$databaseDoc,
$executionActionPerDatabase,
$projectTables,
$projectCollectionIds
);
} catch (Throwable $th) {
Console::error('Failed to delete database ' . $databaseDoc->getAttribute('database') . ': ' . $th->getMessage());
}
},
$databasesToClean
));
// Delete Platforms
$this->deleteByGroup('platforms', [
Query::equal('projectInternalId', [$projectInternalId]),
Query::orderAsc()
], $dbForPlatform);
// Delete project and function rules
$this->deleteByGroup('rules', [
Query::equal('projectInternalId', [$projectInternalId]),
Query::orderAsc()
], $dbForPlatform, function (Document $document) use ($dbForPlatform, $certificates) {
$this->deleteRule($dbForPlatform, $document, $certificates);
});
// Delete Keys
$this->deleteByGroup('keys', [
Query::equal('resourceType', ['projects']),
Query::equal('resourceInternalId', [$projectInternalId]),
Query::orderAsc()
], $dbForPlatform);
// Delete Webhooks
$this->deleteByGroup('webhooks', [
Query::equal('projectInternalId', [$projectInternalId]),
Query::orderAsc()
], $dbForPlatform);
// Delete VCS Installations
$this->deleteByGroup('installations', [
Query::equal('projectInternalId', [$projectInternalId]),
Query::orderAsc()
], $dbForPlatform);
// Delete VCS Repositories
$this->deleteByGroup('repositories', [
Query::equal('projectInternalId', [$projectInternalId]),
Query::orderAsc()
], $dbForPlatform);
// Delete VCS comments
$this->deleteByGroup('vcsComments', [
Query::equal('projectInternalId', [$projectInternalId]),
Query::orderAsc()
], $dbForPlatform);
// Delete Schedules
$this->deleteByGroup('schedules', [
Query::equal('projectId', [$projectId]),
Query::orderAsc()
], $dbForPlatform);
// Delete metadata table
if ($projectTables) {
batch(array_map(
fn ($databaseDoc) => fn () =>
$executionActionPerDatabase(
$databaseDoc,
fn (Database $dbForDatabases) =>
$dbForDatabases->deleteCollection(Database::METADATA)
),
fn ($databaseDoc) => function () use ($databaseDoc, $executionActionPerDatabase) {
try {
$executionActionPerDatabase(
$databaseDoc,
fn (Database $dbForDatabases) =>
$dbForDatabases->deleteCollection(Database::METADATA)
);
} catch (Throwable $th) {
Console::error('Failed to delete metadata table for database ' . $databaseDoc->getAttribute('database') . ': ' . $th->getMessage());
}
},
$databasesToClean
));
} else {
@@ -764,19 +807,47 @@ class Deletes extends Action
$queries[] = Query::orderAsc();
$this->deleteByGroup(
Database::METADATA,
$queries,
$dbForProject
);
try {
$this->deleteByGroup(
Database::METADATA,
$queries,
$dbForProject
);
} catch (Throwable $th) {
Console::error('Failed to delete metadata documents: ' . $th->getMessage());
}
}
// Delete all storage directories
$deviceForFiles->delete($deviceForFiles->getRoot(), true);
$deviceForSites->delete($deviceForSites->getRoot(), true);
$deviceForFunctions->delete($deviceForFunctions->getRoot(), true);
$deviceForBuilds->delete($deviceForBuilds->getRoot(), true);
$deviceForCache->delete($deviceForCache->getRoot(), true);
try {
$deviceForFiles->delete($deviceForFiles->getRoot(), true);
} catch (Throwable $th) {
Console::error('Failed to delete files storage directory: ' . $th->getMessage());
}
try {
$deviceForSites->delete($deviceForSites->getRoot(), true);
} catch (Throwable $th) {
Console::error('Failed to delete sites storage directory: ' . $th->getMessage());
}
try {
$deviceForFunctions->delete($deviceForFunctions->getRoot(), true);
} catch (Throwable $th) {
Console::error('Failed to delete functions storage directory: ' . $th->getMessage());
}
try {
$deviceForBuilds->delete($deviceForBuilds->getRoot(), true);
} catch (Throwable $th) {
Console::error('Failed to delete builds storage directory: ' . $th->getMessage());
}
try {
$deviceForCache->delete($deviceForCache->getRoot(), true);
} catch (Throwable $th) {
Console::error('Failed to delete cache storage directory: ' . $th->getMessage());
}
} finally {
$dbForProject->enableValidation();
@@ -56,6 +56,8 @@ class ConsoleConsoleClientTest extends Scope
$this->assertEquals($response['body']['total'], \count($response['body']['oAuth2Providers']));
$providerIds = \array_column($response['body']['oAuth2Providers'], '$id');
$this->assertEquals('amazon', $providerIds[0]);
$this->assertEquals('zoom', $providerIds[\count($providerIds) - 1]);
// Well-known providers must be present
$this->assertContains('github', $providerIds);
@@ -99,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];
+123 -109
View File
@@ -478,9 +478,8 @@ trait OAuth2Base
$this->assertSame(200, $response['headers']['status-code']);
$this->assertSame('apple', $response['body']['$id']);
$this->assertSame('ip.appwrite.app.web', $response['body']['serviceId']);
// keyId / teamId / p8File are write-only — PATCH response must not echo them back.
$this->assertSame('', $response['body']['keyId']);
$this->assertSame('', $response['body']['teamId']);
$this->assertSame('P4000000N8', $response['body']['keyId']);
$this->assertSame('D4000000R6', $response['body']['teamId']);
$this->assertSame('', $response['body']['p8File']);
$this->assertSame(false, $response['body']['enabled']);
@@ -511,12 +510,10 @@ trait OAuth2Base
]);
$this->assertSame(200, $response['headers']['status-code']);
// serviceId is the (non-secret) clientId; keyId/teamId are write-only
// and must not surface in the response. Persistence of the merged
// values is verified separately via the enable-after-merge tests.
$this->assertSame('ip.appwrite.app.seed', $response['body']['serviceId']);
$this->assertSame('', $response['body']['keyId']);
$this->assertSame('', $response['body']['teamId']);
$this->assertSame('KEYUPDATED', $response['body']['keyId']);
$this->assertSame('TEAMSEED01', $response['body']['teamId']);
$this->assertSame('', $response['body']['p8File']);
// Cleanup
$this->updateOAuth2('apple', [
@@ -546,9 +543,9 @@ trait OAuth2Base
'teamId' => 'TEAMROTATED',
]);
$this->assertSame(200, $teamOnly['headers']['status-code']);
// teamId is write-only; verify only the non-secret serviceId echo.
// The actual merge is validated by the enable-after-merge call below.
$this->assertSame('', $teamOnly['body']['teamId']);
$this->assertSame('TEAMROTATED', $teamOnly['body']['teamId']);
$this->assertSame('KEYMERGE01', $teamOnly['body']['keyId']);
$this->assertSame('', $teamOnly['body']['p8File']);
$this->assertSame('ip.appwrite.app.merge', $teamOnly['body']['serviceId']);
// Patch only `serviceId` — keyId/teamId/p8File live in the JSON blob
@@ -669,9 +666,8 @@ trait OAuth2Base
$this->assertSame(200, $response['headers']['status-code']);
$this->assertSame('ip.appwrite.app.read', $response['body']['serviceId']);
// All three secret-bearing fields must be hidden on read.
$this->assertSame('', $response['body']['keyId']);
$this->assertSame('', $response['body']['teamId']);
$this->assertSame('KEYREAD', $response['body']['keyId']);
$this->assertSame('TEAMREAD', $response['body']['teamId']);
$this->assertSame('', $response['body']['p8File']);
// Cleanup
@@ -699,13 +695,13 @@ trait OAuth2Base
$this->assertSame(200, $update['headers']['status-code']);
$this->assertTrue($update['body']['enabled']);
// GET must hide all three secret-bearing fields while keeping serviceId.
// GET must hide p8File while keeping the non-secret fields.
$get = $this->getOAuth2Provider('apple');
$this->assertSame(200, $get['headers']['status-code']);
$this->assertTrue($get['body']['enabled']);
$this->assertSame('ip.appwrite.app.enable', $get['body']['serviceId']);
$this->assertSame('', $get['body']['keyId']);
$this->assertSame('', $get['body']['teamId']);
$this->assertSame('ENABLEKEY', $get['body']['keyId']);
$this->assertSame('ENABLETEAM', $get['body']['teamId']);
$this->assertSame('', $get['body']['p8File']);
// Cleanup
@@ -876,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']);
@@ -924,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',
@@ -943,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,
]);
}
@@ -988,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']);
@@ -1046,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',
@@ -1065,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,
]);
}
@@ -1110,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']);
@@ -1200,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', [
@@ -1222,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']);
@@ -1231,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,
]);
}
@@ -1274,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']);
@@ -1335,7 +1350,7 @@ trait OAuth2Base
$this->updateOAuth2('microsoft', [
'applicationId' => '',
'applicationSecret' => '',
'tenant' => 'common',
'tenant' => '',
'enabled' => false,
]);
}
@@ -1350,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,
]);
}
@@ -1391,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,
]);
}
@@ -2405,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.
// =========================================================================