Merge pull request #11970 from appwrite/feat-mocks-public-api

Feat: Public mock phone APIs
This commit is contained in:
Matej Bačo
2026-04-23 10:18:14 +02:00
committed by GitHub
24 changed files with 1596 additions and 14 deletions
+15
View File
@@ -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,
],
];
+2
View File
@@ -55,6 +55,8 @@ $admins = [
'tables.write',
'platforms.read',
'platforms.write',
'mocks.read',
'mocks.write',
'policies.write',
'templates.read',
'templates.write',
+8
View File
@@ -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",
+1 -13
View File
@@ -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')
+2
View File
@@ -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));
+5
View File
@@ -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';
@@ -0,0 +1,111 @@
<?php
namespace Appwrite\Platform\Modules\Project\Http\Project\MockPhone;
use Appwrite\Auth\Validator\Phone;
use Appwrite\Event\Event as QueueEvent;
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\Database;
use Utopia\Database\DateTime;
use Utopia\Database\Document;
use Utopia\Database\Validator\Authorization;
use Utopia\Platform\Action;
use Utopia\Platform\Scope\HTTP;
use Utopia\Validator\Text;
class Create extends Action
{
use HTTP;
public static function getName()
{
return 'createProjectMockPhone';
}
public function __construct()
{
$this
->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: <<<EOT
Create a new mock phone for your project. Use this endpoint to register a mock phone number and its sign-in OTP for your testers.
EOT,
auth: [AuthType::ADMIN, AuthType::KEY],
responses: [
new SDKResponse(
code: Response::STATUS_CODE_CREATED,
model: Response::MODEL_MOCK_NUMBER,
)
],
))
->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);
}
}
@@ -0,0 +1,103 @@
<?php
namespace Appwrite\Platform\Modules\Project\Http\Project\MockPhone;
use Appwrite\Auth\Validator\Phone;
use Appwrite\Event\Event as QueueEvent;
use Appwrite\Extend\Exception;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\ContentType;
use Appwrite\SDK\Method;
use Appwrite\SDK\Response as SDKResponse;
use Appwrite\Utopia\Response;
use Utopia\Database\Database;
use Utopia\Database\Document;
use Utopia\Database\Validator\Authorization;
use Utopia\Platform\Action;
use Utopia\Platform\Scope\HTTP;
class Delete extends Action
{
use HTTP;
public static function getName()
{
return 'deleteProjectMockPhone';
}
public function __construct()
{
$this
->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: <<<EOT
Delete a mock phone by its unique number. This endpoint removes the mock phone and its OTP configuration from the project.
EOT,
auth: [AuthType::ADMIN, AuthType::KEY],
responses: [
new SDKResponse(
code: Response::STATUS_CODE_NOCONTENT,
model: Response::MODEL_NONE,
)
],
contentType: ContentType::NONE
))
->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();
}
}
@@ -0,0 +1,78 @@
<?php
namespace Appwrite\Platform\Modules\Project\Http\Project\MockPhone;
use Appwrite\Auth\Validator\Phone;
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\Platform\Action;
use Utopia\Platform\Scope\HTTP;
class Get extends Action
{
use HTTP;
public static function getName()
{
return 'getProjectMockPhone';
}
public function __construct()
{
$this
->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: <<<EOT
Get a mock phone by its unique number. This endpoint returns the mock phone's OTP.
EOT,
auth: [AuthType::ADMIN, AuthType::KEY],
responses: [
new SDKResponse(
code: Response::STATUS_CODE_OK,
model: Response::MODEL_MOCK_NUMBER
)
]
))
->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);
}
}
@@ -0,0 +1,107 @@
<?php
namespace Appwrite\Platform\Modules\Project\Http\Project\MockPhone;
use Appwrite\Auth\Validator\Phone;
use Appwrite\Event\Event as QueueEvent;
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\Database;
use Utopia\Database\DateTime;
use Utopia\Database\Document;
use Utopia\Database\Validator\Authorization;
use Utopia\Platform\Action;
use Utopia\Platform\Scope\HTTP;
use Utopia\Validator\Text;
class Update extends Action
{
use HTTP;
public static function getName()
{
return 'updateProjectMockPhone';
}
public function __construct()
{
$this
->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: <<<EOT
Update a mock phone by its unique number. Use this endpoint to update the mock phone's OTP.
EOT,
auth: [AuthType::ADMIN, AuthType::KEY],
responses: [
new SDKResponse(
code: Response::STATUS_CODE_OK,
model: Response::MODEL_MOCK_NUMBER
)
]
))
->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);
}
}
@@ -0,0 +1,69 @@
<?php
namespace Appwrite\Platform\Modules\Project\Http\Project\MockPhone;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\Method;
use Appwrite\SDK\Response as SDKResponse;
use Appwrite\Utopia\Response;
use Utopia\Database\Document;
use Utopia\Platform\Action;
use Utopia\Platform\Scope\HTTP;
use Utopia\Validator\Boolean;
class XList extends Action
{
use HTTP;
public static function getName()
{
return 'listProjectMockPhones';
}
public function __construct()
{
$this
->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: <<<EOT
Get a list of all mock phones in the project. This endpoint returns an array of all mock phones and their OTPs.
EOT,
auth: [AuthType::ADMIN, AuthType::KEY],
responses: [
new SDKResponse(
code: Response::STATUS_CODE_OK,
model: Response::MODEL_MOCK_NUMBER_LIST,
)
]
))
->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);
}
}
@@ -0,0 +1,91 @@
<?php
namespace Appwrite\Platform\Modules\Project\Http\Project\Templates\Email;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\Method;
use Appwrite\SDK\Response as SDKResponse;
use Appwrite\Utopia\Response;
use Utopia\Database\Document;
use Utopia\Platform\Action;
use Utopia\Platform\Scope\HTTP;
use Utopia\Validator\Boolean;
class XList extends Action
{
use HTTP;
public static function getName()
{
return 'listProjectEmailTemplates';
}
public function __construct()
{
$this
->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: <<<EOT
Get a list of all custom email templates configured for the project. This endpoint returns an array of all configured email templates and their locales.
EOT,
auth: [AuthType::ADMIN, AuthType::KEY],
responses: [
new SDKResponse(
code: Response::STATUS_CODE_OK,
model: Response::MODEL_EMAIL_TEMPLATE_LIST,
)
]
))
->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);
}
}
@@ -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());
@@ -391,6 +391,8 @@ class Migrations extends Action
'keys.write',
'platforms.read',
'platforms.write',
'mocks.read',
'mocks.write',
'policies.write',
'templates.read',
'templates.write',
+2
View File
@@ -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';
@@ -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']);
@@ -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
*
+2
View File
@@ -169,6 +169,8 @@ trait ProjectCustom
'keys.write',
'platforms.read',
'platforms.write',
'mocks.read',
'mocks.write',
'policies.write',
'templates.read',
'templates.write',
@@ -0,0 +1,500 @@
<?php
namespace Tests\E2E\Services\Project;
use Tests\E2E\Client;
use Utopia\Database\Validator\Datetime as DatetimeValidator;
trait MockPhonesBase
{
// Create mock phone tests
public function testCreateMockPhone(): void
{
$number = $this->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);
}
}
@@ -0,0 +1,14 @@
<?php
namespace Tests\E2E\Services\Project;
use Tests\E2E\Scopes\ProjectCustom;
use Tests\E2E\Scopes\Scope;
use Tests\E2E\Scopes\SideConsole;
class MockPhonesConsoleClientTest extends Scope
{
use MockPhonesBase;
use ProjectCustom;
use SideConsole;
}
@@ -0,0 +1,14 @@
<?php
namespace Tests\E2E\Services\Project;
use Tests\E2E\Scopes\ProjectCustom;
use Tests\E2E\Scopes\Scope;
use Tests\E2E\Scopes\SideServer;
class MockPhonesCustomServerTest extends Scope
{
use MockPhonesBase;
use ProjectCustom;
use SideServer;
}
@@ -0,0 +1,152 @@
<?php
namespace Tests\E2E\Services\Project;
use Tests\E2E\Client;
use Tests\E2E\Scopes\ProjectCustom;
use Tests\E2E\Scopes\Scope;
use Tests\E2E\Scopes\SideServer;
use Utopia\Database\Helpers\ID;
class MockPhonesSessionIntegrationTest extends Scope
{
use ProjectCustom;
use SideServer;
public function testMockPhoneSessionIntegration(): void
{
$projectId = $this->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);
}
}
+20
View File
@@ -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
@@ -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,