From 2e42633e1281afbf96c1f11ee86251bf79fd0ec2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Wed, 22 Apr 2026 11:30:39 +0200 Subject: [PATCH 01/34] Add public mocks API for phones --- app/config/errors.php | 10 ++ app/controllers/api/projects.php | 14 +-- app/init/models.php | 1 + src/Appwrite/Extend/Exception.php | 4 + .../Project/Http/Project/MockPhone/Create.php | 107 ++++++++++++++++++ .../Project/Http/Project/MockPhone/Delete.php | 103 +++++++++++++++++ .../Project/Http/Project/MockPhone/Get.php | 78 +++++++++++++ .../Project/Http/Project/MockPhone/Update.php | 107 ++++++++++++++++++ .../Project/Http/Project/MockPhone/XList.php | 69 +++++++++++ .../Modules/Project/Services/Http.php | 12 ++ src/Appwrite/Utopia/Response.php | 1 + src/Appwrite/Utopia/Response/Filters/V23.php | 14 +++ .../Utopia/Response/Model/MockNumber.php | 14 ++- 13 files changed, 520 insertions(+), 14 deletions(-) create mode 100644 src/Appwrite/Platform/Modules/Project/Http/Project/MockPhone/Create.php create mode 100644 src/Appwrite/Platform/Modules/Project/Http/Project/MockPhone/Delete.php create mode 100644 src/Appwrite/Platform/Modules/Project/Http/Project/MockPhone/Get.php create mode 100644 src/Appwrite/Platform/Modules/Project/Http/Project/MockPhone/Update.php create mode 100644 src/Appwrite/Platform/Modules/Project/Http/Project/MockPhone/XList.php diff --git a/app/config/errors.php b/app/config/errors.php index 4190c6e277..9a4710cb33 100644 --- a/app/config/errors.php +++ b/app/config/errors.php @@ -1408,4 +1408,14 @@ return [ 'description' => 'When using project API key, make sure to pass x-appwrite-project header with your project ID.', 'code' => 403, ], + Exception::MOCK_NUMBER_ALREADY_EXISTS => [ + 'name' => Exception::MOCK_NUMBER_ALREADY_EXISTS, + 'description' => 'Mock number with the requested number already exists. Try again with a different number. or update OTP of existing mock number.', + 'code' => 409, + ], + Exception::MOCK_NUMBER_NOT_FOUND => [ + 'name' => Exception::MOCK_NUMBER_NOT_FOUND, + 'description' => 'Mock number with the requested number could not be found.', + 'code' => 404, + ], ]; diff --git a/app/controllers/api/projects.php b/app/controllers/api/projects.php index bd5d0504cf..9241043209 100644 --- a/app/controllers/api/projects.php +++ b/app/controllers/api/projects.php @@ -170,23 +170,11 @@ Http::patch('/v1/projects/:projectId/auth/:method') $response->dynamic($project, Response::MODEL_PROJECT); }); +// Backwards compatibility Http::patch('/v1/projects/:projectId/auth/mock-numbers') ->desc('Update the mock numbers for the project') ->groups(['api', 'projects']) ->label('scope', 'projects.write') - ->label('sdk', new Method( - namespace: 'projects', - group: 'auth', - name: 'updateMockNumbers', - description: '/docs/references/projects/update-mock-numbers.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_PROJECT, - ) - ] - )) ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform']) ->param('numbers', '', new ArrayList(new MockNumber(), 10), 'An array of mock numbers and their corresponding verification codes (OTPs). Each number should be a valid E.164 formatted phone number. Maximum of 10 numbers are allowed.') ->inject('response') diff --git a/app/init/models.php b/app/init/models.php index f654c10121..4d1ccc2824 100644 --- a/app/init/models.php +++ b/app/init/models.php @@ -210,6 +210,7 @@ Response::setModel(new BaseList('Currencies List', Response::MODEL_CURRENCY_LIST Response::setModel(new BaseList('Phones List', Response::MODEL_PHONE_LIST, 'phones', Response::MODEL_PHONE)); Response::setModel(new BaseList('Metric List', Response::MODEL_METRIC_LIST, 'metrics', Response::MODEL_METRIC, true, false)); Response::setModel(new BaseList('Variables List', Response::MODEL_VARIABLE_LIST, 'variables', Response::MODEL_VARIABLE)); +Response::setModel(new BaseList('Mock Numbers List', Response::MODEL_MOCK_NUMBER_LIST, 'mockNumbers', Response::MODEL_MOCK_NUMBER)); Response::setModel(new BaseList('Status List', Response::MODEL_HEALTH_STATUS_LIST, 'statuses', Response::MODEL_HEALTH_STATUS)); Response::setModel(new BaseList('Rule List', Response::MODEL_PROXY_RULE_LIST, 'rules', Response::MODEL_PROXY_RULE)); Response::setModel(new BaseList('Schedules List', Response::MODEL_SCHEDULE_LIST, 'schedules', Response::MODEL_SCHEDULE)); diff --git a/src/Appwrite/Extend/Exception.php b/src/Appwrite/Extend/Exception.php index 58a21b5517..b1651fec13 100644 --- a/src/Appwrite/Extend/Exception.php +++ b/src/Appwrite/Extend/Exception.php @@ -384,6 +384,10 @@ class Exception extends \Exception public const string MESSAGE_TARGET_NOT_PUSH = 'message_target_not_push'; public const string MESSAGE_MISSING_SCHEDULE = 'message_missing_schedule'; + /** Mocks */ + public const string MOCK_NUMBER_ALREADY_EXISTS = 'mock_number_already_exists'; + public const string MOCK_NUMBER_NOT_FOUND = 'mock_number_not_found'; + /** Targets */ public const string TARGET_PROVIDER_INVALID_TYPE = 'target_provider_invalid_type'; diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/MockPhone/Create.php b/src/Appwrite/Platform/Modules/Project/Http/Project/MockPhone/Create.php new file mode 100644 index 0000000000..8aa2bcf642 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/MockPhone/Create.php @@ -0,0 +1,107 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_POST) + ->setHttpPath('/v1/project/mock-phones') + ->desc('Create project mock phone') + ->groups(['api', 'project']) + ->label('scope', 'mocks.write') + ->label('event', 'mock-phones.[number].create') + ->label('audits.event', 'project.mock-phone.create') + ->label('audits.resource', 'project.mock-phone/{response.number}') + ->label('sdk', new Method( + namespace: 'project', + group: 'mocks', + name: 'createMockPhone', + description: <<param('number', null, new Phone(), 'Phone number to associate with the mock phone. Must be a valid E.164 formatted phone number.') + ->param('otp', '', new Text(6, 6, Text::NUMBERS), 'One-time password (OTP) to associate with the mock phone. Must be a 6-digit numeric code.') + ->inject('response') + ->inject('queueForEvents') + ->inject('project') + ->inject('dbForPlatform') + ->inject('authorization') + ->callback($this->action(...)); + } + + public function action( + string $number, + string $otp, + Response $response, + QueueEvent $queueForEvents, + Document $project, + Database $dbForPlatform, + Authorization $authorization, + ) { + $auths = $project->getAttribute('auths', []); + + $mockNumbers = $auths['mockNumbers'] ?? []; + + foreach ($mockNumbers as $mockNumber) { + if ($mockNumber['number'] === $number) { + throw new Exception(Exception::MOCK_NUMBER_ALREADY_EXISTS); + } + } + + // Set to now date + $mockNumber = [ + 'number' => $number, + 'otp' => $otp, + '$createdAt' => DateTime::now(), + '$updatedAt' => DateTime::now(), + ]; + + $mockNumbers[] = $mockNumber; + $auths['mockNumbers'] = $mockNumbers; + + $updates = new Document([ + 'auths' => $auths, + ]); + + $authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), $updates)); + + $queueForEvents->setParam('number', $number); + + $response + ->setStatusCode(Response::STATUS_CODE_CREATED) + ->dynamic(new Document($mockNumber), Response::MODEL_MOCK_NUMBER); + } +} diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/MockPhone/Delete.php b/src/Appwrite/Platform/Modules/Project/Http/Project/MockPhone/Delete.php new file mode 100644 index 0000000000..af7afae120 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/MockPhone/Delete.php @@ -0,0 +1,103 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_DELETE) + ->setHttpPath('/v1/project/mock-phones/:number') + ->desc('Delete project mock phone') + ->groups(['api', 'project']) + ->label('scope', 'mocks.write') + ->label('event', 'mock-phones.[number].delete') + ->label('audits.event', 'project.mock-phone.delete') + ->label('audits.resource', 'project.mock-phone/{request.number}') + ->label('sdk', new Method( + namespace: 'project', + group: 'mocks', + name: 'deleteMockPhone', + description: <<param('number', null, new Phone(), 'Phone number associated with the mock phone. Must be a valid E.164 formatted phone number.') + ->inject('response') + ->inject('queueForEvents') + ->inject('project') + ->inject('dbForPlatform') + ->inject('authorization') + ->callback($this->action(...)); + } + + public function action( + string $number, + Response $response, + QueueEvent $queueForEvents, + Document $project, + Database $dbForPlatform, + Authorization $authorization, + ) { + $auths = $project->getAttribute('auths', []); + + $mockNumbers = $auths['mockNumbers'] ?? []; + + $mockNumberIndex = null; + foreach ($mockNumbers as $index => $mock) { + if ($mock['number'] === $number) { + $mockNumberIndex = $index; + break; + } + } + + if (\is_null($mockNumberIndex)) { + throw new Exception(Exception::MOCK_NUMBER_NOT_FOUND); + } + + unset($mockNumbers[$mockNumberIndex]); + $mockNumbers = array_values($mockNumbers); + + $auths['mockNumbers'] = $mockNumbers; + + $updates = new Document([ + 'auths' => $auths, + ]); + + $authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), $updates)); + + $queueForEvents->setParam('number', $number); + + $response->noContent(); + } +} diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/MockPhone/Get.php b/src/Appwrite/Platform/Modules/Project/Http/Project/MockPhone/Get.php new file mode 100644 index 0000000000..8f799e98d7 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/MockPhone/Get.php @@ -0,0 +1,78 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/project/mock-phones/:number') + ->desc('Get project mock phone') + ->groups(['api', 'project']) + ->label('scope', 'mocks.read') + ->label('sdk', new Method( + namespace: 'project', + group: 'mocks', + name: 'getMockPhone', + description: <<param('number', null, new Phone(), 'Phone number associated with the mock phone. Must be a valid E.164 formatted phone number.') + ->inject('response') + ->inject('project') + ->callback($this->action(...)); + } + + public function action( + string $number, + Response $response, + Document $project + ) { + $auths = $project->getAttribute('auths', []); + + $mockNumbers = $auths['mockNumbers'] ?? []; + + $mockNumberIndex = null; + foreach ($mockNumbers as $index => $mock) { + if ($mock['number'] === $number) { + $mockNumberIndex = $index; + break; + } + } + + if (\is_null($mockNumberIndex)) { + throw new Exception(Exception::MOCK_NUMBER_NOT_FOUND); + } + + $response + ->setStatusCode(Response::STATUS_CODE_OK) + ->dynamic(new Document($mockNumbers[$mockNumberIndex]), Response::MODEL_MOCK_NUMBER); + } +} diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/MockPhone/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/MockPhone/Update.php new file mode 100644 index 0000000000..4924f53ff8 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/MockPhone/Update.php @@ -0,0 +1,107 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_PUT) + ->setHttpPath('/v1/project/mock-phones/:number') + ->desc('Update project mock phone') + ->groups(['api', 'project']) + ->label('scope', 'mocks.write') + ->label('event', 'mock-phones.[number].update') + ->label('audits.event', 'project.mock-phone.update') + ->label('audits.resource', 'project.mock-phone/{response.number}') + ->label('sdk', new Method( + namespace: 'project', + group: 'mocks', + name: 'updateMockPhone', + description: <<param('number', null, new Phone(), 'Phone number associated with the mock phone. Must be a valid E.164 formatted phone number.') + ->param('otp', '', new Text(6, 6, Text::NUMBERS), 'One-time password (OTP) to associate with the mock phone. Must be a 6-digit numeric code.') + ->inject('response') + ->inject('queueForEvents') + ->inject('project') + ->inject('dbForPlatform') + ->inject('authorization') + ->callback($this->action(...)); + } + + public function action( + string $number, + string $otp, + Response $response, + QueueEvent $queueForEvents, + Document $project, + Database $dbForPlatform, + Authorization $authorization, + ) { + $auths = $project->getAttribute('auths', []); + + $mockNumbers = $auths['mockNumbers'] ?? []; + + $mockNumberIndex = null; + foreach ($mockNumbers as $index => $mock) { + if ($mock['number'] === $number) { + $mockNumberIndex = $index; + break; + } + } + + if (\is_null($mockNumberIndex)) { + throw new Exception(Exception::MOCK_NUMBER_NOT_FOUND); + } + + $mockNumbers[$mockNumberIndex]['otp'] = $otp; + $mockNumbers[$mockNumberIndex]['$updatedAt'] = DateTime::now(); + + $auths['mockNumbers'] = $mockNumbers; + + $updates = new Document([ + 'auths' => $auths, + ]); + + $authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), $updates)); + + $queueForEvents->setParam('number', $number); + + $response + ->setStatusCode(Response::STATUS_CODE_OK) + ->dynamic(new Document($mockNumbers[$mockNumberIndex]), Response::MODEL_MOCK_NUMBER); + } +} diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/MockPhone/XList.php b/src/Appwrite/Platform/Modules/Project/Http/Project/MockPhone/XList.php new file mode 100644 index 0000000000..a12aa11108 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/MockPhone/XList.php @@ -0,0 +1,69 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/project/mock-phones') + ->desc('List project mock phones') + ->groups(['api', 'project']) + ->label('scope', 'mocks.read') + ->label('sdk', new Method( + namespace: 'project', + group: 'mocks', + name: 'listMockPhones', + description: <<param('total', true, new Boolean(true), 'When set to false, the total count returned will be 0 and will not be calculated.', true) + ->inject('response') + ->inject('project') + ->callback($this->action(...)); + } + + public function action( + bool $includeTotal, + Response $response, + Document $project, + ) { + $auths = $project->getAttribute('auths', []); + $mockNumbers = $auths['mockNumbers'] ?? []; + + $total = $includeTotal ? \count($mockNumbers) : 0; + + $mockNumbers = \array_map(fn ($mockNumber) => new Document($mockNumber), $mockNumbers); + + $response->dynamic(new Document([ + 'mockNumbers' => $mockNumbers, + 'total' => $total, + ]), Response::MODEL_MOCK_NUMBER_LIST); + } +} diff --git a/src/Appwrite/Platform/Modules/Project/Services/Http.php b/src/Appwrite/Platform/Modules/Project/Services/Http.php index 331ad9482e..c353c6a4f3 100644 --- a/src/Appwrite/Platform/Modules/Project/Services/Http.php +++ b/src/Appwrite/Platform/Modules/Project/Services/Http.php @@ -9,6 +9,11 @@ use Appwrite\Platform\Modules\Project\Http\Project\Keys\Get as GetKey; use Appwrite\Platform\Modules\Project\Http\Project\Keys\Update as UpdateKey; use Appwrite\Platform\Modules\Project\Http\Project\Keys\XList as ListKeys; use Appwrite\Platform\Modules\Project\Http\Project\Labels\Update as UpdateProjectLabels; +use Appwrite\Platform\Modules\Project\Http\Project\MockPhone\Create as CreateMockPhone; +use Appwrite\Platform\Modules\Project\Http\Project\MockPhone\Delete as DeleteMockPhone; +use Appwrite\Platform\Modules\Project\Http\Project\MockPhone\Get as GetMockPhone; +use Appwrite\Platform\Modules\Project\Http\Project\MockPhone\Update as UpdateMockPhone; +use Appwrite\Platform\Modules\Project\Http\Project\MockPhone\XList as ListMockPhones; use Appwrite\Platform\Modules\Project\Http\Project\Platforms\Android\Create as CreateAndroidPlatform; use Appwrite\Platform\Modules\Project\Http\Project\Platforms\Android\Update as UpdateAndroidPlatform; use Appwrite\Platform\Modules\Project\Http\Project\Platforms\Apple\Create as CreateApplePlatform; @@ -95,6 +100,13 @@ class Http extends Service $this->addAction(GetPlatform::getName(), new GetPlatform()); $this->addAction(ListPlatforms::getName(), new ListPlatforms()); + // Mock Phones + $this->addAction(CreateMockPhone::getName(), new CreateMockPhone()); + $this->addAction(ListMockPhones::getName(), new ListMockPhones()); + $this->addAction(GetMockPhone::getName(), new GetMockPhone()); + $this->addAction(UpdateMockPhone::getName(), new UpdateMockPhone()); + $this->addAction(DeleteMockPhone::getName(), new DeleteMockPhone()); + // Policies $this->addAction(UpdateMembershipPrivacyPolicy::getName(), new UpdateMembershipPrivacyPolicy()); $this->addAction(UpdatePasswordDictionaryPolicy::getName(), new UpdatePasswordDictionaryPolicy()); diff --git a/src/Appwrite/Utopia/Response.php b/src/Appwrite/Utopia/Response.php index d747373b59..56ba5635b1 100644 --- a/src/Appwrite/Utopia/Response.php +++ b/src/Appwrite/Utopia/Response.php @@ -254,6 +254,7 @@ class Response extends SwooleResponse public const MODEL_DEV_KEY = 'devKey'; public const MODEL_DEV_KEY_LIST = 'devKeyList'; public const MODEL_MOCK_NUMBER = 'mockNumber'; + public const MODEL_MOCK_NUMBER_LIST = 'mockNumberList'; public const MODEL_AUTH_PROVIDER = 'authProvider'; public const MODEL_AUTH_PROVIDER_LIST = 'authProviderList'; public const MODEL_PLATFORM_APPLE = 'platformApple'; diff --git a/src/Appwrite/Utopia/Response/Filters/V23.php b/src/Appwrite/Utopia/Response/Filters/V23.php index 51d223de37..cd8ce44c0a 100644 --- a/src/Appwrite/Utopia/Response/Filters/V23.php +++ b/src/Appwrite/Utopia/Response/Filters/V23.php @@ -16,10 +16,24 @@ class V23 extends Filter Response::MODEL_PROJECT => $this->parseProject($content), Response::MODEL_PROJECT_LIST => $this->handleList($content, 'projects', fn ($item) => $this->parseProject($item)), Response::MODEL_EMAIL_TEMPLATE => $this->parseEmailTemplate($content), + Response::MODEL_MOCK_NUMBER => $this->parseMockNumber($content), default => $content, }; } + private function parseMockNumber(array $content): array + { + unset($content['$createdAt']); + unset($content['$updatedAt']); + + if (isset($content['number'])) { + $content['phone'] = $content['number']; + unset($content['number']); + } + + return $content; + } + private function parseMembership(array $content): array { unset($content['userPhone']); diff --git a/src/Appwrite/Utopia/Response/Model/MockNumber.php b/src/Appwrite/Utopia/Response/Model/MockNumber.php index 14ce747da6..eee788dbab 100644 --- a/src/Appwrite/Utopia/Response/Model/MockNumber.php +++ b/src/Appwrite/Utopia/Response/Model/MockNumber.php @@ -10,7 +10,7 @@ class MockNumber extends Model public function __construct() { $this - ->addRule('phone', [ + ->addRule('number', [ 'type' => self::TYPE_STRING, 'description' => 'Mock phone number for testing phone authentication. Useful for testing phone authentication without sending an SMS.', 'default' => '', @@ -22,6 +22,18 @@ class MockNumber extends Model 'default' => '', 'example' => '123456', ]) + ->addRule('$createdAt', [ + 'type' => self::TYPE_DATETIME, + 'description' => 'Attribute creation date in ISO 8601 format.', + 'default' => '', + 'example' => self::TYPE_DATETIME_EXAMPLE, + ]) + ->addRule('$updatedAt', [ + 'type' => self::TYPE_DATETIME, + 'description' => 'Attribute update date in ISO 8601 format.', + 'default' => '', + 'example' => self::TYPE_DATETIME_EXAMPLE, + ]); ; } From eeadba3b592880fa5b05a10b07b19a3d46847cf6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Wed, 22 Apr 2026 11:36:54 +0200 Subject: [PATCH 02/34] Add missing endpoint in email templates --- app/init/models.php | 1 + .../Http/Project/Templates/Email/XList.php | 91 +++++++ .../Modules/Project/Services/Http.php | 2 + src/Appwrite/Utopia/Response.php | 1 + tests/e2e/Services/Project/TemplatesBase.php | 246 ++++++++++++++++++ 5 files changed, 341 insertions(+) create mode 100644 src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/XList.php diff --git a/app/init/models.php b/app/init/models.php index 4d1ccc2824..8f569d3252 100644 --- a/app/init/models.php +++ b/app/init/models.php @@ -211,6 +211,7 @@ Response::setModel(new BaseList('Phones List', Response::MODEL_PHONE_LIST, 'phon Response::setModel(new BaseList('Metric List', Response::MODEL_METRIC_LIST, 'metrics', Response::MODEL_METRIC, true, false)); Response::setModel(new BaseList('Variables List', Response::MODEL_VARIABLE_LIST, 'variables', Response::MODEL_VARIABLE)); Response::setModel(new BaseList('Mock Numbers List', Response::MODEL_MOCK_NUMBER_LIST, 'mockNumbers', Response::MODEL_MOCK_NUMBER)); +Response::setModel(new BaseList('Email Templates List', Response::MODEL_EMAIL_TEMPLATE_LIST, 'templates', Response::MODEL_EMAIL_TEMPLATE)); Response::setModel(new BaseList('Status List', Response::MODEL_HEALTH_STATUS_LIST, 'statuses', Response::MODEL_HEALTH_STATUS)); Response::setModel(new BaseList('Rule List', Response::MODEL_PROXY_RULE_LIST, 'rules', Response::MODEL_PROXY_RULE)); Response::setModel(new BaseList('Schedules List', Response::MODEL_SCHEDULE_LIST, 'schedules', Response::MODEL_SCHEDULE)); diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/XList.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/XList.php new file mode 100644 index 0000000000..8b13bdb28a --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/XList.php @@ -0,0 +1,91 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/project/templates/email') + ->desc('List project email templates') + ->groups(['api', 'project']) + ->label('scope', 'templates.read') + ->label('sdk', new Method( + namespace: 'project', + group: 'templates', + name: 'listEmailTemplates', + description: <<param('total', true, new Boolean(true), 'When set to false, the total count returned will be 0 and will not be calculated.', true) + ->inject('response') + ->inject('project') + ->callback($this->action(...)); + } + + public function action( + bool $includeTotal, + Response $response, + Document $project, + ) { + $templates = $project->getAttribute('templates', []); + + $emailTemplates = []; + foreach ($templates as $key => $template) { + if (!\str_starts_with($key, 'email.')) { + continue; + } + + $suffix = \substr($key, \strlen('email.')); + $parts = \explode('-', $suffix, 2); + if (\count($parts) !== 2) { + continue; + } + + [$templateId, $locale] = $parts; + + $template['templateId'] = $templateId; + $template['locale'] = $locale; + + // Backwards compatibility + if (!\is_null($template['replyTo'] ?? null)) { + $template['replyToEmail'] = $template['replyToEmail'] ?? $template['replyTo'] ?? ''; + } + + $emailTemplates[] = new Document($template); + } + + $total = $includeTotal ? \count($emailTemplates) : 0; + + $response->dynamic(new Document([ + 'templates' => $emailTemplates, + 'total' => $total, + ]), Response::MODEL_EMAIL_TEMPLATE_LIST); + } +} diff --git a/src/Appwrite/Platform/Modules/Project/Services/Http.php b/src/Appwrite/Platform/Modules/Project/Services/Http.php index c353c6a4f3..86a7b2c055 100644 --- a/src/Appwrite/Platform/Modules/Project/Services/Http.php +++ b/src/Appwrite/Platform/Modules/Project/Services/Http.php @@ -42,6 +42,7 @@ use Appwrite\Platform\Modules\Project\Http\Project\SMTP\Tests\Create as CreateSM use Appwrite\Platform\Modules\Project\Http\Project\SMTP\Update as UpdateSMTP; use Appwrite\Platform\Modules\Project\Http\Project\Templates\Email\Get as GetTemplate; use Appwrite\Platform\Modules\Project\Http\Project\Templates\Email\Update as UpdateTemplate; +use Appwrite\Platform\Modules\Project\Http\Project\Templates\Email\XList as ListTemplates; use Appwrite\Platform\Modules\Project\Http\Project\Variables\Create as CreateVariable; use Appwrite\Platform\Modules\Project\Http\Project\Variables\Delete as DeleteVariable; use Appwrite\Platform\Modules\Project\Http\Project\Variables\Get as GetVariable; @@ -68,6 +69,7 @@ class Http extends Service $this->addAction(CreateSMTPTest::getName(), new CreateSMTPTest()); // Templates + $this->addAction(ListTemplates::getName(), new ListTemplates()); $this->addAction(GetTemplate::getName(), new GetTemplate()); $this->addAction(UpdateTemplate::getName(), new UpdateTemplate()); diff --git a/src/Appwrite/Utopia/Response.php b/src/Appwrite/Utopia/Response.php index 56ba5635b1..d72b52e4cb 100644 --- a/src/Appwrite/Utopia/Response.php +++ b/src/Appwrite/Utopia/Response.php @@ -267,6 +267,7 @@ class Response extends SwooleResponse public const MODEL_VARIABLE_LIST = 'variableList'; public const MODEL_VCS = 'vcs'; public const MODEL_EMAIL_TEMPLATE = 'emailTemplate'; + public const MODEL_EMAIL_TEMPLATE_LIST = 'emailTemplateList'; // Health public const MODEL_HEALTH_STATUS = 'healthStatus'; diff --git a/tests/e2e/Services/Project/TemplatesBase.php b/tests/e2e/Services/Project/TemplatesBase.php index 72a14210a5..cb7c1bf0b3 100644 --- a/tests/e2e/Services/Project/TemplatesBase.php +++ b/tests/e2e/Services/Project/TemplatesBase.php @@ -579,6 +579,233 @@ trait TemplatesBase } } + // List email template tests + + public function testListEmailTemplatesReturnsSeededTemplate(): void + { + $this->ensureSMTPEnabled(); + + $subject = 'List subject ' . \uniqid(); + $seed = $this->updateEmailTemplate( + templateId: 'verification', + locale: 'en', + subject: $subject, + message: 'List body', + ); + $this->assertSame(200, $seed['headers']['status-code']); + + $response = $this->listEmailTemplates(); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertArrayHasKey('templates', $response['body']); + $this->assertArrayHasKey('total', $response['body']); + $this->assertIsArray($response['body']['templates']); + $this->assertIsInt($response['body']['total']); + $this->assertGreaterThanOrEqual(1, $response['body']['total']); + + $found = null; + foreach ($response['body']['templates'] as $template) { + if ( + $template['templateId'] === 'verification' + && $template['locale'] === 'en' + && $template['subject'] === $subject + ) { + $found = $template; + break; + } + } + $this->assertNotNull($found, 'seeded verification/en template must appear in the list'); + } + + public function testListEmailTemplatesResponseModel(): void + { + $this->ensureSMTPEnabled(); + + $seed = $this->updateEmailTemplate( + templateId: 'invitation', + locale: 'en', + subject: 'Shape subject ' . \uniqid(), + message: 'Shape body', + senderName: 'Shape Sender', + senderEmail: 'shape@appwrite.io', + replyToEmail: 'shape-reply@appwrite.io', + replyToName: 'Shape Reply', + ); + $this->assertSame(200, $seed['headers']['status-code']); + + $response = $this->listEmailTemplates(); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertNotEmpty($response['body']['templates']); + + foreach ($response['body']['templates'] as $template) { + $this->assertArrayHasKey('templateId', $template); + $this->assertArrayHasKey('locale', $template); + $this->assertArrayHasKey('subject', $template); + $this->assertArrayHasKey('message', $template); + $this->assertArrayHasKey('senderName', $template); + $this->assertArrayHasKey('senderEmail', $template); + $this->assertArrayHasKey('replyToEmail', $template); + $this->assertArrayHasKey('replyToName', $template); + } + } + + public function testListEmailTemplatesSeparatesLocales(): void + { + $this->ensureSMTPEnabled(); + + $runId = \uniqid(); + $enSubject = "Multi-locale EN {$runId}"; + $frSubject = "Multi-locale FR {$runId}"; + + $this->assertSame(200, $this->updateEmailTemplate( + templateId: 'recovery', + locale: 'en', + subject: $enSubject, + message: 'EN body', + )['headers']['status-code']); + + $this->assertSame(200, $this->updateEmailTemplate( + templateId: 'recovery', + locale: 'fr', + subject: $frSubject, + message: 'FR body', + )['headers']['status-code']); + + $response = $this->listEmailTemplates(); + $this->assertSame(200, $response['headers']['status-code']); + + $foundEn = false; + $foundFr = false; + foreach ($response['body']['templates'] as $template) { + if ($template['templateId'] === 'recovery' && $template['locale'] === 'en' && $template['subject'] === $enSubject) { + $foundEn = true; + } + if ($template['templateId'] === 'recovery' && $template['locale'] === 'fr' && $template['subject'] === $frSubject) { + $foundFr = true; + } + } + + $this->assertTrue($foundEn, 'recovery/en must appear in the list'); + $this->assertTrue($foundFr, 'recovery/fr must appear in the list'); + } + + public function testListEmailTemplatesUpdateDoesNotDuplicate(): void + { + $this->ensureSMTPEnabled(); + + $runId = \uniqid(); + $firstSubject = "First {$runId}"; + $secondSubject = "Second {$runId}"; + + $this->assertSame(200, $this->updateEmailTemplate( + templateId: 'mfaChallenge', + locale: 'en', + subject: $firstSubject, + message: 'Body', + )['headers']['status-code']); + + $before = $this->listEmailTemplates(); + $this->assertSame(200, $before['headers']['status-code']); + $beforeTotal = $before['body']['total']; + + $this->assertSame(200, $this->updateEmailTemplate( + templateId: 'mfaChallenge', + locale: 'en', + subject: $secondSubject, + message: 'Body', + )['headers']['status-code']); + + $after = $this->listEmailTemplates(); + $this->assertSame(200, $after['headers']['status-code']); + + // Same templateId/locale must remain a single entry, not accumulate. + $this->assertSame($beforeTotal, $after['body']['total']); + + $matches = \array_values(\array_filter( + $after['body']['templates'], + fn ($t) => $t['templateId'] === 'mfaChallenge' && $t['locale'] === 'en', + )); + $this->assertCount(1, $matches); + $this->assertSame($secondSubject, $matches[0]['subject']); + } + + public function testListEmailTemplatesTotalFalse(): void + { + $this->ensureSMTPEnabled(); + + // Ensure at least one template exists so `templates` is non-empty. + $this->assertSame(200, $this->updateEmailTemplate( + templateId: 'verification', + locale: 'en', + subject: 'Total-false subject', + message: 'Body', + )['headers']['status-code']); + + $response = $this->listEmailTemplates(total: false); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertIsInt($response['body']['total']); + $this->assertSame(0, $response['body']['total']); + $this->assertNotEmpty($response['body']['templates']); + } + + public function testListEmailTemplatesTotalMatchesCount(): void + { + $this->ensureSMTPEnabled(); + + $this->assertSame(200, $this->updateEmailTemplate( + templateId: 'verification', + locale: 'en', + subject: 'Match subject', + message: 'Body', + )['headers']['status-code']); + + $response = $this->listEmailTemplates(); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(\count($response['body']['templates']), $response['body']['total']); + } + + public function testListEmailTemplatesOnlyReturnsCustomizedTemplates(): void + { + $this->ensureSMTPEnabled(); + + // Seed exactly one template so we have a stable marker to count against. + $marker = 'Customized-only ' . \uniqid(); + $this->assertSame(200, $this->updateEmailTemplate( + templateId: 'otpSession', + locale: 'en', + subject: $marker, + message: 'Body', + )['headers']['status-code']); + + $response = $this->listEmailTemplates(); + $this->assertSame(200, $response['headers']['status-code']); + + // Every returned entry must be a real stored template (has templateId+locale set, + // not a synthesized default row for every possible type). + foreach ($response['body']['templates'] as $template) { + $this->assertNotEmpty($template['templateId']); + $this->assertNotEmpty($template['locale']); + } + + // A `(templateId, locale)` pair that has never been customized in this test + // run must NOT show up. 'otpSession'/'pt-br' has no writer anywhere in the file. + $uncustomized = \array_filter( + $response['body']['templates'], + fn ($t) => $t['templateId'] === 'otpSession' && $t['locale'] === 'pt-br', + ); + $this->assertEmpty($uncustomized, 'uncustomized (templateId, locale) pairs must not appear'); + } + + public function testListEmailTemplatesWithoutAuthentication(): void + { + $response = $this->listEmailTemplates(authenticated: false); + + $this->assertSame(401, $response['headers']['status-code']); + } + // Backwards compatibility (x-appwrite-response-format: 1.9.1) public function testGetEmailTemplateLegacyResponseFormat(): void @@ -804,6 +1031,25 @@ trait TemplatesBase return $this->client->call(Client::METHOD_GET, '/project/templates/email/' . $templateId, $headers, $params); } + protected function listEmailTemplates(?bool $total = null, bool $authenticated = true): mixed + { + $headers = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]; + + if ($authenticated) { + $headers = \array_merge($headers, $this->getHeaders()); + } + + $params = []; + if ($total !== null) { + $params['total'] = $total; + } + + return $this->client->call(Client::METHOD_GET, '/project/templates/email', $headers, $params); + } + protected function updateEmailTemplate( string $templateId, ?string $locale = null, From f770277ea5fe143f4b8d3b66553c197f05e96a27 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Wed, 22 Apr 2026 11:42:39 +0200 Subject: [PATCH 03/34] New mock phones tests --- tests/e2e/Services/Project/MockPhonesBase.php | 500 ++++++++++++++++++ .../MockPhonesSessionIntegrationTest.php | 152 ++++++ 2 files changed, 652 insertions(+) create mode 100644 tests/e2e/Services/Project/MockPhonesBase.php create mode 100644 tests/e2e/Services/Project/MockPhonesSessionIntegrationTest.php diff --git a/tests/e2e/Services/Project/MockPhonesBase.php b/tests/e2e/Services/Project/MockPhonesBase.php new file mode 100644 index 0000000000..10ddf8aa0c --- /dev/null +++ b/tests/e2e/Services/Project/MockPhonesBase.php @@ -0,0 +1,500 @@ +uniquePhoneNumber(); + + $response = $this->createMockPhone($number, '123456'); + + $this->assertSame(201, $response['headers']['status-code']); + $this->assertSame($number, $response['body']['number']); + $this->assertSame('123456', $response['body']['otp']); + + $dateValidator = new DatetimeValidator(); + $this->assertTrue($dateValidator->isValid($response['body']['$createdAt'])); + $this->assertTrue($dateValidator->isValid($response['body']['$updatedAt'])); + + // Verify via GET + $get = $this->getMockPhone($number); + $this->assertSame(200, $get['headers']['status-code']); + $this->assertSame($number, $get['body']['number']); + $this->assertSame('123456', $get['body']['otp']); + + // Verify via LIST + $list = $this->listMockPhones(); + $this->assertSame(200, $list['headers']['status-code']); + $numbers = \array_column($list['body']['mockNumbers'], 'number'); + $this->assertContains($number, $numbers); + + // Cleanup + $this->deleteMockPhone($number); + } + + public function testCreateMockPhoneAlreadyExists(): void + { + $number = $this->uniquePhoneNumber(); + + $first = $this->createMockPhone($number, '123456'); + $this->assertSame(201, $first['headers']['status-code']); + + $duplicate = $this->createMockPhone($number, '654321'); + $this->assertSame(409, $duplicate['headers']['status-code']); + $this->assertSame('mock_number_already_exists', $duplicate['body']['type']); + + // Original OTP must remain unchanged + $get = $this->getMockPhone($number); + $this->assertSame(200, $get['headers']['status-code']); + $this->assertSame('123456', $get['body']['otp']); + + // Cleanup + $this->deleteMockPhone($number); + } + + public function testCreateMockPhoneInvalidNumber(): void + { + // Missing `+` prefix — Phone validator rejects. + $response = $this->createMockPhone('16555551234', '123456'); + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testCreateMockPhoneNumberTooLong(): void + { + // 16 digits exceeds the E.164 15-digit maximum. + $response = $this->createMockPhone('+1234567890987654', '123456'); + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testCreateMockPhoneInvalidOtpTooShort(): void + { + $response = $this->createMockPhone($this->uniquePhoneNumber(), '123'); + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testCreateMockPhoneInvalidOtpTooLong(): void + { + $response = $this->createMockPhone($this->uniquePhoneNumber(), '1234567'); + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testCreateMockPhoneInvalidOtpNonNumeric(): void + { + $response = $this->createMockPhone($this->uniquePhoneNumber(), 'abc123'); + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testCreateMockPhoneMissingNumber(): void + { + $response = $this->createMockPhone(null, '123456'); + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testCreateMockPhoneMissingOtp(): void + { + $response = $this->createMockPhone($this->uniquePhoneNumber(), null); + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testCreateMockPhoneWithoutAuthentication(): void + { + $response = $this->createMockPhone($this->uniquePhoneNumber(), '123456', authenticated: false); + $this->assertSame(401, $response['headers']['status-code']); + } + + // Get mock phone tests + + public function testGetMockPhone(): void + { + $number = $this->uniquePhoneNumber(); + $create = $this->createMockPhone($number, '987654'); + $this->assertSame(201, $create['headers']['status-code']); + + $response = $this->getMockPhone($number); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame($number, $response['body']['number']); + $this->assertSame('987654', $response['body']['otp']); + + $dateValidator = new DatetimeValidator(); + $this->assertTrue($dateValidator->isValid($response['body']['$createdAt'])); + $this->assertTrue($dateValidator->isValid($response['body']['$updatedAt'])); + + // Cleanup + $this->deleteMockPhone($number); + } + + public function testGetMockPhoneNotFound(): void + { + $response = $this->getMockPhone($this->uniquePhoneNumber()); + + $this->assertSame(404, $response['headers']['status-code']); + $this->assertSame('mock_number_not_found', $response['body']['type']); + } + + public function testGetMockPhoneInvalidNumber(): void + { + // Path param is still validated with the Phone validator. + $response = $this->getMockPhone('not-a-phone'); + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testGetMockPhoneWithoutAuthentication(): void + { + $number = $this->uniquePhoneNumber(); + $create = $this->createMockPhone($number, '123456'); + $this->assertSame(201, $create['headers']['status-code']); + + $response = $this->getMockPhone($number, authenticated: false); + $this->assertSame(401, $response['headers']['status-code']); + + // Cleanup + $this->deleteMockPhone($number); + } + + // Update mock phone tests + + public function testUpdateMockPhone(): void + { + $number = $this->uniquePhoneNumber(); + $create = $this->createMockPhone($number, '111111'); + $this->assertSame(201, $create['headers']['status-code']); + + $createdAt = $create['body']['$createdAt']; + + // Sleep a bit so $updatedAt shifts noticeably — makes the assertion below meaningful. + \sleep(1); + + $update = $this->updateMockPhone($number, '222222'); + + $this->assertSame(200, $update['headers']['status-code']); + $this->assertSame($number, $update['body']['number']); + $this->assertSame('222222', $update['body']['otp']); + $this->assertSame($createdAt, $update['body']['$createdAt']); + $this->assertNotSame($createdAt, $update['body']['$updatedAt']); + + // Verify persistence via GET + $get = $this->getMockPhone($number); + $this->assertSame(200, $get['headers']['status-code']); + $this->assertSame('222222', $get['body']['otp']); + + // Cleanup + $this->deleteMockPhone($number); + } + + public function testUpdateMockPhoneNotFound(): void + { + $response = $this->updateMockPhone($this->uniquePhoneNumber(), '123456'); + + $this->assertSame(404, $response['headers']['status-code']); + $this->assertSame('mock_number_not_found', $response['body']['type']); + } + + public function testUpdateMockPhoneInvalidOtp(): void + { + $number = $this->uniquePhoneNumber(); + $create = $this->createMockPhone($number, '123456'); + $this->assertSame(201, $create['headers']['status-code']); + + $response = $this->updateMockPhone($number, 'abc123'); + $this->assertSame(400, $response['headers']['status-code']); + + // Original OTP must remain unchanged + $get = $this->getMockPhone($number); + $this->assertSame('123456', $get['body']['otp']); + + // Cleanup + $this->deleteMockPhone($number); + } + + public function testUpdateMockPhoneMissingOtp(): void + { + $number = $this->uniquePhoneNumber(); + $create = $this->createMockPhone($number, '123456'); + $this->assertSame(201, $create['headers']['status-code']); + + $response = $this->updateMockPhone($number, null); + $this->assertSame(400, $response['headers']['status-code']); + + // Cleanup + $this->deleteMockPhone($number); + } + + public function testUpdateMockPhoneWithoutAuthentication(): void + { + $number = $this->uniquePhoneNumber(); + $create = $this->createMockPhone($number, '123456'); + $this->assertSame(201, $create['headers']['status-code']); + + $response = $this->updateMockPhone($number, '654321', authenticated: false); + $this->assertSame(401, $response['headers']['status-code']); + + // Verify it's unchanged + $get = $this->getMockPhone($number); + $this->assertSame('123456', $get['body']['otp']); + + // Cleanup + $this->deleteMockPhone($number); + } + + // List mock phones tests + + public function testListMockPhones(): void + { + $number1 = $this->uniquePhoneNumber(); + $number2 = $this->uniquePhoneNumber(); + $number3 = $this->uniquePhoneNumber(); + + $this->assertSame(201, $this->createMockPhone($number1, '111111')['headers']['status-code']); + $this->assertSame(201, $this->createMockPhone($number2, '222222')['headers']['status-code']); + $this->assertSame(201, $this->createMockPhone($number3, '333333')['headers']['status-code']); + + $response = $this->listMockPhones(); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertArrayHasKey('mockNumbers', $response['body']); + $this->assertArrayHasKey('total', $response['body']); + $this->assertIsArray($response['body']['mockNumbers']); + $this->assertIsInt($response['body']['total']); + $this->assertGreaterThanOrEqual(3, $response['body']['total']); + $this->assertGreaterThanOrEqual(3, \count($response['body']['mockNumbers'])); + + // Verify shape of each entry + foreach ($response['body']['mockNumbers'] as $entry) { + $this->assertArrayHasKey('number', $entry); + $this->assertArrayHasKey('otp', $entry); + $this->assertArrayHasKey('$createdAt', $entry); + $this->assertArrayHasKey('$updatedAt', $entry); + } + + // All three seeded phones must be in the list + $numbers = \array_column($response['body']['mockNumbers'], 'number'); + $this->assertContains($number1, $numbers); + $this->assertContains($number2, $numbers); + $this->assertContains($number3, $numbers); + + // Cleanup + $this->deleteMockPhone($number1); + $this->deleteMockPhone($number2); + $this->deleteMockPhone($number3); + } + + public function testListMockPhonesTotalFalse(): void + { + $number = $this->uniquePhoneNumber(); + $create = $this->createMockPhone($number, '123456'); + $this->assertSame(201, $create['headers']['status-code']); + + $response = $this->listMockPhones(total: false); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(0, $response['body']['total']); + $this->assertGreaterThanOrEqual(1, \count($response['body']['mockNumbers'])); + + // Cleanup + $this->deleteMockPhone($number); + } + + public function testListMockPhonesTotalMatchesCount(): void + { + $number = $this->uniquePhoneNumber(); + $create = $this->createMockPhone($number, '123456'); + $this->assertSame(201, $create['headers']['status-code']); + + $response = $this->listMockPhones(); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(\count($response['body']['mockNumbers']), $response['body']['total']); + + // Cleanup + $this->deleteMockPhone($number); + } + + public function testListMockPhonesWithoutAuthentication(): void + { + $response = $this->listMockPhones(authenticated: false); + $this->assertSame(401, $response['headers']['status-code']); + } + + // Delete mock phone tests + + public function testDeleteMockPhone(): void + { + $number = $this->uniquePhoneNumber(); + $create = $this->createMockPhone($number, '123456'); + $this->assertSame(201, $create['headers']['status-code']); + + // Confirm it exists + $this->assertSame(200, $this->getMockPhone($number)['headers']['status-code']); + + $response = $this->deleteMockPhone($number); + $this->assertSame(204, $response['headers']['status-code']); + $this->assertEmpty($response['body']); + + // Confirm it is gone + $get = $this->getMockPhone($number); + $this->assertSame(404, $get['headers']['status-code']); + $this->assertSame('mock_number_not_found', $get['body']['type']); + } + + public function testDeleteMockPhoneNotFound(): void + { + $response = $this->deleteMockPhone($this->uniquePhoneNumber()); + + $this->assertSame(404, $response['headers']['status-code']); + $this->assertSame('mock_number_not_found', $response['body']['type']); + } + + public function testDeleteMockPhoneDoubleDelete(): void + { + $number = $this->uniquePhoneNumber(); + $this->assertSame(201, $this->createMockPhone($number, '123456')['headers']['status-code']); + + $first = $this->deleteMockPhone($number); + $this->assertSame(204, $first['headers']['status-code']); + + $second = $this->deleteMockPhone($number); + $this->assertSame(404, $second['headers']['status-code']); + $this->assertSame('mock_number_not_found', $second['body']['type']); + } + + public function testDeleteMockPhoneRemovedFromList(): void + { + $number = $this->uniquePhoneNumber(); + $create = $this->createMockPhone($number, '123456'); + $this->assertSame(201, $create['headers']['status-code']); + + $before = $this->listMockPhones(); + $this->assertSame(200, $before['headers']['status-code']); + $this->assertContains($number, \array_column($before['body']['mockNumbers'], 'number')); + $countBefore = $before['body']['total']; + + $delete = $this->deleteMockPhone($number); + $this->assertSame(204, $delete['headers']['status-code']); + + $after = $this->listMockPhones(); + $this->assertSame(200, $after['headers']['status-code']); + $this->assertSame($countBefore - 1, $after['body']['total']); + $this->assertNotContains($number, \array_column($after['body']['mockNumbers'], 'number')); + } + + public function testDeleteMockPhoneWithoutAuthentication(): void + { + $number = $this->uniquePhoneNumber(); + $create = $this->createMockPhone($number, '123456'); + $this->assertSame(201, $create['headers']['status-code']); + + $response = $this->deleteMockPhone($number, authenticated: false); + $this->assertSame(401, $response['headers']['status-code']); + + // Still present + $this->assertSame(200, $this->getMockPhone($number)['headers']['status-code']); + + // Cleanup + $this->deleteMockPhone($number); + } + + // Helpers + + protected function createMockPhone(?string $number, ?string $otp, bool $authenticated = true): mixed + { + $headers = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]; + + if ($authenticated) { + $headers = \array_merge($headers, $this->getHeaders()); + } + + $params = []; + if ($number !== null) { + $params['number'] = $number; + } + if ($otp !== null) { + $params['otp'] = $otp; + } + + return $this->client->call(Client::METHOD_POST, '/project/mock-phones', $headers, $params); + } + + protected function getMockPhone(string $number, bool $authenticated = true): mixed + { + $headers = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]; + + if ($authenticated) { + $headers = \array_merge($headers, $this->getHeaders()); + } + + return $this->client->call(Client::METHOD_GET, '/project/mock-phones/' . \urlencode($number), $headers); + } + + protected function updateMockPhone(string $number, ?string $otp, bool $authenticated = true): mixed + { + $headers = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]; + + if ($authenticated) { + $headers = \array_merge($headers, $this->getHeaders()); + } + + $params = []; + if ($otp !== null) { + $params['otp'] = $otp; + } + + return $this->client->call(Client::METHOD_PUT, '/project/mock-phones/' . \urlencode($number), $headers, $params); + } + + protected function listMockPhones(?bool $total = null, bool $authenticated = true): mixed + { + $headers = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]; + + if ($authenticated) { + $headers = \array_merge($headers, $this->getHeaders()); + } + + $params = []; + if ($total !== null) { + $params['total'] = $total; + } + + return $this->client->call(Client::METHOD_GET, '/project/mock-phones', $headers, $params); + } + + protected function deleteMockPhone(string $number, bool $authenticated = true): mixed + { + $headers = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]; + + if ($authenticated) { + $headers = \array_merge($headers, $this->getHeaders()); + } + + return $this->client->call(Client::METHOD_DELETE, '/project/mock-phones/' . \urlencode($number), $headers); + } + + protected function uniquePhoneNumber(): string + { + // E.164: leading '+', first digit 1-9, 10 more digits. Randomised to avoid + // collisions between interleaved tests that all live in the same project. + return '+1' . \random_int(2000000000, 9999999999); + } +} diff --git a/tests/e2e/Services/Project/MockPhonesSessionIntegrationTest.php b/tests/e2e/Services/Project/MockPhonesSessionIntegrationTest.php new file mode 100644 index 0000000000..8ff65e3fd1 --- /dev/null +++ b/tests/e2e/Services/Project/MockPhonesSessionIntegrationTest.php @@ -0,0 +1,152 @@ +getProject()['$id']; + $apiKey = $this->getProject()['apiKey']; + + $serverHeaders = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $apiKey, + ]; + + $clientHeaders = [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ]; + + // Step 1: Configure two mock phones with distinct OTPs. + $phoneA = '+1' . \random_int(2000000000, 9999999999); + $phoneB = '+1' . \random_int(2000000000, 9999999999); + $otpA = '111111'; + $otpB = '222222'; + + $mockA = $this->client->call(Client::METHOD_POST, '/project/mock-phones', $serverHeaders, [ + 'number' => $phoneA, + 'otp' => $otpA, + ]); + $this->assertSame(201, $mockA['headers']['status-code']); + $this->assertSame($phoneA, $mockA['body']['number']); + $this->assertSame($otpA, $mockA['body']['otp']); + + $mockB = $this->client->call(Client::METHOD_POST, '/project/mock-phones', $serverHeaders, [ + 'number' => $phoneB, + 'otp' => $otpB, + ]); + $this->assertSame(201, $mockB['headers']['status-code']); + $this->assertSame($phoneB, $mockB['body']['number']); + $this->assertSame($otpB, $mockB['body']['otp']); + + // Step 2 (Phone A): sign-in flow that also creates the user (userId = unique()). + $tokenA = $this->client->call(Client::METHOD_POST, '/account/tokens/phone', $clientHeaders, [ + 'userId' => ID::unique(), + 'phone' => $phoneA, + ]); + $this->assertSame(201, $tokenA['headers']['status-code']); + $userIdA = $tokenA['body']['userId']; + $this->assertNotEmpty($userIdA); + + // Arbitrary wrong OTP must be rejected. + $wrongA = $this->client->call(Client::METHOD_PUT, '/account/sessions/phone', $clientHeaders, [ + 'userId' => $userIdA, + 'secret' => '999999', + ]); + $this->assertSame(401, $wrongA['headers']['status-code']); + + // Phone B's OTP must not unlock Phone A's user — proves OTPs are scoped to the mock record. + $crossA = $this->client->call(Client::METHOD_PUT, '/account/sessions/phone', $clientHeaders, [ + 'userId' => $userIdA, + 'secret' => $otpB, + ]); + $this->assertSame(401, $crossA['headers']['status-code']); + + // Correct mock OTP establishes the session. + $sessionA = $this->client->call(Client::METHOD_PUT, '/account/sessions/phone', $clientHeaders, [ + 'userId' => $userIdA, + 'secret' => $otpA, + ]); + $this->assertSame(201, $sessionA['headers']['status-code']); + $this->assertNotEmpty($sessionA['cookies']['a_session_' . $projectId] ?? null); + $cookieA = $sessionA['cookies']['a_session_' . $projectId]; + + // GET /account using the session confirms identity. + $accountA = $this->client->call(Client::METHOD_GET, '/account', \array_merge($clientHeaders, [ + 'cookie' => 'a_session_' . $projectId . '=' . $cookieA, + ])); + $this->assertSame(200, $accountA['headers']['status-code']); + $this->assertSame($userIdA, $accountA['body']['$id']); + $this->assertSame($phoneA, $accountA['body']['phone']); + $this->assertTrue($accountA['body']['phoneVerification']); + + // Step 3 (Phone B): pre-create the user server-side, then sign in with the mock OTP. + $precreated = $this->client->call(Client::METHOD_POST, '/users', $serverHeaders, [ + 'userId' => ID::unique(), + 'phone' => $phoneB, + ]); + $this->assertSame(201, $precreated['headers']['status-code']); + $userIdB = $precreated['body']['$id']; + $this->assertSame($phoneB, $precreated['body']['phone']); + + $tokenB = $this->client->call(Client::METHOD_POST, '/account/tokens/phone', $clientHeaders, [ + 'userId' => $userIdB, + 'phone' => $phoneB, + ]); + $this->assertSame(201, $tokenB['headers']['status-code']); + $this->assertSame($userIdB, $tokenB['body']['userId']); + + // Arbitrary wrong OTP must be rejected. + $wrongB = $this->client->call(Client::METHOD_PUT, '/account/sessions/phone', $clientHeaders, [ + 'userId' => $userIdB, + 'secret' => '000000', + ]); + $this->assertSame(401, $wrongB['headers']['status-code']); + + // Phone A's OTP must not unlock Phone B's user. + $crossB = $this->client->call(Client::METHOD_PUT, '/account/sessions/phone', $clientHeaders, [ + 'userId' => $userIdB, + 'secret' => $otpA, + ]); + $this->assertSame(401, $crossB['headers']['status-code']); + + // Correct mock OTP establishes the session. + $sessionB = $this->client->call(Client::METHOD_PUT, '/account/sessions/phone', $clientHeaders, [ + 'userId' => $userIdB, + 'secret' => $otpB, + ]); + $this->assertSame(201, $sessionB['headers']['status-code']); + $this->assertNotEmpty($sessionB['cookies']['a_session_' . $projectId] ?? null); + $cookieB = $sessionB['cookies']['a_session_' . $projectId]; + + // GET /account using the session confirms identity. + $accountB = $this->client->call(Client::METHOD_GET, '/account', \array_merge($clientHeaders, [ + 'cookie' => 'a_session_' . $projectId . '=' . $cookieB, + ])); + $this->assertSame(200, $accountB['headers']['status-code']); + $this->assertSame($userIdB, $accountB['body']['$id']); + $this->assertSame($phoneB, $accountB['body']['phone']); + $this->assertTrue($accountB['body']['phoneVerification']); + + // Cross-check: the two flows produced distinct users. + $this->assertNotSame($userIdA, $userIdB); + $this->assertNotSame($accountA['body']['phone'], $accountB['body']['phone']); + + // Cleanup mock phone config to avoid polluting project state for later tests. + $this->client->call(Client::METHOD_DELETE, '/project/mock-phones/' . \urlencode($phoneA), $serverHeaders); + $this->client->call(Client::METHOD_DELETE, '/project/mock-phones/' . \urlencode($phoneB), $serverHeaders); + } +} From 7578b5644cf029872dae385d34cc246190197b2f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Wed, 22 Apr 2026 12:00:15 +0200 Subject: [PATCH 04/34] AI review fixes --- app/config/errors.php | 5 +++++ src/Appwrite/Extend/Exception.php | 1 + .../Modules/Project/Http/Project/MockPhone/Create.php | 8 ++++++-- .../Modules/Project/Http/Project/MockPhone/Delete.php | 2 +- .../Modules/Project/Http/Project/MockPhone/Get.php | 2 +- .../Modules/Project/Http/Project/MockPhone/Update.php | 2 +- src/Appwrite/Utopia/Response/Model/MockNumber.php | 11 +++++++++++ 7 files changed, 26 insertions(+), 5 deletions(-) diff --git a/app/config/errors.php b/app/config/errors.php index 9a4710cb33..07b0cd59ed 100644 --- a/app/config/errors.php +++ b/app/config/errors.php @@ -1418,4 +1418,9 @@ return [ 'description' => 'Mock number with the requested number could not be found.', 'code' => 404, ], + Exception::MOCK_NUMBER_LIMIT_EXCEEDED => [ + 'name' => Exception::MOCK_NUMBER_LIMIT_EXCEEDED, + 'description' => 'The maximum number of mock phones for this project has been reached.', + 'code' => 400, + ], ]; diff --git a/src/Appwrite/Extend/Exception.php b/src/Appwrite/Extend/Exception.php index b1651fec13..6fc3e88635 100644 --- a/src/Appwrite/Extend/Exception.php +++ b/src/Appwrite/Extend/Exception.php @@ -387,6 +387,7 @@ class Exception extends \Exception /** Mocks */ public const string MOCK_NUMBER_ALREADY_EXISTS = 'mock_number_already_exists'; public const string MOCK_NUMBER_NOT_FOUND = 'mock_number_not_found'; + public const string MOCK_NUMBER_LIMIT_EXCEEDED = 'mock_number_limit_exceeded'; /** Targets */ public const string TARGET_PROVIDER_INVALID_TYPE = 'target_provider_invalid_type'; diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/MockPhone/Create.php b/src/Appwrite/Platform/Modules/Project/Http/Project/MockPhone/Create.php index 8aa2bcf642..f4002c60ef 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/MockPhone/Create.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/MockPhone/Create.php @@ -75,15 +75,19 @@ class Create extends Action $mockNumbers = $auths['mockNumbers'] ?? []; + if (\count($mockNumbers) >= APP_LIMIT_COUNT) { + throw new Exception(Exception::MOCK_NUMBER_LIMIT_EXCEEDED); + } + foreach ($mockNumbers as $mockNumber) { - if ($mockNumber['number'] === $number) { + if ($mockNumber['phone'] === $number) { throw new Exception(Exception::MOCK_NUMBER_ALREADY_EXISTS); } } // Set to now date $mockNumber = [ - 'number' => $number, + 'phone' => $number, 'otp' => $otp, '$createdAt' => DateTime::now(), '$updatedAt' => DateTime::now(), diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/MockPhone/Delete.php b/src/Appwrite/Platform/Modules/Project/Http/Project/MockPhone/Delete.php index af7afae120..0fb23e1764 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/MockPhone/Delete.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/MockPhone/Delete.php @@ -75,7 +75,7 @@ class Delete extends Action $mockNumberIndex = null; foreach ($mockNumbers as $index => $mock) { - if ($mock['number'] === $number) { + if ($mock['phone'] === $number) { $mockNumberIndex = $index; break; } diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/MockPhone/Get.php b/src/Appwrite/Platform/Modules/Project/Http/Project/MockPhone/Get.php index 8f799e98d7..a51095b368 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/MockPhone/Get.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/MockPhone/Get.php @@ -61,7 +61,7 @@ class Get extends Action $mockNumberIndex = null; foreach ($mockNumbers as $index => $mock) { - if ($mock['number'] === $number) { + if ($mock['phone'] === $number) { $mockNumberIndex = $index; break; } diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/MockPhone/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/MockPhone/Update.php index 4924f53ff8..48b90a1b97 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/MockPhone/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/MockPhone/Update.php @@ -77,7 +77,7 @@ class Update extends Action $mockNumberIndex = null; foreach ($mockNumbers as $index => $mock) { - if ($mock['number'] === $number) { + if ($mock['phone'] === $number) { $mockNumberIndex = $index; break; } diff --git a/src/Appwrite/Utopia/Response/Model/MockNumber.php b/src/Appwrite/Utopia/Response/Model/MockNumber.php index eee788dbab..507700bc5b 100644 --- a/src/Appwrite/Utopia/Response/Model/MockNumber.php +++ b/src/Appwrite/Utopia/Response/Model/MockNumber.php @@ -4,6 +4,7 @@ namespace Appwrite\Utopia\Response\Model; use Appwrite\Utopia\Response; use Appwrite\Utopia\Response\Model; +use Utopia\Database\Document; class MockNumber extends Model { @@ -37,6 +38,16 @@ class MockNumber extends Model ; } + public function filter(Document $document): Document + { + if ($document->isSet('phone')) { + $document->setAttribute('number', $document->getAttribute('phone')); + $document->removeAttribute('phone'); + } + + return $document; + } + /** * Get Name * From 355d4323fc9bb5eebfb2a307340955da998e1a55 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Wed, 22 Apr 2026 12:01:42 +0200 Subject: [PATCH 05/34] Fix tests not running --- .../Project/MockPhonesConsoleClientTest.php | 14 ++++++++++++++ .../Project/MockPhonesCustomServerTest.php | 14 ++++++++++++++ 2 files changed, 28 insertions(+) create mode 100644 tests/e2e/Services/Project/MockPhonesConsoleClientTest.php create mode 100644 tests/e2e/Services/Project/MockPhonesCustomServerTest.php diff --git a/tests/e2e/Services/Project/MockPhonesConsoleClientTest.php b/tests/e2e/Services/Project/MockPhonesConsoleClientTest.php new file mode 100644 index 0000000000..c4819774bf --- /dev/null +++ b/tests/e2e/Services/Project/MockPhonesConsoleClientTest.php @@ -0,0 +1,14 @@ + Date: Wed, 22 Apr 2026 12:13:10 +0200 Subject: [PATCH 06/34] Add mocks scopes --- app/config/roles.php | 2 ++ app/config/scopes/project.php | 8 ++++++++ src/Appwrite/Platform/Workers/Migrations.php | 2 ++ tests/e2e/Scopes/ProjectCustom.php | 2 ++ 4 files changed, 14 insertions(+) diff --git a/app/config/roles.php b/app/config/roles.php index 50b0cb3dfc..62efb4d809 100644 --- a/app/config/roles.php +++ b/app/config/roles.php @@ -55,6 +55,8 @@ $admins = [ 'tables.write', 'platforms.read', 'platforms.write', + 'mocks.read', + 'mocks.write', 'policies.write', 'templates.read', 'templates.write', diff --git a/app/config/scopes/project.php b/app/config/scopes/project.php index c5fba3ed2b..2c78cb921c 100644 --- a/app/config/scopes/project.php +++ b/app/config/scopes/project.php @@ -204,6 +204,14 @@ return [ // List of publicly visible scopes "description" => "Access to create, update, and delete project\'s platforms", ], + "mocks.read" => [ + "description" => + "Access to read project\'s mocks", + ], + "mocks.write" => [ + "description" => + "Access to create, update, and delete project\'s mocks", + ], "policies.write" => [ "description" => "Access to update project\'s policies", diff --git a/src/Appwrite/Platform/Workers/Migrations.php b/src/Appwrite/Platform/Workers/Migrations.php index 52ad64d975..0225983d2f 100644 --- a/src/Appwrite/Platform/Workers/Migrations.php +++ b/src/Appwrite/Platform/Workers/Migrations.php @@ -391,6 +391,8 @@ class Migrations extends Action 'keys.write', 'platforms.read', 'platforms.write', + 'mocks.read', + 'mocks.write', 'policies.write', 'templates.read', 'templates.write', diff --git a/tests/e2e/Scopes/ProjectCustom.php b/tests/e2e/Scopes/ProjectCustom.php index 86d7de8849..e5a86c07fd 100644 --- a/tests/e2e/Scopes/ProjectCustom.php +++ b/tests/e2e/Scopes/ProjectCustom.php @@ -169,6 +169,8 @@ trait ProjectCustom 'keys.write', 'platforms.read', 'platforms.write', + 'mocks.read', + 'mocks.write', 'policies.write', 'templates.read', 'templates.write', From d1ade3872e9b0c94d8e6cb07abe959583bf5dc88 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Wed, 22 Apr 2026 12:22:00 +0200 Subject: [PATCH 07/34] Fix failing tests --- tests/e2e/Services/Project/MockPhonesBase.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/e2e/Services/Project/MockPhonesBase.php b/tests/e2e/Services/Project/MockPhonesBase.php index 10ddf8aa0c..02ddcd73bc 100644 --- a/tests/e2e/Services/Project/MockPhonesBase.php +++ b/tests/e2e/Services/Project/MockPhonesBase.php @@ -436,7 +436,7 @@ trait MockPhonesBase $headers = \array_merge($headers, $this->getHeaders()); } - return $this->client->call(Client::METHOD_GET, '/project/mock-phones/' . \urlencode($number), $headers); + return $this->client->call(Client::METHOD_GET, '/project/mock-phones/' . $number, $headers); } protected function updateMockPhone(string $number, ?string $otp, bool $authenticated = true): mixed @@ -455,7 +455,7 @@ trait MockPhonesBase $params['otp'] = $otp; } - return $this->client->call(Client::METHOD_PUT, '/project/mock-phones/' . \urlencode($number), $headers, $params); + return $this->client->call(Client::METHOD_PUT, '/project/mock-phones/' . $number, $headers, $params); } protected function listMockPhones(?bool $total = null, bool $authenticated = true): mixed @@ -488,7 +488,7 @@ trait MockPhonesBase $headers = \array_merge($headers, $this->getHeaders()); } - return $this->client->call(Client::METHOD_DELETE, '/project/mock-phones/' . \urlencode($number), $headers); + return $this->client->call(Client::METHOD_DELETE, '/project/mock-phones/' . $number, $headers); } protected function uniquePhoneNumber(): string From 6648a1987bd8f79d15abd5032bcb21034ecaa842 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Wed, 22 Apr 2026 13:14:43 +0200 Subject: [PATCH 08/34] Fix tests --- tests/e2e/Services/Project/SMTPBase.php | 20 ++++++++++++++++++++ tests/e2e/Services/Project/TemplatesBase.php | 2 +- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/tests/e2e/Services/Project/SMTPBase.php b/tests/e2e/Services/Project/SMTPBase.php index 4bdf073e19..748fb3502b 100644 --- a/tests/e2e/Services/Project/SMTPBase.php +++ b/tests/e2e/Services/Project/SMTPBase.php @@ -2,11 +2,31 @@ namespace Tests\E2E\Services\Project; +use PHPUnit\Framework\Attributes\Before; use Tests\E2E\Client; use Utopia\Database\Helpers\ID; trait SMTPBase { + // The ProjectCustom trait reuses the same project across tests in a class. + // Since the SMTP PATCH endpoint is additive (unset fields are preserved), + // state leaks across tests. Reset to a known-good, maildev-compatible + // configuration before each test so tests that don't specify credentials + // still connect cleanly. + #[Before(priority: -1)] + protected function resetProjectSMTP(): void + { + $this->updateSMTP( + senderName: 'Test Sender', + senderEmail: 'sender@example.com', + host: 'maildev', + port: 1025, + username: 'user', + password: 'password', + enabled: false, + ); + } + // Update SMTP status tests public function testUpdateSMTPStatusEnable(): void diff --git a/tests/e2e/Services/Project/TemplatesBase.php b/tests/e2e/Services/Project/TemplatesBase.php index cb7c1bf0b3..b57a20a8d9 100644 --- a/tests/e2e/Services/Project/TemplatesBase.php +++ b/tests/e2e/Services/Project/TemplatesBase.php @@ -912,7 +912,7 @@ trait TemplatesBase 'x-appwrite-project' => 'console', 'cookie' => 'a_session_console=' . $this->getRoot()['session'], ], - ['alerts' => true], + ['enabled' => true], ); $this->assertSame(200, $alertsResponse['headers']['status-code'], 'failed to enable session alerts'); From a85c5e582c6e561723fe6ebabf49e81ae1fac47f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Wed, 22 Apr 2026 14:19:04 +0200 Subject: [PATCH 09/34] Add auth method APIs (public) --- app/controllers/api/projects.php | 40 --------- .../Http/Project/AuthMethods/Update.php | 89 +++++++++++++++++++ .../Modules/Project/Services/Http.php | 4 + src/Appwrite/Utopia/Request/Filters/V23.php | 18 ++++ 4 files changed, 111 insertions(+), 40 deletions(-) create mode 100644 src/Appwrite/Platform/Modules/Project/Http/Project/AuthMethods/Update.php diff --git a/app/controllers/api/projects.php b/app/controllers/api/projects.php index bd5d0504cf..66d3cf7487 100644 --- a/app/controllers/api/projects.php +++ b/app/controllers/api/projects.php @@ -130,46 +130,6 @@ Http::patch('/v1/projects/:projectId/oauth2') $response->dynamic($project, Response::MODEL_PROJECT); }); -Http::patch('/v1/projects/:projectId/auth/:method') - ->desc('Update project auth method status. Use this endpoint to enable or disable a given auth method for this project.') - ->groups(['api', 'projects']) - ->label('scope', 'projects.write') - ->label('sdk', new Method( - namespace: 'projects', - group: 'auth', - name: 'updateAuthStatus', - description: '/docs/references/projects/update-auth-status.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_PROJECT, - ) - ] - )) - ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform']) - ->param('method', '', new WhiteList(\array_keys(Config::getParam('auth')), true), 'Auth Method. Possible values: ' . implode(',', \array_keys(Config::getParam('auth'))), false) - ->param('status', false, new Boolean(true), 'Set the status of this auth method.') - ->inject('response') - ->inject('dbForPlatform') - ->action(function (string $projectId, string $method, bool $status, Response $response, Database $dbForPlatform) { - - $project = $dbForPlatform->getDocument('projects', $projectId); - $auth = Config::getParam('auth')[$method] ?? []; - $authKey = $auth['key'] ?? ''; - - if ($project->isEmpty()) { - throw new Exception(Exception::PROJECT_NOT_FOUND); - } - - $auths = $project->getAttribute('auths', []); - $auths[$authKey] = $status; - - $project = $dbForPlatform->updateDocument('projects', $project->getId(), $project->setAttribute('auths', $auths)); - - $response->dynamic($project, Response::MODEL_PROJECT); - }); - Http::patch('/v1/projects/:projectId/auth/mock-numbers') ->desc('Update the mock numbers for the project') ->groups(['api', 'projects']) diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/AuthMethods/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/AuthMethods/Update.php new file mode 100644 index 0000000000..b01a977ee9 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/AuthMethods/Update.php @@ -0,0 +1,89 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH) + ->setHttpPath('/v1/project/auth-methods/:methodId') + ->httpAlias('/v1/projects/:projectId/auth/:methodId') + ->desc('Update project auth method status. Use this endpoint to enable or disable a given auth method for this project.') + ->groups(['api', 'project']) + ->label('scope', 'project.write') + ->label('event', 'authMethod.[methodId].update') + ->label('audits.event', 'project.authMethods.[methodId].update') + ->label('audits.resource', 'project.authMethods/{response.$id}') + ->label('sdk', new Method( + namespace: 'project', + group: null, + name: 'updateAuthMethod', + description: <<param('methodId', '', new WhiteList(\array_keys(Config::getParam('auth')), true), 'Auth Method ID. Possible values: ' . implode(',', \array_keys(Config::getParam('auth'))), false) + ->param('enabled', null, new Boolean(), 'Auth method status.') + ->inject('response') + ->inject('dbForPlatform') + ->inject('project') + ->inject('authorization') + ->inject('queueForEvents') + ->callback($this->action(...)); + } + + public function action( + string $methodId, + bool $enabled, + Response $response, + Database $dbForPlatform, + Document $project, + Authorization $authorization, + Event $queueForEvents + ): void { + $auth = Config::getParam('auth')[$methodId] ?? []; + $authKey = $auth['key'] ?? ''; + + $auths = $project->getAttribute('auths', []); + $auths[$authKey] = $enabled; + + $project = $authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), new Document([ + 'auths' => $auths, + ]))); + + $queueForEvents->setParam('methodId', $methodId); + + $response->dynamic($project, Response::MODEL_PROJECT); + } +} diff --git a/src/Appwrite/Platform/Modules/Project/Services/Http.php b/src/Appwrite/Platform/Modules/Project/Services/Http.php index 331ad9482e..a59eca16af 100644 --- a/src/Appwrite/Platform/Modules/Project/Services/Http.php +++ b/src/Appwrite/Platform/Modules/Project/Services/Http.php @@ -3,6 +3,7 @@ namespace Appwrite\Platform\Modules\Project\Services; use Appwrite\Platform\Modules\Project\Http\Init; +use Appwrite\Platform\Modules\Project\Http\Project\AuthMethods\Update as UpdateAuthMethod; use Appwrite\Platform\Modules\Project\Http\Project\Keys\Create as CreateKey; use Appwrite\Platform\Modules\Project\Http\Project\Keys\Delete as DeleteKey; use Appwrite\Platform\Modules\Project\Http\Project\Keys\Get as GetKey; @@ -105,5 +106,8 @@ class Http extends Service $this->addAction(UpdateSessionInvalidationPolicy::getName(), new UpdateSessionInvalidationPolicy()); $this->addAction(UpdateSessionLimitPolicy::getName(), new UpdateSessionLimitPolicy()); $this->addAction(UpdateUserLimitPolicy::getName(), new UpdateUserLimitPolicy()); + + // Auth Methods + $this->addAction(UpdateAuthMethod::getName(), new UpdateAuthMethod()); } } diff --git a/src/Appwrite/Utopia/Request/Filters/V23.php b/src/Appwrite/Utopia/Request/Filters/V23.php index b10c26c449..e509900417 100644 --- a/src/Appwrite/Utopia/Request/Filters/V23.php +++ b/src/Appwrite/Utopia/Request/Filters/V23.php @@ -32,6 +32,9 @@ class V23 extends Filter case 'project.updateSessionLimitPolicy': $content = $this->parseLimitToTotal($content); break; + case 'project.updateAuthMethod': + $content = $this->parseUpdateAuthMethod($content); + break; } return $content; @@ -60,6 +63,21 @@ class V23 extends Filter return $content; } + protected function parseUpdateAuthMethod(array $content): array + { + if (isset($content['status'])) { + $content['enabled'] = $content['status']; + unset($content['status']); + } + + if (isset($content['method'])) { + $content['methodId'] = $content['method']; + unset($content['method']); + } + + return $content; + } + protected function parseLimitToTotal(array $content): array { if (isset($content['limit'])) { From bb4fdefee7954349f94f60473f2246b4d2d63d6e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Wed, 22 Apr 2026 14:51:10 +0200 Subject: [PATCH 10/34] New tests for auth methods (base + integration) --- .../e2e/Services/Project/AuthMethodsBase.php | 337 ++++++++++++++++++ .../Project/AuthMethodsConsoleClientTest.php | 14 + .../Project/AuthMethodsCustomServerTest.php | 14 + .../Project/AuthMethodsIntegrationTest.php | 184 ++++++++++ 4 files changed, 549 insertions(+) create mode 100644 tests/e2e/Services/Project/AuthMethodsBase.php create mode 100644 tests/e2e/Services/Project/AuthMethodsConsoleClientTest.php create mode 100644 tests/e2e/Services/Project/AuthMethodsCustomServerTest.php create mode 100644 tests/e2e/Services/Project/AuthMethodsIntegrationTest.php diff --git a/tests/e2e/Services/Project/AuthMethodsBase.php b/tests/e2e/Services/Project/AuthMethodsBase.php new file mode 100644 index 0000000000..afa58a3640 --- /dev/null +++ b/tests/e2e/Services/Project/AuthMethodsBase.php @@ -0,0 +1,337 @@ + response field name exposed by the Project model. + */ + protected static array $authMethods = [ + 'email-password' => 'authEmailPassword', + 'magic-url' => 'authUsersAuthMagicURL', + 'email-otp' => 'authEmailOtp', + 'anonymous' => 'authAnonymous', + 'invites' => 'authInvites', + 'jwt' => 'authJWT', + 'phone' => 'authPhone', + ]; + + // Success flow + + public function testDisableAuthMethod(): void + { + foreach (self::$authMethods as $methodId => $responseKey) { + $response = $this->updateAuthMethod($methodId, false); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertNotEmpty($response['body']['$id']); + $this->assertSame(false, $response['body'][$responseKey]); + } + + // Cleanup + foreach (self::$authMethods as $methodId => $responseKey) { + $this->updateAuthMethod($methodId, true); + } + } + + public function testEnableAuthMethod(): void + { + // Disable first + foreach (self::$authMethods as $methodId => $responseKey) { + $this->updateAuthMethod($methodId, false); + } + + // Re-enable + foreach (self::$authMethods as $methodId => $responseKey) { + $response = $this->updateAuthMethod($methodId, true); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertNotEmpty($response['body']['$id']); + $this->assertSame(true, $response['body'][$responseKey]); + } + } + + public function testDisableAuthMethodIdempotent(): void + { + $first = $this->updateAuthMethod('email-password', false); + $this->assertSame(200, $first['headers']['status-code']); + $this->assertSame(false, $first['body']['authEmailPassword']); + + $second = $this->updateAuthMethod('email-password', false); + $this->assertSame(200, $second['headers']['status-code']); + $this->assertSame(false, $second['body']['authEmailPassword']); + + // Cleanup + $this->updateAuthMethod('email-password', true); + } + + public function testEnableAuthMethodIdempotent(): void + { + $first = $this->updateAuthMethod('email-password', true); + $this->assertSame(200, $first['headers']['status-code']); + $this->assertSame(true, $first['body']['authEmailPassword']); + + $second = $this->updateAuthMethod('email-password', true); + $this->assertSame(200, $second['headers']['status-code']); + $this->assertSame(true, $second['body']['authEmailPassword']); + } + + public function testDisableOneMethodDoesNotAffectOther(): void + { + // Ensure both start enabled + $this->updateAuthMethod('email-password', true); + $this->updateAuthMethod('magic-url', true); + + $response = $this->updateAuthMethod('email-password', false); + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(false, $response['body']['authEmailPassword']); + $this->assertSame(true, $response['body']['authUsersAuthMagicURL']); + + // Cleanup + $this->updateAuthMethod('email-password', true); + } + + public function testDisabledEmailPasswordBlocksSessionCreation(): void + { + $this->updateAuthMethod('email-password', false); + + // Unauthenticated account creation would normally be permitted; with the + // method disabled we expect the shared auth filter to reject it. + $response = $this->client->call(Client::METHOD_POST, '/account', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], [ + 'userId' => 'unique()', + 'email' => 'disabled-method-' . \uniqid() . '@appwrite.io', + 'password' => 'password123', + ]); + + $this->assertSame(501, $response['headers']['status-code']); + $this->assertSame('user_auth_method_unsupported', $response['body']['type']); + + // Cleanup + $this->updateAuthMethod('email-password', true); + } + + public function testEnabledEmailPasswordAllowsSessionCreation(): void + { + $this->updateAuthMethod('email-password', false); + $this->updateAuthMethod('email-password', true); + + $response = $this->client->call(Client::METHOD_POST, '/account', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], [ + 'userId' => 'unique()', + 'email' => 'enabled-method-' . \uniqid() . '@appwrite.io', + 'password' => 'password123', + ]); + + $this->assertNotSame(501, $response['headers']['status-code']); + $this->assertNotSame('user_auth_method_unsupported', $response['body']['type'] ?? ''); + } + + public function testDisabledAnonymousBlocksSessionCreation(): void + { + $this->updateAuthMethod('anonymous', false); + + $response = $this->client->call(Client::METHOD_POST, '/account/sessions/anonymous', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]); + + $this->assertSame(501, $response['headers']['status-code']); + $this->assertSame('user_auth_method_unsupported', $response['body']['type']); + + // Cleanup + $this->updateAuthMethod('anonymous', true); + } + + public function testResponseModel(): void + { + $response = $this->updateAuthMethod('email-password', false); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertArrayHasKey('$id', $response['body']); + $this->assertArrayHasKey('name', $response['body']); + foreach (self::$authMethods as $methodId => $responseKey) { + $this->assertArrayHasKey($responseKey, $response['body']); + } + + // Cleanup + $this->updateAuthMethod('email-password', true); + } + + // Failure flow + + public function testUpdateAuthMethodWithoutAuthentication(): void + { + $response = $this->updateAuthMethod('email-password', false, false); + + $this->assertSame(401, $response['headers']['status-code']); + } + + public function testUpdateAuthMethodInvalidMethodId(): void + { + $response = $this->updateAuthMethod('invalid-method', false); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testUpdateAuthMethodEmptyMethodId(): void + { + $response = $this->updateAuthMethod('', false); + + $this->assertSame(404, $response['headers']['status-code']); + } + + public function testUpdateAuthMethodMissingEnabled(): void + { + $headers = \array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()); + + $response = $this->client->call( + Client::METHOD_PATCH, + '/project/auth-methods/email-password', + $headers, + [] + ); + + $this->assertSame(400, $response['headers']['status-code']); + } + + // Backwards compatibility + + public function testUpdateAuthMethodLegacyAliasPath(): void + { + $headers = \array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()); + + $projectId = $this->getProject()['$id']; + + // Disable via the legacy `/v1/projects/:projectId/auth/:methodId` alias + $response = $this->client->call( + Client::METHOD_PATCH, + '/projects/' . $projectId . '/auth/email-password', + $headers, + [ + 'enabled' => false, + ] + ); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertNotEmpty($response['body']['$id']); + $this->assertSame(false, $response['body']['authEmailPassword']); + + // Re-enable via the legacy alias + $response = $this->client->call( + Client::METHOD_PATCH, + '/projects/' . $projectId . '/auth/email-password', + $headers, + [ + 'enabled' => true, + ] + ); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(true, $response['body']['authEmailPassword']); + } + + public function testUpdateAuthMethodLegacyStatusParam(): void + { + // Old SDK passed `status` in the body. The V23 request filter (triggered + // via `x-appwrite-response-format: 1.9.1`) must rename it to `enabled`. + $headers = \array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.1', + ], $this->getHeaders()); + + $projectId = $this->getProject()['$id']; + + $response = $this->client->call( + Client::METHOD_PATCH, + '/projects/' . $projectId . '/auth/email-password', + $headers, + [ + 'status' => false, + ] + ); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(false, $response['body']['authEmailPassword']); + + $response = $this->client->call( + Client::METHOD_PATCH, + '/projects/' . $projectId . '/auth/email-password', + $headers, + [ + 'status' => true, + ] + ); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(true, $response['body']['authEmailPassword']); + } + + public function testUpdateAuthMethodLegacyMethodParam(): void + { + // Old SDK also had `method` as a path identifier; the V23 filter renames + // a stray `method` body field to `methodId`. The URL path parameter of + // the alias already binds to `:methodId`, so supplying `method` in the + // body is tolerated. + $headers = \array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.1', + ], $this->getHeaders()); + + $projectId = $this->getProject()['$id']; + + $response = $this->client->call( + Client::METHOD_PATCH, + '/projects/' . $projectId . '/auth/email-password', + $headers, + [ + 'method' => 'email-password', + 'status' => false, + ] + ); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(false, $response['body']['authEmailPassword']); + + // Cleanup + $this->updateAuthMethod('email-password', true); + } + + // Helpers + + protected function updateAuthMethod(string $methodId, bool $enabled, bool $authenticated = true): mixed + { + $headers = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]; + + if ($authenticated) { + $headers = \array_merge($headers, $this->getHeaders()); + } + + return $this->client->call( + Client::METHOD_PATCH, + '/project/auth-methods/' . $methodId, + $headers, + [ + 'enabled' => $enabled, + ] + ); + } +} diff --git a/tests/e2e/Services/Project/AuthMethodsConsoleClientTest.php b/tests/e2e/Services/Project/AuthMethodsConsoleClientTest.php new file mode 100644 index 0000000000..e1ae5de357 --- /dev/null +++ b/tests/e2e/Services/Project/AuthMethodsConsoleClientTest.php @@ -0,0 +1,14 @@ +getProject()['$id']; + $apiKey = $this->getProject()['apiKey']; + + $serverHeaders = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $apiKey, + ]; + + // Public headers carry no session / api key — this forces the shared + // auth init to actually evaluate the auth-method gate (it is bypassed + // for privileged / app users). + $publicHeaders = [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ]; + + $setAuthMethod = function (string $methodId, bool $enabled) use ($serverHeaders): void { + $response = $this->client->call( + Client::METHOD_PATCH, + '/project/auth-methods/' . $methodId, + $serverHeaders, + ['enabled' => $enabled] + ); + $this->assertSame(200, $response['headers']['status-code'], 'Failed to toggle ' . $methodId); + }; + + $methods = ['email-password', 'magic-url', 'email-otp', 'anonymous', 'invites', 'jwt', 'phone']; + + // Step 1 — Disable every auth method up front. + foreach ($methods as $methodId) { + $setAuthMethod($methodId, false); + } + + $assertBlocked = function (array $response, string $context): void { + $this->assertSame(501, $response['headers']['status-code'], $context . ' should be blocked with 501'); + $this->assertSame('user_auth_method_unsupported', $response['body']['type'] ?? '', $context . ' should return user_auth_method_unsupported'); + }; + + $assertNotBlocked = function (array $response, string $context): void { + $this->assertNotSame(501, $response['headers']['status-code'], $context . ' should not be blocked after enabling'); + $this->assertNotSame('user_auth_method_unsupported', $response['body']['type'] ?? '', $context . ' should not return user_auth_method_unsupported after enabling'); + }; + + $email = 'auth_methods_' . \uniqid() . '@localhost.test'; + $password = 'password1234'; + + // Step 2 — anonymous session creation. + $anonymousAttempt = fn () => $this->client->call(Client::METHOD_POST, '/account/sessions/anonymous', $publicHeaders); + + $assertBlocked($anonymousAttempt(), 'Anonymous session (disabled)'); + $setAuthMethod('anonymous', true); + $response = $anonymousAttempt(); + $assertNotBlocked($response, 'Anonymous session (enabled)'); + $this->assertSame(201, $response['headers']['status-code']); + + // Step 3 — email/password account creation. + $createAccount = fn () => $this->client->call(Client::METHOD_POST, '/account', $publicHeaders, [ + 'userId' => ID::unique(), + 'email' => $email, + 'password' => $password, + 'name' => 'Auth Methods User', + ]); + + $assertBlocked($createAccount(), 'Account creation (email-password disabled)'); + $setAuthMethod('email-password', true); + $response = $createAccount(); + $assertNotBlocked($response, 'Account creation (email-password enabled)'); + $this->assertSame(201, $response['headers']['status-code']); + $userId = $response['body']['$id']; + + // Step 4 — email/password session creation (still gated by email-password). + // Disable momentarily to prove the session endpoint is gated too. + $setAuthMethod('email-password', false); + $emailSessionAttempt = fn () => $this->client->call(Client::METHOD_POST, '/account/sessions/email', $publicHeaders, [ + 'email' => $email, + 'password' => $password, + ]); + + $assertBlocked($emailSessionAttempt(), 'Email/password session (disabled)'); + $setAuthMethod('email-password', true); + $response = $emailSessionAttempt(); + $assertNotBlocked($response, 'Email/password session (enabled)'); + $this->assertSame(201, $response['headers']['status-code']); + $sessionSecret = $response['cookies']['a_session_' . $projectId] ?? ''; + $this->assertNotEmpty($sessionSecret, 'Expected a session cookie after email/password login'); + + // Step 5 — email OTP token. + $emailOtpAttempt = fn () => $this->client->call(Client::METHOD_POST, '/account/tokens/email', $publicHeaders, [ + 'userId' => $userId, + 'email' => $email, + ]); + + $assertBlocked($emailOtpAttempt(), 'Email OTP (disabled)'); + $setAuthMethod('email-otp', true); + $response = $emailOtpAttempt(); + $assertNotBlocked($response, 'Email OTP (enabled)'); + $this->assertSame(201, $response['headers']['status-code']); + + // Step 6 — magic URL token. + $magicUrlAttempt = fn () => $this->client->call(Client::METHOD_POST, '/account/tokens/magic-url', $publicHeaders, [ + 'userId' => ID::unique(), + 'email' => 'magic_' . \uniqid() . '@localhost.test', + ]); + + $assertBlocked($magicUrlAttempt(), 'Magic URL (disabled)'); + $setAuthMethod('magic-url', true); + $response = $magicUrlAttempt(); + $assertNotBlocked($response, 'Magic URL (enabled)'); + $this->assertSame(201, $response['headers']['status-code']); + + // Step 7 — phone token. After enabling the auth method the endpoint may + // still fail for provider reasons — we only assert that the auth-method + // gate stops fighting us. + $phoneAttempt = fn () => $this->client->call(Client::METHOD_POST, '/account/tokens/phone', $publicHeaders, [ + 'userId' => ID::unique(), + 'phone' => '+14155550199', + ]); + + $assertBlocked($phoneAttempt(), 'Phone token (disabled)'); + $setAuthMethod('phone', true); + $assertNotBlocked($phoneAttempt(), 'Phone token (enabled)'); + + // Step 8 — team invites. Needs an existing team; the session user + // isn't a team owner, so we don't assert on 201 here — the gate itself + // is what's under test and any non-501 proves it was lifted. + $teamResponse = $this->client->call(Client::METHOD_POST, '/teams', $serverHeaders, [ + 'teamId' => ID::unique(), + 'name' => 'Auth Methods Team', + ]); + $this->assertSame(201, $teamResponse['headers']['status-code']); + $teamId = $teamResponse['body']['$id']; + + $inviteHeaders = \array_merge($publicHeaders, [ + 'cookie' => 'a_session_' . $projectId . '=' . $sessionSecret, + ]); + $inviteAttempt = fn () => $this->client->call(Client::METHOD_POST, '/teams/' . $teamId . '/memberships', $inviteHeaders, [ + 'email' => 'invitee_' . \uniqid() . '@localhost.test', + 'roles' => ['developer'], + 'url' => 'http://localhost/join', + ]); + + $assertBlocked($inviteAttempt(), 'Team invite (disabled)'); + $setAuthMethod('invites', true); + $assertNotBlocked($inviteAttempt(), 'Team invite (enabled)'); + + // Step 9 — JWT creation. Requires an active session. + $sessionHeaders = \array_merge($publicHeaders, [ + 'cookie' => 'a_session_' . $projectId . '=' . $sessionSecret, + ]); + $jwtAttempt = fn () => $this->client->call(Client::METHOD_POST, '/account/jwts', $sessionHeaders); + + $assertBlocked($jwtAttempt(), 'JWT (disabled)'); + $setAuthMethod('jwt', true); + $response = $jwtAttempt(); + $assertNotBlocked($response, 'JWT (enabled)'); + $this->assertSame(201, $response['headers']['status-code']); + + // Step 10 — End goal: GET /v1/account returns 200 using the session we + // built via the (now enabled) email-password flow. + $response = $this->client->call(Client::METHOD_GET, '/account', $sessionHeaders); + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame($userId, $response['body']['$id']); + $this->assertSame($email, $response['body']['email']); + } +} From a0274a7b6ff50d1d71c2e09110ec68727134b43e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Wed, 22 Apr 2026 15:12:58 +0200 Subject: [PATCH 11/34] Fix failing tests --- .../Modules/Project/Http/Project/AuthMethods/Update.php | 4 ++-- tests/e2e/Services/Projects/ProjectsConsoleClientTest.php | 2 ++ 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/AuthMethods/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/AuthMethods/Update.php index b01a977ee9..0d1cd83203 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/AuthMethods/Update.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/AuthMethods/Update.php @@ -52,7 +52,7 @@ class Update extends Action ) ], )) - + ->param('methodId', '', new WhiteList(\array_keys(Config::getParam('auth')), true), 'Auth Method ID. Possible values: ' . implode(',', \array_keys(Config::getParam('auth'))), false) ->param('enabled', null, new Boolean(), 'Auth method status.') ->inject('response') @@ -74,7 +74,7 @@ class Update extends Action ): void { $auth = Config::getParam('auth')[$methodId] ?? []; $authKey = $auth['key'] ?? ''; - + $auths = $project->getAttribute('auths', []); $auths[$authKey] = $enabled; diff --git a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php index ed72d9375c..1de3f3786c 100644 --- a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php +++ b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php @@ -1764,6 +1764,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/' . $index, array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.1', ], $this->getHeaders()), [ 'status' => false, ]); @@ -1860,6 +1861,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/' . $index, array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-response-format' => '1.9.1', ], $this->getHeaders()), [ 'status' => true, ]); From 3283b0bec0bcdeb41ea42ebf4d30667dfe1c30a5 Mon Sep 17 00:00:00 2001 From: loks0n <22452787+loks0n@users.noreply.github.com> Date: Wed, 22 Apr 2026 14:31:17 +0100 Subject: [PATCH 12/34] perf: memoize request filter chain and V20 schema lookups A phpspy profile of a production databases worker showed the V20 backwards-compat request filter accounting for ~40% of in-request samples on `databases.listDocuments` traffic. Two compounding causes: 1. `Request::getParams()` re-ran the entire filter chain on every invocation. The framework and app call `getParams()` several times per request (route param binding, `cacheIdentifier()`, action injection, logging), so V20's recursive schema walk executed N times with identical inputs. 2. Inside `V20::getRelatedCollectionKeys`, the `databases/$databaseId` document was fetched at every recursion frame (up to RELATION_MAX_DEPTH = 3), and sibling relationships pointing at the same related collection each did their own `getDocument` call. This commit: - Memoizes the post-filter params on `Request`. The cache is invalidated by `addFilter`, `resetFilters`, and `setRoute`. `Request` is constructed per HTTP request (app/http.php), so the memo is naturally request-scoped. Helps every request filter version, not just V20. - Splits V20's walk into an entry point that resolves the database namespace once and a pure recursive helper. - Caches the collection `attributes` array per `(databaseNamespace, collectionId)` on the filter instance, so shared related collections collapse to one `getDocument` call. Missing or errored lookups are cached as `null` to avoid retry storms. --- src/Appwrite/Utopia/Request.php | 10 ++ src/Appwrite/Utopia/Request/Filters/V20.php | 121 ++++++++++++++------ 2 files changed, 94 insertions(+), 37 deletions(-) diff --git a/src/Appwrite/Utopia/Request.php b/src/Appwrite/Utopia/Request.php index 32f0fa89a9..66ac4ca932 100644 --- a/src/Appwrite/Utopia/Request.php +++ b/src/Appwrite/Utopia/Request.php @@ -18,6 +18,7 @@ class Request extends UtopiaRequest */ private array $filters = []; private ?Route $route = null; + private ?array $filteredParams = null; public function __construct(SwooleRequest $request) { @@ -32,6 +33,10 @@ class Request extends UtopiaRequest */ public function getParams(): array { + if ($this->filteredParams !== null) { + return $this->filteredParams; + } + $parameters = parent::getParams(); if (!$this->hasFilters() || !$this->hasRoute()) { @@ -49,6 +54,7 @@ class Request extends UtopiaRequest foreach ($this->getFilters() as $filter) { $parameters = $filter->parse($parameters, $id); } + $this->filteredParams = $parameters; return $parameters; } @@ -79,6 +85,7 @@ class Request extends UtopiaRequest $parameters = $filter->parse($parameters, $id); } + $this->filteredParams = $parameters; return $parameters; } @@ -92,6 +99,7 @@ class Request extends UtopiaRequest public function addFilter(Filter $filter): void { $this->filters[] = $filter; + $this->filteredParams = null; } /** @@ -112,6 +120,7 @@ class Request extends UtopiaRequest public function resetFilters(): void { $this->filters = []; + $this->filteredParams = null; } /** @@ -134,6 +143,7 @@ class Request extends UtopiaRequest public function setRoute(?Route $route): void { $this->route = $route; + $this->filteredParams = null; } /** diff --git a/src/Appwrite/Utopia/Request/Filters/V20.php b/src/Appwrite/Utopia/Request/Filters/V20.php index a290656b6e..6b1da2709a 100644 --- a/src/Appwrite/Utopia/Request/Filters/V20.php +++ b/src/Appwrite/Utopia/Request/Filters/V20.php @@ -10,6 +10,18 @@ use Utopia\Database\Query; class V20 extends Filter { + /** + * Per-instance (request-scoped) memo of the `attributes` array for a given + * `(databaseNamespace, collectionId)`. Avoids re-fetching the same collection + * document when multiple relationships in the same schema point at it, and + * when `parse()` is re-entered before `Request::getParams()` memoization warms. + * + * A `null` value means we already tried and the collection was missing or errored. + * + * @var array>|null> + */ + private array $collectionAttributesCache = []; + // Convert 1.7 params to 1.8 public function parse(array $content, string $model): array { @@ -106,36 +118,21 @@ class V20 extends Filter * Recursively includes nested relationships up to 3 levels deep. * Prevents infinite loops by tracking all visited collections in the current path. */ - private function getRelatedCollectionKeys( - ?string $databaseId = null, - ?string $collectionId = null, - ?string $prefix = null, - int $depth = 1, - array $visited = [] - ): array { - $databaseId ??= $this->getParamValue('databaseId'); - $collectionId ??= $this->getParamValue('collectionId'); + private function getRelatedCollectionKeys(): array + { + $databaseId = $this->getParamValue('databaseId'); + $collectionId = $this->getParamValue('collectionId'); - if ( - empty($databaseId) || - empty($collectionId) || - $depth > Database::RELATION_MAX_DEPTH - ) { + if (empty($databaseId) || empty($collectionId)) { return []; } - // Check if we've already visited this collection in the current path to prevent cycles - if (in_array($collectionId, $visited)) { - return []; - } - - $visited[] = $collectionId; - $dbForProject = $this->getDbForProject(); if ($dbForProject === null) { return []; } + // Resolve the database namespace once, outside the recursion. try { $database = $dbForProject->getAuthorization()->skip(fn () => $dbForProject->getDocument( 'databases', @@ -148,19 +145,42 @@ class V20 extends Filter return []; } - try { - $collection = $database = $dbForProject->getAuthorization()->skip(fn () => $dbForProject->getDocument( - 'database_' . $database->getSequence(), - $collectionId - )); - if ($collection->isEmpty()) { - return []; - } - } catch (\Throwable) { + $databaseNamespace = 'database_' . $database->getSequence(); + + return $this->walkRelatedCollectionKeys( + $dbForProject, + $databaseNamespace, + $collectionId, + null, + 1, + [] + ); + } + + private function walkRelatedCollectionKeys( + Database $dbForProject, + string $databaseNamespace, + string $collectionId, + ?string $prefix, + int $depth, + array $visited + ): array { + if ($depth > Database::RELATION_MAX_DEPTH) { return []; } - $attributes = $collection->getAttribute('attributes', []); + // Check if we've already visited this collection in the current path to prevent cycles + if (in_array($collectionId, $visited, true)) { + return []; + } + + $attributes = $this->getCollectionAttributes($dbForProject, $databaseNamespace, $collectionId); + if ($attributes === null) { + return []; + } + + $visited[] = $collectionId; + $relationshipKeys = []; foreach ($attributes as $attr) { @@ -176,27 +196,54 @@ class V20 extends Filter $relatedCollectionId = $attr['relatedCollection'] ?? null; // Skip this relationship entirely if it points to an already visited collection - if ($relatedCollectionId && in_array($relatedCollectionId, $visited)) { + if ($relatedCollectionId && in_array($relatedCollectionId, $visited, true)) { continue; } - // Add the wildcard select for this relationship $relationshipKeys[] = $fullKey . '.*'; - // Continue recursively if we have a related collection if ($relatedCollectionId) { - $nestedKeys = $this->getRelatedCollectionKeys( - $databaseId, + $nestedKeys = $this->walkRelatedCollectionKeys( + $dbForProject, + $databaseNamespace, $relatedCollectionId, $fullKey, $depth + 1, $visited ); - $relationshipKeys = \array_merge($relationshipKeys, $nestedKeys); } } return \array_values(\array_unique($relationshipKeys)); } + + /** + * @return array>|null + */ + private function getCollectionAttributes( + Database $dbForProject, + string $databaseNamespace, + string $collectionId + ): ?array { + $cacheKey = $databaseNamespace . ':' . $collectionId; + if (\array_key_exists($cacheKey, $this->collectionAttributesCache)) { + return $this->collectionAttributesCache[$cacheKey]; + } + + try { + $collection = $dbForProject->getAuthorization()->skip(fn () => $dbForProject->getDocument( + $databaseNamespace, + $collectionId + )); + } catch (\Throwable) { + return $this->collectionAttributesCache[$cacheKey] = null; + } + + if ($collection->isEmpty()) { + return $this->collectionAttributesCache[$cacheKey] = null; + } + + return $this->collectionAttributesCache[$cacheKey] = $collection->getAttribute('attributes', []); + } } From b0939b92c36ac5fb3fb011ab1b43a13f82a34588 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Wed, 22 Apr 2026 17:02:22 +0200 Subject: [PATCH 13/34] Fix failing account tests --- tests/e2e/Services/Account/AccountCustomClientTest.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/e2e/Services/Account/AccountCustomClientTest.php b/tests/e2e/Services/Account/AccountCustomClientTest.php index c96676b598..da788c3caa 100644 --- a/tests/e2e/Services/Account/AccountCustomClientTest.php +++ b/tests/e2e/Services/Account/AccountCustomClientTest.php @@ -772,6 +772,7 @@ class AccountCustomClientTest extends Scope 'origin' => 'http://localhost', 'content-type' => 'application/json', 'x-appwrite-project' => 'console', + 'x-appwrite-response-format' => '1.9.1', 'cookie' => 'a_session_console=' . $this->getRoot()['session'], ]), [ 'status' => true, @@ -3695,6 +3696,7 @@ class AccountCustomClientTest extends Scope 'origin' => 'http://localhost', 'content-type' => 'application/json', 'x-appwrite-project' => 'console', + 'x-appwrite-response-format' => '1.9.1', 'cookie' => 'a_session_console=' . $this->getRoot()['session'], ]), [ 'status' => false, From c36b8fbabf6270936be86bd6307c07689dabfe44 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Thu, 23 Apr 2026 10:07:32 +0200 Subject: [PATCH 14/34] Fix membershiip privacy bug on production --- app/config/console.php | 5 +++++ .../Platform/Modules/Teams/Http/Memberships/Get.php | 11 ++++++----- .../Platform/Modules/Teams/Http/Memberships/XList.php | 11 ++++++----- 3 files changed, 17 insertions(+), 10 deletions(-) diff --git a/app/config/console.php b/app/config/console.php index 0b0d6c5881..b7a3f2195a 100644 --- a/app/config/console.php +++ b/app/config/console.php @@ -34,6 +34,11 @@ $console = [ 'legalAddress' => '', 'legalTaxId' => '', 'auths' => [ + 'membershipsUserName' => true, + 'membershipsUserEmail' => true, + 'membershipsMfa' => true, + 'membershipsUserId' => true, + 'membershipsUserPhone' => true, 'mockNumbers' => [], 'invites' => System::getEnv('_APP_CONSOLE_INVITES', 'enabled') === 'enabled', 'limit' => (System::getEnv('_APP_CONSOLE_WHITELIST_ROOT', 'enabled') === 'enabled') ? 1 : 0, // limit signup to 1 user diff --git a/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Get.php b/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Get.php index d146684a20..49f9a36507 100644 --- a/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Get.php +++ b/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Get.php @@ -70,12 +70,13 @@ class Get extends Action throw new Exception(Exception::MEMBERSHIP_NOT_FOUND); } + // Default should be "false", but existing projects already relay on this being "true" $membershipsPrivacy = [ - 'userName' => $project->getAttribute('auths', [])['membershipsUserName'] ?? false, - 'userEmail' => $project->getAttribute('auths', [])['membershipsUserEmail'] ?? false, - 'mfa' => $project->getAttribute('auths', [])['membershipsMfa'] ?? false, - 'userId' => $project->getAttribute('auths', [])['membershipsUserId'] ?? false, - 'userPhone' => $project->getAttribute('auths', [])['membershipsUserPhone'] ?? false, + 'userName' => $project->getAttribute('auths', [])['membershipsUserName'] ?? true, + 'userEmail' => $project->getAttribute('auths', [])['membershipsUserEmail'] ?? true, + 'mfa' => $project->getAttribute('auths', [])['membershipsMfa'] ?? true, + 'userId' => $project->getAttribute('auths', [])['membershipsUserId'] ?? true, + 'userPhone' => $project->getAttribute('auths', [])['membershipsUserPhone'] ?? true, ]; $roles = $authorization->getRoles(); diff --git a/src/Appwrite/Platform/Modules/Teams/Http/Memberships/XList.php b/src/Appwrite/Platform/Modules/Teams/Http/Memberships/XList.php index 70b78e02c6..816ca53e3a 100644 --- a/src/Appwrite/Platform/Modules/Teams/Http/Memberships/XList.php +++ b/src/Appwrite/Platform/Modules/Teams/Http/Memberships/XList.php @@ -123,12 +123,13 @@ class XList extends Action $memberships = array_filter($memberships, fn (Document $membership) => !empty($membership->getAttribute('userId'))); + // Default should be "false", but existing projects already relay on this being "true" $membershipsPrivacy = [ - 'userName' => $project->getAttribute('auths', [])['membershipsUserName'] ?? false, - 'userEmail' => $project->getAttribute('auths', [])['membershipsUserEmail'] ?? false, - 'mfa' => $project->getAttribute('auths', [])['membershipsMfa'] ?? false, - 'userId' => $project->getAttribute('auths', [])['membershipsUserId'] ?? false, - 'userPhone' => $project->getAttribute('auths', [])['membershipsUserPhone'] ?? false, + 'userName' => $project->getAttribute('auths', [])['membershipsUserName'] ?? true, + 'userEmail' => $project->getAttribute('auths', [])['membershipsUserEmail'] ?? true, + 'mfa' => $project->getAttribute('auths', [])['membershipsMfa'] ?? true, + 'userId' => $project->getAttribute('auths', [])['membershipsUserId'] ?? true, + 'userPhone' => $project->getAttribute('auths', [])['membershipsUserPhone'] ?? true, ]; $roles = $authorization->getRoles(); From 48353faa9b9d0af4920f48bd6a90b6f7e050b4cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Thu, 23 Apr 2026 10:13:01 +0200 Subject: [PATCH 15/34] Apply suggestions from code review Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> --- src/Appwrite/Platform/Modules/Teams/Http/Memberships/Get.php | 2 +- src/Appwrite/Platform/Modules/Teams/Http/Memberships/XList.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Get.php b/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Get.php index 49f9a36507..ef8d130855 100644 --- a/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Get.php +++ b/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Get.php @@ -70,7 +70,7 @@ class Get extends Action throw new Exception(Exception::MEMBERSHIP_NOT_FOUND); } - // Default should be "false", but existing projects already relay on this being "true" + // Default should be "false", but existing projects already rely on this being "true" $membershipsPrivacy = [ 'userName' => $project->getAttribute('auths', [])['membershipsUserName'] ?? true, 'userEmail' => $project->getAttribute('auths', [])['membershipsUserEmail'] ?? true, diff --git a/src/Appwrite/Platform/Modules/Teams/Http/Memberships/XList.php b/src/Appwrite/Platform/Modules/Teams/Http/Memberships/XList.php index 816ca53e3a..7835c8051f 100644 --- a/src/Appwrite/Platform/Modules/Teams/Http/Memberships/XList.php +++ b/src/Appwrite/Platform/Modules/Teams/Http/Memberships/XList.php @@ -123,7 +123,7 @@ class XList extends Action $memberships = array_filter($memberships, fn (Document $membership) => !empty($membership->getAttribute('userId'))); - // Default should be "false", but existing projects already relay on this being "true" + // Default should be "false", but existing projects already rely on this being "true" $membershipsPrivacy = [ 'userName' => $project->getAttribute('auths', [])['membershipsUserName'] ?? true, 'userEmail' => $project->getAttribute('auths', [])['membershipsUserEmail'] ?? true, From 83724ce96f0f7ef1ea56dda645996e6dedaa9d6e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Thu, 23 Apr 2026 10:37:35 +0200 Subject: [PATCH 16/34] Console membership privacy test coverage --- .../Services/Teams/TeamsConsoleClientTest.php | 59 +++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/tests/e2e/Services/Teams/TeamsConsoleClientTest.php b/tests/e2e/Services/Teams/TeamsConsoleClientTest.php index 2a1367d749..da19a26c87 100644 --- a/tests/e2e/Services/Teams/TeamsConsoleClientTest.php +++ b/tests/e2e/Services/Teams/TeamsConsoleClientTest.php @@ -14,6 +14,65 @@ class TeamsConsoleClientTest extends Scope use ProjectConsole; use SideClient; + public function testConsoleMembershipPrivacyDefaults(): void + { + $teamData = $this->createTeamHelper(); + $membershipData = $this->createAndAcceptMembershipHelper($teamData['teamUid'], $teamData['teamName']); + + $teamUid = $teamData['teamUid']; + $projectId = $this->getProject()['$id']; + $owner = $this->getUser(); + $memberHeaders = [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'cookie' => 'a_session_' . $projectId . '=' . $membershipData['session'], + ]; + + $ownerMemberships = $this->client->call(Client::METHOD_GET, '/teams/' . $teamUid . '/memberships', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders())); + + $this->assertEquals(200, $ownerMemberships['headers']['status-code']); + $this->assertEquals(2, $ownerMemberships['body']['total']); + + $ownerMembershipsByUser = []; + foreach ($ownerMemberships['body']['memberships'] as $membership) { + $ownerMembershipsByUser[$membership['userId']] = $membership; + } + + $this->assertArrayHasKey($owner['$id'], $ownerMembershipsByUser); + $this->assertContains('owner', $ownerMembershipsByUser[$owner['$id']]['roles']); + + $this->assertArrayHasKey($membershipData['userUid'], $ownerMembershipsByUser); + $this->assertNotContains('owner', $ownerMembershipsByUser[$membershipData['userUid']]['roles']); + $this->assertSame($membershipData['userUid'], $ownerMembershipsByUser[$membershipData['userUid']]['userId']); + $this->assertSame($membershipData['name'], $ownerMembershipsByUser[$membershipData['userUid']]['userName']); + $this->assertSame($membershipData['email'], $ownerMembershipsByUser[$membershipData['userUid']]['userEmail']); + $this->assertFalse($ownerMembershipsByUser[$membershipData['userUid']]['mfa']); + + $memberMemberships = $this->client->call(Client::METHOD_GET, '/teams/' . $teamUid . '/memberships', $memberHeaders); + + $this->assertEquals(200, $memberMemberships['headers']['status-code']); + $this->assertEquals(2, $memberMemberships['body']['total']); + + $memberMembershipsByUser = []; + foreach ($memberMemberships['body']['memberships'] as $membership) { + $memberMembershipsByUser[$membership['userId']] = $membership; + } + + $this->assertArrayHasKey($owner['$id'], $memberMembershipsByUser); + $this->assertSame($owner['$id'], $memberMembershipsByUser[$owner['$id']]['userId']); + $this->assertSame($owner['name'], $memberMembershipsByUser[$owner['$id']]['userName']); + $this->assertSame($owner['email'], $memberMembershipsByUser[$owner['$id']]['userEmail']); + $this->assertFalse($memberMembershipsByUser[$owner['$id']]['mfa']); + $this->assertContains('owner', $memberMembershipsByUser[$owner['$id']]['roles']); + + $this->assertArrayHasKey($membershipData['userUid'], $memberMembershipsByUser); + $this->assertNotContains('owner', $memberMembershipsByUser[$membershipData['userUid']]['roles']); + } + public function testTeamCreateMembershipConsole(): void { $teamData = $this->createTeamHelper(); From 51fa0770a6866d8d6516ef75805237915c0ed764 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Thu, 23 Apr 2026 12:43:45 +0200 Subject: [PATCH 17/34] Add queries to mock numbers list --- .../Project/Http/Project/MockPhone/XList.php | 18 +++++++ tests/e2e/Services/Project/MockPhonesBase.php | 52 ++++++++++++++++++- 2 files changed, 69 insertions(+), 1 deletion(-) diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/MockPhone/XList.php b/src/Appwrite/Platform/Modules/Project/Http/Project/MockPhone/XList.php index a12aa11108..82aa7f1446 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/MockPhone/XList.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/MockPhone/XList.php @@ -2,11 +2,17 @@ namespace Appwrite\Platform\Modules\Project\Http\Project\MockPhone; +use Appwrite\Extend\Exception; use Appwrite\SDK\AuthType; use Appwrite\SDK\Method; use Appwrite\SDK\Response as SDKResponse; use Appwrite\Utopia\Response; use Utopia\Database\Document; +use Utopia\Database\Exception\Query as QueryException; +use Utopia\Database\Query; +use Utopia\Database\Validator\Queries; +use Utopia\Database\Validator\Query\Limit; +use Utopia\Database\Validator\Query\Offset; use Utopia\Platform\Action; use Utopia\Platform\Scope\HTTP; use Utopia\Validator\Boolean; @@ -43,6 +49,7 @@ class XList extends Action ) ] )) + ->param('queries', [], new Queries([new Limit(), new Offset()]), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Only supported methods are limit and offset', true) ->param('total', true, new Boolean(true), 'When set to false, the total count returned will be 0 and will not be calculated.', true) ->inject('response') ->inject('project') @@ -50,14 +57,25 @@ class XList extends Action } public function action( + array $queries, bool $includeTotal, Response $response, Document $project, ) { + try { + $queries = Query::parseQueries($queries); + } catch (QueryException $e) { + throw new Exception(Exception::GENERAL_QUERY_INVALID, $e->getMessage()); + } + $auths = $project->getAttribute('auths', []); $mockNumbers = $auths['mockNumbers'] ?? []; + $grouped = Query::groupByType($queries); + $limit = $grouped['limit'] ?? null; + $offset = $grouped['offset'] ?? 0; $total = $includeTotal ? \count($mockNumbers) : 0; + $mockNumbers = \array_slice($mockNumbers, $offset, $limit); $mockNumbers = \array_map(fn ($mockNumber) => new Document($mockNumber), $mockNumbers); diff --git a/tests/e2e/Services/Project/MockPhonesBase.php b/tests/e2e/Services/Project/MockPhonesBase.php index 02ddcd73bc..e41a8901bf 100644 --- a/tests/e2e/Services/Project/MockPhonesBase.php +++ b/tests/e2e/Services/Project/MockPhonesBase.php @@ -3,6 +3,7 @@ namespace Tests\E2E\Services\Project; use Tests\E2E\Client; +use Utopia\Database\Query; use Utopia\Database\Validator\Datetime as DatetimeValidator; trait MockPhonesBase @@ -317,6 +318,52 @@ trait MockPhonesBase $this->deleteMockPhone($number); } + public function testListMockPhonesWithLimit(): void + { + $number1 = $this->uniquePhoneNumber(); + $number2 = $this->uniquePhoneNumber(); + + $this->assertSame(201, $this->createMockPhone($number1, '111111')['headers']['status-code']); + $this->assertSame(201, $this->createMockPhone($number2, '222222')['headers']['status-code']); + + $response = $this->listMockPhones([ + Query::limit(1)->toString(), + ]); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertCount(1, $response['body']['mockNumbers']); + $this->assertGreaterThanOrEqual(2, $response['body']['total']); + + // Cleanup + $this->deleteMockPhone($number1); + $this->deleteMockPhone($number2); + } + + public function testListMockPhonesWithOffset(): void + { + $number1 = $this->uniquePhoneNumber(); + $number2 = $this->uniquePhoneNumber(); + + $this->assertSame(201, $this->createMockPhone($number1, '111111')['headers']['status-code']); + $this->assertSame(201, $this->createMockPhone($number2, '222222')['headers']['status-code']); + + $listAll = $this->listMockPhones(); + $this->assertSame(200, $listAll['headers']['status-code']); + $totalAll = \count($listAll['body']['mockNumbers']); + + $listOffset = $this->listMockPhones([ + Query::offset(1)->toString(), + ]); + + $this->assertSame(200, $listOffset['headers']['status-code']); + $this->assertCount($totalAll - 1, $listOffset['body']['mockNumbers']); + $this->assertSame($listAll['body']['total'], $listOffset['body']['total']); + + // Cleanup + $this->deleteMockPhone($number1); + $this->deleteMockPhone($number2); + } + public function testListMockPhonesWithoutAuthentication(): void { $response = $this->listMockPhones(authenticated: false); @@ -458,7 +505,7 @@ trait MockPhonesBase return $this->client->call(Client::METHOD_PUT, '/project/mock-phones/' . $number, $headers, $params); } - protected function listMockPhones(?bool $total = null, bool $authenticated = true): mixed + protected function listMockPhones(?array $queries = null, ?bool $total = null, bool $authenticated = true): mixed { $headers = [ 'content-type' => 'application/json', @@ -470,6 +517,9 @@ trait MockPhonesBase } $params = []; + if ($queries !== null) { + $params['queries'] = $queries; + } if ($total !== null) { $params['total'] = $total; } From c1dfeae3238045052410e324b6a651230547f86c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Thu, 23 Apr 2026 13:06:05 +0200 Subject: [PATCH 18/34] Add queries to email tempaltes list --- .../Http/Project/Templates/Email/XList.php | 23 +++++++ tests/e2e/Services/Project/TemplatesBase.php | 68 ++++++++++++++++++- 2 files changed, 90 insertions(+), 1 deletion(-) diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/XList.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/XList.php index 8b13bdb28a..d15f2f856c 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/XList.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Templates/Email/XList.php @@ -2,11 +2,17 @@ namespace Appwrite\Platform\Modules\Project\Http\Project\Templates\Email; +use Appwrite\Extend\Exception; use Appwrite\SDK\AuthType; use Appwrite\SDK\Method; use Appwrite\SDK\Response as SDKResponse; use Appwrite\Utopia\Response; use Utopia\Database\Document; +use Utopia\Database\Exception\Query as QueryException; +use Utopia\Database\Query; +use Utopia\Database\Validator\Queries; +use Utopia\Database\Validator\Query\Limit; +use Utopia\Database\Validator\Query\Offset; use Utopia\Platform\Action; use Utopia\Platform\Scope\HTTP; use Utopia\Validator\Boolean; @@ -43,17 +49,28 @@ class XList extends Action ) ] )) + ->param('queries', [], new Queries([new Limit(), new Offset()]), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Only supported methods are limit and offset', true) ->param('total', true, new Boolean(true), 'When set to false, the total count returned will be 0 and will not be calculated.', true) ->inject('response') ->inject('project') ->callback($this->action(...)); } + /** + * @param array $queries + */ public function action( + array $queries, bool $includeTotal, Response $response, Document $project, ) { + try { + $queries = Query::parseQueries($queries); + } catch (QueryException $e) { + throw new Exception(Exception::GENERAL_QUERY_INVALID, $e->getMessage()); + } + $templates = $project->getAttribute('templates', []); $emailTemplates = []; @@ -83,6 +100,12 @@ class XList extends Action $total = $includeTotal ? \count($emailTemplates) : 0; + $grouped = Query::groupByType($queries); + $offset = $grouped['offset'] ?? 0; + $limit = $grouped['limit'] ?? null; + + $emailTemplates = \array_slice($emailTemplates, $offset, $limit); + $response->dynamic(new Document([ 'templates' => $emailTemplates, 'total' => $total, diff --git a/tests/e2e/Services/Project/TemplatesBase.php b/tests/e2e/Services/Project/TemplatesBase.php index b57a20a8d9..b240c945b3 100644 --- a/tests/e2e/Services/Project/TemplatesBase.php +++ b/tests/e2e/Services/Project/TemplatesBase.php @@ -4,6 +4,7 @@ namespace Tests\E2E\Services\Project; use Tests\E2E\Client; use Utopia\Database\Helpers\ID; +use Utopia\Database\Query; trait TemplatesBase { @@ -767,6 +768,68 @@ trait TemplatesBase $this->assertSame(\count($response['body']['templates']), $response['body']['total']); } + public function testListEmailTemplatesWithLimit(): void + { + $this->ensureSMTPEnabled(); + + $runId = \uniqid(); + + $this->assertSame(200, $this->updateEmailTemplate( + templateId: 'verification', + locale: 'en', + subject: "Limit verification {$runId}", + message: 'Body', + )['headers']['status-code']); + + $this->assertSame(200, $this->updateEmailTemplate( + templateId: 'recovery', + locale: 'en', + subject: "Limit recovery {$runId}", + message: 'Body', + )['headers']['status-code']); + + $response = $this->listEmailTemplates([ + Query::limit(1)->toString(), + ]); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertCount(1, $response['body']['templates']); + $this->assertGreaterThanOrEqual(2, $response['body']['total']); + } + + public function testListEmailTemplatesWithOffset(): void + { + $this->ensureSMTPEnabled(); + + $runId = \uniqid(); + + $this->assertSame(200, $this->updateEmailTemplate( + templateId: 'magicSession', + locale: 'en', + subject: "Offset magic {$runId}", + message: 'Body', + )['headers']['status-code']); + + $this->assertSame(200, $this->updateEmailTemplate( + templateId: 'sessionAlert', + locale: 'en', + subject: "Offset session {$runId}", + message: 'Body', + )['headers']['status-code']); + + $listAll = $this->listEmailTemplates(); + $this->assertSame(200, $listAll['headers']['status-code']); + $totalAll = \count($listAll['body']['templates']); + + $listOffset = $this->listEmailTemplates([ + Query::offset(1)->toString(), + ]); + + $this->assertSame(200, $listOffset['headers']['status-code']); + $this->assertCount($totalAll - 1, $listOffset['body']['templates']); + $this->assertSame($listAll['body']['total'], $listOffset['body']['total']); + } + public function testListEmailTemplatesOnlyReturnsCustomizedTemplates(): void { $this->ensureSMTPEnabled(); @@ -1031,7 +1094,7 @@ trait TemplatesBase return $this->client->call(Client::METHOD_GET, '/project/templates/email/' . $templateId, $headers, $params); } - protected function listEmailTemplates(?bool $total = null, bool $authenticated = true): mixed + protected function listEmailTemplates(?array $queries = null, ?bool $total = null, bool $authenticated = true): mixed { $headers = [ 'content-type' => 'application/json', @@ -1043,6 +1106,9 @@ trait TemplatesBase } $params = []; + if ($queries !== null) { + $params['queries'] = $queries; + } if ($total !== null) { $params['total'] = $total; } From cef7a5197f543ca60d93ecb552fab30791504d81 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Thu, 23 Apr 2026 13:24:39 +0200 Subject: [PATCH 19/34] List policies API --- app/init/models.php | 20 +++ .../Project/Http/Project/Policies/XList.php | 132 +++++++++++++++ src/Appwrite/Utopia/Response.php | 10 ++ .../Utopia/Response/Model/PolicyBase.php | 19 +++ .../Utopia/Response/Model/PolicyList.php | 46 +++++ .../Model/PolicyMembershipPrivacy.php | 59 +++++++ .../Model/PolicyPasswordDictionary.php | 34 ++++ .../Response/Model/PolicyPasswordHistory.php | 34 ++++ .../Model/PolicyPasswordPersonalData.php | 34 ++++ .../Response/Model/PolicySessionAlert.php | 34 ++++ .../Response/Model/PolicySessionDuration.php | 34 ++++ .../Model/PolicySessionInvalidation.php | 34 ++++ .../Response/Model/PolicySessionLimit.php | 34 ++++ .../Utopia/Response/Model/PolicyUserLimit.php | 34 ++++ tests/e2e/Services/Project/PoliciesBase.php | 160 ++++++++++++++++++ 15 files changed, 718 insertions(+) create mode 100644 src/Appwrite/Platform/Modules/Project/Http/Project/Policies/XList.php create mode 100644 src/Appwrite/Utopia/Response/Model/PolicyBase.php create mode 100644 src/Appwrite/Utopia/Response/Model/PolicyList.php create mode 100644 src/Appwrite/Utopia/Response/Model/PolicyMembershipPrivacy.php create mode 100644 src/Appwrite/Utopia/Response/Model/PolicyPasswordDictionary.php create mode 100644 src/Appwrite/Utopia/Response/Model/PolicyPasswordHistory.php create mode 100644 src/Appwrite/Utopia/Response/Model/PolicyPasswordPersonalData.php create mode 100644 src/Appwrite/Utopia/Response/Model/PolicySessionAlert.php create mode 100644 src/Appwrite/Utopia/Response/Model/PolicySessionDuration.php create mode 100644 src/Appwrite/Utopia/Response/Model/PolicySessionInvalidation.php create mode 100644 src/Appwrite/Utopia/Response/Model/PolicySessionLimit.php create mode 100644 src/Appwrite/Utopia/Response/Model/PolicyUserLimit.php diff --git a/app/init/models.php b/app/init/models.php index 8f569d3252..b713d61cd2 100644 --- a/app/init/models.php +++ b/app/init/models.php @@ -112,6 +112,16 @@ use Appwrite\Utopia\Response\Model\PlatformLinux; use Appwrite\Utopia\Response\Model\PlatformList; use Appwrite\Utopia\Response\Model\PlatformWeb; use Appwrite\Utopia\Response\Model\PlatformWindows; +use Appwrite\Utopia\Response\Model\PolicyList; +use Appwrite\Utopia\Response\Model\PolicyMembershipPrivacy; +use Appwrite\Utopia\Response\Model\PolicyPasswordDictionary; +use Appwrite\Utopia\Response\Model\PolicyPasswordHistory; +use Appwrite\Utopia\Response\Model\PolicyPasswordPersonalData; +use Appwrite\Utopia\Response\Model\PolicySessionAlert; +use Appwrite\Utopia\Response\Model\PolicySessionDuration; +use Appwrite\Utopia\Response\Model\PolicySessionInvalidation; +use Appwrite\Utopia\Response\Model\PolicySessionLimit; +use Appwrite\Utopia\Response\Model\PolicyUserLimit; use Appwrite\Utopia\Response\Model\Preferences; use Appwrite\Utopia\Response\Model\Project; use Appwrite\Utopia\Response\Model\Provider; @@ -211,6 +221,7 @@ Response::setModel(new BaseList('Phones List', Response::MODEL_PHONE_LIST, 'phon Response::setModel(new BaseList('Metric List', Response::MODEL_METRIC_LIST, 'metrics', Response::MODEL_METRIC, true, false)); Response::setModel(new BaseList('Variables List', Response::MODEL_VARIABLE_LIST, 'variables', Response::MODEL_VARIABLE)); Response::setModel(new BaseList('Mock Numbers List', Response::MODEL_MOCK_NUMBER_LIST, 'mockNumbers', Response::MODEL_MOCK_NUMBER)); +Response::setModel(new PolicyList()); Response::setModel(new BaseList('Email Templates List', Response::MODEL_EMAIL_TEMPLATE_LIST, 'templates', Response::MODEL_EMAIL_TEMPLATE)); Response::setModel(new BaseList('Status List', Response::MODEL_HEALTH_STATUS_LIST, 'statuses', Response::MODEL_HEALTH_STATUS)); Response::setModel(new BaseList('Rule List', Response::MODEL_PROXY_RULE_LIST, 'rules', Response::MODEL_PROXY_RULE)); @@ -339,6 +350,15 @@ Response::setModel(new Webhook()); Response::setModel(new Key()); Response::setModel(new DevKey()); Response::setModel(new MockNumber()); +Response::setModel(new PolicyPasswordDictionary()); +Response::setModel(new PolicyPasswordHistory()); +Response::setModel(new PolicyPasswordPersonalData()); +Response::setModel(new PolicySessionAlert()); +Response::setModel(new PolicySessionDuration()); +Response::setModel(new PolicySessionInvalidation()); +Response::setModel(new PolicySessionLimit()); +Response::setModel(new PolicyUserLimit()); +Response::setModel(new PolicyMembershipPrivacy()); Response::setModel(new AuthProvider()); Response::setModel(new PlatformWeb()); Response::setModel(new PlatformApple()); diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/XList.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/XList.php new file mode 100644 index 0000000000..893b28fef2 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/XList.php @@ -0,0 +1,132 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/project/policies') + ->desc('List project policies') + ->groups(['api', 'project']) + ->label('scope', 'policies.read') + ->label('sdk', new Method( + namespace: 'project', + group: 'policies', + name: 'listPolicies', + description: <<param('queries', [], new Queries([new Limit(), new Offset()]), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Only supported methods are limit and offset', true) + ->param('total', true, new Boolean(true), 'When set to false, the total count returned will be 0 and will not be calculated.', true) + ->inject('response') + ->inject('project') + ->callback($this->action(...)); + } + + /** + * @param array $queries + */ + public function action( + array $queries, + bool $includeTotal, + Response $response, + Document $project, + ) { + try { + $queries = Query::parseQueries($queries); + } catch (QueryException $e) { + throw new Exception(Exception::GENERAL_QUERY_INVALID, $e->getMessage()); + } + + $auths = $project->getAttribute('auths', []); + + $policies = [ + new Document([ + '$id' => 'password-dictionary', + 'enabled' => $auths['passwordDictionary'] ?? false, + ]), + new Document([ + '$id' => 'password-history', + 'total' => $auths['passwordHistory'] ?? 0, + ]), + new Document([ + '$id' => 'password-personal-data', + 'enabled' => $auths['personalDataCheck'] ?? false, + ]), + new Document([ + '$id' => 'session-alert', + 'enabled' => $auths['sessionAlerts'] ?? false, + ]), + new Document([ + '$id' => 'session-duration', + 'duration' => $auths['duration'] ?? TOKEN_EXPIRATION_LOGIN_LONG, + ]), + new Document([ + '$id' => 'session-invalidation', + 'enabled' => $auths['invalidateSessions'] ?? true, + ]), + new Document([ + '$id' => 'session-limit', + 'total' => $auths['maxSessions'] ?? 0, + ]), + new Document([ + '$id' => 'user-limit', + 'total' => $auths['limit'] ?? 0, + ]), + new Document([ + '$id' => 'membership-privacy', + 'userId' => $auths['membershipsUserId'] ?? false, + 'userEmail' => $auths['membershipsUserEmail'] ?? false, + 'userPhone' => $auths['membershipsUserPhone'] ?? false, + 'userName' => $auths['membershipsUserName'] ?? false, + 'userMFA' => $auths['membershipsMfa'] ?? false, + ]), + ]; + + $total = $includeTotal ? \count($policies) : 0; + + $grouped = Query::groupByType($queries); + $offset = $grouped['offset'] ?? 0; + $limit = $grouped['limit'] ?? null; + + $policies = \array_slice($policies, $offset, $limit); + + $response->dynamic(new Document([ + 'policies' => $policies, + 'total' => $total, + ]), Response::MODEL_POLICY_LIST); + } +} diff --git a/src/Appwrite/Utopia/Response.php b/src/Appwrite/Utopia/Response.php index d72b52e4cb..c4e616ea12 100644 --- a/src/Appwrite/Utopia/Response.php +++ b/src/Appwrite/Utopia/Response.php @@ -255,6 +255,16 @@ class Response extends SwooleResponse public const MODEL_DEV_KEY_LIST = 'devKeyList'; public const MODEL_MOCK_NUMBER = 'mockNumber'; public const MODEL_MOCK_NUMBER_LIST = 'mockNumberList'; + public const MODEL_POLICY_LIST = 'policyList'; + public const MODEL_POLICY_PASSWORD_DICTIONARY = 'policyPasswordDictionary'; + public const MODEL_POLICY_PASSWORD_HISTORY = 'policyPasswordHistory'; + public const MODEL_POLICY_PASSWORD_PERSONAL_DATA = 'policyPasswordPersonalData'; + public const MODEL_POLICY_SESSION_ALERT = 'policySessionAlert'; + public const MODEL_POLICY_SESSION_DURATION = 'policySessionDuration'; + public const MODEL_POLICY_SESSION_INVALIDATION = 'policySessionInvalidation'; + public const MODEL_POLICY_SESSION_LIMIT = 'policySessionLimit'; + public const MODEL_POLICY_USER_LIMIT = 'policyUserLimit'; + public const MODEL_POLICY_MEMBERSHIP_PRIVACY = 'policyMembershipPrivacy'; public const MODEL_AUTH_PROVIDER = 'authProvider'; public const MODEL_AUTH_PROVIDER_LIST = 'authProviderList'; public const MODEL_PLATFORM_APPLE = 'platformApple'; diff --git a/src/Appwrite/Utopia/Response/Model/PolicyBase.php b/src/Appwrite/Utopia/Response/Model/PolicyBase.php new file mode 100644 index 0000000000..04a44d9ffd --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/PolicyBase.php @@ -0,0 +1,19 @@ +addRule('$id', [ + 'type' => self::TYPE_STRING, + 'description' => 'Policy ID.', + 'default' => '', + 'example' => 'password-dictionary', + ]); + } +} diff --git a/src/Appwrite/Utopia/Response/Model/PolicyList.php b/src/Appwrite/Utopia/Response/Model/PolicyList.php new file mode 100644 index 0000000000..09548fedcf --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/PolicyList.php @@ -0,0 +1,46 @@ +addRule('total', [ + 'type' => self::TYPE_INTEGER, + 'description' => 'Total number of policies in the given project.', + 'default' => 0, + 'example' => 9, + ]) + ->addRule('policies', [ + 'type' => [ + Response::MODEL_POLICY_PASSWORD_DICTIONARY, + Response::MODEL_POLICY_PASSWORD_HISTORY, + Response::MODEL_POLICY_PASSWORD_PERSONAL_DATA, + Response::MODEL_POLICY_SESSION_ALERT, + Response::MODEL_POLICY_SESSION_DURATION, + Response::MODEL_POLICY_SESSION_INVALIDATION, + Response::MODEL_POLICY_SESSION_LIMIT, + Response::MODEL_POLICY_USER_LIMIT, + Response::MODEL_POLICY_MEMBERSHIP_PRIVACY, + ], + 'description' => 'List of policies.', + 'default' => [], + 'array' => true, + ]); + } + + public function getName(): string + { + return 'Policies List'; + } + + public function getType(): string + { + return Response::MODEL_POLICY_LIST; + } +} diff --git a/src/Appwrite/Utopia/Response/Model/PolicyMembershipPrivacy.php b/src/Appwrite/Utopia/Response/Model/PolicyMembershipPrivacy.php new file mode 100644 index 0000000000..fe2851d35b --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/PolicyMembershipPrivacy.php @@ -0,0 +1,59 @@ + 'membership-privacy', + ]; + + public function __construct() + { + parent::__construct(); + + $this + ->addRule('userId', [ + 'type' => self::TYPE_BOOLEAN, + 'description' => 'Whether user ID is visible in memberships.', + 'default' => false, + 'example' => true, + ]) + ->addRule('userEmail', [ + 'type' => self::TYPE_BOOLEAN, + 'description' => 'Whether user email is visible in memberships.', + 'default' => false, + 'example' => true, + ]) + ->addRule('userPhone', [ + 'type' => self::TYPE_BOOLEAN, + 'description' => 'Whether user phone is visible in memberships.', + 'default' => false, + 'example' => true, + ]) + ->addRule('userName', [ + 'type' => self::TYPE_BOOLEAN, + 'description' => 'Whether user name is visible in memberships.', + 'default' => false, + 'example' => true, + ]) + ->addRule('userMFA', [ + 'type' => self::TYPE_BOOLEAN, + 'description' => 'Whether user MFA status is visible in memberships.', + 'default' => false, + 'example' => true, + ]); + } + + public function getName(): string + { + return 'Policy Membership Privacy'; + } + + public function getType(): string + { + return Response::MODEL_POLICY_MEMBERSHIP_PRIVACY; + } +} diff --git a/src/Appwrite/Utopia/Response/Model/PolicyPasswordDictionary.php b/src/Appwrite/Utopia/Response/Model/PolicyPasswordDictionary.php new file mode 100644 index 0000000000..78cd284332 --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/PolicyPasswordDictionary.php @@ -0,0 +1,34 @@ + 'password-dictionary', + ]; + + public function __construct() + { + parent::__construct(); + + $this->addRule('enabled', [ + 'type' => self::TYPE_BOOLEAN, + 'description' => 'Whether password dictionary policy is enabled.', + 'default' => false, + 'example' => true, + ]); + } + + public function getName(): string + { + return 'Policy Password Dictionary'; + } + + public function getType(): string + { + return Response::MODEL_POLICY_PASSWORD_DICTIONARY; + } +} diff --git a/src/Appwrite/Utopia/Response/Model/PolicyPasswordHistory.php b/src/Appwrite/Utopia/Response/Model/PolicyPasswordHistory.php new file mode 100644 index 0000000000..a9b5951db6 --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/PolicyPasswordHistory.php @@ -0,0 +1,34 @@ + 'password-history', + ]; + + public function __construct() + { + parent::__construct(); + + $this->addRule('total', [ + 'type' => self::TYPE_INTEGER, + 'description' => 'Password history length. A value of 0 means the policy is disabled.', + 'default' => 0, + 'example' => 5, + ]); + } + + public function getName(): string + { + return 'Policy Password History'; + } + + public function getType(): string + { + return Response::MODEL_POLICY_PASSWORD_HISTORY; + } +} diff --git a/src/Appwrite/Utopia/Response/Model/PolicyPasswordPersonalData.php b/src/Appwrite/Utopia/Response/Model/PolicyPasswordPersonalData.php new file mode 100644 index 0000000000..feffd95f1b --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/PolicyPasswordPersonalData.php @@ -0,0 +1,34 @@ + 'password-personal-data', + ]; + + public function __construct() + { + parent::__construct(); + + $this->addRule('enabled', [ + 'type' => self::TYPE_BOOLEAN, + 'description' => 'Whether password personal data policy is enabled.', + 'default' => false, + 'example' => true, + ]); + } + + public function getName(): string + { + return 'Policy Password Personal Data'; + } + + public function getType(): string + { + return Response::MODEL_POLICY_PASSWORD_PERSONAL_DATA; + } +} diff --git a/src/Appwrite/Utopia/Response/Model/PolicySessionAlert.php b/src/Appwrite/Utopia/Response/Model/PolicySessionAlert.php new file mode 100644 index 0000000000..4f1a66c65c --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/PolicySessionAlert.php @@ -0,0 +1,34 @@ + 'session-alert', + ]; + + public function __construct() + { + parent::__construct(); + + $this->addRule('enabled', [ + 'type' => self::TYPE_BOOLEAN, + 'description' => 'Whether session alert policy is enabled.', + 'default' => false, + 'example' => true, + ]); + } + + public function getName(): string + { + return 'Policy Session Alert'; + } + + public function getType(): string + { + return Response::MODEL_POLICY_SESSION_ALERT; + } +} diff --git a/src/Appwrite/Utopia/Response/Model/PolicySessionDuration.php b/src/Appwrite/Utopia/Response/Model/PolicySessionDuration.php new file mode 100644 index 0000000000..1242802c42 --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/PolicySessionDuration.php @@ -0,0 +1,34 @@ + 'session-duration', + ]; + + public function __construct() + { + parent::__construct(); + + $this->addRule('duration', [ + 'type' => self::TYPE_INTEGER, + 'description' => 'Session duration in seconds.', + 'default' => TOKEN_EXPIRATION_LOGIN_LONG, + 'example' => 3600, + ]); + } + + public function getName(): string + { + return 'Policy Session Duration'; + } + + public function getType(): string + { + return Response::MODEL_POLICY_SESSION_DURATION; + } +} diff --git a/src/Appwrite/Utopia/Response/Model/PolicySessionInvalidation.php b/src/Appwrite/Utopia/Response/Model/PolicySessionInvalidation.php new file mode 100644 index 0000000000..12cbe10851 --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/PolicySessionInvalidation.php @@ -0,0 +1,34 @@ + 'session-invalidation', + ]; + + public function __construct() + { + parent::__construct(); + + $this->addRule('enabled', [ + 'type' => self::TYPE_BOOLEAN, + 'description' => 'Whether session invalidation policy is enabled.', + 'default' => true, + 'example' => true, + ]); + } + + public function getName(): string + { + return 'Policy Session Invalidation'; + } + + public function getType(): string + { + return Response::MODEL_POLICY_SESSION_INVALIDATION; + } +} diff --git a/src/Appwrite/Utopia/Response/Model/PolicySessionLimit.php b/src/Appwrite/Utopia/Response/Model/PolicySessionLimit.php new file mode 100644 index 0000000000..2f187ef1f9 --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/PolicySessionLimit.php @@ -0,0 +1,34 @@ + 'session-limit', + ]; + + public function __construct() + { + parent::__construct(); + + $this->addRule('total', [ + 'type' => self::TYPE_INTEGER, + 'description' => 'Maximum number of sessions allowed per user. A value of 0 means the policy is disabled.', + 'default' => 0, + 'example' => 10, + ]); + } + + public function getName(): string + { + return 'Policy Session Limit'; + } + + public function getType(): string + { + return Response::MODEL_POLICY_SESSION_LIMIT; + } +} diff --git a/src/Appwrite/Utopia/Response/Model/PolicyUserLimit.php b/src/Appwrite/Utopia/Response/Model/PolicyUserLimit.php new file mode 100644 index 0000000000..0ae80445ea --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/PolicyUserLimit.php @@ -0,0 +1,34 @@ + 'user-limit', + ]; + + public function __construct() + { + parent::__construct(); + + $this->addRule('total', [ + 'type' => self::TYPE_INTEGER, + 'description' => 'Maximum number of users allowed in the project. A value of 0 means the policy is disabled.', + 'default' => 0, + 'example' => 100, + ]); + } + + public function getName(): string + { + return 'Policy User Limit'; + } + + public function getType(): string + { + return Response::MODEL_POLICY_USER_LIMIT; + } +} diff --git a/tests/e2e/Services/Project/PoliciesBase.php b/tests/e2e/Services/Project/PoliciesBase.php index 84f5938d3e..7d532c98c1 100644 --- a/tests/e2e/Services/Project/PoliciesBase.php +++ b/tests/e2e/Services/Project/PoliciesBase.php @@ -3,9 +3,154 @@ namespace Tests\E2E\Services\Project; use Tests\E2E\Client; +use Utopia\Database\Query; trait PoliciesBase { + // ========================================================================= + // List Policies + // ========================================================================= + + public function testListPolicies(): void + { + $response = $this->listPolicies(); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertArrayHasKey('policies', $response['body']); + $this->assertArrayHasKey('total', $response['body']); + $this->assertIsArray($response['body']['policies']); + $this->assertIsInt($response['body']['total']); + $this->assertSame(9, $response['body']['total']); + $this->assertCount(9, $response['body']['policies']); + + $policyIds = \array_column($response['body']['policies'], '$id'); + + $this->assertContains('password-dictionary', $policyIds); + $this->assertContains('password-history', $policyIds); + $this->assertContains('password-personal-data', $policyIds); + $this->assertContains('session-alert', $policyIds); + $this->assertContains('session-duration', $policyIds); + $this->assertContains('session-invalidation', $policyIds); + $this->assertContains('session-limit', $policyIds); + $this->assertContains('user-limit', $policyIds); + $this->assertContains('membership-privacy', $policyIds); + } + + public function testListPoliciesResponseModel(): void + { + $response = $this->listPolicies(); + + $this->assertSame(200, $response['headers']['status-code']); + + foreach ($response['body']['policies'] as $policy) { + $this->assertArrayHasKey('$id', $policy); + } + + $byId = []; + foreach ($response['body']['policies'] as $policy) { + $byId[$policy['$id']] = $policy; + } + + $this->assertArrayHasKey('enabled', $byId['password-dictionary']); + $this->assertArrayHasKey('total', $byId['password-history']); + $this->assertArrayHasKey('enabled', $byId['password-personal-data']); + $this->assertArrayHasKey('enabled', $byId['session-alert']); + $this->assertArrayHasKey('duration', $byId['session-duration']); + $this->assertArrayHasKey('enabled', $byId['session-invalidation']); + $this->assertArrayHasKey('total', $byId['session-limit']); + $this->assertArrayHasKey('total', $byId['user-limit']); + $this->assertArrayHasKey('userId', $byId['membership-privacy']); + $this->assertArrayHasKey('userEmail', $byId['membership-privacy']); + $this->assertArrayHasKey('userPhone', $byId['membership-privacy']); + $this->assertArrayHasKey('userName', $byId['membership-privacy']); + $this->assertArrayHasKey('userMFA', $byId['membership-privacy']); + } + + public function testListPoliciesReflectsUpdates(): void + { + $this->updatePasswordDictionaryPolicy(true); + $this->updatePasswordHistoryPolicy(5); + $this->updateSessionDurationPolicy(3600); + $this->updateMembershipPrivacyPolicy([ + 'userId' => true, + 'userEmail' => true, + 'userPhone' => false, + 'userName' => true, + 'userMFA' => true, + ]); + + $response = $this->listPolicies(); + + $this->assertSame(200, $response['headers']['status-code']); + + $byId = []; + foreach ($response['body']['policies'] as $policy) { + $byId[$policy['$id']] = $policy; + } + + $this->assertSame(true, $byId['password-dictionary']['enabled']); + $this->assertSame(5, $byId['password-history']['total']); + $this->assertSame(3600, $byId['session-duration']['duration']); + $this->assertSame(true, $byId['membership-privacy']['userId']); + $this->assertSame(true, $byId['membership-privacy']['userEmail']); + $this->assertSame(false, $byId['membership-privacy']['userPhone']); + $this->assertSame(true, $byId['membership-privacy']['userName']); + $this->assertSame(true, $byId['membership-privacy']['userMFA']); + + // Cleanup + $this->updatePasswordDictionaryPolicy(false); + $this->updatePasswordHistoryPolicy(null); + $this->updateSessionDurationPolicy(31536000); + $this->updateMembershipPrivacyPolicy([ + 'userId' => false, + 'userEmail' => false, + 'userPhone' => false, + 'userName' => false, + 'userMFA' => false, + ]); + } + + public function testListPoliciesTotalFalse(): void + { + $response = $this->listPolicies(total: false); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(0, $response['body']['total']); + $this->assertCount(9, $response['body']['policies']); + } + + public function testListPoliciesWithLimit(): void + { + $response = $this->listPolicies([ + Query::limit(1)->toString(), + ]); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertCount(1, $response['body']['policies']); + $this->assertSame(9, $response['body']['total']); + } + + public function testListPoliciesWithOffset(): void + { + $listAll = $this->listPolicies(); + $this->assertSame(200, $listAll['headers']['status-code']); + + $listOffset = $this->listPolicies([ + Query::offset(1)->toString(), + ]); + + $this->assertSame(200, $listOffset['headers']['status-code']); + $this->assertCount(\count($listAll['body']['policies']) - 1, $listOffset['body']['policies']); + $this->assertSame($listAll['body']['total'], $listOffset['body']['total']); + } + + public function testListPoliciesWithoutAuthentication(): void + { + $response = $this->listPolicies(authenticated: false); + + $this->assertSame(401, $response['headers']['status-code']); + } + // ========================================================================= // Password Dictionary Policy // ========================================================================= @@ -842,6 +987,21 @@ trait PoliciesBase ]); } + protected function listPolicies(?array $queries = null, ?bool $total = null, bool $authenticated = true): mixed + { + $params = []; + + if ($queries !== null) { + $params['queries'] = $queries; + } + + if ($total !== null) { + $params['total'] = $total; + } + + return $this->client->call(Client::METHOD_GET, '/project/policies', $this->buildHeaders($authenticated), $params); + } + protected function updatePasswordDictionaryPolicy(bool $enabled, bool $authenticated = true): mixed { return $this->client->call(Client::METHOD_PATCH, '/project/policies/password-dictionary', $this->buildHeaders($authenticated), [ From 6d86b8fd0d33ef15d30f9ef76bab988aeaa46a3f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Thu, 23 Apr 2026 13:25:21 +0200 Subject: [PATCH 20/34] Removal of project JWTs --- app/controllers/api/projects.php | 45 -------------------------------- 1 file changed, 45 deletions(-) diff --git a/app/controllers/api/projects.php b/app/controllers/api/projects.php index f24c9a2bed..748363e3be 100644 --- a/app/controllers/api/projects.php +++ b/app/controllers/api/projects.php @@ -1,6 +1,5 @@ noContent(); }); -// JWT Keys - -Http::post('/v1/projects/:projectId/jwts') - ->groups(['api', 'projects']) - ->desc('Create JWT') - ->label('scope', 'projects.write') - ->label('sdk', new Method( - namespace: 'projects', - group: 'auth', - name: 'createJWT', - description: '/docs/references/projects/create-jwt.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_CREATED, - model: Response::MODEL_JWT, - ) - ] - )) - ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform']) - ->param('scopes', [], new ArrayList(new WhiteList(array_keys(Config::getParam('projectScopes')), true), APP_LIMIT_ARRAY_PARAMS_SIZE), 'List of scopes allowed for JWT key. Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' scopes are allowed.') - ->param('duration', 900, new Range(0, 3600), 'Time in seconds before JWT expires. Default duration is 900 seconds, and maximum is 3600 seconds.', true) - ->inject('response') - ->inject('dbForPlatform') - ->action(function (string $projectId, array $scopes, int $duration, Response $response, Database $dbForPlatform) { - - $project = $dbForPlatform->getDocument('projects', $projectId); - - if ($project->isEmpty()) { - throw new Exception(Exception::PROJECT_NOT_FOUND); - } - - $jwt = new JWT(System::getEnv('_APP_OPENSSL_KEY_V1'), 'HS256', $duration, 0); - - $response - ->setStatusCode(Response::STATUS_CODE_CREATED) - ->dynamic(new Document(['jwt' => API_KEY_DYNAMIC . '_' . $jwt->encode([ - 'projectId' => $project->getId(), - 'scopes' => $scopes - ])]), Response::MODEL_JWT); - }); - // Backwards compatibility Http::delete('/v1/projects/:projectId/templates/email') ->alias('/v1/projects/:projectId/templates/email/:type/:locale') From b99139661e4e8945a8652ac8ff1065b897889efb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Thu, 23 Apr 2026 13:37:19 +0200 Subject: [PATCH 21/34] Migrate delete project endpoint --- app/controllers/api/projects.php | 46 ----------- app/controllers/shared/api.php | 5 +- .../Modules/Project/Http/Project/Delete.php | 81 +++++++++++++++++++ .../Modules/Project/Services/Http.php | 2 + 4 files changed, 86 insertions(+), 48 deletions(-) create mode 100644 src/Appwrite/Platform/Modules/Project/Http/Project/Delete.php diff --git a/app/controllers/api/projects.php b/app/controllers/api/projects.php index 748363e3be..3e9aaf4458 100644 --- a/app/controllers/api/projects.php +++ b/app/controllers/api/projects.php @@ -1,10 +1,8 @@ dynamic($project, Response::MODEL_PROJECT); }); -Http::delete('/v1/projects/:projectId') - ->desc('Delete project') - ->groups(['api', 'projects']) - ->label('audits.event', 'projects.delete') - ->label('audits.resource', 'project/{request.projectId}') - ->label('scope', 'projects.write') - ->label('sdk', new Method( - namespace: 'projects', - group: 'projects', - name: 'delete', - description: '/docs/references/projects/delete.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_NOCONTENT, - model: Response::MODEL_NONE, - ) - ], - contentType: ContentType::NONE - )) - ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform']) - ->inject('response') - ->inject('user') - ->inject('dbForPlatform') - ->inject('queueForDeletes') - ->action(function (string $projectId, Response $response, Document $user, Database $dbForPlatform, Delete $queueForDeletes) { - $project = $dbForPlatform->getDocument('projects', $projectId); - - if ($project->isEmpty()) { - throw new Exception(Exception::PROJECT_NOT_FOUND); - } - - $queueForDeletes - ->setProject($project) - ->setType(DELETE_TYPE_DOCUMENT) - ->setDocument($project); - - if (!$dbForPlatform->deleteDocument('projects', $projectId)) { - throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Failed to remove project from DB'); - } - - $response->noContent(); - }); - // Backwards compatibility Http::delete('/v1/projects/:projectId/templates/email') ->alias('/v1/projects/:projectId/templates/email/:type/:locale') diff --git a/app/controllers/shared/api.php b/app/controllers/shared/api.php index 8b8c7ee066..fa6e5c28ab 100644 --- a/app/controllers/shared/api.php +++ b/app/controllers/shared/api.php @@ -44,7 +44,7 @@ use Utopia\System\System; use Utopia\Telemetry\Adapter as Telemetry; use Utopia\Validator\WhiteList; -$parseLabel = function (string $label, array $responsePayload, array $requestParams, User $user) { +$parseLabel = function (string $label, array $responsePayload, array $requestParams, User $user, Document $project) { preg_match_all('/{(.*?)}/', $label, $matches); foreach ($matches[1] as $pos => $match) { $find = $matches[0][$pos]; @@ -59,6 +59,7 @@ $parseLabel = function (string $label, array $responsePayload, array $requestPar $params = match ($namespace) { 'user' => (array) $user, + 'project' => $project->getArrayCopy(), 'request' => $requestParams, default => $responsePayload, }; @@ -903,7 +904,7 @@ Http::shutdown() */ $pattern = $route->getLabel('audits.resource', null); if (! empty($pattern)) { - $resource = $parseLabel($pattern, $responsePayload, $requestParams, $user); + $resource = $parseLabel($pattern, $responsePayload, $requestParams, $user, $project); if (! empty($resource) && $resource !== $pattern) { $auditContext->resource = $resource; } diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Delete.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Delete.php new file mode 100644 index 0000000000..0a60e4ce4d --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Delete.php @@ -0,0 +1,81 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_DELETE) + ->setHttpPath('/v1/project') + ->httpAlias('/v1/projects/:projectId') + ->desc('Delete project') + ->groups(['api', 'project']) + ->label('scope', 'project.write') + ->label('event', 'project.delete') + ->label('audits.event', 'project.delete') + ->label('audits.resource', 'project/{project.$id}') + ->label('sdk', new Method( + namespace: 'project', + group: null, + name: 'delete', + description: <<inject('response') + ->inject('dbForPlatform') + ->inject('queueForDeletes') + ->inject('authorization') + ->inject('project') + ->callback($this->action(...)); + } + + public function action( + Response $response, + Database $dbForPlatform, + DeleteQueue $queueForDeletes, + Authorization $authorization, + Document $project, + ) { + $queueForDeletes + ->setProject($project) + ->setType(DELETE_TYPE_DOCUMENT) + ->setDocument($project); + + if (!$authorization->skip(fn () => $dbForPlatform->deleteDocument('projects', $project->getId()))) { + throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Failed to remove project from DB'); + } + + $response->noContent(); + } +} diff --git a/src/Appwrite/Platform/Modules/Project/Services/Http.php b/src/Appwrite/Platform/Modules/Project/Services/Http.php index b0babc8247..04c2deed0b 100644 --- a/src/Appwrite/Platform/Modules/Project/Services/Http.php +++ b/src/Appwrite/Platform/Modules/Project/Services/Http.php @@ -4,6 +4,7 @@ namespace Appwrite\Platform\Modules\Project\Services; use Appwrite\Platform\Modules\Project\Http\Init; use Appwrite\Platform\Modules\Project\Http\Project\AuthMethods\Update as UpdateAuthMethod; +use Appwrite\Platform\Modules\Project\Http\Project\Delete as DeleteProject; use Appwrite\Platform\Modules\Project\Http\Project\Keys\Create as CreateKey; use Appwrite\Platform\Modules\Project\Http\Project\Keys\Delete as DeleteKey; use Appwrite\Platform\Modules\Project\Http\Project\Keys\Get as GetKey; @@ -61,6 +62,7 @@ class Http extends Service $this->addAction(Init::getName(), new Init()); // Project + $this->addAction(DeleteProject::getName(), new DeleteProject()); $this->addAction(UpdateProjectLabels::getName(), new UpdateProjectLabels()); $this->addAction(UpdateProjectProtocol::getName(), new UpdateProjectProtocol()); $this->addAction(UpdateProjectService::getName(), new UpdateProjectService()); From a0a3849b16e9aa054e9895e0d8d77a8f50fe980a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Thu, 23 Apr 2026 13:37:32 +0200 Subject: [PATCH 22/34] Remove unsupported bulk endpoints --- app/controllers/api/projects.php | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/app/controllers/api/projects.php b/app/controllers/api/projects.php index 3e9aaf4458..cf920b695f 100644 --- a/app/controllers/api/projects.php +++ b/app/controllers/api/projects.php @@ -58,22 +58,6 @@ Http::get('/v1/projects/:projectId') $response->dynamic($project, Response::MODEL_PROJECT); }); -Http::patch('/v1/projects/:projectId/service/all') - ->desc('Update all service status') - ->groups(['api', 'projects']) - ->label('scope', 'projects.write') - ->action(function () { - throw new Exception(Exception::GENERAL_NOT_IMPLEMENTED, 'Bulk API no longer exists for services. Please change status individually.'); - }); - -Http::patch('/v1/projects/:projectId/api/all') - ->desc('Update all API status') - ->groups(['api', 'projects']) - ->label('scope', 'projects.write') - ->action(function () { - throw new Exception(Exception::GENERAL_NOT_IMPLEMENTED, 'Bulk API no longer exists for services. Please change status individually.'); - }); - Http::patch('/v1/projects/:projectId/oauth2') ->desc('Update project OAuth2') ->groups(['api', 'projects']) From c246fb0f837af10afa955617c8cafdcf502dc24e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Thu, 23 Apr 2026 13:41:11 +0200 Subject: [PATCH 23/34] Project deletion tests --- tests/e2e/Services/Projects/ProjectsBase.php | 78 +++++++++++++++++++ .../Projects/ProjectsCustomServerTest.php | 1 + 2 files changed, 79 insertions(+) diff --git a/tests/e2e/Services/Projects/ProjectsBase.php b/tests/e2e/Services/Projects/ProjectsBase.php index ef83e65d95..220e3c62bd 100644 --- a/tests/e2e/Services/Projects/ProjectsBase.php +++ b/tests/e2e/Services/Projects/ProjectsBase.php @@ -2,6 +2,7 @@ namespace Tests\E2E\Services\Projects; +use PHPUnit\Framework\Attributes\Group; use Tests\E2E\Client; use Utopia\Database\Helpers\ID; use Utopia\Database\Helpers\Role; @@ -17,6 +18,83 @@ trait ProjectsBase private static array $cachedProjectWithAuthLimit = []; private static array $cachedProjectWithServicesDisabled = []; + protected function createProjectForDeleteTest(): array + { + $rootHeaders = [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'cookie' => 'a_session_console=' . $this->getRoot()['session'], + 'x-appwrite-project' => 'console', + ]; + + $team = $this->client->call(Client::METHOD_POST, '/teams', $rootHeaders, [ + 'teamId' => ID::unique(), + 'name' => 'Delete Project Team', + ]); + + $this->assertSame(201, $team['headers']['status-code']); + + $project = $this->client->call(Client::METHOD_POST, '/projects', $rootHeaders, [ + 'projectId' => ID::unique(), + 'name' => 'Delete Project Test', + 'teamId' => $team['body']['$id'], + 'region' => System::getEnv('_APP_REGION', 'default'), + ]); + + $this->assertSame(201, $project['headers']['status-code']); + + $key = $this->client->call(Client::METHOD_POST, '/projects/' . $project['body']['$id'] . '/keys', $rootHeaders, [ + 'keyId' => ID::unique(), + 'name' => 'Delete Project Key', + 'scopes' => [ + 'project.read', + 'project.write', + ], + ]); + + $this->assertSame(201, $key['headers']['status-code']); + + return [ + 'projectId' => $project['body']['$id'], + 'apiKey' => $key['body']['secret'], + ]; + } + + #[Group('projectsCRUD')] + public function testDeleteProject(): void + { + $project = $this->createProjectForDeleteTest(); + + $headers = match ($this->getSide()) { + 'server' => [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $project['projectId'], + 'x-appwrite-key' => $project['apiKey'], + 'x-appwrite-mode' => 'admin', + ], + default => [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'cookie' => 'a_session_console=' . $this->getRoot()['session'], + 'x-appwrite-project' => $project['projectId'], + 'x-appwrite-mode' => 'admin', + ], + }; + + $response = $this->client->call(Client::METHOD_DELETE, '/project', $headers); + + $this->assertSame(204, $response['headers']['status-code']); + + $get = $this->client->call(Client::METHOD_GET, '/projects/' . $project['projectId'], [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'cookie' => 'a_session_console=' . $this->getRoot()['session'], + 'x-appwrite-project' => 'console', + ]); + + $this->assertSame(404, $get['headers']['status-code']); + } + /** * Setup and cache a basic project with team */ diff --git a/tests/e2e/Services/Projects/ProjectsCustomServerTest.php b/tests/e2e/Services/Projects/ProjectsCustomServerTest.php index 313a4d53be..d87c2cbf78 100644 --- a/tests/e2e/Services/Projects/ProjectsCustomServerTest.php +++ b/tests/e2e/Services/Projects/ProjectsCustomServerTest.php @@ -10,6 +10,7 @@ use Utopia\System\System; class ProjectsCustomServerTest extends Scope { + use ProjectsBase; use ProjectCustom; use SideServer; From bdbc5b92df0bcb65b4a7b6dd1557275bfa97b129 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Thu, 23 Apr 2026 13:47:31 +0200 Subject: [PATCH 24/34] Fix after code review --- app/config/roles.php | 1 + app/config/scopes/project.php | 4 ++++ app/controllers/shared/api.php | 4 ++-- src/Appwrite/Platform/Workers/Migrations.php | 1 + tests/e2e/Scopes/ProjectCustom.php | 1 + 5 files changed, 9 insertions(+), 2 deletions(-) diff --git a/app/config/roles.php b/app/config/roles.php index 62efb4d809..33c7ffc9de 100644 --- a/app/config/roles.php +++ b/app/config/roles.php @@ -57,6 +57,7 @@ $admins = [ 'platforms.write', 'mocks.read', 'mocks.write', + 'policies.read', 'policies.write', 'templates.read', 'templates.write', diff --git a/app/config/scopes/project.php b/app/config/scopes/project.php index 2c78cb921c..592e032ba1 100644 --- a/app/config/scopes/project.php +++ b/app/config/scopes/project.php @@ -212,6 +212,10 @@ return [ // List of publicly visible scopes "description" => "Access to create, update, and delete project\'s mocks", ], + "policies.read" => [ + "description" => + "Access to read project\'s policies", + ], "policies.write" => [ "description" => "Access to update project\'s policies", diff --git a/app/controllers/shared/api.php b/app/controllers/shared/api.php index fa6e5c28ab..7c2f527ccf 100644 --- a/app/controllers/shared/api.php +++ b/app/controllers/shared/api.php @@ -977,12 +977,12 @@ Http::shutdown() if (! empty($data['payload']) && $statusCode >= 200 && $statusCode < 300) { $pattern = $route->getLabel('cache.resource', null); if (! empty($pattern)) { - $resource = $parseLabel($pattern, $responsePayload, $requestParams, $user); + $resource = $parseLabel($pattern, $responsePayload, $requestParams, $user, $project); } $pattern = $route->getLabel('cache.resourceType', null); if (! empty($pattern)) { - $resourceType = $parseLabel($pattern, $responsePayload, $requestParams, $user); + $resourceType = $parseLabel($pattern, $responsePayload, $requestParams, $user, $project); } $cache = new Cache( diff --git a/src/Appwrite/Platform/Workers/Migrations.php b/src/Appwrite/Platform/Workers/Migrations.php index 0225983d2f..cfe8d2d567 100644 --- a/src/Appwrite/Platform/Workers/Migrations.php +++ b/src/Appwrite/Platform/Workers/Migrations.php @@ -393,6 +393,7 @@ class Migrations extends Action 'platforms.write', 'mocks.read', 'mocks.write', + 'policies.read', 'policies.write', 'templates.read', 'templates.write', diff --git a/tests/e2e/Scopes/ProjectCustom.php b/tests/e2e/Scopes/ProjectCustom.php index e5a86c07fd..f531ed774d 100644 --- a/tests/e2e/Scopes/ProjectCustom.php +++ b/tests/e2e/Scopes/ProjectCustom.php @@ -171,6 +171,7 @@ trait ProjectCustom 'platforms.write', 'mocks.read', 'mocks.write', + 'policies.read', 'policies.write', 'templates.read', 'templates.write', From 9c6ed9565e05787d1e702443ff36aa4c2dc8586e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Thu, 23 Apr 2026 14:07:58 +0200 Subject: [PATCH 25/34] Remove tests of removed endpoints --- .../Modules/Project/Services/Http.php | 2 + .../Projects/ProjectsConsoleClientTest.php | 166 ------------------ 2 files changed, 2 insertions(+), 166 deletions(-) diff --git a/src/Appwrite/Platform/Modules/Project/Services/Http.php b/src/Appwrite/Platform/Modules/Project/Services/Http.php index 04c2deed0b..703359ea4e 100644 --- a/src/Appwrite/Platform/Modules/Project/Services/Http.php +++ b/src/Appwrite/Platform/Modules/Project/Services/Http.php @@ -38,6 +38,7 @@ use Appwrite\Platform\Modules\Project\Http\Project\Policies\SessionDuration\Upda use Appwrite\Platform\Modules\Project\Http\Project\Policies\SessionInvalidation\Update as UpdateSessionInvalidationPolicy; use Appwrite\Platform\Modules\Project\Http\Project\Policies\SessionLimit\Update as UpdateSessionLimitPolicy; use Appwrite\Platform\Modules\Project\Http\Project\Policies\UserLimit\Update as UpdateUserLimitPolicy; +use Appwrite\Platform\Modules\Project\Http\Project\Policies\XList as ListPolicies; use Appwrite\Platform\Modules\Project\Http\Project\Protocols\Update as UpdateProjectProtocol; use Appwrite\Platform\Modules\Project\Http\Project\Services\Update as UpdateProjectService; use Appwrite\Platform\Modules\Project\Http\Project\SMTP\Tests\Create as CreateSMTPTest; @@ -113,6 +114,7 @@ class Http extends Service $this->addAction(DeleteMockPhone::getName(), new DeleteMockPhone()); // Policies + $this->addAction(ListPolicies::getName(), new ListPolicies()); $this->addAction(UpdateMembershipPrivacyPolicy::getName(), new UpdateMembershipPrivacyPolicy()); $this->addAction(UpdatePasswordDictionaryPolicy::getName(), new UpdatePasswordDictionaryPolicy()); $this->addAction(UpdatePasswordHistoryPolicy::getName(), new UpdatePasswordHistoryPolicy()); diff --git a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php index 1de3f3786c..f88db41e8c 100644 --- a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php +++ b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php @@ -2636,120 +2636,6 @@ class ProjectsConsoleClientTest extends Scope $this->assertEquals(false, $response['body']['authPersonalDataCheck']); } - public function testUpdateProjectServicesAll(): void - { - $team = $this->client->call(Client::METHOD_POST, '/teams', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'cookie' => 'a_session_console=' . $this->getRoot()['session'], - ]), [ - 'teamId' => ID::unique(), - 'name' => 'Project Test', - ]); - - $this->assertEquals(201, $team['headers']['status-code']); - $this->assertNotEmpty($team['body']['$id']); - - $project = $this->client->call(Client::METHOD_POST, '/projects', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'cookie' => 'a_session_console=' . $this->getRoot()['session'], - ]), [ - 'projectId' => ID::unique(), - 'name' => 'Project Test', - 'teamId' => $team['body']['$id'], - 'region' => System::getEnv('_APP_REGION', 'default') - ]); - - $this->assertEquals(201, $project['headers']['status-code']); - $this->assertNotEmpty($project['body']['$id']); - - $id = $project['body']['$id']; - - // Bulk disable should no longer work - $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/service/all', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-response-format' => '1.9.0', - 'cookie' => 'a_session_console=' . $this->getRoot()['session'], - ]), [ - 'status' => false, - ]); - - $this->assertEquals(405, $response['headers']['status-code']); - $this->assertEquals('general_not_implemented', $response['body']['type']); - - // Bulk enable should no longer work - $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/service/all', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-response-format' => '1.9.0', - 'cookie' => 'a_session_console=' . $this->getRoot()['session'], - ]), [ - 'status' => true, - ]); - - $this->assertEquals(405, $response['headers']['status-code']); - $this->assertEquals('general_not_implemented', $response['body']['type']); - } - - public function testUpdateProjectApisAll(): void - { - $team = $this->client->call(Client::METHOD_POST, '/teams', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'cookie' => 'a_session_console=' . $this->getRoot()['session'], - ]), [ - 'teamId' => ID::unique(), - 'name' => 'Project Test', - ]); - - $this->assertEquals(201, $team['headers']['status-code']); - $this->assertNotEmpty($team['body']['$id']); - - $project = $this->client->call(Client::METHOD_POST, '/projects', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'cookie' => 'a_session_console=' . $this->getRoot()['session'], - ]), [ - 'projectId' => ID::unique(), - 'name' => 'Project Test', - 'teamId' => $team['body']['$id'], - 'region' => System::getEnv('_APP_REGION', 'default') - ]); - - $this->assertEquals(201, $project['headers']['status-code']); - $this->assertNotEmpty($project['body']['$id']); - - $id = $project['body']['$id']; - - // Bulk disable should no longer work - $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/api/all', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-response-format' => '1.9.0', - 'cookie' => 'a_session_console=' . $this->getRoot()['session'], - ]), [ - 'status' => false, - ]); - - $this->assertEquals(405, $response['headers']['status-code']); - $this->assertEquals('general_not_implemented', $response['body']['type']); - - // Bulk enable should no longer work - $response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/api/all', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-response-format' => '1.9.0', - 'cookie' => 'a_session_console=' . $this->getRoot()['session'], - ]), [ - 'status' => true, - ]); - - $this->assertEquals(405, $response['headers']['status-code']); - $this->assertEquals('general_not_implemented', $response['body']['type']); - } - public function testUpdateProjectApiStatus(): void { $team = $this->client->call(Client::METHOD_POST, '/teams', array_merge([ @@ -4055,58 +3941,6 @@ class ProjectsConsoleClientTest extends Scope $this->assertEmpty($response['body']); } - // JWT Keys - - public function testJWTKey(): void - { - $data = $this->setupProjectData(); - $id = $data['projectId']; - - // Create JWT key - $response = $this->client->call(Client::METHOD_POST, '/projects/' . $id . '/jwts', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders()), [ - 'duration' => 5, - 'scopes' => ['users.read'], - ]); - - $this->assertEquals(201, $response['headers']['status-code']); - $this->assertNotEmpty($response['body']['jwt']); - - $jwt = $response['body']['jwt']; - - // Ensure JWT key works - $response = $this->client->call(Client::METHOD_GET, '/users', [ - 'content-type' => 'application/json', - 'x-appwrite-project' => $id, - 'x-appwrite-key' => $jwt, - ]); - - $this->assertEquals(200, $response['headers']['status-code']); - $this->assertArrayHasKey('users', $response['body']); - - // Ensure JWT key respect scopes - $response = $this->client->call(Client::METHOD_GET, '/functions', [ - 'content-type' => 'application/json', - 'x-appwrite-project' => $id, - 'x-appwrite-key' => $jwt, - ]); - - $this->assertEquals(401, $response['headers']['status-code']); - - // Ensure JWT key expires - \sleep(10); - - $response = $this->client->call(Client::METHOD_GET, '/users', [ - 'content-type' => 'application/json', - 'x-appwrite-project' => $id, - 'x-appwrite-key' => $jwt, - ]); - - $this->assertEquals(401, $response['headers']['status-code']); - } - // Platforms public function testCreateProjectPlatform(): void From a48fd13ced5c20a857d30f102a1f7c1cebd7cfc2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Thu, 23 Apr 2026 15:19:49 +0200 Subject: [PATCH 26/34] Add getPolicy + tests + move wrongly placed project tests --- .../Project/Http/Project/Policies/Get.php | 151 ++++++++++++++++++ .../Modules/Project/Services/Http.php | 2 + tests/e2e/Services/Project/PoliciesBase.php | 115 +++++++++++++ tests/e2e/Services/Project/ProjectBase.php | 7 + .../Project/ProjectConsoleClientTest.php | 33 ++++ .../Project/ProjectCustomServerTest.php | 14 ++ tests/e2e/Services/Projects/ProjectsBase.php | 77 --------- 7 files changed, 322 insertions(+), 77 deletions(-) create mode 100644 src/Appwrite/Platform/Modules/Project/Http/Project/Policies/Get.php create mode 100644 tests/e2e/Services/Project/ProjectBase.php create mode 100644 tests/e2e/Services/Project/ProjectConsoleClientTest.php create mode 100644 tests/e2e/Services/Project/ProjectCustomServerTest.php diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/Get.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/Get.php new file mode 100644 index 0000000000..3d633cd2e4 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/Get.php @@ -0,0 +1,151 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/project/policies/:policyId') + ->desc('Get project policy') + ->groups(['api', 'project']) + ->label('scope', 'policies.read') + ->label('sdk', new Method( + namespace: 'project', + group: 'policies', + name: 'getPolicy', + description: <<param('policyId', '', new WhiteList([ + 'password-dictionary', + 'password-history', + 'password-personal-data', + 'session-alert', + 'session-duration', + 'session-invalidation', + 'session-limit', + 'user-limit', + 'membership-privacy', + ], true), 'Policy ID. Can be one of: password-dictionary, password-history, password-personal-data, session-alert, session-duration, session-invalidation, session-limit, user-limit, membership-privacy.') + ->inject('response') + ->inject('project') + ->callback($this->action(...)); + } + + public function action( + string $policyId, + Response $response, + Document $project, + ): void { + $auths = $project->getAttribute('auths', []); + + [$policy, $model] = match ($policyId) { + 'password-dictionary' => [ + new Document([ + '$id' => 'password-dictionary', + 'enabled' => $auths['passwordDictionary'] ?? false, + ]), + Response::MODEL_POLICY_PASSWORD_DICTIONARY, + ], + 'password-history' => [ + new Document([ + '$id' => 'password-history', + 'total' => $auths['passwordHistory'] ?? 0, + ]), + Response::MODEL_POLICY_PASSWORD_HISTORY, + ], + 'password-personal-data' => [ + new Document([ + '$id' => 'password-personal-data', + 'enabled' => $auths['personalDataCheck'] ?? false, + ]), + Response::MODEL_POLICY_PASSWORD_PERSONAL_DATA, + ], + 'session-alert' => [ + new Document([ + '$id' => 'session-alert', + 'enabled' => $auths['sessionAlerts'] ?? false, + ]), + Response::MODEL_POLICY_SESSION_ALERT, + ], + 'session-duration' => [ + new Document([ + '$id' => 'session-duration', + 'duration' => $auths['duration'] ?? TOKEN_EXPIRATION_LOGIN_LONG, + ]), + Response::MODEL_POLICY_SESSION_DURATION, + ], + 'session-invalidation' => [ + new Document([ + '$id' => 'session-invalidation', + 'enabled' => $auths['invalidateSessions'] ?? true, + ]), + Response::MODEL_POLICY_SESSION_INVALIDATION, + ], + 'session-limit' => [ + new Document([ + '$id' => 'session-limit', + 'total' => $auths['maxSessions'] ?? 0, + ]), + Response::MODEL_POLICY_SESSION_LIMIT, + ], + 'user-limit' => [ + new Document([ + '$id' => 'user-limit', + 'total' => $auths['limit'] ?? 0, + ]), + Response::MODEL_POLICY_USER_LIMIT, + ], + 'membership-privacy' => [ + new Document([ + '$id' => 'membership-privacy', + 'userId' => $auths['membershipsUserId'] ?? false, + 'userEmail' => $auths['membershipsUserEmail'] ?? false, + 'userPhone' => $auths['membershipsUserPhone'] ?? false, + 'userName' => $auths['membershipsUserName'] ?? false, + 'userMFA' => $auths['membershipsMfa'] ?? false, + ]), + Response::MODEL_POLICY_MEMBERSHIP_PRIVACY, + ], + }; + + $response->dynamic($policy, $model); + } +} diff --git a/src/Appwrite/Platform/Modules/Project/Services/Http.php b/src/Appwrite/Platform/Modules/Project/Services/Http.php index 703359ea4e..64dad109f8 100644 --- a/src/Appwrite/Platform/Modules/Project/Services/Http.php +++ b/src/Appwrite/Platform/Modules/Project/Services/Http.php @@ -29,6 +29,7 @@ use Appwrite\Platform\Modules\Project\Http\Project\Platforms\Web\Update as Updat use Appwrite\Platform\Modules\Project\Http\Project\Platforms\Windows\Create as CreateWindowsPlatform; use Appwrite\Platform\Modules\Project\Http\Project\Platforms\Windows\Update as UpdateWindowsPlatform; use Appwrite\Platform\Modules\Project\Http\Project\Platforms\XList as ListPlatforms; +use Appwrite\Platform\Modules\Project\Http\Project\Policies\Get as GetPolicy; use Appwrite\Platform\Modules\Project\Http\Project\Policies\MembershipPrivacy\Update as UpdateMembershipPrivacyPolicy; use Appwrite\Platform\Modules\Project\Http\Project\Policies\PasswordDictionary\Update as UpdatePasswordDictionaryPolicy; use Appwrite\Platform\Modules\Project\Http\Project\Policies\PasswordHistory\Update as UpdatePasswordHistoryPolicy; @@ -115,6 +116,7 @@ class Http extends Service // Policies $this->addAction(ListPolicies::getName(), new ListPolicies()); + $this->addAction(GetPolicy::getName(), new GetPolicy()); $this->addAction(UpdateMembershipPrivacyPolicy::getName(), new UpdateMembershipPrivacyPolicy()); $this->addAction(UpdatePasswordDictionaryPolicy::getName(), new UpdatePasswordDictionaryPolicy()); $this->addAction(UpdatePasswordHistoryPolicy::getName(), new UpdatePasswordHistoryPolicy()); diff --git a/tests/e2e/Services/Project/PoliciesBase.php b/tests/e2e/Services/Project/PoliciesBase.php index 7d532c98c1..04906c6c2b 100644 --- a/tests/e2e/Services/Project/PoliciesBase.php +++ b/tests/e2e/Services/Project/PoliciesBase.php @@ -7,6 +7,116 @@ use Utopia\Database\Query; trait PoliciesBase { + // ========================================================================= + // Get Policy + // ========================================================================= + + public function testGetPolicy(): void + { + $expectedFields = [ + 'password-dictionary' => ['enabled'], + 'password-history' => ['total'], + 'password-personal-data' => ['enabled'], + 'session-alert' => ['enabled'], + 'session-duration' => ['duration'], + 'session-invalidation' => ['enabled'], + 'session-limit' => ['total'], + 'user-limit' => ['total'], + 'membership-privacy' => ['userId', 'userEmail', 'userPhone', 'userName', 'userMFA'], + ]; + + foreach ($expectedFields as $policyId => $fields) { + $response = $this->getPolicy($policyId); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame($policyId, $response['body']['$id']); + + foreach ($fields as $field) { + $this->assertArrayHasKey($field, $response['body']); + } + } + } + + public function testGetPolicyMatchesListPolicies(): void + { + $list = $this->listPolicies(); + + $this->assertSame(200, $list['headers']['status-code']); + + $byId = []; + foreach ($list['body']['policies'] as $policy) { + $byId[$policy['$id']] = $policy; + } + + foreach (\array_keys($byId) as $policyId) { + $response = $this->getPolicy($policyId); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame($byId[$policyId], $response['body']); + } + } + + public function testGetPolicyReflectsUpdates(): void + { + $this->updatePasswordDictionaryPolicy(true); + $this->updatePasswordHistoryPolicy(5); + $this->updateSessionDurationPolicy(3600); + $this->updateMembershipPrivacyPolicy([ + 'userId' => true, + 'userEmail' => true, + 'userPhone' => false, + 'userName' => true, + 'userMFA' => true, + ]); + + $passwordDictionary = $this->getPolicy('password-dictionary'); + $passwordHistory = $this->getPolicy('password-history'); + $sessionDuration = $this->getPolicy('session-duration'); + $membershipPrivacy = $this->getPolicy('membership-privacy'); + + $this->assertSame(200, $passwordDictionary['headers']['status-code']); + $this->assertSame(true, $passwordDictionary['body']['enabled']); + + $this->assertSame(200, $passwordHistory['headers']['status-code']); + $this->assertSame(5, $passwordHistory['body']['total']); + + $this->assertSame(200, $sessionDuration['headers']['status-code']); + $this->assertSame(3600, $sessionDuration['body']['duration']); + + $this->assertSame(200, $membershipPrivacy['headers']['status-code']); + $this->assertSame(true, $membershipPrivacy['body']['userId']); + $this->assertSame(true, $membershipPrivacy['body']['userEmail']); + $this->assertSame(false, $membershipPrivacy['body']['userPhone']); + $this->assertSame(true, $membershipPrivacy['body']['userName']); + $this->assertSame(true, $membershipPrivacy['body']['userMFA']); + + // Cleanup + $this->updatePasswordDictionaryPolicy(false); + $this->updatePasswordHistoryPolicy(null); + $this->updateSessionDurationPolicy(31536000); + $this->updateMembershipPrivacyPolicy([ + 'userId' => false, + 'userEmail' => false, + 'userPhone' => false, + 'userName' => false, + 'userMFA' => false, + ]); + } + + public function testGetPolicyWithoutAuthentication(): void + { + $response = $this->getPolicy('password-dictionary', authenticated: false); + + $this->assertSame(401, $response['headers']['status-code']); + } + + public function testGetPolicyInvalidPolicyId(): void + { + $response = $this->getPolicy('invalid-policy'); + + $this->assertSame(400, $response['headers']['status-code']); + } + // ========================================================================= // List Policies // ========================================================================= @@ -1002,6 +1112,11 @@ trait PoliciesBase return $this->client->call(Client::METHOD_GET, '/project/policies', $this->buildHeaders($authenticated), $params); } + protected function getPolicy(string $policyId, bool $authenticated = true): mixed + { + return $this->client->call(Client::METHOD_GET, '/project/policies/' . $policyId, $this->buildHeaders($authenticated)); + } + protected function updatePasswordDictionaryPolicy(bool $enabled, bool $authenticated = true): mixed { return $this->client->call(Client::METHOD_PATCH, '/project/policies/password-dictionary', $this->buildHeaders($authenticated), [ diff --git a/tests/e2e/Services/Project/ProjectBase.php b/tests/e2e/Services/Project/ProjectBase.php new file mode 100644 index 0000000000..3caec392a5 --- /dev/null +++ b/tests/e2e/Services/Project/ProjectBase.php @@ -0,0 +1,7 @@ + 'http://localhost', - 'content-type' => 'application/json', - 'cookie' => 'a_session_console=' . $this->getRoot()['session'], - 'x-appwrite-project' => 'console', - ]; - - $team = $this->client->call(Client::METHOD_POST, '/teams', $rootHeaders, [ - 'teamId' => ID::unique(), - 'name' => 'Delete Project Team', - ]); - - $this->assertSame(201, $team['headers']['status-code']); - - $project = $this->client->call(Client::METHOD_POST, '/projects', $rootHeaders, [ - 'projectId' => ID::unique(), - 'name' => 'Delete Project Test', - 'teamId' => $team['body']['$id'], - 'region' => System::getEnv('_APP_REGION', 'default'), - ]); - - $this->assertSame(201, $project['headers']['status-code']); - - $key = $this->client->call(Client::METHOD_POST, '/projects/' . $project['body']['$id'] . '/keys', $rootHeaders, [ - 'keyId' => ID::unique(), - 'name' => 'Delete Project Key', - 'scopes' => [ - 'project.read', - 'project.write', - ], - ]); - - $this->assertSame(201, $key['headers']['status-code']); - - return [ - 'projectId' => $project['body']['$id'], - 'apiKey' => $key['body']['secret'], - ]; - } - - #[Group('projectsCRUD')] - public function testDeleteProject(): void - { - $project = $this->createProjectForDeleteTest(); - - $headers = match ($this->getSide()) { - 'server' => [ - 'content-type' => 'application/json', - 'x-appwrite-project' => $project['projectId'], - 'x-appwrite-key' => $project['apiKey'], - 'x-appwrite-mode' => 'admin', - ], - default => [ - 'origin' => 'http://localhost', - 'content-type' => 'application/json', - 'cookie' => 'a_session_console=' . $this->getRoot()['session'], - 'x-appwrite-project' => $project['projectId'], - 'x-appwrite-mode' => 'admin', - ], - }; - - $response = $this->client->call(Client::METHOD_DELETE, '/project', $headers); - - $this->assertSame(204, $response['headers']['status-code']); - - $get = $this->client->call(Client::METHOD_GET, '/projects/' . $project['projectId'], [ - 'origin' => 'http://localhost', - 'content-type' => 'application/json', - 'cookie' => 'a_session_console=' . $this->getRoot()['session'], - 'x-appwrite-project' => 'console', - ]); - - $this->assertSame(404, $get['headers']['status-code']); - } - /** * Setup and cache a basic project with team */ From 7a3c001452df542066ef23ee9b7c49f8fa94343a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Thu, 23 Apr 2026 15:22:40 +0200 Subject: [PATCH 27/34] Re-add project removal tests --- .../Project/ProjectConsoleClientTest.php | 107 +++++++++++++++--- 1 file changed, 94 insertions(+), 13 deletions(-) diff --git a/tests/e2e/Services/Project/ProjectConsoleClientTest.php b/tests/e2e/Services/Project/ProjectConsoleClientTest.php index fecc1907f8..fd55d14e43 100644 --- a/tests/e2e/Services/Project/ProjectConsoleClientTest.php +++ b/tests/e2e/Services/Project/ProjectConsoleClientTest.php @@ -2,32 +2,113 @@ namespace Tests\E2E\Services\Project; +use Tests\E2E\Client; use Tests\E2E\Scopes\ProjectCustom; use Tests\E2E\Scopes\Scope; use Tests\E2E\Scopes\SideConsole; +use Utopia\Database\Helpers\ID; +use Utopia\System\System; class ProjectConsoleClientTest extends Scope { use ProjectBase; use ProjectCustom; use SideConsole; - + public function testDeleteProject(): void { - // TODO: - // 1. Create new team - // 2. Create new project - // 3. Delete project - // 4. Verify project is deleted + $team = $this->createTeam('Delete Project Team'); + $project = $this->createProject($team['body']['$id'], 'Delete Project'); + + $response = $this->client->call(Client::METHOD_DELETE, '/project', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $project['body']['$id'], + ], $this->getHeaders())); + + $this->assertSame(204, $response['headers']['status-code']); + + $getProject = $this->getConsoleProject($project['body']['$id']); + + $this->assertSame(404, $getProject['headers']['status-code']); } - + public function testDeleteProjectUsingKey(): void { - // TODO: - // 1. Create new team - // 2. Create new project - // 3. Create new API key - // 4. Delete project using API key - // 5. Verify project is deleted + $team = $this->createTeam('Delete Project Key Team'); + $project = $this->createProject($team['body']['$id'], 'Delete Project Using Key'); + $apiKey = $this->createProjectKey($project['body']['$id'], ['project.write']); + + $response = $this->client->call(Client::METHOD_DELETE, '/project', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $project['body']['$id'], + 'x-appwrite-key' => $apiKey, + ]); + + $this->assertSame(204, $response['headers']['status-code']); + + $getProject = $this->getConsoleProject($project['body']['$id']); + + $this->assertSame(404, $getProject['headers']['status-code']); + } + + protected function createTeam(string $name): array + { + $response = $this->client->call(Client::METHOD_POST, '/teams', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'teamId' => ID::unique(), + 'name' => $name, + ]); + + $this->assertSame(201, $response['headers']['status-code']); + $this->assertSame($name, $response['body']['name']); + $this->assertNotEmpty($response['body']['$id']); + + return $response; + } + + protected function createProject(string $teamId, string $name): array + { + $response = $this->client->call(Client::METHOD_POST, '/projects', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'projectId' => ID::unique(), + 'region' => System::getEnv('_APP_REGION', 'default'), + 'name' => $name, + 'teamId' => $teamId, + ]); + + $this->assertSame(201, $response['headers']['status-code']); + $this->assertSame($name, $response['body']['name']); + $this->assertNotEmpty($response['body']['$id']); + + return $response; + } + + protected function createProjectKey(string $projectId, array $scopes): string + { + $response = $this->client->call(Client::METHOD_POST, '/projects/' . $projectId . '/keys', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'keyId' => ID::unique(), + 'name' => 'Delete Project Key', + 'scopes' => $scopes, + ]); + + $this->assertSame(201, $response['headers']['status-code']); + $this->assertNotEmpty($response['body']['secret']); + + return $response['body']['secret']; + } + + protected function getConsoleProject(string $projectId): array + { + return $this->client->call(Client::METHOD_GET, '/projects/' . $projectId, array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); } } From 8c634a95e433374913d971b4926a54e3db38ad4b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Thu, 23 Apr 2026 15:28:10 +0200 Subject: [PATCH 28/34] Fix failing tests --- .../Project/ProjectConsoleClientTest.php | 28 +++++++++---------- 1 file changed, 13 insertions(+), 15 deletions(-) diff --git a/tests/e2e/Services/Project/ProjectConsoleClientTest.php b/tests/e2e/Services/Project/ProjectConsoleClientTest.php index fd55d14e43..0ba69c21b6 100644 --- a/tests/e2e/Services/Project/ProjectConsoleClientTest.php +++ b/tests/e2e/Services/Project/ProjectConsoleClientTest.php @@ -53,10 +53,7 @@ class ProjectConsoleClientTest extends Scope protected function createTeam(string $name): array { - $response = $this->client->call(Client::METHOD_POST, '/teams', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders()), [ + $response = $this->client->call(Client::METHOD_POST, '/teams', $this->getConsoleSessionHeaders(), [ 'teamId' => ID::unique(), 'name' => $name, ]); @@ -70,10 +67,7 @@ class ProjectConsoleClientTest extends Scope protected function createProject(string $teamId, string $name): array { - $response = $this->client->call(Client::METHOD_POST, '/projects', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders()), [ + $response = $this->client->call(Client::METHOD_POST, '/projects', $this->getConsoleSessionHeaders(), [ 'projectId' => ID::unique(), 'region' => System::getEnv('_APP_REGION', 'default'), 'name' => $name, @@ -89,10 +83,7 @@ class ProjectConsoleClientTest extends Scope protected function createProjectKey(string $projectId, array $scopes): string { - $response = $this->client->call(Client::METHOD_POST, '/projects/' . $projectId . '/keys', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders()), [ + $response = $this->client->call(Client::METHOD_POST, '/projects/' . $projectId . '/keys', $this->getConsoleSessionHeaders(), [ 'keyId' => ID::unique(), 'name' => 'Delete Project Key', 'scopes' => $scopes, @@ -106,9 +97,16 @@ class ProjectConsoleClientTest extends Scope protected function getConsoleProject(string $projectId): array { - return $this->client->call(Client::METHOD_GET, '/projects/' . $projectId, array_merge([ + return $this->client->call(Client::METHOD_GET, '/projects/' . $projectId, $this->getConsoleSessionHeaders()); + } + + protected function getConsoleSessionHeaders(): array + { + return [ + 'origin' => 'http://localhost', 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders())); + 'cookie' => 'a_session_console=' . $this->getRoot()['session'], + 'x-appwrite-project' => 'console', + ]; } } From 4b3963512cb153597f7a08a3a3907ec0813245ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Thu, 23 Apr 2026 15:28:20 +0200 Subject: [PATCH 29/34] Linter fix --- tests/e2e/Services/Project/ProjectBase.php | 2 +- tests/e2e/Services/Projects/ProjectsBase.php | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/e2e/Services/Project/ProjectBase.php b/tests/e2e/Services/Project/ProjectBase.php index 3caec392a5..fa4d2ca7fa 100644 --- a/tests/e2e/Services/Project/ProjectBase.php +++ b/tests/e2e/Services/Project/ProjectBase.php @@ -4,4 +4,4 @@ namespace Tests\E2E\Services\Project; trait ProjectBase { -} \ No newline at end of file +} diff --git a/tests/e2e/Services/Projects/ProjectsBase.php b/tests/e2e/Services/Projects/ProjectsBase.php index 7c97c03ccc..ef83e65d95 100644 --- a/tests/e2e/Services/Projects/ProjectsBase.php +++ b/tests/e2e/Services/Projects/ProjectsBase.php @@ -2,7 +2,6 @@ namespace Tests\E2E\Services\Projects; -use PHPUnit\Framework\Attributes\Group; use Tests\E2E\Client; use Utopia\Database\Helpers\ID; use Utopia\Database\Helpers\Role; From 4de3009f67f06ce5f05b9d4c511fb978d575b982 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Thu, 23 Apr 2026 15:36:16 +0200 Subject: [PATCH 30/34] Fix analyser --- .../Platform/Modules/Project/Http/Project/Policies/Get.php | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/Get.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/Get.php index 3d633cd2e4..3ffe30f1fa 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/Get.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Policies/Get.php @@ -144,6 +144,7 @@ class Get extends Action ]), Response::MODEL_POLICY_MEMBERSHIP_PRIVACY, ], + default => throw new \LogicException('Unknown policy ID: ' . $policyId), }; $response->dynamic($policy, $model); From 5beeca5a992b590c616d700f076cbf28c75c9e62 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Thu, 23 Apr 2026 15:57:09 +0200 Subject: [PATCH 31/34] Placeholder test --- tests/e2e/Services/Project/ProjectCustomServerTest.php | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/e2e/Services/Project/ProjectCustomServerTest.php b/tests/e2e/Services/Project/ProjectCustomServerTest.php index ccfd7ce549..0936b7b271 100644 --- a/tests/e2e/Services/Project/ProjectCustomServerTest.php +++ b/tests/e2e/Services/Project/ProjectCustomServerTest.php @@ -11,4 +11,11 @@ class ProjectCustomServerTest extends Scope use ProjectBase; use ProjectCustom; use SideServer; + + // Just a blank test so we dont have warning about empty test class + // You can remove this after adding some custom server tests, or some project base tests + public function testProjectServerLogic(): void + { + $this->assertTrue(true); + } } From e3231393b97d5d29e9e35720c187ba2b74f5d3f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Thu, 23 Apr 2026 16:06:45 +0200 Subject: [PATCH 32/34] Fix anayser --- tests/e2e/Services/Project/ProjectCustomServerTest.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/e2e/Services/Project/ProjectCustomServerTest.php b/tests/e2e/Services/Project/ProjectCustomServerTest.php index 0936b7b271..a719d4b372 100644 --- a/tests/e2e/Services/Project/ProjectCustomServerTest.php +++ b/tests/e2e/Services/Project/ProjectCustomServerTest.php @@ -12,10 +12,10 @@ class ProjectCustomServerTest extends Scope use ProjectCustom; use SideServer; - // Just a blank test so we dont have warning about empty test class + // Placeholder until this scope has custom server-specific coverage. // You can remove this after adding some custom server tests, or some project base tests public function testProjectServerLogic(): void { - $this->assertTrue(true); + $this->expectNotToPerformAssertions(); } } From 89819db7758f7aee9d888f419e83032beab37010 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Fri, 24 Apr 2026 16:12:42 +0530 Subject: [PATCH 33/34] added exporter --- app/realtime.php | 3 +++ 1 file changed, 3 insertions(+) diff --git a/app/realtime.php b/app/realtime.php index 71aa251069..31ec3e4557 100644 --- a/app/realtime.php +++ b/app/realtime.php @@ -45,6 +45,7 @@ use Utopia\WebSocket\Adapter; use Utopia\WebSocket\Server; require_once __DIR__ . '/init.php'; +require_once __DIR__ . '/init/span.php'; /** @var Registry $register */ $register = $GLOBALS['register'] ?? throw new \RuntimeException('Registry not initialized'); @@ -272,6 +273,8 @@ $adapter ->setPackageMaxLength(64000) // Default maximum Package Size (64kb) ->setWorkerNumber($workerNumber); +$adapter->getNative()->set(['dispatch_mode' => 2]); + $server = new Server($adapter); // Allows overriding From 06336626955d25dfcf00a2c196371f44a9fdf034 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Fri, 24 Apr 2026 16:22:57 +0530 Subject: [PATCH 34/34] removed dispatch experiment --- app/realtime.php | 2 -- 1 file changed, 2 deletions(-) diff --git a/app/realtime.php b/app/realtime.php index 31ec3e4557..0e7388b83f 100644 --- a/app/realtime.php +++ b/app/realtime.php @@ -273,8 +273,6 @@ $adapter ->setPackageMaxLength(64000) // Default maximum Package Size (64kb) ->setWorkerNumber($workerNumber); -$adapter->getNative()->set(['dispatch_mode' => 2]); - $server = new Server($adapter); // Allows overriding