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 41242fa1c6..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.28.0", + "version": "1.28.1", "source": { "type": "git", "url": "https://github.com/appwrite/sdk-generator.git", - "reference": "e363fffd220172c5f1a5032038fa3fdafeeb2dfb" + "reference": "009118ccda8ccece2b9fc043c158cb1dd3efaa88" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/appwrite/sdk-generator/zipball/e363fffd220172c5f1a5032038fa3fdafeeb2dfb", - "reference": "e363fffd220172c5f1a5032038fa3fdafeeb2dfb", + "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.28.0" + "source": "https://github.com/appwrite/sdk-generator/tree/1.28.1" }, - "time": "2026-05-08T03:37:44+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 6028bd109b..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() ]); } @@ -190,6 +190,23 @@ class Google extends OAuth2 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. 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/Workers/Migrations.php b/src/Appwrite/Platform/Workers/Migrations.php index b6c295b3bb..b4b8893b62 100644 --- a/src/Appwrite/Platform/Workers/Migrations.php +++ b/src/Appwrite/Platform/Workers/Migrations.php @@ -293,6 +293,7 @@ class Migrations extends Action $this->getDatabasesDB, Config::getParam('collections', [])['databases']['collections'], OnDuplicate::tryFrom($options['onDuplicate'] ?? '') ?? OnDuplicate::Fail, + $this->resolveDestinationDatabaseDsn(...), ), DestinationCSV::getName() => new DestinationCSV( $this->deviceForFiles, @@ -316,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/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 //