Merge remote-tracking branch 'origin/1.9.x' into feat-insights-module

# Conflicts:
#	composer.lock
#	src/Appwrite/Platform/Workers/Migrations.php
This commit is contained in:
Jake Barnby
2026-05-12 00:23:17 +12:00
14 changed files with 601 additions and 161 deletions
+20
View File
@@ -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'),
],
],
],
+1 -1
View File
@@ -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.*",
Generated
+38 -38
View File
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "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",
+53 -3
View File
@@ -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;
}
}
@@ -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 = [];
@@ -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());
}
}
+4
View File
@@ -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');
}
+14 -8
View File
@@ -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
@@ -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
*
+20 -5
View File
@@ -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<br>\n\n> [!TIP]\n> $tip\n\n";
$text .= "\n<br>\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;
}
@@ -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']);
}
}
@@ -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']);
}
}
+153
View File
@@ -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
//
+154
View File
@@ -0,0 +1,154 @@
<?php
namespace Tests\Unit\Vcs;
use Appwrite\Vcs\Comment;
use PHPUnit\Framework\TestCase;
use Utopia\Database\Document;
class CommentTest extends TestCase
{
public function testTipIsPreservedAcrossMultipleGenerations(): 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'],
''
);
$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']));
}
}