diff --git a/app/config/sdks.php b/app/config/sdks.php index e89265b05e..cbfe76dce4 100644 --- a/app/config/sdks.php +++ b/app/config/sdks.php @@ -320,6 +320,26 @@ return [ 'repoBranch' => 'main', 'changelog' => \realpath(__DIR__ . '/../../docs/sdks/claude-plugin/CHANGELOG.md'), ], + [ + 'key' => 'codex-plugin', + 'name' => 'CodexPlugin', + 'version' => '0.1.0', + 'url' => 'https://github.com/appwrite/codex-plugin.git', + 'enabled' => true, + 'beta' => false, + 'dev' => false, + 'hidden' => false, + 'spec' => 'static', + 'family' => APP_SDK_PLATFORM_STATIC, + 'prism' => 'codex-plugin', + 'source' => \realpath(__DIR__ . '/../sdks/static-codex-plugin'), + 'gitUrl' => 'git@github.com:appwrite/codex-plugin.git', + 'gitRepoName' => 'codex-plugin', + 'gitUserName' => 'appwrite', + 'gitBranch' => 'dev', + 'repoBranch' => 'main', + 'changelog' => \realpath(__DIR__ . '/../../docs/sdks/codex-plugin/CHANGELOG.md'), + ], ], ], diff --git a/composer.json b/composer.json index 55106d33d1..7d68a838f8 100644 --- a/composer.json +++ b/composer.json @@ -51,7 +51,7 @@ "ext-sockets": "*", "appwrite/php-runtimes": "0.20.*", "appwrite/php-clamav": "2.0.*", - "utopia-php/abuse": "1.2.*", + "utopia-php/abuse": "1.3.*", "utopia-php/agents": "1.2.*", "utopia-php/analytics": "0.15.*", "utopia-php/audit": "2.2.*", diff --git a/composer.lock b/composer.lock index fb32342cd7..0aa58ca03e 100644 --- a/composer.lock +++ b/composer.lock @@ -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": "4ef65b015dba97e91f6571b061787653", + "content-hash": "d58736fec3028d1f9aedd055e6d82684", "packages": [ { "name": "adhocore/jwt", @@ -69,25 +69,25 @@ }, { "name": "appwrite/appwrite", - "version": "19.1.0", + "version": "23.1.0", "source": { "type": "git", "url": "https://github.com/appwrite/sdk-for-php.git", - "reference": "8738e812062f899c85b2598eef43d6a247f08a56" + "reference": "2f275921f10ceb7cff99f2d463f7328b296234fa" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/appwrite/sdk-for-php/zipball/8738e812062f899c85b2598eef43d6a247f08a56", - "reference": "8738e812062f899c85b2598eef43d6a247f08a56", + "url": "https://api.github.com/repos/appwrite/sdk-for-php/zipball/2f275921f10ceb7cff99f2d463f7328b296234fa", + "reference": "2f275921f10ceb7cff99f2d463f7328b296234fa", "shasum": "" }, "require": { "ext-curl": "*", "ext-json": "*", - "php": ">=7.1.0" + "php": ">=8.2.0" }, "require-dev": { - "mockery/mockery": "^1.6.12", + "mockery/mockery": "1.6.12", "phpunit/phpunit": "^10" }, "type": "library", @@ -100,14 +100,14 @@ "license": [ "BSD-3-Clause" ], - "description": "Appwrite is an open-source self-hosted backend server that abstract and simplify complex and repetitive development tasks behind a very simple REST API", + "description": "Appwrite is an open-source self-hosted backend server that abstracts and simplifies complex and repetitive development tasks behind a very simple REST API", "support": { "email": "team@appwrite.io", "issues": "https://github.com/appwrite/sdk-for-php/issues", - "source": "https://github.com/appwrite/sdk-for-php/tree/19.1.0", + "source": "https://github.com/appwrite/sdk-for-php/tree/23.1.0", "url": "https://appwrite.io/support" }, - "time": "2025-12-18T08:07:43+00:00" + "time": "2026-05-08T13:44:58+00:00" }, { "name": "appwrite/php-clamav", @@ -3359,24 +3359,24 @@ }, { "name": "utopia-php/abuse", - "version": "1.2.3", + "version": "1.3.0", "source": { "type": "git", "url": "https://github.com/utopia-php/abuse.git", - "reference": "53f4274939353522ba331f55bcff6e6011ffc56c" + "reference": "5d7efbe5c6b0cf7d06003114fd86e24ba785582f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/abuse/zipball/53f4274939353522ba331f55bcff6e6011ffc56c", - "reference": "53f4274939353522ba331f55bcff6e6011ffc56c", + "url": "https://api.github.com/repos/utopia-php/abuse/zipball/5d7efbe5c6b0cf7d06003114fd86e24ba785582f", + "reference": "5d7efbe5c6b0cf7d06003114fd86e24ba785582f", "shasum": "" }, "require": { - "appwrite/appwrite": "19.*", + "appwrite/appwrite": "23.*", "ext-curl": "*", "ext-pdo": "*", "ext-redis": "*", - "php": ">=8.0", + "php": ">=8.2", "utopia-php/database": "5.*" }, "require-dev": { @@ -3405,9 +3405,9 @@ ], "support": { "issues": "https://github.com/utopia-php/abuse/issues", - "source": "https://github.com/utopia-php/abuse/tree/1.2.3" + "source": "https://github.com/utopia-php/abuse/tree/1.3.0" }, - "time": "2026-04-29T11:19:08+00:00" + "time": "2026-05-11T08:07:02+00:00" }, { "name": "utopia-php/agents", @@ -3614,16 +3614,16 @@ }, { "name": "utopia-php/cache", - "version": "1.0.1", + "version": "1.0.2", "source": { "type": "git", "url": "https://github.com/utopia-php/cache.git", - "reference": "05ceba981436a4022553f7aaa2a05fa049d0f71c" + "reference": "d36f9050c39c02e09a7763389c9e71258e74af1f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/cache/zipball/05ceba981436a4022553f7aaa2a05fa049d0f71c", - "reference": "05ceba981436a4022553f7aaa2a05fa049d0f71c", + "url": "https://api.github.com/repos/utopia-php/cache/zipball/d36f9050c39c02e09a7763389c9e71258e74af1f", + "reference": "d36f9050c39c02e09a7763389c9e71258e74af1f", "shasum": "" }, "require": { @@ -3660,9 +3660,9 @@ ], "support": { "issues": "https://github.com/utopia-php/cache/issues", - "source": "https://github.com/utopia-php/cache/tree/1.0.1" + "source": "https://github.com/utopia-php/cache/tree/1.0.2" }, - "time": "2026-03-12T03:39:09+00:00" + "time": "2026-05-08T11:40:20+00:00" }, { "name": "utopia-php/cli", @@ -4541,24 +4541,24 @@ }, { "name": "utopia-php/migration", - "version": "1.10.2", + "version": "1.11.0", "source": { "type": "git", "url": "https://github.com/utopia-php/migration.git", - "reference": "211d01b90ccab9729029151c6c61f543bd755c2e" + "reference": "0fca44f40ad07bf2d56e9396afa6fa6d9b098ef1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/migration/zipball/211d01b90ccab9729029151c6c61f543bd755c2e", - "reference": "211d01b90ccab9729029151c6c61f543bd755c2e", + "url": "https://api.github.com/repos/utopia-php/migration/zipball/0fca44f40ad07bf2d56e9396afa6fa6d9b098ef1", + "reference": "0fca44f40ad07bf2d56e9396afa6fa6d9b098ef1", "shasum": "" }, "require": { - "appwrite/appwrite": "19.*", + "appwrite/appwrite": "23.*", "ext-curl": "*", "ext-openssl": "*", "halaxa/json-machine": "^1.2", - "php": ">=8.1", + "php": ">=8.2", "utopia-php/database": "5.*", "utopia-php/dsn": "0.2.*", "utopia-php/storage": "2.*" @@ -4590,9 +4590,9 @@ ], "support": { "issues": "https://github.com/utopia-php/migration/issues", - "source": "https://github.com/utopia-php/migration/tree/1.10.2" + "source": "https://github.com/utopia-php/migration/tree/1.11.0" }, - "time": "2026-05-08T06:25:47+00:00" + "time": "2026-05-11T08:13:06+00:00" }, { "name": "utopia-php/mongo", @@ -5476,16 +5476,16 @@ "packages-dev": [ { "name": "appwrite/sdk-generator", - "version": "1.27.5", + "version": "1.28.1", "source": { "type": "git", "url": "https://github.com/appwrite/sdk-generator.git", - "reference": "9faa38b48d422f3da764a719712905c83b3922cb" + "reference": "009118ccda8ccece2b9fc043c158cb1dd3efaa88" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/appwrite/sdk-generator/zipball/9faa38b48d422f3da764a719712905c83b3922cb", - "reference": "9faa38b48d422f3da764a719712905c83b3922cb", + "url": "https://api.github.com/repos/appwrite/sdk-generator/zipball/009118ccda8ccece2b9fc043c158cb1dd3efaa88", + "reference": "009118ccda8ccece2b9fc043c158cb1dd3efaa88", "shasum": "" }, "require": { @@ -5521,9 +5521,9 @@ "description": "Appwrite PHP library for generating API SDKs for multiple programming languages and platforms", "support": { "issues": "https://github.com/appwrite/sdk-generator/issues", - "source": "https://github.com/appwrite/sdk-generator/tree/1.27.5" + "source": "https://github.com/appwrite/sdk-generator/tree/1.28.1" }, - "time": "2026-05-05T12:09:40+00:00" + "time": "2026-05-08T13:24:33+00:00" }, { "name": "brianium/paratest", diff --git a/src/Appwrite/Auth/OAuth2/Google.php b/src/Appwrite/Auth/OAuth2/Google.php index 79894c2422..1166a313c6 100644 --- a/src/Appwrite/Auth/OAuth2/Google.php +++ b/src/Appwrite/Auth/OAuth2/Google.php @@ -55,7 +55,7 @@ class Google extends OAuth2 'state' => \json_encode($this->state), 'response_type' => 'code', 'access_type' => 'offline', - 'prompt' => 'consent' + 'prompt' => $this->getPrompt() ]); } @@ -72,7 +72,7 @@ class Google extends OAuth2 'https://oauth2.googleapis.com/token?' . \http_build_query([ 'code' => $code, 'client_id' => $this->appID, - 'client_secret' => $this->appSecret, + 'client_secret' => $this->getClientSecret(), 'redirect_uri' => $this->callback, 'scope' => null, 'grant_type' => 'authorization_code' @@ -95,7 +95,7 @@ class Google extends OAuth2 'https://oauth2.googleapis.com/token?' . \http_build_query([ 'refresh_token' => $refreshToken, 'client_id' => $this->appID, - 'client_secret' => $this->appSecret, + 'client_secret' => $this->getClientSecret(), 'grant_type' => 'refresh_token' ]) ), true); @@ -177,4 +177,54 @@ class Google extends OAuth2 return $this->user; } + + /** + * Extracts the Client Secret from the JSON stored in appSecret + * + * @return string + */ + protected function getClientSecret(): string + { + $secret = $this->getAppSecret(); + + return $secret['clientSecret'] ?? $this->appSecret; + } + + /** + * Extracts the prompt values from the JSON stored in appSecret + * + * @return string + */ + protected function getPrompt(): string + { + $secret = $this->getAppSecret(); + $prompt = $secret['prompt'] ?? []; + + if (empty($prompt)) { + $prompt = ['consent']; + } + + return \implode(' ', $prompt); + } + + /** + * Decode the JSON stored in appSecret. + * Falls back to treating the raw string as the client secret for backwards compatibility. + * + * @return array + */ + protected function getAppSecret(): array + { + try { + $secret = \json_decode($this->appSecret, true, 512, JSON_THROW_ON_ERROR); + } catch (\Throwable $th) { + return ['clientSecret' => $this->appSecret]; + } + + if (!\is_array($secret)) { + return ['clientSecret' => $this->appSecret]; + } + + return $secret; + } } diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Create.php index 8e1895e8f6..294a6712a9 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Create.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Create.php @@ -36,10 +36,10 @@ class Create extends Action { // TODO: use database worker for for creating the v2 schema if not present // it is considered that the v2 metadata schema is already created during server start in the http.php - return self::constructDatabaseDSNFromProjectDatabase($this->getDatabaseType(), $project->getAttribute('region'), $project->getAttribute('database')); + return $this->constructDatabaseDSNFromProjectDatabase($this->getDatabaseType(), $project->getAttribute('region'), $project->getAttribute('database')); } - public static function constructDatabaseDSNFromProjectDatabase(string $databasetype, $region, ?string $dsn = null): string + private function constructDatabaseDSNFromProjectDatabase(string $databasetype, $region, ?string $dsn = null): string { $databases = []; $databaseKeys = []; diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Google/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Google/Update.php index 9b985f4aed..2a061d09ce 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Google/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Google/Update.php @@ -3,8 +3,22 @@ namespace Appwrite\Platform\Modules\Project\Http\Project\OAuth2\Google; use Appwrite\Auth\OAuth2\Google; +use Appwrite\Event\Event as QueueEvent; +use Appwrite\Extend\Exception; +use Appwrite\Platform\Action; use Appwrite\Platform\Modules\Project\Http\Project\OAuth2\Base; +use Appwrite\SDK\AuthType; +use Appwrite\SDK\Method; +use Appwrite\SDK\Response as SDKResponse; use Appwrite\Utopia\Response; +use Utopia\Database\Database; +use Utopia\Database\Document; +use Utopia\Database\Validator\Authorization; +use Utopia\Validator\ArrayList; +use Utopia\Validator\Boolean; +use Utopia\Validator\Nullable; +use Utopia\Validator\Text; +use Utopia\Validator\WhiteList; class Update extends Base { @@ -52,4 +66,118 @@ class Update extends Base { return 'GOCSPX-2k8gsR0000000000000000VNahJj'; } + + public static function getParameters(): array + { + return \array_merge(parent::getParameters(), [ + [ + '$id' => 'prompt', + 'name' => 'Prompt', + 'example' => '["consent"]', + 'hint' => '', + ], + ]); + } + + public function __construct() + { + $providerId = static::getProviderId(); + $providerLabel = static::getProviderLabel(); + + $this + ->setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH) + ->setHttpPath('/v1/project/oauth2/' . $providerId) + ->desc('Update project OAuth2 ' . $providerLabel) + ->groups(['api', 'project']) + ->label('scope', 'oauth2.write') + ->label('event', 'oauth2.[providerId].update') + ->label('audits.event', 'project.oauth2.[providerId].update') + ->label('audits.resource', 'project.oauth2/{response.$id}') + ->label('sdk', new Method( + namespace: 'project', + group: 'oauth2', + name: static::getProviderSDKMethod(), + description: 'Update the project OAuth2 ' . $providerLabel . ' configuration.', + auth: [AuthType::ADMIN, AuthType::KEY], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_OK, + model: static::getResponseModel(), + ) + ], + )) + ->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('prompt', null, new Nullable(new ArrayList(new WhiteList(['none', 'consent', 'select_account'], true), 3)), 'Array of Google OAuth2 prompt values. If "none" is included, it must be the only element. "none" means: don\'t display any authentication or consent screens. Must not be specified with other values. "consent" means: prompt the user for consent. "select_account" means: prompt the user to select an account.', 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') + ->inject('project') + ->inject('authorization') + ->inject('queueForEvents') + ->callback($this->handle(...)); + } + + public function buildReadResponse(Document $project): Document + { + $providerId = static::getProviderId(); + $oAuthProviders = $project->getAttribute('oAuthProviders', []); + $decoded = $this->decodeStoredSecret($project); + + return new Document([ + '$id' => $providerId, + 'enabled' => $oAuthProviders[$providerId . 'Enabled'] ?? false, + static::getClientIdParamName() => $oAuthProviders[$providerId . 'Appid'] ?? '', + static::getClientSecretParamName() => '', + 'prompt' => $decoded['prompt'] ?? ['consent'], + ]); + } + + /** + * Custom callback used instead of the parent's `action()` because Google + * takes an additional optional `prompt` parameter. The method is named + * differently to avoid an LSP-incompatible override of Base::action(). + */ + public function handle( + ?string $clientId, + ?string $clientSecret, + ?array $prompt, + ?bool $enabled, + Response $response, + Database $dbForPlatform, + Document $project, + Authorization $authorization, + QueueEvent $queueForEvents + ): void { + $providerId = static::getProviderId(); + $queueForEvents->setParam('providerId', $providerId); + + if ($prompt !== null) { + if (empty($prompt)) { + throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'Prompt array cannot be empty.'); + } + + if (\in_array('none', $prompt) && \count($prompt) > 1) { + throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'When "none" is used as a prompt value, it must be the only element in the array.'); + } + } + + $storedRaw = $project->getAttribute('oAuthProviders', [])[$providerId . 'Secret'] ?? ''; + $existing = $this->decodeStoredSecret($project); + + // Backwards compatibility: secrets stored before the prompt feature + // were saved as plain strings. Treat the raw value as clientSecret. + if (!empty($storedRaw) && empty($existing)) { + $existing = ['clientSecret' => $storedRaw]; + } + + $encodedSecret = \json_encode([ + 'clientSecret' => $clientSecret ?? ($existing['clientSecret'] ?? ''), + 'prompt' => $prompt ?? ($existing['prompt'] ?? ['consent']), + ]); + + $project = $this->persistCredentials($project, $dbForPlatform, $authorization, $clientId, $encodedSecret, $enabled); + + $response->dynamic($this->buildReadResponse($project), static::getResponseModel()); + } } diff --git a/src/Appwrite/Platform/Tasks/SDKs.php b/src/Appwrite/Platform/Tasks/SDKs.php index b1580f0e68..fbf965bd00 100644 --- a/src/Appwrite/Platform/Tasks/SDKs.php +++ b/src/Appwrite/Platform/Tasks/SDKs.php @@ -7,6 +7,7 @@ use Appwrite\SDK\Language\Android; use Appwrite\SDK\Language\Apple; use Appwrite\SDK\Language\ClaudePlugin; use Appwrite\SDK\Language\CLI; +use Appwrite\SDK\Language\CodexPlugin; use Appwrite\SDK\Language\CursorPlugin; use Appwrite\SDK\Language\Dart; use Appwrite\SDK\Language\Deno; @@ -455,6 +456,9 @@ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND case 'claude-plugin': $config = new ClaudePlugin(); break; + case 'codex-plugin': + $config = new CodexPlugin(); + break; default: throw new \Exception('Language "' . $language['key'] . '" not supported'); } diff --git a/src/Appwrite/Platform/Workers/Migrations.php b/src/Appwrite/Platform/Workers/Migrations.php index 82a9aba91f..b4b8893b62 100644 --- a/src/Appwrite/Platform/Workers/Migrations.php +++ b/src/Appwrite/Platform/Workers/Migrations.php @@ -9,7 +9,6 @@ use Appwrite\Event\Message\Usage as UsageMessage; use Appwrite\Event\Publisher\Usage as UsagePublisher; use Appwrite\Event\Realtime; use Appwrite\Extend\Exception; -use Appwrite\Platform\Modules\Databases\Http\Databases\Create as DatabaseCreate; use Appwrite\Template\Template; use Appwrite\Usage\Context; use Utopia\Compression\Compression; @@ -285,8 +284,6 @@ class Migrations extends Action $options = $migration->getAttribute('options', []); $credentials = $migration->getAttribute('credentials'); - $destinationProject = $this->project; - return match ($destination) { DestinationAppwrite::getName() => new DestinationAppwrite( $this->project->getId(), @@ -296,11 +293,7 @@ class Migrations extends Action $this->getDatabasesDB, Config::getParam('collections', [])['databases']['collections'], OnDuplicate::tryFrom($options['onDuplicate'] ?? '') ?? OnDuplicate::Fail, - fn (ResourceDatabase $resource): string => DatabaseCreate::constructDatabaseDSNFromProjectDatabase( - $resource->getType() ?: 'legacy', - $destinationProject->getAttribute('region'), - $destinationProject->getAttribute('database'), - ), + $this->resolveDestinationDatabaseDsn(...), ), DestinationCSV::getName() => new DestinationCSV( $this->deviceForFiles, @@ -324,6 +317,19 @@ class Migrations extends Action }; } + /** + * Legacy / tablesdb databases route to the destination project's DSN (same as a fresh + * Databases create), while documentsdb / vectorsdb keep the source DSN — the dedicated-DB + * backfill that would re-point them is not run during migrations. + */ + private function resolveDestinationDatabaseDsn(ResourceDatabase $resource): string + { + return match ($resource->getType()) { + DATABASE_TYPE_DOCUMENTSDB, DATABASE_TYPE_VECTORSDB => (string) $resource->getDatabase(), + default => (string) $this->project->getAttribute('database', ''), + }; + } + /** * @throws AuthorizationException * @throws Structure diff --git a/src/Appwrite/Utopia/Response/Model/OAuth2Google.php b/src/Appwrite/Utopia/Response/Model/OAuth2Google.php index 3dbc892631..ebef9aecf7 100644 --- a/src/Appwrite/Utopia/Response/Model/OAuth2Google.php +++ b/src/Appwrite/Utopia/Response/Model/OAuth2Google.php @@ -25,6 +25,20 @@ class OAuth2Google extends OAuth2Base return 'GOCSPX-2k8gsR0000000000000000VNahJj'; } + public function __construct() + { + parent::__construct(); + + $this->addRule('prompt', [ + 'type' => self::TYPE_ENUM, + 'description' => 'Google OAuth2 prompt values.', + 'default' => ['consent'], + 'example' => ['consent'], + 'array' => true, + 'enum' => ['none', 'consent', 'select_account'], + ]); + } + /** * Get Name * diff --git a/src/Appwrite/Vcs/Comment.php b/src/Appwrite/Vcs/Comment.php index 4dc0174e50..8741ecff6c 100644 --- a/src/Appwrite/Vcs/Comment.php +++ b/src/Appwrite/Vcs/Comment.php @@ -50,6 +50,8 @@ class Comment protected string $statePrefix = '[appwrite]: #'; + protected ?string $tip = null; + /** * @var mixed[] $builds */ @@ -81,7 +83,14 @@ class Comment public function generateComment(): string { - $json = \json_encode($this->builds); + if ($this->tip === null) { + $this->tip = $this->tips[\array_rand($this->tips)]; + } + + $json = \json_encode([ + 'builds' => $this->builds, + 'tip' => $this->tip, + ]); $text = $this->statePrefix . \base64_encode($json) . "\n\n"; @@ -226,8 +235,7 @@ class Comment $i++; } - $tip = $this->tips[array_rand($this->tips)]; - $text .= "\n
\n\n> [!TIP]\n> $tip\n\n"; + $text .= "\n
\n\n> [!TIP]\n> {$this->tip}\n\n"; return $text; } @@ -252,8 +260,15 @@ class Comment $json = \base64_decode($state); - $builds = \json_decode($json, true); - $this->builds = \is_array($builds) ? $builds : []; + $data = \json_decode($json, true); + + if (\is_array($data) && \array_key_exists('builds', $data)) { + $this->builds = \is_array($data['builds']) ? $data['builds'] : []; + $this->tip = $data['tip'] ?? null; + } else { + // Backward compatibility with old state format (builds array only) + $this->builds = \is_array($data) ? $data : []; + } return $this; } diff --git a/tests/e2e/Services/Databases/VectorsDB/DatabasesConsoleClientTest.php b/tests/e2e/Services/Databases/VectorsDB/DatabasesConsoleClientTest.php index abe4d4968b..a7cad1c0f7 100644 --- a/tests/e2e/Services/Databases/VectorsDB/DatabasesConsoleClientTest.php +++ b/tests/e2e/Services/Databases/VectorsDB/DatabasesConsoleClientTest.php @@ -10,7 +10,6 @@ use Tests\E2E\Scopes\SideConsole; use Utopia\Database\Helpers\ID; use Utopia\Database\Helpers\Permission; use Utopia\Database\Helpers\Role; -use Utopia\Database\Query; class DatabasesConsoleClientTest extends Scope { @@ -258,55 +257,4 @@ class DatabasesConsoleClientTest extends Scope $this->assertIsArray($response['body']['documents']); } - #[Depends('testCreateCollection')] - public function testGetCollectionLogs(array $data) - { - $databaseId = $data['databaseId']; - /** - * Test for SUCCESS - */ - $logs = $this->client->call(Client::METHOD_GET, '/vectorsdb/' . $databaseId . '/collections/' . $data['moviesId'] . '/logs', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders())); - - $this->assertEquals(200, $logs['headers']['status-code']); - $this->assertIsArray($logs['body']['logs']); - $this->assertIsNumeric($logs['body']['total']); - - $logs = $this->client->call(Client::METHOD_GET, '/vectorsdb/' . $databaseId . '/collections/' . $data['moviesId'] . '/logs', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders()), [ - 'queries' => [Query::limit(1)->toString()] - ]); - - $this->assertEquals(200, $logs['headers']['status-code']); - $this->assertIsArray($logs['body']['logs']); - $this->assertLessThanOrEqual(1, count($logs['body']['logs'])); - $this->assertIsNumeric($logs['body']['total']); - - $logs = $this->client->call(Client::METHOD_GET, '/vectorsdb/' . $databaseId . '/collections/' . $data['moviesId'] . '/logs', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders()), [ - 'queries' => [Query::offset(1)->toString()] - ]); - - $this->assertEquals(200, $logs['headers']['status-code']); - $this->assertIsArray($logs['body']['logs']); - $this->assertIsNumeric($logs['body']['total']); - - $logs = $this->client->call(Client::METHOD_GET, '/vectorsdb/' . $databaseId . '/collections/' . $data['moviesId'] . '/logs', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders()), [ - 'queries' => [Query::offset(1)->toString(), Query::limit(1)->toString()] - ]); - - $this->assertEquals(200, $logs['headers']['status-code']); - $this->assertIsArray($logs['body']['logs']); - $this->assertLessThanOrEqual(1, count($logs['body']['logs'])); - $this->assertIsNumeric($logs['body']['total']); - } } diff --git a/tests/e2e/Services/Databases/VectorsDBConsoleClientTest.php b/tests/e2e/Services/Databases/VectorsDBConsoleClientTest.php index 80c2bc9d5c..238d197158 100644 --- a/tests/e2e/Services/Databases/VectorsDBConsoleClientTest.php +++ b/tests/e2e/Services/Databases/VectorsDBConsoleClientTest.php @@ -10,7 +10,6 @@ use Tests\E2E\Scopes\SideConsole; use Utopia\Database\Helpers\ID; use Utopia\Database\Helpers\Permission; use Utopia\Database\Helpers\Role; -use Utopia\Database\Query; class VectorsDBConsoleClientTest extends Scope { @@ -258,55 +257,4 @@ class VectorsDBConsoleClientTest extends Scope $this->assertIsArray($response['body']['documents']); } - #[Depends('testCreateCollection')] - public function testGetCollectionLogs(array $data) - { - $databaseId = $data['databaseId']; - /** - * Test for SUCCESS - */ - $logs = $this->client->call(Client::METHOD_GET, '/vectorsdb/' . $databaseId . '/collections/' . $data['moviesId'] . '/logs', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders())); - - $this->assertEquals(200, $logs['headers']['status-code']); - $this->assertIsArray($logs['body']['logs']); - $this->assertIsNumeric($logs['body']['total']); - - $logs = $this->client->call(Client::METHOD_GET, '/vectorsdb/' . $databaseId . '/collections/' . $data['moviesId'] . '/logs', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders()), [ - 'queries' => [Query::limit(1)->toString()] - ]); - - $this->assertEquals(200, $logs['headers']['status-code']); - $this->assertIsArray($logs['body']['logs']); - $this->assertLessThanOrEqual(1, count($logs['body']['logs'])); - $this->assertIsNumeric($logs['body']['total']); - - $logs = $this->client->call(Client::METHOD_GET, '/vectorsdb/' . $databaseId . '/collections/' . $data['moviesId'] . '/logs', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders()), [ - 'queries' => [Query::offset(1)->toString()] - ]); - - $this->assertEquals(200, $logs['headers']['status-code']); - $this->assertIsArray($logs['body']['logs']); - $this->assertIsNumeric($logs['body']['total']); - - $logs = $this->client->call(Client::METHOD_GET, '/vectorsdb/' . $databaseId . '/collections/' . $data['moviesId'] . '/logs', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders()), [ - 'queries' => [Query::offset(1)->toString(), Query::limit(1)->toString()] - ]); - - $this->assertEquals(200, $logs['headers']['status-code']); - $this->assertIsArray($logs['body']['logs']); - $this->assertLessThanOrEqual(1, count($logs['body']['logs'])); - $this->assertIsNumeric($logs['body']['total']); - } } diff --git a/tests/e2e/Services/Project/OAuth2Base.php b/tests/e2e/Services/Project/OAuth2Base.php index 47d92a2a58..5959a584ea 100644 --- a/tests/e2e/Services/Project/OAuth2Base.php +++ b/tests/e2e/Services/Project/OAuth2Base.php @@ -2564,6 +2564,159 @@ trait OAuth2Base ]); } + // ========================================================================= + // Update Google (clientId + clientSecret + optional prompt) + // ========================================================================= + + /** + * Default prompt MUST run before any other Google test that sets a custom + * prompt value. The global resetProjectOAuth2() only clears Amazon state, + * so Google state leaks across tests in the same class. Running this first + * guarantees the stored JSON blob has no pre-existing "prompt" key. + */ + public function testUpdateOAuth2GoogleDefaultPrompt(): void + { + // When prompt is omitted and nothing is stored, the default is ['consent']. + $response = $this->updateOAuth2('google', [ + 'clientId' => 'google-default-client', + 'clientSecret' => 'google-default-secret', + 'enabled' => false, + ]); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(['consent'], $response['body']['prompt']); + + // Cleanup + $this->updateOAuth2('google', [ + 'clientId' => '', + 'clientSecret' => '', + 'enabled' => false, + ]); + } + + public function testUpdateOAuth2Google(): void + { + $response = $this->updateOAuth2('google', [ + 'clientId' => '120000000095-92ifjb00000000000000000000g7ijfb.apps.googleusercontent.com', + 'clientSecret' => 'GOCSPX-2k8gsR0000000000000000VNahJj', + 'prompt' => ['select_account'], + 'enabled' => false, + ]); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame('google', $response['body']['$id']); + $this->assertSame('120000000095-92ifjb00000000000000000000g7ijfb.apps.googleusercontent.com', $response['body']['clientId']); + $this->assertSame(['select_account'], $response['body']['prompt']); + + // Cleanup + $this->updateOAuth2('google', [ + 'clientId' => '', + 'clientSecret' => '', + 'enabled' => false, + ]); + } + + public function testUpdateOAuth2GooglePartialPreservesPrompt(): void + { + // Seed clientSecret + prompt. + $this->updateOAuth2('google', [ + 'clientId' => 'google-seed-client', + 'clientSecret' => 'google-seed-secret', + 'prompt' => ['consent', 'select_account'], + 'enabled' => false, + ]); + + // Update only clientId. + $response = $this->updateOAuth2('google', [ + 'clientId' => 'google-rotated-client', + ]); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame('google-rotated-client', $response['body']['clientId']); + $this->assertSame(['consent', 'select_account'], $response['body']['prompt']); + + // Cleanup + $this->updateOAuth2('google', [ + 'clientId' => '', + 'clientSecret' => '', + 'enabled' => false, + ]); + } + + public function testUpdateOAuth2GooglePromptNoneAloneRejected(): void + { + $response = $this->updateOAuth2('google', [ + 'clientId' => 'whatever', + 'clientSecret' => 'whatever', + 'prompt' => ['none', 'consent'], + 'enabled' => false, + ]); + + $this->assertSame(400, $response['headers']['status-code']); + $this->assertSame('general_argument_invalid', $response['body']['type']); + } + + public function testUpdateOAuth2GooglePromptEmptyArrayRejected(): void + { + $response = $this->updateOAuth2('google', [ + 'clientId' => 'whatever', + 'clientSecret' => 'whatever', + 'prompt' => [], + 'enabled' => false, + ]); + + $this->assertSame(400, $response['headers']['status-code']); + $this->assertSame('general_argument_invalid', $response['body']['type']); + } + + public function testUpdateOAuth2GooglePromptNoneAloneAccepted(): void + { + $response = $this->updateOAuth2('google', [ + 'clientId' => '120000000095-92ifjb00000000000000000000g7ijfb.apps.googleusercontent.com', + 'clientSecret' => 'GOCSPX-2k8gsR0000000000000000VNahJj', + 'prompt' => ['none'], + 'enabled' => false, + ]); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(['none'], $response['body']['prompt']); + + // Cleanup + $this->updateOAuth2('google', [ + 'clientId' => '', + 'clientSecret' => '', + 'enabled' => false, + ]); + } + + public function testUpdateOAuth2GoogleEnableAndReadBack(): void + { + $update = $this->updateOAuth2('google', [ + 'clientId' => 'google-enable-client', + 'clientSecret' => 'google-enable-secret', + 'prompt' => ['select_account'], + 'enabled' => true, + ]); + + $this->assertSame(200, $update['headers']['status-code']); + $this->assertTrue($update['body']['enabled']); + + // GET must hide clientSecret while keeping clientId and prompt. + $get = $this->getOAuth2Provider('google'); + $this->assertSame(200, $get['headers']['status-code']); + $this->assertTrue($get['body']['enabled']); + $this->assertSame('google-enable-client', $get['body']['clientId']); + $this->assertSame(['select_account'], $get['body']['prompt']); + $this->assertSame('', $get['body']['clientSecret']); + + // Cleanup + $this->updateOAuth2('google', [ + 'clientId' => '', + 'clientSecret' => '', + 'enabled' => false, + ]); + } + // ========================================================================= // Smoke test: every plain (clientId + clientSecret) provider // diff --git a/tests/unit/Vcs/CommentTest.php b/tests/unit/Vcs/CommentTest.php new file mode 100644 index 0000000000..29973089c6 --- /dev/null +++ b/tests/unit/Vcs/CommentTest.php @@ -0,0 +1,154 @@ + 'localhost']); + $comment->addBuild( + new Document(['$id' => 'project1', 'name' => 'Test Project', 'region' => 'default']), + new Document(['$id' => 'func1', 'name' => 'Test Function']), + 'function', + 'ready', + 'dep1', + ['type' => 'logs'], + '' + ); + + $first = $comment->generateComment(); + $firstTip = $this->extractTip($first); + + $this->assertNotNull($firstTip); + $this->assertNotEmpty($firstTip); + + $second = $comment->generateComment(); + $secondTip = $this->extractTip($second); + + $this->assertEquals($firstTip, $secondTip); + } + + public function testTipIsRestoredFromParsedComment(): void + { + $comment = new Comment(['consoleHostname' => 'localhost']); + $comment->addBuild( + new Document(['$id' => 'project1', 'name' => 'Test Project', 'region' => 'default']), + new Document(['$id' => 'func1', 'name' => 'Test Function']), + 'function', + 'ready', + 'dep1', + ['type' => 'logs'], + '' + ); + + $original = $comment->generateComment(); + $originalTip = $this->extractTip($original); + + $parsed = new Comment(['consoleHostname' => 'localhost']); + $parsed->parseComment($original); + $parsed->addBuild( + new Document(['$id' => 'project1', 'name' => 'Test Project', 'region' => 'default']), + new Document(['$id' => 'func2', 'name' => 'Another Function']), + 'function', + 'building', + 'dep2', + ['type' => 'logs'], + '' + ); + + $regenerated = $parsed->generateComment(); + $regeneratedTip = $this->extractTip($regenerated); + + $this->assertEquals($originalTip, $regeneratedTip); + } + + public function testBackwardCompatibilityWithOldStateFormat(): void + { + $oldBuilds = [ + 'project1_func1' => [ + 'projectName' => 'Test Project', + 'projectId' => 'project1', + 'region' => 'default', + 'resourceName' => 'Test Function', + 'resourceId' => 'func1', + 'resourceType' => 'function', + 'buildStatus' => 'ready', + 'deploymentId' => 'dep1', + 'action' => ['type' => 'logs'], + 'previewUrl' => '', + ], + ]; + + $oldState = '[appwrite]: #' . \base64_encode(\json_encode($oldBuilds)) . "\n\n"; + $oldState .= "> [!TIP]\n> Old tip that should be ignored\n\n"; + + $comment = new Comment(['consoleHostname' => 'localhost']); + $comment->parseComment($oldState); + + $new = $comment->generateComment(); + $newTip = $this->extractTip($new); + + $this->assertNotNull($newTip); + $this->assertNotEquals('Old tip that should be ignored', $newTip); + $this->assertContains($newTip, $this->getTips()); + } + + public function testParseOldStateFormatWithOnlyBuilds(): void + { + $oldBuilds = [ + 'project1_func1' => [ + 'projectName' => 'Test Project', + 'projectId' => 'project1', + 'region' => 'default', + 'resourceName' => 'Test Function', + 'resourceId' => 'func1', + 'resourceType' => 'function', + 'buildStatus' => 'ready', + 'deploymentId' => 'dep1', + 'action' => ['type' => 'logs'], + 'previewUrl' => '', + ], + ]; + + $state = '[appwrite]: #' . \base64_encode(\json_encode($oldBuilds)) . "\n\n"; + + $comment = new Comment(['consoleHostname' => 'localhost']); + $comment->parseComment($state); + + $this->assertEquals(false, $comment->isEmpty()); + + $first = $comment->generateComment(); + $firstTip = $this->extractTip($first); + + $this->assertNotNull($firstTip); + $this->assertNotEmpty($firstTip); + $this->assertContains($firstTip, $this->getTips()); + + $second = $comment->generateComment(); + $secondTip = $this->extractTip($second); + + $this->assertEquals($firstTip, $secondTip); + } + + private function extractTip(string $comment): ?string + { + if (\preg_match('/> \[!TIP\]\n> (.+)/', $comment, $matches)) { + return $matches[1]; + } + + return null; + } + + private function getTips(): array + { + $reflection = new \ReflectionClass(Comment::class); + $property = $reflection->getProperty('tips'); + + return $property->getValue(new Comment(['consoleHostname' => 'localhost'])); + } +}