diff --git a/app/config/errors.php b/app/config/errors.php index 4190c6e277..07b0cd59ed 100644 --- a/app/config/errors.php +++ b/app/config/errors.php @@ -1408,4 +1408,19 @@ 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, + ], + 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/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/app/controllers/api/projects.php b/app/controllers/api/projects.php index 66d3cf7487..f24c9a2bed 100644 --- a/app/controllers/api/projects.php +++ b/app/controllers/api/projects.php @@ -130,23 +130,11 @@ Http::patch('/v1/projects/:projectId/oauth2') $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..8f569d3252 100644 --- a/app/init/models.php +++ b/app/init/models.php @@ -210,6 +210,8 @@ 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('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/Extend/Exception.php b/src/Appwrite/Extend/Exception.php index 58a21b5517..6fc3e88635 100644 --- a/src/Appwrite/Extend/Exception.php +++ b/src/Appwrite/Extend/Exception.php @@ -384,6 +384,11 @@ 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'; + 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 new file mode 100644 index 0000000000..f4002c60ef --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/MockPhone/Create.php @@ -0,0 +1,111 @@ +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'] ?? []; + + if (\count($mockNumbers) >= APP_LIMIT_COUNT) { + throw new Exception(Exception::MOCK_NUMBER_LIMIT_EXCEEDED); + } + + foreach ($mockNumbers as $mockNumber) { + if ($mockNumber['phone'] === $number) { + throw new Exception(Exception::MOCK_NUMBER_ALREADY_EXISTS); + } + } + + // Set to now date + $mockNumber = [ + 'phone' => $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..0fb23e1764 --- /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['phone'] === $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..a51095b368 --- /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['phone'] === $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..48b90a1b97 --- /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['phone'] === $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/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 a59eca16af..b0babc8247 100644 --- a/src/Appwrite/Platform/Modules/Project/Services/Http.php +++ b/src/Appwrite/Platform/Modules/Project/Services/Http.php @@ -10,6 +10,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; @@ -38,6 +43,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; @@ -64,6 +70,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()); @@ -96,6 +103,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/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/src/Appwrite/Utopia/Response.php b/src/Appwrite/Utopia/Response.php index d747373b59..d72b52e4cb 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'; @@ -266,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/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..507700bc5b 100644 --- a/src/Appwrite/Utopia/Response/Model/MockNumber.php +++ b/src/Appwrite/Utopia/Response/Model/MockNumber.php @@ -4,13 +4,14 @@ namespace Appwrite\Utopia\Response\Model; use Appwrite\Utopia\Response; use Appwrite\Utopia\Response\Model; +use Utopia\Database\Document; 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,9 +23,31 @@ 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, + ]); ; } + public function filter(Document $document): Document + { + if ($document->isSet('phone')) { + $document->setAttribute('number', $document->getAttribute('phone')); + $document->removeAttribute('phone'); + } + + return $document; + } + /** * Get Name * 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', diff --git a/tests/e2e/Services/Project/MockPhonesBase.php b/tests/e2e/Services/Project/MockPhonesBase.php new file mode 100644 index 0000000000..02ddcd73bc --- /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/' . $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/' . $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/' . $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/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 @@ +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); + } +} 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 d415c3267d..b57a20a8d9 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,