mirror of
https://github.com/appwrite/appwrite.git
synced 2026-05-26 13:51:13 +00:00
Merge remote-tracking branch 'origin/1.9.x' into feat/skip-duplicates
This commit is contained in:
@@ -34,6 +34,11 @@ $console = [
|
||||
'legalAddress' => '',
|
||||
'legalTaxId' => '',
|
||||
'auths' => [
|
||||
'membershipsUserName' => true,
|
||||
'membershipsUserEmail' => true,
|
||||
'membershipsMfa' => true,
|
||||
'membershipsUserId' => true,
|
||||
'membershipsUserPhone' => true,
|
||||
'mockNumbers' => [],
|
||||
'invites' => System::getEnv('_APP_CONSOLE_INVITES', 'enabled') === 'enabled',
|
||||
'limit' => (System::getEnv('_APP_CONSOLE_WHITELIST_ROOT', 'enabled') === 'enabled') ? 1 : 0, // limit signup to 1 user
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
];
|
||||
|
||||
@@ -55,6 +55,9 @@ $admins = [
|
||||
'tables.write',
|
||||
'platforms.read',
|
||||
'platforms.write',
|
||||
'mocks.read',
|
||||
'mocks.write',
|
||||
'policies.read',
|
||||
'policies.write',
|
||||
'templates.read',
|
||||
'templates.write',
|
||||
|
||||
@@ -204,6 +204,18 @@ 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.read" => [
|
||||
"description" =>
|
||||
"Access to read project\'s policies",
|
||||
],
|
||||
"policies.write" => [
|
||||
"description" =>
|
||||
"Access to update project\'s policies",
|
||||
|
||||
@@ -1,14 +1,10 @@
|
||||
<?php
|
||||
|
||||
use Ahc\Jwt\JWT;
|
||||
use Appwrite\Auth\Validator\MockNumber;
|
||||
use Appwrite\Event\Delete;
|
||||
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\Database\Validator\Queries\Keys;
|
||||
use Appwrite\Utopia\Response;
|
||||
use Utopia\Config\Config;
|
||||
use Utopia\Database\Database;
|
||||
@@ -19,7 +15,6 @@ use Utopia\System\System;
|
||||
use Utopia\Validator\ArrayList;
|
||||
use Utopia\Validator\Boolean;
|
||||
use Utopia\Validator\Nullable;
|
||||
use Utopia\Validator\Range;
|
||||
use Utopia\Validator\Text;
|
||||
use Utopia\Validator\WhiteList;
|
||||
|
||||
@@ -63,22 +58,6 @@ Http::get('/v1/projects/:projectId')
|
||||
$response->dynamic($project, Response::MODEL_PROJECT);
|
||||
});
|
||||
|
||||
Http::patch('/v1/projects/:projectId/service/all')
|
||||
->desc('Update all service status')
|
||||
->groups(['api', 'projects'])
|
||||
->label('scope', 'projects.write')
|
||||
->action(function () {
|
||||
throw new Exception(Exception::GENERAL_NOT_IMPLEMENTED, 'Bulk API no longer exists for services. Please change status individually.');
|
||||
});
|
||||
|
||||
Http::patch('/v1/projects/:projectId/api/all')
|
||||
->desc('Update all API status')
|
||||
->groups(['api', 'projects'])
|
||||
->label('scope', 'projects.write')
|
||||
->action(function () {
|
||||
throw new Exception(Exception::GENERAL_NOT_IMPLEMENTED, 'Bulk API no longer exists for services. Please change status individually.');
|
||||
});
|
||||
|
||||
Http::patch('/v1/projects/:projectId/oauth2')
|
||||
->desc('Update project OAuth2')
|
||||
->groups(['api', 'projects'])
|
||||
@@ -130,63 +109,11 @@ Http::patch('/v1/projects/:projectId/oauth2')
|
||||
$response->dynamic($project, Response::MODEL_PROJECT);
|
||||
});
|
||||
|
||||
Http::patch('/v1/projects/:projectId/auth/:method')
|
||||
->desc('Update project auth method status. Use this endpoint to enable or disable a given auth method for this project.')
|
||||
->groups(['api', 'projects'])
|
||||
->label('scope', 'projects.write')
|
||||
->label('sdk', new Method(
|
||||
namespace: 'projects',
|
||||
group: 'auth',
|
||||
name: 'updateAuthStatus',
|
||||
description: '/docs/references/projects/update-auth-status.md',
|
||||
auth: [AuthType::ADMIN],
|
||||
responses: [
|
||||
new SDKResponse(
|
||||
code: Response::STATUS_CODE_OK,
|
||||
model: Response::MODEL_PROJECT,
|
||||
)
|
||||
]
|
||||
))
|
||||
->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform'])
|
||||
->param('method', '', new WhiteList(\array_keys(Config::getParam('auth')), true), 'Auth Method. Possible values: ' . implode(',', \array_keys(Config::getParam('auth'))), false)
|
||||
->param('status', false, new Boolean(true), 'Set the status of this auth method.')
|
||||
->inject('response')
|
||||
->inject('dbForPlatform')
|
||||
->action(function (string $projectId, string $method, bool $status, Response $response, Database $dbForPlatform) {
|
||||
|
||||
$project = $dbForPlatform->getDocument('projects', $projectId);
|
||||
$auth = Config::getParam('auth')[$method] ?? [];
|
||||
$authKey = $auth['key'] ?? '';
|
||||
|
||||
if ($project->isEmpty()) {
|
||||
throw new Exception(Exception::PROJECT_NOT_FOUND);
|
||||
}
|
||||
|
||||
$auths = $project->getAttribute('auths', []);
|
||||
$auths[$authKey] = $status;
|
||||
|
||||
$project = $dbForPlatform->updateDocument('projects', $project->getId(), $project->setAttribute('auths', $auths));
|
||||
|
||||
$response->dynamic($project, Response::MODEL_PROJECT);
|
||||
});
|
||||
|
||||
// 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')
|
||||
@@ -216,92 +143,6 @@ Http::patch('/v1/projects/:projectId/auth/mock-numbers')
|
||||
$response->dynamic($project, Response::MODEL_PROJECT);
|
||||
});
|
||||
|
||||
Http::delete('/v1/projects/:projectId')
|
||||
->desc('Delete project')
|
||||
->groups(['api', 'projects'])
|
||||
->label('audits.event', 'projects.delete')
|
||||
->label('audits.resource', 'project/{request.projectId}')
|
||||
->label('scope', 'projects.write')
|
||||
->label('sdk', new Method(
|
||||
namespace: 'projects',
|
||||
group: 'projects',
|
||||
name: 'delete',
|
||||
description: '/docs/references/projects/delete.md',
|
||||
auth: [AuthType::ADMIN],
|
||||
responses: [
|
||||
new SDKResponse(
|
||||
code: Response::STATUS_CODE_NOCONTENT,
|
||||
model: Response::MODEL_NONE,
|
||||
)
|
||||
],
|
||||
contentType: ContentType::NONE
|
||||
))
|
||||
->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform'])
|
||||
->inject('response')
|
||||
->inject('user')
|
||||
->inject('dbForPlatform')
|
||||
->inject('queueForDeletes')
|
||||
->action(function (string $projectId, Response $response, Document $user, Database $dbForPlatform, Delete $queueForDeletes) {
|
||||
$project = $dbForPlatform->getDocument('projects', $projectId);
|
||||
|
||||
if ($project->isEmpty()) {
|
||||
throw new Exception(Exception::PROJECT_NOT_FOUND);
|
||||
}
|
||||
|
||||
$queueForDeletes
|
||||
->setProject($project)
|
||||
->setType(DELETE_TYPE_DOCUMENT)
|
||||
->setDocument($project);
|
||||
|
||||
if (!$dbForPlatform->deleteDocument('projects', $projectId)) {
|
||||
throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Failed to remove project from DB');
|
||||
}
|
||||
|
||||
$response->noContent();
|
||||
});
|
||||
|
||||
// JWT Keys
|
||||
|
||||
Http::post('/v1/projects/:projectId/jwts')
|
||||
->groups(['api', 'projects'])
|
||||
->desc('Create JWT')
|
||||
->label('scope', 'projects.write')
|
||||
->label('sdk', new Method(
|
||||
namespace: 'projects',
|
||||
group: 'auth',
|
||||
name: 'createJWT',
|
||||
description: '/docs/references/projects/create-jwt.md',
|
||||
auth: [AuthType::ADMIN],
|
||||
responses: [
|
||||
new SDKResponse(
|
||||
code: Response::STATUS_CODE_CREATED,
|
||||
model: Response::MODEL_JWT,
|
||||
)
|
||||
]
|
||||
))
|
||||
->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform'])
|
||||
->param('scopes', [], new ArrayList(new WhiteList(array_keys(Config::getParam('projectScopes')), true), APP_LIMIT_ARRAY_PARAMS_SIZE), 'List of scopes allowed for JWT key. Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' scopes are allowed.')
|
||||
->param('duration', 900, new Range(0, 3600), 'Time in seconds before JWT expires. Default duration is 900 seconds, and maximum is 3600 seconds.', true)
|
||||
->inject('response')
|
||||
->inject('dbForPlatform')
|
||||
->action(function (string $projectId, array $scopes, int $duration, Response $response, Database $dbForPlatform) {
|
||||
|
||||
$project = $dbForPlatform->getDocument('projects', $projectId);
|
||||
|
||||
if ($project->isEmpty()) {
|
||||
throw new Exception(Exception::PROJECT_NOT_FOUND);
|
||||
}
|
||||
|
||||
$jwt = new JWT(System::getEnv('_APP_OPENSSL_KEY_V1'), 'HS256', $duration, 0);
|
||||
|
||||
$response
|
||||
->setStatusCode(Response::STATUS_CODE_CREATED)
|
||||
->dynamic(new Document(['jwt' => API_KEY_DYNAMIC . '_' . $jwt->encode([
|
||||
'projectId' => $project->getId(),
|
||||
'scopes' => $scopes
|
||||
])]), Response::MODEL_JWT);
|
||||
});
|
||||
|
||||
// Backwards compatibility
|
||||
Http::delete('/v1/projects/:projectId/templates/email')
|
||||
->alias('/v1/projects/:projectId/templates/email/:type/:locale')
|
||||
|
||||
@@ -44,7 +44,7 @@ use Utopia\System\System;
|
||||
use Utopia\Telemetry\Adapter as Telemetry;
|
||||
use Utopia\Validator\WhiteList;
|
||||
|
||||
$parseLabel = function (string $label, array $responsePayload, array $requestParams, User $user) {
|
||||
$parseLabel = function (string $label, array $responsePayload, array $requestParams, User $user, Document $project) {
|
||||
preg_match_all('/{(.*?)}/', $label, $matches);
|
||||
foreach ($matches[1] as $pos => $match) {
|
||||
$find = $matches[0][$pos];
|
||||
@@ -59,6 +59,7 @@ $parseLabel = function (string $label, array $responsePayload, array $requestPar
|
||||
|
||||
$params = match ($namespace) {
|
||||
'user' => (array) $user,
|
||||
'project' => $project->getArrayCopy(),
|
||||
'request' => $requestParams,
|
||||
default => $responsePayload,
|
||||
};
|
||||
@@ -903,7 +904,7 @@ Http::shutdown()
|
||||
*/
|
||||
$pattern = $route->getLabel('audits.resource', null);
|
||||
if (! empty($pattern)) {
|
||||
$resource = $parseLabel($pattern, $responsePayload, $requestParams, $user);
|
||||
$resource = $parseLabel($pattern, $responsePayload, $requestParams, $user, $project);
|
||||
if (! empty($resource) && $resource !== $pattern) {
|
||||
$auditContext->resource = $resource;
|
||||
}
|
||||
@@ -976,12 +977,12 @@ Http::shutdown()
|
||||
if (! empty($data['payload']) && $statusCode >= 200 && $statusCode < 300) {
|
||||
$pattern = $route->getLabel('cache.resource', null);
|
||||
if (! empty($pattern)) {
|
||||
$resource = $parseLabel($pattern, $responsePayload, $requestParams, $user);
|
||||
$resource = $parseLabel($pattern, $responsePayload, $requestParams, $user, $project);
|
||||
}
|
||||
|
||||
$pattern = $route->getLabel('cache.resourceType', null);
|
||||
if (! empty($pattern)) {
|
||||
$resourceType = $parseLabel($pattern, $responsePayload, $requestParams, $user);
|
||||
$resourceType = $parseLabel($pattern, $responsePayload, $requestParams, $user, $project);
|
||||
}
|
||||
|
||||
$cache = new Cache(
|
||||
|
||||
@@ -112,6 +112,16 @@ use Appwrite\Utopia\Response\Model\PlatformLinux;
|
||||
use Appwrite\Utopia\Response\Model\PlatformList;
|
||||
use Appwrite\Utopia\Response\Model\PlatformWeb;
|
||||
use Appwrite\Utopia\Response\Model\PlatformWindows;
|
||||
use Appwrite\Utopia\Response\Model\PolicyList;
|
||||
use Appwrite\Utopia\Response\Model\PolicyMembershipPrivacy;
|
||||
use Appwrite\Utopia\Response\Model\PolicyPasswordDictionary;
|
||||
use Appwrite\Utopia\Response\Model\PolicyPasswordHistory;
|
||||
use Appwrite\Utopia\Response\Model\PolicyPasswordPersonalData;
|
||||
use Appwrite\Utopia\Response\Model\PolicySessionAlert;
|
||||
use Appwrite\Utopia\Response\Model\PolicySessionDuration;
|
||||
use Appwrite\Utopia\Response\Model\PolicySessionInvalidation;
|
||||
use Appwrite\Utopia\Response\Model\PolicySessionLimit;
|
||||
use Appwrite\Utopia\Response\Model\PolicyUserLimit;
|
||||
use Appwrite\Utopia\Response\Model\Preferences;
|
||||
use Appwrite\Utopia\Response\Model\Project;
|
||||
use Appwrite\Utopia\Response\Model\Provider;
|
||||
@@ -210,6 +220,9 @@ 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 PolicyList());
|
||||
Response::setModel(new BaseList('Email Templates List', Response::MODEL_EMAIL_TEMPLATE_LIST, 'templates', Response::MODEL_EMAIL_TEMPLATE));
|
||||
Response::setModel(new BaseList('Status List', Response::MODEL_HEALTH_STATUS_LIST, 'statuses', Response::MODEL_HEALTH_STATUS));
|
||||
Response::setModel(new BaseList('Rule List', Response::MODEL_PROXY_RULE_LIST, 'rules', Response::MODEL_PROXY_RULE));
|
||||
Response::setModel(new BaseList('Schedules List', Response::MODEL_SCHEDULE_LIST, 'schedules', Response::MODEL_SCHEDULE));
|
||||
@@ -337,6 +350,15 @@ Response::setModel(new Webhook());
|
||||
Response::setModel(new Key());
|
||||
Response::setModel(new DevKey());
|
||||
Response::setModel(new MockNumber());
|
||||
Response::setModel(new PolicyPasswordDictionary());
|
||||
Response::setModel(new PolicyPasswordHistory());
|
||||
Response::setModel(new PolicyPasswordPersonalData());
|
||||
Response::setModel(new PolicySessionAlert());
|
||||
Response::setModel(new PolicySessionDuration());
|
||||
Response::setModel(new PolicySessionInvalidation());
|
||||
Response::setModel(new PolicySessionLimit());
|
||||
Response::setModel(new PolicyUserLimit());
|
||||
Response::setModel(new PolicyMembershipPrivacy());
|
||||
Response::setModel(new AuthProvider());
|
||||
Response::setModel(new PlatformWeb());
|
||||
Response::setModel(new PlatformApple());
|
||||
|
||||
@@ -45,6 +45,7 @@ use Utopia\WebSocket\Adapter;
|
||||
use Utopia\WebSocket\Server;
|
||||
|
||||
require_once __DIR__ . '/init.php';
|
||||
require_once __DIR__ . '/init/span.php';
|
||||
|
||||
/** @var Registry $register */
|
||||
$register = $GLOBALS['register'] ?? throw new \RuntimeException('Registry not initialized');
|
||||
|
||||
@@ -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,89 @@
|
||||
<?php
|
||||
|
||||
namespace Appwrite\Platform\Modules\Project\Http\Project\AuthMethods;
|
||||
|
||||
use Appwrite\Event\Event;
|
||||
use Appwrite\Platform\Action;
|
||||
use Appwrite\SDK\AuthType;
|
||||
use Appwrite\SDK\Method;
|
||||
use Appwrite\SDK\Response as SDKResponse;
|
||||
use Appwrite\Utopia\Response;
|
||||
use Utopia\Config\Config;
|
||||
use Utopia\Database\Database;
|
||||
use Utopia\Database\Document;
|
||||
use Utopia\Database\Validator\Authorization;
|
||||
use Utopia\Platform\Scope\HTTP;
|
||||
use Utopia\Validator\Boolean;
|
||||
use Utopia\Validator\WhiteList;
|
||||
|
||||
class Update extends Action
|
||||
{
|
||||
use HTTP;
|
||||
|
||||
public static function getName()
|
||||
{
|
||||
return 'updateProjectAuthMethod';
|
||||
}
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this
|
||||
->setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH)
|
||||
->setHttpPath('/v1/project/auth-methods/:methodId')
|
||||
->httpAlias('/v1/projects/:projectId/auth/:methodId')
|
||||
->desc('Update project auth method status. Use this endpoint to enable or disable a given auth method for this project.')
|
||||
->groups(['api', 'project'])
|
||||
->label('scope', 'project.write')
|
||||
->label('event', 'authMethod.[methodId].update')
|
||||
->label('audits.event', 'project.authMethods.[methodId].update')
|
||||
->label('audits.resource', 'project.authMethods/{response.$id}')
|
||||
->label('sdk', new Method(
|
||||
namespace: 'project',
|
||||
group: null,
|
||||
name: 'updateAuthMethod',
|
||||
description: <<<EOT
|
||||
Update properties of a specific auth method. Use this endpoint to enable or disable a method in your project.
|
||||
EOT,
|
||||
auth: [AuthType::ADMIN, AuthType::KEY],
|
||||
responses: [
|
||||
new SDKResponse(
|
||||
code: Response::STATUS_CODE_OK,
|
||||
model: Response::MODEL_PROJECT,
|
||||
)
|
||||
],
|
||||
))
|
||||
|
||||
->param('methodId', '', new WhiteList(\array_keys(Config::getParam('auth')), true), 'Auth Method ID. Possible values: ' . implode(',', \array_keys(Config::getParam('auth'))), false)
|
||||
->param('enabled', null, new Boolean(), 'Auth method status.')
|
||||
->inject('response')
|
||||
->inject('dbForPlatform')
|
||||
->inject('project')
|
||||
->inject('authorization')
|
||||
->inject('queueForEvents')
|
||||
->callback($this->action(...));
|
||||
}
|
||||
|
||||
public function action(
|
||||
string $methodId,
|
||||
bool $enabled,
|
||||
Response $response,
|
||||
Database $dbForPlatform,
|
||||
Document $project,
|
||||
Authorization $authorization,
|
||||
Event $queueForEvents
|
||||
): void {
|
||||
$auth = Config::getParam('auth')[$methodId] ?? [];
|
||||
$authKey = $auth['key'] ?? '';
|
||||
|
||||
$auths = $project->getAttribute('auths', []);
|
||||
$auths[$authKey] = $enabled;
|
||||
|
||||
$project = $authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), new Document([
|
||||
'auths' => $auths,
|
||||
])));
|
||||
|
||||
$queueForEvents->setParam('methodId', $methodId);
|
||||
|
||||
$response->dynamic($project, Response::MODEL_PROJECT);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
<?php
|
||||
|
||||
namespace Appwrite\Platform\Modules\Project\Http\Project;
|
||||
|
||||
use Appwrite\Event\Delete as DeleteQueue;
|
||||
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 'deleteProject';
|
||||
}
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this
|
||||
->setHttpMethod(Action::HTTP_REQUEST_METHOD_DELETE)
|
||||
->setHttpPath('/v1/project')
|
||||
->httpAlias('/v1/projects/:projectId')
|
||||
->desc('Delete project')
|
||||
->groups(['api', 'project'])
|
||||
->label('scope', 'project.write')
|
||||
->label('event', 'project.delete')
|
||||
->label('audits.event', 'project.delete')
|
||||
->label('audits.resource', 'project/{project.$id}')
|
||||
->label('sdk', new Method(
|
||||
namespace: 'project',
|
||||
group: null,
|
||||
name: 'delete',
|
||||
description: <<<EOT
|
||||
Delete a project.
|
||||
EOT,
|
||||
auth: [AuthType::ADMIN, AuthType::KEY],
|
||||
responses: [
|
||||
new SDKResponse(
|
||||
code: Response::STATUS_CODE_NOCONTENT,
|
||||
model: Response::MODEL_NONE,
|
||||
)
|
||||
],
|
||||
contentType: ContentType::NONE
|
||||
))
|
||||
->inject('response')
|
||||
->inject('dbForPlatform')
|
||||
->inject('queueForDeletes')
|
||||
->inject('authorization')
|
||||
->inject('project')
|
||||
->callback($this->action(...));
|
||||
}
|
||||
|
||||
public function action(
|
||||
Response $response,
|
||||
Database $dbForPlatform,
|
||||
DeleteQueue $queueForDeletes,
|
||||
Authorization $authorization,
|
||||
Document $project,
|
||||
) {
|
||||
$queueForDeletes
|
||||
->setProject($project)
|
||||
->setType(DELETE_TYPE_DOCUMENT)
|
||||
->setDocument($project);
|
||||
|
||||
if (!$authorization->skip(fn () => $dbForPlatform->deleteDocument('projects', $project->getId()))) {
|
||||
throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Failed to remove project from DB');
|
||||
}
|
||||
|
||||
$response->noContent();
|
||||
}
|
||||
}
|
||||
@@ -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,87 @@
|
||||
<?php
|
||||
|
||||
namespace Appwrite\Platform\Modules\Project\Http\Project\MockPhone;
|
||||
|
||||
use Appwrite\Extend\Exception;
|
||||
use Appwrite\SDK\AuthType;
|
||||
use Appwrite\SDK\Method;
|
||||
use Appwrite\SDK\Response as SDKResponse;
|
||||
use Appwrite\Utopia\Response;
|
||||
use Utopia\Database\Document;
|
||||
use Utopia\Database\Exception\Query as QueryException;
|
||||
use Utopia\Database\Query;
|
||||
use Utopia\Database\Validator\Queries;
|
||||
use Utopia\Database\Validator\Query\Limit;
|
||||
use Utopia\Database\Validator\Query\Offset;
|
||||
use Utopia\Platform\Action;
|
||||
use Utopia\Platform\Scope\HTTP;
|
||||
use Utopia\Validator\Boolean;
|
||||
|
||||
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('queries', [], new Queries([new Limit(), new Offset()]), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Only supported methods are limit and offset', true)
|
||||
->param('total', true, new Boolean(true), 'When set to false, the total count returned will be 0 and will not be calculated.', true)
|
||||
->inject('response')
|
||||
->inject('project')
|
||||
->callback($this->action(...));
|
||||
}
|
||||
|
||||
public function action(
|
||||
array $queries,
|
||||
bool $includeTotal,
|
||||
Response $response,
|
||||
Document $project,
|
||||
) {
|
||||
try {
|
||||
$queries = Query::parseQueries($queries);
|
||||
} catch (QueryException $e) {
|
||||
throw new Exception(Exception::GENERAL_QUERY_INVALID, $e->getMessage());
|
||||
}
|
||||
|
||||
$auths = $project->getAttribute('auths', []);
|
||||
$mockNumbers = $auths['mockNumbers'] ?? [];
|
||||
$grouped = Query::groupByType($queries);
|
||||
$limit = $grouped['limit'] ?? null;
|
||||
$offset = $grouped['offset'] ?? 0;
|
||||
|
||||
$total = $includeTotal ? \count($mockNumbers) : 0;
|
||||
$mockNumbers = \array_slice($mockNumbers, $offset, $limit);
|
||||
|
||||
$mockNumbers = \array_map(fn ($mockNumber) => new Document($mockNumber), $mockNumbers);
|
||||
|
||||
$response->dynamic(new Document([
|
||||
'mockNumbers' => $mockNumbers,
|
||||
'total' => $total,
|
||||
]), Response::MODEL_MOCK_NUMBER_LIST);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
<?php
|
||||
|
||||
namespace Appwrite\Platform\Modules\Project\Http\Project\Policies;
|
||||
|
||||
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\WhiteList;
|
||||
|
||||
class Get extends Action
|
||||
{
|
||||
use HTTP;
|
||||
|
||||
public static function getName()
|
||||
{
|
||||
return 'getProjectPolicy';
|
||||
}
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this
|
||||
->setHttpMethod(Action::HTTP_REQUEST_METHOD_GET)
|
||||
->setHttpPath('/v1/project/policies/:policyId')
|
||||
->desc('Get project policy')
|
||||
->groups(['api', 'project'])
|
||||
->label('scope', 'policies.read')
|
||||
->label('sdk', new Method(
|
||||
namespace: 'project',
|
||||
group: 'policies',
|
||||
name: 'getPolicy',
|
||||
description: <<<EOT
|
||||
Get a policy by its unique ID. This endpoint returns the current configuration for the requested project policy.
|
||||
EOT,
|
||||
auth: [AuthType::ADMIN, AuthType::KEY],
|
||||
responses: [
|
||||
new SDKResponse(
|
||||
code: Response::STATUS_CODE_OK,
|
||||
model: [
|
||||
Response::MODEL_POLICY_PASSWORD_DICTIONARY,
|
||||
Response::MODEL_POLICY_PASSWORD_HISTORY,
|
||||
Response::MODEL_POLICY_PASSWORD_PERSONAL_DATA,
|
||||
Response::MODEL_POLICY_SESSION_ALERT,
|
||||
Response::MODEL_POLICY_SESSION_DURATION,
|
||||
Response::MODEL_POLICY_SESSION_INVALIDATION,
|
||||
Response::MODEL_POLICY_SESSION_LIMIT,
|
||||
Response::MODEL_POLICY_USER_LIMIT,
|
||||
Response::MODEL_POLICY_MEMBERSHIP_PRIVACY,
|
||||
],
|
||||
)
|
||||
]
|
||||
))
|
||||
->param('policyId', '', new WhiteList([
|
||||
'password-dictionary',
|
||||
'password-history',
|
||||
'password-personal-data',
|
||||
'session-alert',
|
||||
'session-duration',
|
||||
'session-invalidation',
|
||||
'session-limit',
|
||||
'user-limit',
|
||||
'membership-privacy',
|
||||
], true), 'Policy ID. Can be one of: password-dictionary, password-history, password-personal-data, session-alert, session-duration, session-invalidation, session-limit, user-limit, membership-privacy.')
|
||||
->inject('response')
|
||||
->inject('project')
|
||||
->callback($this->action(...));
|
||||
}
|
||||
|
||||
public function action(
|
||||
string $policyId,
|
||||
Response $response,
|
||||
Document $project,
|
||||
): void {
|
||||
$auths = $project->getAttribute('auths', []);
|
||||
|
||||
[$policy, $model] = match ($policyId) {
|
||||
'password-dictionary' => [
|
||||
new Document([
|
||||
'$id' => 'password-dictionary',
|
||||
'enabled' => $auths['passwordDictionary'] ?? false,
|
||||
]),
|
||||
Response::MODEL_POLICY_PASSWORD_DICTIONARY,
|
||||
],
|
||||
'password-history' => [
|
||||
new Document([
|
||||
'$id' => 'password-history',
|
||||
'total' => $auths['passwordHistory'] ?? 0,
|
||||
]),
|
||||
Response::MODEL_POLICY_PASSWORD_HISTORY,
|
||||
],
|
||||
'password-personal-data' => [
|
||||
new Document([
|
||||
'$id' => 'password-personal-data',
|
||||
'enabled' => $auths['personalDataCheck'] ?? false,
|
||||
]),
|
||||
Response::MODEL_POLICY_PASSWORD_PERSONAL_DATA,
|
||||
],
|
||||
'session-alert' => [
|
||||
new Document([
|
||||
'$id' => 'session-alert',
|
||||
'enabled' => $auths['sessionAlerts'] ?? false,
|
||||
]),
|
||||
Response::MODEL_POLICY_SESSION_ALERT,
|
||||
],
|
||||
'session-duration' => [
|
||||
new Document([
|
||||
'$id' => 'session-duration',
|
||||
'duration' => $auths['duration'] ?? TOKEN_EXPIRATION_LOGIN_LONG,
|
||||
]),
|
||||
Response::MODEL_POLICY_SESSION_DURATION,
|
||||
],
|
||||
'session-invalidation' => [
|
||||
new Document([
|
||||
'$id' => 'session-invalidation',
|
||||
'enabled' => $auths['invalidateSessions'] ?? true,
|
||||
]),
|
||||
Response::MODEL_POLICY_SESSION_INVALIDATION,
|
||||
],
|
||||
'session-limit' => [
|
||||
new Document([
|
||||
'$id' => 'session-limit',
|
||||
'total' => $auths['maxSessions'] ?? 0,
|
||||
]),
|
||||
Response::MODEL_POLICY_SESSION_LIMIT,
|
||||
],
|
||||
'user-limit' => [
|
||||
new Document([
|
||||
'$id' => 'user-limit',
|
||||
'total' => $auths['limit'] ?? 0,
|
||||
]),
|
||||
Response::MODEL_POLICY_USER_LIMIT,
|
||||
],
|
||||
'membership-privacy' => [
|
||||
new Document([
|
||||
'$id' => 'membership-privacy',
|
||||
'userId' => $auths['membershipsUserId'] ?? false,
|
||||
'userEmail' => $auths['membershipsUserEmail'] ?? false,
|
||||
'userPhone' => $auths['membershipsUserPhone'] ?? false,
|
||||
'userName' => $auths['membershipsUserName'] ?? false,
|
||||
'userMFA' => $auths['membershipsMfa'] ?? false,
|
||||
]),
|
||||
Response::MODEL_POLICY_MEMBERSHIP_PRIVACY,
|
||||
],
|
||||
default => throw new \LogicException('Unknown policy ID: ' . $policyId),
|
||||
};
|
||||
|
||||
$response->dynamic($policy, $model);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
<?php
|
||||
|
||||
namespace Appwrite\Platform\Modules\Project\Http\Project\Policies;
|
||||
|
||||
use Appwrite\Extend\Exception;
|
||||
use Appwrite\SDK\AuthType;
|
||||
use Appwrite\SDK\Method;
|
||||
use Appwrite\SDK\Response as SDKResponse;
|
||||
use Appwrite\Utopia\Response;
|
||||
use Utopia\Database\Document;
|
||||
use Utopia\Database\Exception\Query as QueryException;
|
||||
use Utopia\Database\Query;
|
||||
use Utopia\Database\Validator\Queries;
|
||||
use Utopia\Database\Validator\Query\Limit;
|
||||
use Utopia\Database\Validator\Query\Offset;
|
||||
use Utopia\Platform\Action;
|
||||
use Utopia\Platform\Scope\HTTP;
|
||||
use Utopia\Validator\Boolean;
|
||||
|
||||
class XList extends Action
|
||||
{
|
||||
use HTTP;
|
||||
|
||||
public static function getName()
|
||||
{
|
||||
return 'listPolicies';
|
||||
}
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this
|
||||
->setHttpMethod(Action::HTTP_REQUEST_METHOD_GET)
|
||||
->setHttpPath('/v1/project/policies')
|
||||
->desc('List project policies')
|
||||
->groups(['api', 'project'])
|
||||
->label('scope', 'policies.read')
|
||||
->label('sdk', new Method(
|
||||
namespace: 'project',
|
||||
group: 'policies',
|
||||
name: 'listPolicies',
|
||||
description: <<<EOT
|
||||
Get a list of all project policies and their current configuration.
|
||||
EOT,
|
||||
auth: [AuthType::ADMIN, AuthType::KEY],
|
||||
responses: [
|
||||
new SDKResponse(
|
||||
code: Response::STATUS_CODE_OK,
|
||||
model: Response::MODEL_POLICY_LIST,
|
||||
)
|
||||
]
|
||||
))
|
||||
->param('queries', [], new Queries([new Limit(), new Offset()]), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Only supported methods are limit and offset', true)
|
||||
->param('total', true, new Boolean(true), 'When set to false, the total count returned will be 0 and will not be calculated.', true)
|
||||
->inject('response')
|
||||
->inject('project')
|
||||
->callback($this->action(...));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string> $queries
|
||||
*/
|
||||
public function action(
|
||||
array $queries,
|
||||
bool $includeTotal,
|
||||
Response $response,
|
||||
Document $project,
|
||||
) {
|
||||
try {
|
||||
$queries = Query::parseQueries($queries);
|
||||
} catch (QueryException $e) {
|
||||
throw new Exception(Exception::GENERAL_QUERY_INVALID, $e->getMessage());
|
||||
}
|
||||
|
||||
$auths = $project->getAttribute('auths', []);
|
||||
|
||||
$policies = [
|
||||
new Document([
|
||||
'$id' => 'password-dictionary',
|
||||
'enabled' => $auths['passwordDictionary'] ?? false,
|
||||
]),
|
||||
new Document([
|
||||
'$id' => 'password-history',
|
||||
'total' => $auths['passwordHistory'] ?? 0,
|
||||
]),
|
||||
new Document([
|
||||
'$id' => 'password-personal-data',
|
||||
'enabled' => $auths['personalDataCheck'] ?? false,
|
||||
]),
|
||||
new Document([
|
||||
'$id' => 'session-alert',
|
||||
'enabled' => $auths['sessionAlerts'] ?? false,
|
||||
]),
|
||||
new Document([
|
||||
'$id' => 'session-duration',
|
||||
'duration' => $auths['duration'] ?? TOKEN_EXPIRATION_LOGIN_LONG,
|
||||
]),
|
||||
new Document([
|
||||
'$id' => 'session-invalidation',
|
||||
'enabled' => $auths['invalidateSessions'] ?? true,
|
||||
]),
|
||||
new Document([
|
||||
'$id' => 'session-limit',
|
||||
'total' => $auths['maxSessions'] ?? 0,
|
||||
]),
|
||||
new Document([
|
||||
'$id' => 'user-limit',
|
||||
'total' => $auths['limit'] ?? 0,
|
||||
]),
|
||||
new Document([
|
||||
'$id' => 'membership-privacy',
|
||||
'userId' => $auths['membershipsUserId'] ?? false,
|
||||
'userEmail' => $auths['membershipsUserEmail'] ?? false,
|
||||
'userPhone' => $auths['membershipsUserPhone'] ?? false,
|
||||
'userName' => $auths['membershipsUserName'] ?? false,
|
||||
'userMFA' => $auths['membershipsMfa'] ?? false,
|
||||
]),
|
||||
];
|
||||
|
||||
$total = $includeTotal ? \count($policies) : 0;
|
||||
|
||||
$grouped = Query::groupByType($queries);
|
||||
$offset = $grouped['offset'] ?? 0;
|
||||
$limit = $grouped['limit'] ?? null;
|
||||
|
||||
$policies = \array_slice($policies, $offset, $limit);
|
||||
|
||||
$response->dynamic(new Document([
|
||||
'policies' => $policies,
|
||||
'total' => $total,
|
||||
]), Response::MODEL_POLICY_LIST);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
<?php
|
||||
|
||||
namespace Appwrite\Platform\Modules\Project\Http\Project\Templates\Email;
|
||||
|
||||
use Appwrite\Extend\Exception;
|
||||
use Appwrite\SDK\AuthType;
|
||||
use Appwrite\SDK\Method;
|
||||
use Appwrite\SDK\Response as SDKResponse;
|
||||
use Appwrite\Utopia\Response;
|
||||
use Utopia\Database\Document;
|
||||
use Utopia\Database\Exception\Query as QueryException;
|
||||
use Utopia\Database\Query;
|
||||
use Utopia\Database\Validator\Queries;
|
||||
use Utopia\Database\Validator\Query\Limit;
|
||||
use Utopia\Database\Validator\Query\Offset;
|
||||
use Utopia\Platform\Action;
|
||||
use Utopia\Platform\Scope\HTTP;
|
||||
use Utopia\Validator\Boolean;
|
||||
|
||||
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('queries', [], new Queries([new Limit(), new Offset()]), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Only supported methods are limit and offset', true)
|
||||
->param('total', true, new Boolean(true), 'When set to false, the total count returned will be 0 and will not be calculated.', true)
|
||||
->inject('response')
|
||||
->inject('project')
|
||||
->callback($this->action(...));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string> $queries
|
||||
*/
|
||||
public function action(
|
||||
array $queries,
|
||||
bool $includeTotal,
|
||||
Response $response,
|
||||
Document $project,
|
||||
) {
|
||||
try {
|
||||
$queries = Query::parseQueries($queries);
|
||||
} catch (QueryException $e) {
|
||||
throw new Exception(Exception::GENERAL_QUERY_INVALID, $e->getMessage());
|
||||
}
|
||||
|
||||
$templates = $project->getAttribute('templates', []);
|
||||
|
||||
$emailTemplates = [];
|
||||
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;
|
||||
|
||||
$grouped = Query::groupByType($queries);
|
||||
$offset = $grouped['offset'] ?? 0;
|
||||
$limit = $grouped['limit'] ?? null;
|
||||
|
||||
$emailTemplates = \array_slice($emailTemplates, $offset, $limit);
|
||||
|
||||
$response->dynamic(new Document([
|
||||
'templates' => $emailTemplates,
|
||||
'total' => $total,
|
||||
]), Response::MODEL_EMAIL_TEMPLATE_LIST);
|
||||
}
|
||||
}
|
||||
@@ -3,12 +3,19 @@
|
||||
namespace Appwrite\Platform\Modules\Project\Services;
|
||||
|
||||
use Appwrite\Platform\Modules\Project\Http\Init;
|
||||
use Appwrite\Platform\Modules\Project\Http\Project\AuthMethods\Update as UpdateAuthMethod;
|
||||
use Appwrite\Platform\Modules\Project\Http\Project\Delete as DeleteProject;
|
||||
use Appwrite\Platform\Modules\Project\Http\Project\Keys\Create as CreateKey;
|
||||
use Appwrite\Platform\Modules\Project\Http\Project\Keys\Delete as DeleteKey;
|
||||
use Appwrite\Platform\Modules\Project\Http\Project\Keys\Get as GetKey;
|
||||
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;
|
||||
@@ -22,6 +29,7 @@ use Appwrite\Platform\Modules\Project\Http\Project\Platforms\Web\Update as Updat
|
||||
use Appwrite\Platform\Modules\Project\Http\Project\Platforms\Windows\Create as CreateWindowsPlatform;
|
||||
use Appwrite\Platform\Modules\Project\Http\Project\Platforms\Windows\Update as UpdateWindowsPlatform;
|
||||
use Appwrite\Platform\Modules\Project\Http\Project\Platforms\XList as ListPlatforms;
|
||||
use Appwrite\Platform\Modules\Project\Http\Project\Policies\Get as GetPolicy;
|
||||
use Appwrite\Platform\Modules\Project\Http\Project\Policies\MembershipPrivacy\Update as UpdateMembershipPrivacyPolicy;
|
||||
use Appwrite\Platform\Modules\Project\Http\Project\Policies\PasswordDictionary\Update as UpdatePasswordDictionaryPolicy;
|
||||
use Appwrite\Platform\Modules\Project\Http\Project\Policies\PasswordHistory\Update as UpdatePasswordHistoryPolicy;
|
||||
@@ -31,12 +39,14 @@ use Appwrite\Platform\Modules\Project\Http\Project\Policies\SessionDuration\Upda
|
||||
use Appwrite\Platform\Modules\Project\Http\Project\Policies\SessionInvalidation\Update as UpdateSessionInvalidationPolicy;
|
||||
use Appwrite\Platform\Modules\Project\Http\Project\Policies\SessionLimit\Update as UpdateSessionLimitPolicy;
|
||||
use Appwrite\Platform\Modules\Project\Http\Project\Policies\UserLimit\Update as UpdateUserLimitPolicy;
|
||||
use Appwrite\Platform\Modules\Project\Http\Project\Policies\XList as ListPolicies;
|
||||
use Appwrite\Platform\Modules\Project\Http\Project\Protocols\Update as UpdateProjectProtocol;
|
||||
use Appwrite\Platform\Modules\Project\Http\Project\Services\Update as UpdateProjectService;
|
||||
use Appwrite\Platform\Modules\Project\Http\Project\SMTP\Tests\Create as CreateSMTPTest;
|
||||
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;
|
||||
@@ -54,6 +64,7 @@ class Http extends Service
|
||||
$this->addAction(Init::getName(), new Init());
|
||||
|
||||
// Project
|
||||
$this->addAction(DeleteProject::getName(), new DeleteProject());
|
||||
$this->addAction(UpdateProjectLabels::getName(), new UpdateProjectLabels());
|
||||
$this->addAction(UpdateProjectProtocol::getName(), new UpdateProjectProtocol());
|
||||
$this->addAction(UpdateProjectService::getName(), new UpdateProjectService());
|
||||
@@ -63,6 +74,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());
|
||||
|
||||
@@ -95,7 +107,16 @@ 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(ListPolicies::getName(), new ListPolicies());
|
||||
$this->addAction(GetPolicy::getName(), new GetPolicy());
|
||||
$this->addAction(UpdateMembershipPrivacyPolicy::getName(), new UpdateMembershipPrivacyPolicy());
|
||||
$this->addAction(UpdatePasswordDictionaryPolicy::getName(), new UpdatePasswordDictionaryPolicy());
|
||||
$this->addAction(UpdatePasswordHistoryPolicy::getName(), new UpdatePasswordHistoryPolicy());
|
||||
@@ -105,5 +126,8 @@ class Http extends Service
|
||||
$this->addAction(UpdateSessionInvalidationPolicy::getName(), new UpdateSessionInvalidationPolicy());
|
||||
$this->addAction(UpdateSessionLimitPolicy::getName(), new UpdateSessionLimitPolicy());
|
||||
$this->addAction(UpdateUserLimitPolicy::getName(), new UpdateUserLimitPolicy());
|
||||
|
||||
// Auth Methods
|
||||
$this->addAction(UpdateAuthMethod::getName(), new UpdateAuthMethod());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,12 +70,13 @@ class Get extends Action
|
||||
throw new Exception(Exception::MEMBERSHIP_NOT_FOUND);
|
||||
}
|
||||
|
||||
// Default should be "false", but existing projects already rely on this being "true"
|
||||
$membershipsPrivacy = [
|
||||
'userName' => $project->getAttribute('auths', [])['membershipsUserName'] ?? false,
|
||||
'userEmail' => $project->getAttribute('auths', [])['membershipsUserEmail'] ?? false,
|
||||
'mfa' => $project->getAttribute('auths', [])['membershipsMfa'] ?? false,
|
||||
'userId' => $project->getAttribute('auths', [])['membershipsUserId'] ?? false,
|
||||
'userPhone' => $project->getAttribute('auths', [])['membershipsUserPhone'] ?? false,
|
||||
'userName' => $project->getAttribute('auths', [])['membershipsUserName'] ?? true,
|
||||
'userEmail' => $project->getAttribute('auths', [])['membershipsUserEmail'] ?? true,
|
||||
'mfa' => $project->getAttribute('auths', [])['membershipsMfa'] ?? true,
|
||||
'userId' => $project->getAttribute('auths', [])['membershipsUserId'] ?? true,
|
||||
'userPhone' => $project->getAttribute('auths', [])['membershipsUserPhone'] ?? true,
|
||||
];
|
||||
|
||||
$roles = $authorization->getRoles();
|
||||
|
||||
@@ -123,12 +123,13 @@ class XList extends Action
|
||||
|
||||
$memberships = array_filter($memberships, fn (Document $membership) => !empty($membership->getAttribute('userId')));
|
||||
|
||||
// Default should be "false", but existing projects already rely on this being "true"
|
||||
$membershipsPrivacy = [
|
||||
'userName' => $project->getAttribute('auths', [])['membershipsUserName'] ?? false,
|
||||
'userEmail' => $project->getAttribute('auths', [])['membershipsUserEmail'] ?? false,
|
||||
'mfa' => $project->getAttribute('auths', [])['membershipsMfa'] ?? false,
|
||||
'userId' => $project->getAttribute('auths', [])['membershipsUserId'] ?? false,
|
||||
'userPhone' => $project->getAttribute('auths', [])['membershipsUserPhone'] ?? false,
|
||||
'userName' => $project->getAttribute('auths', [])['membershipsUserName'] ?? true,
|
||||
'userEmail' => $project->getAttribute('auths', [])['membershipsUserEmail'] ?? true,
|
||||
'mfa' => $project->getAttribute('auths', [])['membershipsMfa'] ?? true,
|
||||
'userId' => $project->getAttribute('auths', [])['membershipsUserId'] ?? true,
|
||||
'userPhone' => $project->getAttribute('auths', [])['membershipsUserPhone'] ?? true,
|
||||
];
|
||||
|
||||
$roles = $authorization->getRoles();
|
||||
|
||||
@@ -393,6 +393,9 @@ class Migrations extends Action
|
||||
'keys.write',
|
||||
'platforms.read',
|
||||
'platforms.write',
|
||||
'mocks.read',
|
||||
'mocks.write',
|
||||
'policies.read',
|
||||
'policies.write',
|
||||
'templates.read',
|
||||
'templates.write',
|
||||
|
||||
@@ -18,6 +18,7 @@ class Request extends UtopiaRequest
|
||||
*/
|
||||
private array $filters = [];
|
||||
private ?Route $route = null;
|
||||
private ?array $filteredParams = null;
|
||||
|
||||
public function __construct(SwooleRequest $request)
|
||||
{
|
||||
@@ -32,6 +33,10 @@ class Request extends UtopiaRequest
|
||||
*/
|
||||
public function getParams(): array
|
||||
{
|
||||
if ($this->filteredParams !== null) {
|
||||
return $this->filteredParams;
|
||||
}
|
||||
|
||||
$parameters = parent::getParams();
|
||||
|
||||
if (!$this->hasFilters() || !$this->hasRoute()) {
|
||||
@@ -49,6 +54,7 @@ class Request extends UtopiaRequest
|
||||
foreach ($this->getFilters() as $filter) {
|
||||
$parameters = $filter->parse($parameters, $id);
|
||||
}
|
||||
$this->filteredParams = $parameters;
|
||||
return $parameters;
|
||||
}
|
||||
|
||||
@@ -79,6 +85,7 @@ class Request extends UtopiaRequest
|
||||
$parameters = $filter->parse($parameters, $id);
|
||||
}
|
||||
|
||||
$this->filteredParams = $parameters;
|
||||
return $parameters;
|
||||
}
|
||||
|
||||
@@ -92,6 +99,7 @@ class Request extends UtopiaRequest
|
||||
public function addFilter(Filter $filter): void
|
||||
{
|
||||
$this->filters[] = $filter;
|
||||
$this->filteredParams = null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -112,6 +120,7 @@ class Request extends UtopiaRequest
|
||||
public function resetFilters(): void
|
||||
{
|
||||
$this->filters = [];
|
||||
$this->filteredParams = null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -134,6 +143,7 @@ class Request extends UtopiaRequest
|
||||
public function setRoute(?Route $route): void
|
||||
{
|
||||
$this->route = $route;
|
||||
$this->filteredParams = null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -10,6 +10,18 @@ use Utopia\Database\Query;
|
||||
|
||||
class V20 extends Filter
|
||||
{
|
||||
/**
|
||||
* Per-instance (request-scoped) memo of the `attributes` array for a given
|
||||
* `(databaseNamespace, collectionId)`. Avoids re-fetching the same collection
|
||||
* document when multiple relationships in the same schema point at it, and
|
||||
* when `parse()` is re-entered before `Request::getParams()` memoization warms.
|
||||
*
|
||||
* A `null` value means we already tried and the collection was missing or errored.
|
||||
*
|
||||
* @var array<string, array<int, array<string, mixed>>|null>
|
||||
*/
|
||||
private array $collectionAttributesCache = [];
|
||||
|
||||
// Convert 1.7 params to 1.8
|
||||
public function parse(array $content, string $model): array
|
||||
{
|
||||
@@ -106,36 +118,21 @@ class V20 extends Filter
|
||||
* Recursively includes nested relationships up to 3 levels deep.
|
||||
* Prevents infinite loops by tracking all visited collections in the current path.
|
||||
*/
|
||||
private function getRelatedCollectionKeys(
|
||||
?string $databaseId = null,
|
||||
?string $collectionId = null,
|
||||
?string $prefix = null,
|
||||
int $depth = 1,
|
||||
array $visited = []
|
||||
): array {
|
||||
$databaseId ??= $this->getParamValue('databaseId');
|
||||
$collectionId ??= $this->getParamValue('collectionId');
|
||||
private function getRelatedCollectionKeys(): array
|
||||
{
|
||||
$databaseId = $this->getParamValue('databaseId');
|
||||
$collectionId = $this->getParamValue('collectionId');
|
||||
|
||||
if (
|
||||
empty($databaseId) ||
|
||||
empty($collectionId) ||
|
||||
$depth > Database::RELATION_MAX_DEPTH
|
||||
) {
|
||||
if (empty($databaseId) || empty($collectionId)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Check if we've already visited this collection in the current path to prevent cycles
|
||||
if (in_array($collectionId, $visited)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$visited[] = $collectionId;
|
||||
|
||||
$dbForProject = $this->getDbForProject();
|
||||
if ($dbForProject === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Resolve the database namespace once, outside the recursion.
|
||||
try {
|
||||
$database = $dbForProject->getAuthorization()->skip(fn () => $dbForProject->getDocument(
|
||||
'databases',
|
||||
@@ -148,19 +145,42 @@ class V20 extends Filter
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
$collection = $database = $dbForProject->getAuthorization()->skip(fn () => $dbForProject->getDocument(
|
||||
'database_' . $database->getSequence(),
|
||||
$collectionId
|
||||
));
|
||||
if ($collection->isEmpty()) {
|
||||
return [];
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
$databaseNamespace = 'database_' . $database->getSequence();
|
||||
|
||||
return $this->walkRelatedCollectionKeys(
|
||||
$dbForProject,
|
||||
$databaseNamespace,
|
||||
$collectionId,
|
||||
null,
|
||||
1,
|
||||
[]
|
||||
);
|
||||
}
|
||||
|
||||
private function walkRelatedCollectionKeys(
|
||||
Database $dbForProject,
|
||||
string $databaseNamespace,
|
||||
string $collectionId,
|
||||
?string $prefix,
|
||||
int $depth,
|
||||
array $visited
|
||||
): array {
|
||||
if ($depth > Database::RELATION_MAX_DEPTH) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$attributes = $collection->getAttribute('attributes', []);
|
||||
// Check if we've already visited this collection in the current path to prevent cycles
|
||||
if (in_array($collectionId, $visited, true)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$attributes = $this->getCollectionAttributes($dbForProject, $databaseNamespace, $collectionId);
|
||||
if ($attributes === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$visited[] = $collectionId;
|
||||
|
||||
$relationshipKeys = [];
|
||||
|
||||
foreach ($attributes as $attr) {
|
||||
@@ -176,27 +196,54 @@ class V20 extends Filter
|
||||
$relatedCollectionId = $attr['relatedCollection'] ?? null;
|
||||
|
||||
// Skip this relationship entirely if it points to an already visited collection
|
||||
if ($relatedCollectionId && in_array($relatedCollectionId, $visited)) {
|
||||
if ($relatedCollectionId && in_array($relatedCollectionId, $visited, true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Add the wildcard select for this relationship
|
||||
$relationshipKeys[] = $fullKey . '.*';
|
||||
|
||||
// Continue recursively if we have a related collection
|
||||
if ($relatedCollectionId) {
|
||||
$nestedKeys = $this->getRelatedCollectionKeys(
|
||||
$databaseId,
|
||||
$nestedKeys = $this->walkRelatedCollectionKeys(
|
||||
$dbForProject,
|
||||
$databaseNamespace,
|
||||
$relatedCollectionId,
|
||||
$fullKey,
|
||||
$depth + 1,
|
||||
$visited
|
||||
);
|
||||
|
||||
$relationshipKeys = \array_merge($relationshipKeys, $nestedKeys);
|
||||
}
|
||||
}
|
||||
|
||||
return \array_values(\array_unique($relationshipKeys));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array<string, mixed>>|null
|
||||
*/
|
||||
private function getCollectionAttributes(
|
||||
Database $dbForProject,
|
||||
string $databaseNamespace,
|
||||
string $collectionId
|
||||
): ?array {
|
||||
$cacheKey = $databaseNamespace . ':' . $collectionId;
|
||||
if (\array_key_exists($cacheKey, $this->collectionAttributesCache)) {
|
||||
return $this->collectionAttributesCache[$cacheKey];
|
||||
}
|
||||
|
||||
try {
|
||||
$collection = $dbForProject->getAuthorization()->skip(fn () => $dbForProject->getDocument(
|
||||
$databaseNamespace,
|
||||
$collectionId
|
||||
));
|
||||
} catch (\Throwable) {
|
||||
return $this->collectionAttributesCache[$cacheKey] = null;
|
||||
}
|
||||
|
||||
if ($collection->isEmpty()) {
|
||||
return $this->collectionAttributesCache[$cacheKey] = null;
|
||||
}
|
||||
|
||||
return $this->collectionAttributesCache[$cacheKey] = $collection->getAttribute('attributes', []);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,6 +32,9 @@ class V23 extends Filter
|
||||
case 'project.updateSessionLimitPolicy':
|
||||
$content = $this->parseLimitToTotal($content);
|
||||
break;
|
||||
case 'project.updateAuthMethod':
|
||||
$content = $this->parseUpdateAuthMethod($content);
|
||||
break;
|
||||
}
|
||||
|
||||
return $content;
|
||||
@@ -60,6 +63,21 @@ class V23 extends Filter
|
||||
return $content;
|
||||
}
|
||||
|
||||
protected function parseUpdateAuthMethod(array $content): array
|
||||
{
|
||||
if (isset($content['status'])) {
|
||||
$content['enabled'] = $content['status'];
|
||||
unset($content['status']);
|
||||
}
|
||||
|
||||
if (isset($content['method'])) {
|
||||
$content['methodId'] = $content['method'];
|
||||
unset($content['method']);
|
||||
}
|
||||
|
||||
return $content;
|
||||
}
|
||||
|
||||
protected function parseLimitToTotal(array $content): array
|
||||
{
|
||||
if (isset($content['limit'])) {
|
||||
|
||||
@@ -254,6 +254,17 @@ 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_POLICY_LIST = 'policyList';
|
||||
public const MODEL_POLICY_PASSWORD_DICTIONARY = 'policyPasswordDictionary';
|
||||
public const MODEL_POLICY_PASSWORD_HISTORY = 'policyPasswordHistory';
|
||||
public const MODEL_POLICY_PASSWORD_PERSONAL_DATA = 'policyPasswordPersonalData';
|
||||
public const MODEL_POLICY_SESSION_ALERT = 'policySessionAlert';
|
||||
public const MODEL_POLICY_SESSION_DURATION = 'policySessionDuration';
|
||||
public const MODEL_POLICY_SESSION_INVALIDATION = 'policySessionInvalidation';
|
||||
public const MODEL_POLICY_SESSION_LIMIT = 'policySessionLimit';
|
||||
public const MODEL_POLICY_USER_LIMIT = 'policyUserLimit';
|
||||
public const MODEL_POLICY_MEMBERSHIP_PRIVACY = 'policyMembershipPrivacy';
|
||||
public const MODEL_AUTH_PROVIDER = 'authProvider';
|
||||
public const MODEL_AUTH_PROVIDER_LIST = 'authProviderList';
|
||||
public const MODEL_PLATFORM_APPLE = 'platformApple';
|
||||
@@ -266,6 +277,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
|
||||
*
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace Appwrite\Utopia\Response\Model;
|
||||
|
||||
use Appwrite\Utopia\Response\Model;
|
||||
|
||||
abstract class PolicyBase extends Model
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
$this
|
||||
->addRule('$id', [
|
||||
'type' => self::TYPE_STRING,
|
||||
'description' => 'Policy ID.',
|
||||
'default' => '',
|
||||
'example' => 'password-dictionary',
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
namespace Appwrite\Utopia\Response\Model;
|
||||
|
||||
use Appwrite\Utopia\Response;
|
||||
use Appwrite\Utopia\Response\Model;
|
||||
|
||||
class PolicyList extends Model
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
$this
|
||||
->addRule('total', [
|
||||
'type' => self::TYPE_INTEGER,
|
||||
'description' => 'Total number of policies in the given project.',
|
||||
'default' => 0,
|
||||
'example' => 9,
|
||||
])
|
||||
->addRule('policies', [
|
||||
'type' => [
|
||||
Response::MODEL_POLICY_PASSWORD_DICTIONARY,
|
||||
Response::MODEL_POLICY_PASSWORD_HISTORY,
|
||||
Response::MODEL_POLICY_PASSWORD_PERSONAL_DATA,
|
||||
Response::MODEL_POLICY_SESSION_ALERT,
|
||||
Response::MODEL_POLICY_SESSION_DURATION,
|
||||
Response::MODEL_POLICY_SESSION_INVALIDATION,
|
||||
Response::MODEL_POLICY_SESSION_LIMIT,
|
||||
Response::MODEL_POLICY_USER_LIMIT,
|
||||
Response::MODEL_POLICY_MEMBERSHIP_PRIVACY,
|
||||
],
|
||||
'description' => 'List of policies.',
|
||||
'default' => [],
|
||||
'array' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
public function getName(): string
|
||||
{
|
||||
return 'Policies List';
|
||||
}
|
||||
|
||||
public function getType(): string
|
||||
{
|
||||
return Response::MODEL_POLICY_LIST;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
namespace Appwrite\Utopia\Response\Model;
|
||||
|
||||
use Appwrite\Utopia\Response;
|
||||
|
||||
class PolicyMembershipPrivacy extends PolicyBase
|
||||
{
|
||||
public array $conditions = [
|
||||
'$id' => 'membership-privacy',
|
||||
];
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct();
|
||||
|
||||
$this
|
||||
->addRule('userId', [
|
||||
'type' => self::TYPE_BOOLEAN,
|
||||
'description' => 'Whether user ID is visible in memberships.',
|
||||
'default' => false,
|
||||
'example' => true,
|
||||
])
|
||||
->addRule('userEmail', [
|
||||
'type' => self::TYPE_BOOLEAN,
|
||||
'description' => 'Whether user email is visible in memberships.',
|
||||
'default' => false,
|
||||
'example' => true,
|
||||
])
|
||||
->addRule('userPhone', [
|
||||
'type' => self::TYPE_BOOLEAN,
|
||||
'description' => 'Whether user phone is visible in memberships.',
|
||||
'default' => false,
|
||||
'example' => true,
|
||||
])
|
||||
->addRule('userName', [
|
||||
'type' => self::TYPE_BOOLEAN,
|
||||
'description' => 'Whether user name is visible in memberships.',
|
||||
'default' => false,
|
||||
'example' => true,
|
||||
])
|
||||
->addRule('userMFA', [
|
||||
'type' => self::TYPE_BOOLEAN,
|
||||
'description' => 'Whether user MFA status is visible in memberships.',
|
||||
'default' => false,
|
||||
'example' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
public function getName(): string
|
||||
{
|
||||
return 'Policy Membership Privacy';
|
||||
}
|
||||
|
||||
public function getType(): string
|
||||
{
|
||||
return Response::MODEL_POLICY_MEMBERSHIP_PRIVACY;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace Appwrite\Utopia\Response\Model;
|
||||
|
||||
use Appwrite\Utopia\Response;
|
||||
|
||||
class PolicyPasswordDictionary extends PolicyBase
|
||||
{
|
||||
public array $conditions = [
|
||||
'$id' => 'password-dictionary',
|
||||
];
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct();
|
||||
|
||||
$this->addRule('enabled', [
|
||||
'type' => self::TYPE_BOOLEAN,
|
||||
'description' => 'Whether password dictionary policy is enabled.',
|
||||
'default' => false,
|
||||
'example' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
public function getName(): string
|
||||
{
|
||||
return 'Policy Password Dictionary';
|
||||
}
|
||||
|
||||
public function getType(): string
|
||||
{
|
||||
return Response::MODEL_POLICY_PASSWORD_DICTIONARY;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace Appwrite\Utopia\Response\Model;
|
||||
|
||||
use Appwrite\Utopia\Response;
|
||||
|
||||
class PolicyPasswordHistory extends PolicyBase
|
||||
{
|
||||
public array $conditions = [
|
||||
'$id' => 'password-history',
|
||||
];
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct();
|
||||
|
||||
$this->addRule('total', [
|
||||
'type' => self::TYPE_INTEGER,
|
||||
'description' => 'Password history length. A value of 0 means the policy is disabled.',
|
||||
'default' => 0,
|
||||
'example' => 5,
|
||||
]);
|
||||
}
|
||||
|
||||
public function getName(): string
|
||||
{
|
||||
return 'Policy Password History';
|
||||
}
|
||||
|
||||
public function getType(): string
|
||||
{
|
||||
return Response::MODEL_POLICY_PASSWORD_HISTORY;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace Appwrite\Utopia\Response\Model;
|
||||
|
||||
use Appwrite\Utopia\Response;
|
||||
|
||||
class PolicyPasswordPersonalData extends PolicyBase
|
||||
{
|
||||
public array $conditions = [
|
||||
'$id' => 'password-personal-data',
|
||||
];
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct();
|
||||
|
||||
$this->addRule('enabled', [
|
||||
'type' => self::TYPE_BOOLEAN,
|
||||
'description' => 'Whether password personal data policy is enabled.',
|
||||
'default' => false,
|
||||
'example' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
public function getName(): string
|
||||
{
|
||||
return 'Policy Password Personal Data';
|
||||
}
|
||||
|
||||
public function getType(): string
|
||||
{
|
||||
return Response::MODEL_POLICY_PASSWORD_PERSONAL_DATA;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace Appwrite\Utopia\Response\Model;
|
||||
|
||||
use Appwrite\Utopia\Response;
|
||||
|
||||
class PolicySessionAlert extends PolicyBase
|
||||
{
|
||||
public array $conditions = [
|
||||
'$id' => 'session-alert',
|
||||
];
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct();
|
||||
|
||||
$this->addRule('enabled', [
|
||||
'type' => self::TYPE_BOOLEAN,
|
||||
'description' => 'Whether session alert policy is enabled.',
|
||||
'default' => false,
|
||||
'example' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
public function getName(): string
|
||||
{
|
||||
return 'Policy Session Alert';
|
||||
}
|
||||
|
||||
public function getType(): string
|
||||
{
|
||||
return Response::MODEL_POLICY_SESSION_ALERT;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace Appwrite\Utopia\Response\Model;
|
||||
|
||||
use Appwrite\Utopia\Response;
|
||||
|
||||
class PolicySessionDuration extends PolicyBase
|
||||
{
|
||||
public array $conditions = [
|
||||
'$id' => 'session-duration',
|
||||
];
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct();
|
||||
|
||||
$this->addRule('duration', [
|
||||
'type' => self::TYPE_INTEGER,
|
||||
'description' => 'Session duration in seconds.',
|
||||
'default' => TOKEN_EXPIRATION_LOGIN_LONG,
|
||||
'example' => 3600,
|
||||
]);
|
||||
}
|
||||
|
||||
public function getName(): string
|
||||
{
|
||||
return 'Policy Session Duration';
|
||||
}
|
||||
|
||||
public function getType(): string
|
||||
{
|
||||
return Response::MODEL_POLICY_SESSION_DURATION;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace Appwrite\Utopia\Response\Model;
|
||||
|
||||
use Appwrite\Utopia\Response;
|
||||
|
||||
class PolicySessionInvalidation extends PolicyBase
|
||||
{
|
||||
public array $conditions = [
|
||||
'$id' => 'session-invalidation',
|
||||
];
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct();
|
||||
|
||||
$this->addRule('enabled', [
|
||||
'type' => self::TYPE_BOOLEAN,
|
||||
'description' => 'Whether session invalidation policy is enabled.',
|
||||
'default' => true,
|
||||
'example' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
public function getName(): string
|
||||
{
|
||||
return 'Policy Session Invalidation';
|
||||
}
|
||||
|
||||
public function getType(): string
|
||||
{
|
||||
return Response::MODEL_POLICY_SESSION_INVALIDATION;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace Appwrite\Utopia\Response\Model;
|
||||
|
||||
use Appwrite\Utopia\Response;
|
||||
|
||||
class PolicySessionLimit extends PolicyBase
|
||||
{
|
||||
public array $conditions = [
|
||||
'$id' => 'session-limit',
|
||||
];
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct();
|
||||
|
||||
$this->addRule('total', [
|
||||
'type' => self::TYPE_INTEGER,
|
||||
'description' => 'Maximum number of sessions allowed per user. A value of 0 means the policy is disabled.',
|
||||
'default' => 0,
|
||||
'example' => 10,
|
||||
]);
|
||||
}
|
||||
|
||||
public function getName(): string
|
||||
{
|
||||
return 'Policy Session Limit';
|
||||
}
|
||||
|
||||
public function getType(): string
|
||||
{
|
||||
return Response::MODEL_POLICY_SESSION_LIMIT;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace Appwrite\Utopia\Response\Model;
|
||||
|
||||
use Appwrite\Utopia\Response;
|
||||
|
||||
class PolicyUserLimit extends PolicyBase
|
||||
{
|
||||
public array $conditions = [
|
||||
'$id' => 'user-limit',
|
||||
];
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct();
|
||||
|
||||
$this->addRule('total', [
|
||||
'type' => self::TYPE_INTEGER,
|
||||
'description' => 'Maximum number of users allowed in the project. A value of 0 means the policy is disabled.',
|
||||
'default' => 0,
|
||||
'example' => 100,
|
||||
]);
|
||||
}
|
||||
|
||||
public function getName(): string
|
||||
{
|
||||
return 'Policy User Limit';
|
||||
}
|
||||
|
||||
public function getType(): string
|
||||
{
|
||||
return Response::MODEL_POLICY_USER_LIMIT;
|
||||
}
|
||||
}
|
||||
@@ -169,6 +169,9 @@ trait ProjectCustom
|
||||
'keys.write',
|
||||
'platforms.read',
|
||||
'platforms.write',
|
||||
'mocks.read',
|
||||
'mocks.write',
|
||||
'policies.read',
|
||||
'policies.write',
|
||||
'templates.read',
|
||||
'templates.write',
|
||||
|
||||
@@ -772,6 +772,7 @@ class AccountCustomClientTest extends Scope
|
||||
'origin' => 'http://localhost',
|
||||
'content-type' => 'application/json',
|
||||
'x-appwrite-project' => 'console',
|
||||
'x-appwrite-response-format' => '1.9.1',
|
||||
'cookie' => 'a_session_console=' . $this->getRoot()['session'],
|
||||
]), [
|
||||
'status' => true,
|
||||
@@ -3695,6 +3696,7 @@ class AccountCustomClientTest extends Scope
|
||||
'origin' => 'http://localhost',
|
||||
'content-type' => 'application/json',
|
||||
'x-appwrite-project' => 'console',
|
||||
'x-appwrite-response-format' => '1.9.1',
|
||||
'cookie' => 'a_session_console=' . $this->getRoot()['session'],
|
||||
]), [
|
||||
'status' => false,
|
||||
|
||||
@@ -0,0 +1,337 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\E2E\Services\Project;
|
||||
|
||||
use Tests\E2E\Client;
|
||||
|
||||
trait AuthMethodsBase
|
||||
{
|
||||
/**
|
||||
* methodId => response field name exposed by the Project model.
|
||||
*/
|
||||
protected static array $authMethods = [
|
||||
'email-password' => 'authEmailPassword',
|
||||
'magic-url' => 'authUsersAuthMagicURL',
|
||||
'email-otp' => 'authEmailOtp',
|
||||
'anonymous' => 'authAnonymous',
|
||||
'invites' => 'authInvites',
|
||||
'jwt' => 'authJWT',
|
||||
'phone' => 'authPhone',
|
||||
];
|
||||
|
||||
// Success flow
|
||||
|
||||
public function testDisableAuthMethod(): void
|
||||
{
|
||||
foreach (self::$authMethods as $methodId => $responseKey) {
|
||||
$response = $this->updateAuthMethod($methodId, false);
|
||||
|
||||
$this->assertSame(200, $response['headers']['status-code']);
|
||||
$this->assertNotEmpty($response['body']['$id']);
|
||||
$this->assertSame(false, $response['body'][$responseKey]);
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
foreach (self::$authMethods as $methodId => $responseKey) {
|
||||
$this->updateAuthMethod($methodId, true);
|
||||
}
|
||||
}
|
||||
|
||||
public function testEnableAuthMethod(): void
|
||||
{
|
||||
// Disable first
|
||||
foreach (self::$authMethods as $methodId => $responseKey) {
|
||||
$this->updateAuthMethod($methodId, false);
|
||||
}
|
||||
|
||||
// Re-enable
|
||||
foreach (self::$authMethods as $methodId => $responseKey) {
|
||||
$response = $this->updateAuthMethod($methodId, true);
|
||||
|
||||
$this->assertSame(200, $response['headers']['status-code']);
|
||||
$this->assertNotEmpty($response['body']['$id']);
|
||||
$this->assertSame(true, $response['body'][$responseKey]);
|
||||
}
|
||||
}
|
||||
|
||||
public function testDisableAuthMethodIdempotent(): void
|
||||
{
|
||||
$first = $this->updateAuthMethod('email-password', false);
|
||||
$this->assertSame(200, $first['headers']['status-code']);
|
||||
$this->assertSame(false, $first['body']['authEmailPassword']);
|
||||
|
||||
$second = $this->updateAuthMethod('email-password', false);
|
||||
$this->assertSame(200, $second['headers']['status-code']);
|
||||
$this->assertSame(false, $second['body']['authEmailPassword']);
|
||||
|
||||
// Cleanup
|
||||
$this->updateAuthMethod('email-password', true);
|
||||
}
|
||||
|
||||
public function testEnableAuthMethodIdempotent(): void
|
||||
{
|
||||
$first = $this->updateAuthMethod('email-password', true);
|
||||
$this->assertSame(200, $first['headers']['status-code']);
|
||||
$this->assertSame(true, $first['body']['authEmailPassword']);
|
||||
|
||||
$second = $this->updateAuthMethod('email-password', true);
|
||||
$this->assertSame(200, $second['headers']['status-code']);
|
||||
$this->assertSame(true, $second['body']['authEmailPassword']);
|
||||
}
|
||||
|
||||
public function testDisableOneMethodDoesNotAffectOther(): void
|
||||
{
|
||||
// Ensure both start enabled
|
||||
$this->updateAuthMethod('email-password', true);
|
||||
$this->updateAuthMethod('magic-url', true);
|
||||
|
||||
$response = $this->updateAuthMethod('email-password', false);
|
||||
$this->assertSame(200, $response['headers']['status-code']);
|
||||
$this->assertSame(false, $response['body']['authEmailPassword']);
|
||||
$this->assertSame(true, $response['body']['authUsersAuthMagicURL']);
|
||||
|
||||
// Cleanup
|
||||
$this->updateAuthMethod('email-password', true);
|
||||
}
|
||||
|
||||
public function testDisabledEmailPasswordBlocksSessionCreation(): void
|
||||
{
|
||||
$this->updateAuthMethod('email-password', false);
|
||||
|
||||
// Unauthenticated account creation would normally be permitted; with the
|
||||
// method disabled we expect the shared auth filter to reject it.
|
||||
$response = $this->client->call(Client::METHOD_POST, '/account', [
|
||||
'content-type' => 'application/json',
|
||||
'x-appwrite-project' => $this->getProject()['$id'],
|
||||
], [
|
||||
'userId' => 'unique()',
|
||||
'email' => 'disabled-method-' . \uniqid() . '@appwrite.io',
|
||||
'password' => 'password123',
|
||||
]);
|
||||
|
||||
$this->assertSame(501, $response['headers']['status-code']);
|
||||
$this->assertSame('user_auth_method_unsupported', $response['body']['type']);
|
||||
|
||||
// Cleanup
|
||||
$this->updateAuthMethod('email-password', true);
|
||||
}
|
||||
|
||||
public function testEnabledEmailPasswordAllowsSessionCreation(): void
|
||||
{
|
||||
$this->updateAuthMethod('email-password', false);
|
||||
$this->updateAuthMethod('email-password', true);
|
||||
|
||||
$response = $this->client->call(Client::METHOD_POST, '/account', [
|
||||
'content-type' => 'application/json',
|
||||
'x-appwrite-project' => $this->getProject()['$id'],
|
||||
], [
|
||||
'userId' => 'unique()',
|
||||
'email' => 'enabled-method-' . \uniqid() . '@appwrite.io',
|
||||
'password' => 'password123',
|
||||
]);
|
||||
|
||||
$this->assertNotSame(501, $response['headers']['status-code']);
|
||||
$this->assertNotSame('user_auth_method_unsupported', $response['body']['type'] ?? '');
|
||||
}
|
||||
|
||||
public function testDisabledAnonymousBlocksSessionCreation(): void
|
||||
{
|
||||
$this->updateAuthMethod('anonymous', false);
|
||||
|
||||
$response = $this->client->call(Client::METHOD_POST, '/account/sessions/anonymous', [
|
||||
'content-type' => 'application/json',
|
||||
'x-appwrite-project' => $this->getProject()['$id'],
|
||||
]);
|
||||
|
||||
$this->assertSame(501, $response['headers']['status-code']);
|
||||
$this->assertSame('user_auth_method_unsupported', $response['body']['type']);
|
||||
|
||||
// Cleanup
|
||||
$this->updateAuthMethod('anonymous', true);
|
||||
}
|
||||
|
||||
public function testResponseModel(): void
|
||||
{
|
||||
$response = $this->updateAuthMethod('email-password', false);
|
||||
|
||||
$this->assertSame(200, $response['headers']['status-code']);
|
||||
$this->assertArrayHasKey('$id', $response['body']);
|
||||
$this->assertArrayHasKey('name', $response['body']);
|
||||
foreach (self::$authMethods as $methodId => $responseKey) {
|
||||
$this->assertArrayHasKey($responseKey, $response['body']);
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
$this->updateAuthMethod('email-password', true);
|
||||
}
|
||||
|
||||
// Failure flow
|
||||
|
||||
public function testUpdateAuthMethodWithoutAuthentication(): void
|
||||
{
|
||||
$response = $this->updateAuthMethod('email-password', false, false);
|
||||
|
||||
$this->assertSame(401, $response['headers']['status-code']);
|
||||
}
|
||||
|
||||
public function testUpdateAuthMethodInvalidMethodId(): void
|
||||
{
|
||||
$response = $this->updateAuthMethod('invalid-method', false);
|
||||
|
||||
$this->assertSame(400, $response['headers']['status-code']);
|
||||
}
|
||||
|
||||
public function testUpdateAuthMethodEmptyMethodId(): void
|
||||
{
|
||||
$response = $this->updateAuthMethod('', false);
|
||||
|
||||
$this->assertSame(404, $response['headers']['status-code']);
|
||||
}
|
||||
|
||||
public function testUpdateAuthMethodMissingEnabled(): void
|
||||
{
|
||||
$headers = \array_merge([
|
||||
'content-type' => 'application/json',
|
||||
'x-appwrite-project' => $this->getProject()['$id'],
|
||||
], $this->getHeaders());
|
||||
|
||||
$response = $this->client->call(
|
||||
Client::METHOD_PATCH,
|
||||
'/project/auth-methods/email-password',
|
||||
$headers,
|
||||
[]
|
||||
);
|
||||
|
||||
$this->assertSame(400, $response['headers']['status-code']);
|
||||
}
|
||||
|
||||
// Backwards compatibility
|
||||
|
||||
public function testUpdateAuthMethodLegacyAliasPath(): void
|
||||
{
|
||||
$headers = \array_merge([
|
||||
'content-type' => 'application/json',
|
||||
'x-appwrite-project' => $this->getProject()['$id'],
|
||||
], $this->getHeaders());
|
||||
|
||||
$projectId = $this->getProject()['$id'];
|
||||
|
||||
// Disable via the legacy `/v1/projects/:projectId/auth/:methodId` alias
|
||||
$response = $this->client->call(
|
||||
Client::METHOD_PATCH,
|
||||
'/projects/' . $projectId . '/auth/email-password',
|
||||
$headers,
|
||||
[
|
||||
'enabled' => false,
|
||||
]
|
||||
);
|
||||
|
||||
$this->assertSame(200, $response['headers']['status-code']);
|
||||
$this->assertNotEmpty($response['body']['$id']);
|
||||
$this->assertSame(false, $response['body']['authEmailPassword']);
|
||||
|
||||
// Re-enable via the legacy alias
|
||||
$response = $this->client->call(
|
||||
Client::METHOD_PATCH,
|
||||
'/projects/' . $projectId . '/auth/email-password',
|
||||
$headers,
|
||||
[
|
||||
'enabled' => true,
|
||||
]
|
||||
);
|
||||
|
||||
$this->assertSame(200, $response['headers']['status-code']);
|
||||
$this->assertSame(true, $response['body']['authEmailPassword']);
|
||||
}
|
||||
|
||||
public function testUpdateAuthMethodLegacyStatusParam(): void
|
||||
{
|
||||
// Old SDK passed `status` in the body. The V23 request filter (triggered
|
||||
// via `x-appwrite-response-format: 1.9.1`) must rename it to `enabled`.
|
||||
$headers = \array_merge([
|
||||
'content-type' => 'application/json',
|
||||
'x-appwrite-project' => $this->getProject()['$id'],
|
||||
'x-appwrite-response-format' => '1.9.1',
|
||||
], $this->getHeaders());
|
||||
|
||||
$projectId = $this->getProject()['$id'];
|
||||
|
||||
$response = $this->client->call(
|
||||
Client::METHOD_PATCH,
|
||||
'/projects/' . $projectId . '/auth/email-password',
|
||||
$headers,
|
||||
[
|
||||
'status' => false,
|
||||
]
|
||||
);
|
||||
|
||||
$this->assertSame(200, $response['headers']['status-code']);
|
||||
$this->assertSame(false, $response['body']['authEmailPassword']);
|
||||
|
||||
$response = $this->client->call(
|
||||
Client::METHOD_PATCH,
|
||||
'/projects/' . $projectId . '/auth/email-password',
|
||||
$headers,
|
||||
[
|
||||
'status' => true,
|
||||
]
|
||||
);
|
||||
|
||||
$this->assertSame(200, $response['headers']['status-code']);
|
||||
$this->assertSame(true, $response['body']['authEmailPassword']);
|
||||
}
|
||||
|
||||
public function testUpdateAuthMethodLegacyMethodParam(): void
|
||||
{
|
||||
// Old SDK also had `method` as a path identifier; the V23 filter renames
|
||||
// a stray `method` body field to `methodId`. The URL path parameter of
|
||||
// the alias already binds to `:methodId`, so supplying `method` in the
|
||||
// body is tolerated.
|
||||
$headers = \array_merge([
|
||||
'content-type' => 'application/json',
|
||||
'x-appwrite-project' => $this->getProject()['$id'],
|
||||
'x-appwrite-response-format' => '1.9.1',
|
||||
], $this->getHeaders());
|
||||
|
||||
$projectId = $this->getProject()['$id'];
|
||||
|
||||
$response = $this->client->call(
|
||||
Client::METHOD_PATCH,
|
||||
'/projects/' . $projectId . '/auth/email-password',
|
||||
$headers,
|
||||
[
|
||||
'method' => 'email-password',
|
||||
'status' => false,
|
||||
]
|
||||
);
|
||||
|
||||
$this->assertSame(200, $response['headers']['status-code']);
|
||||
$this->assertSame(false, $response['body']['authEmailPassword']);
|
||||
|
||||
// Cleanup
|
||||
$this->updateAuthMethod('email-password', true);
|
||||
}
|
||||
|
||||
// Helpers
|
||||
|
||||
protected function updateAuthMethod(string $methodId, bool $enabled, bool $authenticated = true): mixed
|
||||
{
|
||||
$headers = [
|
||||
'content-type' => 'application/json',
|
||||
'x-appwrite-project' => $this->getProject()['$id'],
|
||||
];
|
||||
|
||||
if ($authenticated) {
|
||||
$headers = \array_merge($headers, $this->getHeaders());
|
||||
}
|
||||
|
||||
return $this->client->call(
|
||||
Client::METHOD_PATCH,
|
||||
'/project/auth-methods/' . $methodId,
|
||||
$headers,
|
||||
[
|
||||
'enabled' => $enabled,
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 AuthMethodsConsoleClientTest extends Scope
|
||||
{
|
||||
use AuthMethodsBase;
|
||||
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 AuthMethodsCustomServerTest extends Scope
|
||||
{
|
||||
use AuthMethodsBase;
|
||||
use ProjectCustom;
|
||||
use SideServer;
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
<?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 AuthMethodsIntegrationTest extends Scope
|
||||
{
|
||||
use ProjectCustom;
|
||||
use SideServer;
|
||||
|
||||
public function testAuthMethodsIntegration(): void
|
||||
{
|
||||
$projectId = $this->getProject()['$id'];
|
||||
$apiKey = $this->getProject()['apiKey'];
|
||||
|
||||
$serverHeaders = [
|
||||
'content-type' => 'application/json',
|
||||
'x-appwrite-project' => $projectId,
|
||||
'x-appwrite-key' => $apiKey,
|
||||
];
|
||||
|
||||
// Public headers carry no session / api key — this forces the shared
|
||||
// auth init to actually evaluate the auth-method gate (it is bypassed
|
||||
// for privileged / app users).
|
||||
$publicHeaders = [
|
||||
'origin' => 'http://localhost',
|
||||
'content-type' => 'application/json',
|
||||
'x-appwrite-project' => $projectId,
|
||||
];
|
||||
|
||||
$setAuthMethod = function (string $methodId, bool $enabled) use ($serverHeaders): void {
|
||||
$response = $this->client->call(
|
||||
Client::METHOD_PATCH,
|
||||
'/project/auth-methods/' . $methodId,
|
||||
$serverHeaders,
|
||||
['enabled' => $enabled]
|
||||
);
|
||||
$this->assertSame(200, $response['headers']['status-code'], 'Failed to toggle ' . $methodId);
|
||||
};
|
||||
|
||||
$methods = ['email-password', 'magic-url', 'email-otp', 'anonymous', 'invites', 'jwt', 'phone'];
|
||||
|
||||
// Step 1 — Disable every auth method up front.
|
||||
foreach ($methods as $methodId) {
|
||||
$setAuthMethod($methodId, false);
|
||||
}
|
||||
|
||||
$assertBlocked = function (array $response, string $context): void {
|
||||
$this->assertSame(501, $response['headers']['status-code'], $context . ' should be blocked with 501');
|
||||
$this->assertSame('user_auth_method_unsupported', $response['body']['type'] ?? '', $context . ' should return user_auth_method_unsupported');
|
||||
};
|
||||
|
||||
$assertNotBlocked = function (array $response, string $context): void {
|
||||
$this->assertNotSame(501, $response['headers']['status-code'], $context . ' should not be blocked after enabling');
|
||||
$this->assertNotSame('user_auth_method_unsupported', $response['body']['type'] ?? '', $context . ' should not return user_auth_method_unsupported after enabling');
|
||||
};
|
||||
|
||||
$email = 'auth_methods_' . \uniqid() . '@localhost.test';
|
||||
$password = 'password1234';
|
||||
|
||||
// Step 2 — anonymous session creation.
|
||||
$anonymousAttempt = fn () => $this->client->call(Client::METHOD_POST, '/account/sessions/anonymous', $publicHeaders);
|
||||
|
||||
$assertBlocked($anonymousAttempt(), 'Anonymous session (disabled)');
|
||||
$setAuthMethod('anonymous', true);
|
||||
$response = $anonymousAttempt();
|
||||
$assertNotBlocked($response, 'Anonymous session (enabled)');
|
||||
$this->assertSame(201, $response['headers']['status-code']);
|
||||
|
||||
// Step 3 — email/password account creation.
|
||||
$createAccount = fn () => $this->client->call(Client::METHOD_POST, '/account', $publicHeaders, [
|
||||
'userId' => ID::unique(),
|
||||
'email' => $email,
|
||||
'password' => $password,
|
||||
'name' => 'Auth Methods User',
|
||||
]);
|
||||
|
||||
$assertBlocked($createAccount(), 'Account creation (email-password disabled)');
|
||||
$setAuthMethod('email-password', true);
|
||||
$response = $createAccount();
|
||||
$assertNotBlocked($response, 'Account creation (email-password enabled)');
|
||||
$this->assertSame(201, $response['headers']['status-code']);
|
||||
$userId = $response['body']['$id'];
|
||||
|
||||
// Step 4 — email/password session creation (still gated by email-password).
|
||||
// Disable momentarily to prove the session endpoint is gated too.
|
||||
$setAuthMethod('email-password', false);
|
||||
$emailSessionAttempt = fn () => $this->client->call(Client::METHOD_POST, '/account/sessions/email', $publicHeaders, [
|
||||
'email' => $email,
|
||||
'password' => $password,
|
||||
]);
|
||||
|
||||
$assertBlocked($emailSessionAttempt(), 'Email/password session (disabled)');
|
||||
$setAuthMethod('email-password', true);
|
||||
$response = $emailSessionAttempt();
|
||||
$assertNotBlocked($response, 'Email/password session (enabled)');
|
||||
$this->assertSame(201, $response['headers']['status-code']);
|
||||
$sessionSecret = $response['cookies']['a_session_' . $projectId] ?? '';
|
||||
$this->assertNotEmpty($sessionSecret, 'Expected a session cookie after email/password login');
|
||||
|
||||
// Step 5 — email OTP token.
|
||||
$emailOtpAttempt = fn () => $this->client->call(Client::METHOD_POST, '/account/tokens/email', $publicHeaders, [
|
||||
'userId' => $userId,
|
||||
'email' => $email,
|
||||
]);
|
||||
|
||||
$assertBlocked($emailOtpAttempt(), 'Email OTP (disabled)');
|
||||
$setAuthMethod('email-otp', true);
|
||||
$response = $emailOtpAttempt();
|
||||
$assertNotBlocked($response, 'Email OTP (enabled)');
|
||||
$this->assertSame(201, $response['headers']['status-code']);
|
||||
|
||||
// Step 6 — magic URL token.
|
||||
$magicUrlAttempt = fn () => $this->client->call(Client::METHOD_POST, '/account/tokens/magic-url', $publicHeaders, [
|
||||
'userId' => ID::unique(),
|
||||
'email' => 'magic_' . \uniqid() . '@localhost.test',
|
||||
]);
|
||||
|
||||
$assertBlocked($magicUrlAttempt(), 'Magic URL (disabled)');
|
||||
$setAuthMethod('magic-url', true);
|
||||
$response = $magicUrlAttempt();
|
||||
$assertNotBlocked($response, 'Magic URL (enabled)');
|
||||
$this->assertSame(201, $response['headers']['status-code']);
|
||||
|
||||
// Step 7 — phone token. After enabling the auth method the endpoint may
|
||||
// still fail for provider reasons — we only assert that the auth-method
|
||||
// gate stops fighting us.
|
||||
$phoneAttempt = fn () => $this->client->call(Client::METHOD_POST, '/account/tokens/phone', $publicHeaders, [
|
||||
'userId' => ID::unique(),
|
||||
'phone' => '+14155550199',
|
||||
]);
|
||||
|
||||
$assertBlocked($phoneAttempt(), 'Phone token (disabled)');
|
||||
$setAuthMethod('phone', true);
|
||||
$assertNotBlocked($phoneAttempt(), 'Phone token (enabled)');
|
||||
|
||||
// Step 8 — team invites. Needs an existing team; the session user
|
||||
// isn't a team owner, so we don't assert on 201 here — the gate itself
|
||||
// is what's under test and any non-501 proves it was lifted.
|
||||
$teamResponse = $this->client->call(Client::METHOD_POST, '/teams', $serverHeaders, [
|
||||
'teamId' => ID::unique(),
|
||||
'name' => 'Auth Methods Team',
|
||||
]);
|
||||
$this->assertSame(201, $teamResponse['headers']['status-code']);
|
||||
$teamId = $teamResponse['body']['$id'];
|
||||
|
||||
$inviteHeaders = \array_merge($publicHeaders, [
|
||||
'cookie' => 'a_session_' . $projectId . '=' . $sessionSecret,
|
||||
]);
|
||||
$inviteAttempt = fn () => $this->client->call(Client::METHOD_POST, '/teams/' . $teamId . '/memberships', $inviteHeaders, [
|
||||
'email' => 'invitee_' . \uniqid() . '@localhost.test',
|
||||
'roles' => ['developer'],
|
||||
'url' => 'http://localhost/join',
|
||||
]);
|
||||
|
||||
$assertBlocked($inviteAttempt(), 'Team invite (disabled)');
|
||||
$setAuthMethod('invites', true);
|
||||
$assertNotBlocked($inviteAttempt(), 'Team invite (enabled)');
|
||||
|
||||
// Step 9 — JWT creation. Requires an active session.
|
||||
$sessionHeaders = \array_merge($publicHeaders, [
|
||||
'cookie' => 'a_session_' . $projectId . '=' . $sessionSecret,
|
||||
]);
|
||||
$jwtAttempt = fn () => $this->client->call(Client::METHOD_POST, '/account/jwts', $sessionHeaders);
|
||||
|
||||
$assertBlocked($jwtAttempt(), 'JWT (disabled)');
|
||||
$setAuthMethod('jwt', true);
|
||||
$response = $jwtAttempt();
|
||||
$assertNotBlocked($response, 'JWT (enabled)');
|
||||
$this->assertSame(201, $response['headers']['status-code']);
|
||||
|
||||
// Step 10 — End goal: GET /v1/account returns 200 using the session we
|
||||
// built via the (now enabled) email-password flow.
|
||||
$response = $this->client->call(Client::METHOD_GET, '/account', $sessionHeaders);
|
||||
$this->assertSame(200, $response['headers']['status-code']);
|
||||
$this->assertSame($userId, $response['body']['$id']);
|
||||
$this->assertSame($email, $response['body']['email']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,550 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\E2E\Services\Project;
|
||||
|
||||
use Tests\E2E\Client;
|
||||
use Utopia\Database\Query;
|
||||
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 testListMockPhonesWithLimit(): void
|
||||
{
|
||||
$number1 = $this->uniquePhoneNumber();
|
||||
$number2 = $this->uniquePhoneNumber();
|
||||
|
||||
$this->assertSame(201, $this->createMockPhone($number1, '111111')['headers']['status-code']);
|
||||
$this->assertSame(201, $this->createMockPhone($number2, '222222')['headers']['status-code']);
|
||||
|
||||
$response = $this->listMockPhones([
|
||||
Query::limit(1)->toString(),
|
||||
]);
|
||||
|
||||
$this->assertSame(200, $response['headers']['status-code']);
|
||||
$this->assertCount(1, $response['body']['mockNumbers']);
|
||||
$this->assertGreaterThanOrEqual(2, $response['body']['total']);
|
||||
|
||||
// Cleanup
|
||||
$this->deleteMockPhone($number1);
|
||||
$this->deleteMockPhone($number2);
|
||||
}
|
||||
|
||||
public function testListMockPhonesWithOffset(): void
|
||||
{
|
||||
$number1 = $this->uniquePhoneNumber();
|
||||
$number2 = $this->uniquePhoneNumber();
|
||||
|
||||
$this->assertSame(201, $this->createMockPhone($number1, '111111')['headers']['status-code']);
|
||||
$this->assertSame(201, $this->createMockPhone($number2, '222222')['headers']['status-code']);
|
||||
|
||||
$listAll = $this->listMockPhones();
|
||||
$this->assertSame(200, $listAll['headers']['status-code']);
|
||||
$totalAll = \count($listAll['body']['mockNumbers']);
|
||||
|
||||
$listOffset = $this->listMockPhones([
|
||||
Query::offset(1)->toString(),
|
||||
]);
|
||||
|
||||
$this->assertSame(200, $listOffset['headers']['status-code']);
|
||||
$this->assertCount($totalAll - 1, $listOffset['body']['mockNumbers']);
|
||||
$this->assertSame($listAll['body']['total'], $listOffset['body']['total']);
|
||||
|
||||
// Cleanup
|
||||
$this->deleteMockPhone($number1);
|
||||
$this->deleteMockPhone($number2);
|
||||
}
|
||||
|
||||
public function testListMockPhonesWithoutAuthentication(): void
|
||||
{
|
||||
$response = $this->listMockPhones(authenticated: false);
|
||||
$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(?array $queries = null, ?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 ($queries !== null) {
|
||||
$params['queries'] = $queries;
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -3,9 +3,264 @@
|
||||
namespace Tests\E2E\Services\Project;
|
||||
|
||||
use Tests\E2E\Client;
|
||||
use Utopia\Database\Query;
|
||||
|
||||
trait PoliciesBase
|
||||
{
|
||||
// =========================================================================
|
||||
// Get Policy
|
||||
// =========================================================================
|
||||
|
||||
public function testGetPolicy(): void
|
||||
{
|
||||
$expectedFields = [
|
||||
'password-dictionary' => ['enabled'],
|
||||
'password-history' => ['total'],
|
||||
'password-personal-data' => ['enabled'],
|
||||
'session-alert' => ['enabled'],
|
||||
'session-duration' => ['duration'],
|
||||
'session-invalidation' => ['enabled'],
|
||||
'session-limit' => ['total'],
|
||||
'user-limit' => ['total'],
|
||||
'membership-privacy' => ['userId', 'userEmail', 'userPhone', 'userName', 'userMFA'],
|
||||
];
|
||||
|
||||
foreach ($expectedFields as $policyId => $fields) {
|
||||
$response = $this->getPolicy($policyId);
|
||||
|
||||
$this->assertSame(200, $response['headers']['status-code']);
|
||||
$this->assertSame($policyId, $response['body']['$id']);
|
||||
|
||||
foreach ($fields as $field) {
|
||||
$this->assertArrayHasKey($field, $response['body']);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function testGetPolicyMatchesListPolicies(): void
|
||||
{
|
||||
$list = $this->listPolicies();
|
||||
|
||||
$this->assertSame(200, $list['headers']['status-code']);
|
||||
|
||||
$byId = [];
|
||||
foreach ($list['body']['policies'] as $policy) {
|
||||
$byId[$policy['$id']] = $policy;
|
||||
}
|
||||
|
||||
foreach (\array_keys($byId) as $policyId) {
|
||||
$response = $this->getPolicy($policyId);
|
||||
|
||||
$this->assertSame(200, $response['headers']['status-code']);
|
||||
$this->assertSame($byId[$policyId], $response['body']);
|
||||
}
|
||||
}
|
||||
|
||||
public function testGetPolicyReflectsUpdates(): void
|
||||
{
|
||||
$this->updatePasswordDictionaryPolicy(true);
|
||||
$this->updatePasswordHistoryPolicy(5);
|
||||
$this->updateSessionDurationPolicy(3600);
|
||||
$this->updateMembershipPrivacyPolicy([
|
||||
'userId' => true,
|
||||
'userEmail' => true,
|
||||
'userPhone' => false,
|
||||
'userName' => true,
|
||||
'userMFA' => true,
|
||||
]);
|
||||
|
||||
$passwordDictionary = $this->getPolicy('password-dictionary');
|
||||
$passwordHistory = $this->getPolicy('password-history');
|
||||
$sessionDuration = $this->getPolicy('session-duration');
|
||||
$membershipPrivacy = $this->getPolicy('membership-privacy');
|
||||
|
||||
$this->assertSame(200, $passwordDictionary['headers']['status-code']);
|
||||
$this->assertSame(true, $passwordDictionary['body']['enabled']);
|
||||
|
||||
$this->assertSame(200, $passwordHistory['headers']['status-code']);
|
||||
$this->assertSame(5, $passwordHistory['body']['total']);
|
||||
|
||||
$this->assertSame(200, $sessionDuration['headers']['status-code']);
|
||||
$this->assertSame(3600, $sessionDuration['body']['duration']);
|
||||
|
||||
$this->assertSame(200, $membershipPrivacy['headers']['status-code']);
|
||||
$this->assertSame(true, $membershipPrivacy['body']['userId']);
|
||||
$this->assertSame(true, $membershipPrivacy['body']['userEmail']);
|
||||
$this->assertSame(false, $membershipPrivacy['body']['userPhone']);
|
||||
$this->assertSame(true, $membershipPrivacy['body']['userName']);
|
||||
$this->assertSame(true, $membershipPrivacy['body']['userMFA']);
|
||||
|
||||
// Cleanup
|
||||
$this->updatePasswordDictionaryPolicy(false);
|
||||
$this->updatePasswordHistoryPolicy(null);
|
||||
$this->updateSessionDurationPolicy(31536000);
|
||||
$this->updateMembershipPrivacyPolicy([
|
||||
'userId' => false,
|
||||
'userEmail' => false,
|
||||
'userPhone' => false,
|
||||
'userName' => false,
|
||||
'userMFA' => false,
|
||||
]);
|
||||
}
|
||||
|
||||
public function testGetPolicyWithoutAuthentication(): void
|
||||
{
|
||||
$response = $this->getPolicy('password-dictionary', authenticated: false);
|
||||
|
||||
$this->assertSame(401, $response['headers']['status-code']);
|
||||
}
|
||||
|
||||
public function testGetPolicyInvalidPolicyId(): void
|
||||
{
|
||||
$response = $this->getPolicy('invalid-policy');
|
||||
|
||||
$this->assertSame(400, $response['headers']['status-code']);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// List Policies
|
||||
// =========================================================================
|
||||
|
||||
public function testListPolicies(): void
|
||||
{
|
||||
$response = $this->listPolicies();
|
||||
|
||||
$this->assertSame(200, $response['headers']['status-code']);
|
||||
$this->assertArrayHasKey('policies', $response['body']);
|
||||
$this->assertArrayHasKey('total', $response['body']);
|
||||
$this->assertIsArray($response['body']['policies']);
|
||||
$this->assertIsInt($response['body']['total']);
|
||||
$this->assertSame(9, $response['body']['total']);
|
||||
$this->assertCount(9, $response['body']['policies']);
|
||||
|
||||
$policyIds = \array_column($response['body']['policies'], '$id');
|
||||
|
||||
$this->assertContains('password-dictionary', $policyIds);
|
||||
$this->assertContains('password-history', $policyIds);
|
||||
$this->assertContains('password-personal-data', $policyIds);
|
||||
$this->assertContains('session-alert', $policyIds);
|
||||
$this->assertContains('session-duration', $policyIds);
|
||||
$this->assertContains('session-invalidation', $policyIds);
|
||||
$this->assertContains('session-limit', $policyIds);
|
||||
$this->assertContains('user-limit', $policyIds);
|
||||
$this->assertContains('membership-privacy', $policyIds);
|
||||
}
|
||||
|
||||
public function testListPoliciesResponseModel(): void
|
||||
{
|
||||
$response = $this->listPolicies();
|
||||
|
||||
$this->assertSame(200, $response['headers']['status-code']);
|
||||
|
||||
foreach ($response['body']['policies'] as $policy) {
|
||||
$this->assertArrayHasKey('$id', $policy);
|
||||
}
|
||||
|
||||
$byId = [];
|
||||
foreach ($response['body']['policies'] as $policy) {
|
||||
$byId[$policy['$id']] = $policy;
|
||||
}
|
||||
|
||||
$this->assertArrayHasKey('enabled', $byId['password-dictionary']);
|
||||
$this->assertArrayHasKey('total', $byId['password-history']);
|
||||
$this->assertArrayHasKey('enabled', $byId['password-personal-data']);
|
||||
$this->assertArrayHasKey('enabled', $byId['session-alert']);
|
||||
$this->assertArrayHasKey('duration', $byId['session-duration']);
|
||||
$this->assertArrayHasKey('enabled', $byId['session-invalidation']);
|
||||
$this->assertArrayHasKey('total', $byId['session-limit']);
|
||||
$this->assertArrayHasKey('total', $byId['user-limit']);
|
||||
$this->assertArrayHasKey('userId', $byId['membership-privacy']);
|
||||
$this->assertArrayHasKey('userEmail', $byId['membership-privacy']);
|
||||
$this->assertArrayHasKey('userPhone', $byId['membership-privacy']);
|
||||
$this->assertArrayHasKey('userName', $byId['membership-privacy']);
|
||||
$this->assertArrayHasKey('userMFA', $byId['membership-privacy']);
|
||||
}
|
||||
|
||||
public function testListPoliciesReflectsUpdates(): void
|
||||
{
|
||||
$this->updatePasswordDictionaryPolicy(true);
|
||||
$this->updatePasswordHistoryPolicy(5);
|
||||
$this->updateSessionDurationPolicy(3600);
|
||||
$this->updateMembershipPrivacyPolicy([
|
||||
'userId' => true,
|
||||
'userEmail' => true,
|
||||
'userPhone' => false,
|
||||
'userName' => true,
|
||||
'userMFA' => true,
|
||||
]);
|
||||
|
||||
$response = $this->listPolicies();
|
||||
|
||||
$this->assertSame(200, $response['headers']['status-code']);
|
||||
|
||||
$byId = [];
|
||||
foreach ($response['body']['policies'] as $policy) {
|
||||
$byId[$policy['$id']] = $policy;
|
||||
}
|
||||
|
||||
$this->assertSame(true, $byId['password-dictionary']['enabled']);
|
||||
$this->assertSame(5, $byId['password-history']['total']);
|
||||
$this->assertSame(3600, $byId['session-duration']['duration']);
|
||||
$this->assertSame(true, $byId['membership-privacy']['userId']);
|
||||
$this->assertSame(true, $byId['membership-privacy']['userEmail']);
|
||||
$this->assertSame(false, $byId['membership-privacy']['userPhone']);
|
||||
$this->assertSame(true, $byId['membership-privacy']['userName']);
|
||||
$this->assertSame(true, $byId['membership-privacy']['userMFA']);
|
||||
|
||||
// Cleanup
|
||||
$this->updatePasswordDictionaryPolicy(false);
|
||||
$this->updatePasswordHistoryPolicy(null);
|
||||
$this->updateSessionDurationPolicy(31536000);
|
||||
$this->updateMembershipPrivacyPolicy([
|
||||
'userId' => false,
|
||||
'userEmail' => false,
|
||||
'userPhone' => false,
|
||||
'userName' => false,
|
||||
'userMFA' => false,
|
||||
]);
|
||||
}
|
||||
|
||||
public function testListPoliciesTotalFalse(): void
|
||||
{
|
||||
$response = $this->listPolicies(total: false);
|
||||
|
||||
$this->assertSame(200, $response['headers']['status-code']);
|
||||
$this->assertSame(0, $response['body']['total']);
|
||||
$this->assertCount(9, $response['body']['policies']);
|
||||
}
|
||||
|
||||
public function testListPoliciesWithLimit(): void
|
||||
{
|
||||
$response = $this->listPolicies([
|
||||
Query::limit(1)->toString(),
|
||||
]);
|
||||
|
||||
$this->assertSame(200, $response['headers']['status-code']);
|
||||
$this->assertCount(1, $response['body']['policies']);
|
||||
$this->assertSame(9, $response['body']['total']);
|
||||
}
|
||||
|
||||
public function testListPoliciesWithOffset(): void
|
||||
{
|
||||
$listAll = $this->listPolicies();
|
||||
$this->assertSame(200, $listAll['headers']['status-code']);
|
||||
|
||||
$listOffset = $this->listPolicies([
|
||||
Query::offset(1)->toString(),
|
||||
]);
|
||||
|
||||
$this->assertSame(200, $listOffset['headers']['status-code']);
|
||||
$this->assertCount(\count($listAll['body']['policies']) - 1, $listOffset['body']['policies']);
|
||||
$this->assertSame($listAll['body']['total'], $listOffset['body']['total']);
|
||||
}
|
||||
|
||||
public function testListPoliciesWithoutAuthentication(): void
|
||||
{
|
||||
$response = $this->listPolicies(authenticated: false);
|
||||
|
||||
$this->assertSame(401, $response['headers']['status-code']);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Password Dictionary Policy
|
||||
// =========================================================================
|
||||
@@ -842,6 +1097,26 @@ trait PoliciesBase
|
||||
]);
|
||||
}
|
||||
|
||||
protected function listPolicies(?array $queries = null, ?bool $total = null, bool $authenticated = true): mixed
|
||||
{
|
||||
$params = [];
|
||||
|
||||
if ($queries !== null) {
|
||||
$params['queries'] = $queries;
|
||||
}
|
||||
|
||||
if ($total !== null) {
|
||||
$params['total'] = $total;
|
||||
}
|
||||
|
||||
return $this->client->call(Client::METHOD_GET, '/project/policies', $this->buildHeaders($authenticated), $params);
|
||||
}
|
||||
|
||||
protected function getPolicy(string $policyId, bool $authenticated = true): mixed
|
||||
{
|
||||
return $this->client->call(Client::METHOD_GET, '/project/policies/' . $policyId, $this->buildHeaders($authenticated));
|
||||
}
|
||||
|
||||
protected function updatePasswordDictionaryPolicy(bool $enabled, bool $authenticated = true): mixed
|
||||
{
|
||||
return $this->client->call(Client::METHOD_PATCH, '/project/policies/password-dictionary', $this->buildHeaders($authenticated), [
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\E2E\Services\Project;
|
||||
|
||||
trait ProjectBase
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\E2E\Services\Project;
|
||||
|
||||
use Tests\E2E\Client;
|
||||
use Tests\E2E\Scopes\ProjectCustom;
|
||||
use Tests\E2E\Scopes\Scope;
|
||||
use Tests\E2E\Scopes\SideConsole;
|
||||
use Utopia\Database\Helpers\ID;
|
||||
use Utopia\System\System;
|
||||
|
||||
class ProjectConsoleClientTest extends Scope
|
||||
{
|
||||
use ProjectBase;
|
||||
use ProjectCustom;
|
||||
use SideConsole;
|
||||
|
||||
public function testDeleteProject(): void
|
||||
{
|
||||
$team = $this->createTeam('Delete Project Team');
|
||||
$project = $this->createProject($team['body']['$id'], 'Delete Project');
|
||||
|
||||
$response = $this->client->call(Client::METHOD_DELETE, '/project', array_merge([
|
||||
'content-type' => 'application/json',
|
||||
'x-appwrite-project' => $project['body']['$id'],
|
||||
], $this->getHeaders()));
|
||||
|
||||
$this->assertSame(204, $response['headers']['status-code']);
|
||||
|
||||
$getProject = $this->getConsoleProject($project['body']['$id']);
|
||||
|
||||
$this->assertSame(404, $getProject['headers']['status-code']);
|
||||
}
|
||||
|
||||
public function testDeleteProjectUsingKey(): void
|
||||
{
|
||||
$team = $this->createTeam('Delete Project Key Team');
|
||||
$project = $this->createProject($team['body']['$id'], 'Delete Project Using Key');
|
||||
$apiKey = $this->createProjectKey($project['body']['$id'], ['project.write']);
|
||||
|
||||
$response = $this->client->call(Client::METHOD_DELETE, '/project', [
|
||||
'content-type' => 'application/json',
|
||||
'x-appwrite-project' => $project['body']['$id'],
|
||||
'x-appwrite-key' => $apiKey,
|
||||
]);
|
||||
|
||||
$this->assertSame(204, $response['headers']['status-code']);
|
||||
|
||||
$getProject = $this->getConsoleProject($project['body']['$id']);
|
||||
|
||||
$this->assertSame(404, $getProject['headers']['status-code']);
|
||||
}
|
||||
|
||||
protected function createTeam(string $name): array
|
||||
{
|
||||
$response = $this->client->call(Client::METHOD_POST, '/teams', $this->getConsoleSessionHeaders(), [
|
||||
'teamId' => ID::unique(),
|
||||
'name' => $name,
|
||||
]);
|
||||
|
||||
$this->assertSame(201, $response['headers']['status-code']);
|
||||
$this->assertSame($name, $response['body']['name']);
|
||||
$this->assertNotEmpty($response['body']['$id']);
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
protected function createProject(string $teamId, string $name): array
|
||||
{
|
||||
$response = $this->client->call(Client::METHOD_POST, '/projects', $this->getConsoleSessionHeaders(), [
|
||||
'projectId' => ID::unique(),
|
||||
'region' => System::getEnv('_APP_REGION', 'default'),
|
||||
'name' => $name,
|
||||
'teamId' => $teamId,
|
||||
]);
|
||||
|
||||
$this->assertSame(201, $response['headers']['status-code']);
|
||||
$this->assertSame($name, $response['body']['name']);
|
||||
$this->assertNotEmpty($response['body']['$id']);
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
protected function createProjectKey(string $projectId, array $scopes): string
|
||||
{
|
||||
$response = $this->client->call(Client::METHOD_POST, '/projects/' . $projectId . '/keys', $this->getConsoleSessionHeaders(), [
|
||||
'keyId' => ID::unique(),
|
||||
'name' => 'Delete Project Key',
|
||||
'scopes' => $scopes,
|
||||
]);
|
||||
|
||||
$this->assertSame(201, $response['headers']['status-code']);
|
||||
$this->assertNotEmpty($response['body']['secret']);
|
||||
|
||||
return $response['body']['secret'];
|
||||
}
|
||||
|
||||
protected function getConsoleProject(string $projectId): array
|
||||
{
|
||||
return $this->client->call(Client::METHOD_GET, '/projects/' . $projectId, $this->getConsoleSessionHeaders());
|
||||
}
|
||||
|
||||
protected function getConsoleSessionHeaders(): array
|
||||
{
|
||||
return [
|
||||
'origin' => 'http://localhost',
|
||||
'content-type' => 'application/json',
|
||||
'cookie' => 'a_session_console=' . $this->getRoot()['session'],
|
||||
'x-appwrite-project' => 'console',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\E2E\Services\Project;
|
||||
|
||||
use Tests\E2E\Scopes\ProjectCustom;
|
||||
use Tests\E2E\Scopes\Scope;
|
||||
use Tests\E2E\Scopes\SideServer;
|
||||
|
||||
class ProjectCustomServerTest extends Scope
|
||||
{
|
||||
use ProjectBase;
|
||||
use ProjectCustom;
|
||||
use SideServer;
|
||||
|
||||
// Placeholder until this scope has custom server-specific coverage.
|
||||
// You can remove this after adding some custom server tests, or some project base tests
|
||||
public function testProjectServerLogic(): void
|
||||
{
|
||||
$this->expectNotToPerformAssertions();
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace Tests\E2E\Services\Project;
|
||||
|
||||
use Tests\E2E\Client;
|
||||
use Utopia\Database\Helpers\ID;
|
||||
use Utopia\Database\Query;
|
||||
|
||||
trait TemplatesBase
|
||||
{
|
||||
@@ -579,6 +580,295 @@ 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 testListEmailTemplatesWithLimit(): void
|
||||
{
|
||||
$this->ensureSMTPEnabled();
|
||||
|
||||
$runId = \uniqid();
|
||||
|
||||
$this->assertSame(200, $this->updateEmailTemplate(
|
||||
templateId: 'verification',
|
||||
locale: 'en',
|
||||
subject: "Limit verification {$runId}",
|
||||
message: 'Body',
|
||||
)['headers']['status-code']);
|
||||
|
||||
$this->assertSame(200, $this->updateEmailTemplate(
|
||||
templateId: 'recovery',
|
||||
locale: 'en',
|
||||
subject: "Limit recovery {$runId}",
|
||||
message: 'Body',
|
||||
)['headers']['status-code']);
|
||||
|
||||
$response = $this->listEmailTemplates([
|
||||
Query::limit(1)->toString(),
|
||||
]);
|
||||
|
||||
$this->assertSame(200, $response['headers']['status-code']);
|
||||
$this->assertCount(1, $response['body']['templates']);
|
||||
$this->assertGreaterThanOrEqual(2, $response['body']['total']);
|
||||
}
|
||||
|
||||
public function testListEmailTemplatesWithOffset(): void
|
||||
{
|
||||
$this->ensureSMTPEnabled();
|
||||
|
||||
$runId = \uniqid();
|
||||
|
||||
$this->assertSame(200, $this->updateEmailTemplate(
|
||||
templateId: 'magicSession',
|
||||
locale: 'en',
|
||||
subject: "Offset magic {$runId}",
|
||||
message: 'Body',
|
||||
)['headers']['status-code']);
|
||||
|
||||
$this->assertSame(200, $this->updateEmailTemplate(
|
||||
templateId: 'sessionAlert',
|
||||
locale: 'en',
|
||||
subject: "Offset session {$runId}",
|
||||
message: 'Body',
|
||||
)['headers']['status-code']);
|
||||
|
||||
$listAll = $this->listEmailTemplates();
|
||||
$this->assertSame(200, $listAll['headers']['status-code']);
|
||||
$totalAll = \count($listAll['body']['templates']);
|
||||
|
||||
$listOffset = $this->listEmailTemplates([
|
||||
Query::offset(1)->toString(),
|
||||
]);
|
||||
|
||||
$this->assertSame(200, $listOffset['headers']['status-code']);
|
||||
$this->assertCount($totalAll - 1, $listOffset['body']['templates']);
|
||||
$this->assertSame($listAll['body']['total'], $listOffset['body']['total']);
|
||||
}
|
||||
|
||||
public function testListEmailTemplatesOnlyReturnsCustomizedTemplates(): void
|
||||
{
|
||||
$this->ensureSMTPEnabled();
|
||||
|
||||
// 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 +1094,28 @@ trait TemplatesBase
|
||||
return $this->client->call(Client::METHOD_GET, '/project/templates/email/' . $templateId, $headers, $params);
|
||||
}
|
||||
|
||||
protected function listEmailTemplates(?array $queries = null, ?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 ($queries !== null) {
|
||||
$params['queries'] = $queries;
|
||||
}
|
||||
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,
|
||||
|
||||
@@ -1764,6 +1764,7 @@ class ProjectsConsoleClientTest extends Scope
|
||||
$response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/' . $index, array_merge([
|
||||
'content-type' => 'application/json',
|
||||
'x-appwrite-project' => $this->getProject()['$id'],
|
||||
'x-appwrite-response-format' => '1.9.1',
|
||||
], $this->getHeaders()), [
|
||||
'status' => false,
|
||||
]);
|
||||
@@ -1860,6 +1861,7 @@ class ProjectsConsoleClientTest extends Scope
|
||||
$response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/auth/' . $index, array_merge([
|
||||
'content-type' => 'application/json',
|
||||
'x-appwrite-project' => $this->getProject()['$id'],
|
||||
'x-appwrite-response-format' => '1.9.1',
|
||||
], $this->getHeaders()), [
|
||||
'status' => true,
|
||||
]);
|
||||
@@ -2634,120 +2636,6 @@ class ProjectsConsoleClientTest extends Scope
|
||||
$this->assertEquals(false, $response['body']['authPersonalDataCheck']);
|
||||
}
|
||||
|
||||
public function testUpdateProjectServicesAll(): void
|
||||
{
|
||||
$team = $this->client->call(Client::METHOD_POST, '/teams', array_merge([
|
||||
'content-type' => 'application/json',
|
||||
'x-appwrite-project' => $this->getProject()['$id'],
|
||||
'cookie' => 'a_session_console=' . $this->getRoot()['session'],
|
||||
]), [
|
||||
'teamId' => ID::unique(),
|
||||
'name' => 'Project Test',
|
||||
]);
|
||||
|
||||
$this->assertEquals(201, $team['headers']['status-code']);
|
||||
$this->assertNotEmpty($team['body']['$id']);
|
||||
|
||||
$project = $this->client->call(Client::METHOD_POST, '/projects', array_merge([
|
||||
'content-type' => 'application/json',
|
||||
'x-appwrite-project' => $this->getProject()['$id'],
|
||||
'cookie' => 'a_session_console=' . $this->getRoot()['session'],
|
||||
]), [
|
||||
'projectId' => ID::unique(),
|
||||
'name' => 'Project Test',
|
||||
'teamId' => $team['body']['$id'],
|
||||
'region' => System::getEnv('_APP_REGION', 'default')
|
||||
]);
|
||||
|
||||
$this->assertEquals(201, $project['headers']['status-code']);
|
||||
$this->assertNotEmpty($project['body']['$id']);
|
||||
|
||||
$id = $project['body']['$id'];
|
||||
|
||||
// Bulk disable should no longer work
|
||||
$response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/service/all', array_merge([
|
||||
'content-type' => 'application/json',
|
||||
'x-appwrite-project' => $this->getProject()['$id'],
|
||||
'x-appwrite-response-format' => '1.9.0',
|
||||
'cookie' => 'a_session_console=' . $this->getRoot()['session'],
|
||||
]), [
|
||||
'status' => false,
|
||||
]);
|
||||
|
||||
$this->assertEquals(405, $response['headers']['status-code']);
|
||||
$this->assertEquals('general_not_implemented', $response['body']['type']);
|
||||
|
||||
// Bulk enable should no longer work
|
||||
$response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/service/all', array_merge([
|
||||
'content-type' => 'application/json',
|
||||
'x-appwrite-project' => $this->getProject()['$id'],
|
||||
'x-appwrite-response-format' => '1.9.0',
|
||||
'cookie' => 'a_session_console=' . $this->getRoot()['session'],
|
||||
]), [
|
||||
'status' => true,
|
||||
]);
|
||||
|
||||
$this->assertEquals(405, $response['headers']['status-code']);
|
||||
$this->assertEquals('general_not_implemented', $response['body']['type']);
|
||||
}
|
||||
|
||||
public function testUpdateProjectApisAll(): void
|
||||
{
|
||||
$team = $this->client->call(Client::METHOD_POST, '/teams', array_merge([
|
||||
'content-type' => 'application/json',
|
||||
'x-appwrite-project' => $this->getProject()['$id'],
|
||||
'cookie' => 'a_session_console=' . $this->getRoot()['session'],
|
||||
]), [
|
||||
'teamId' => ID::unique(),
|
||||
'name' => 'Project Test',
|
||||
]);
|
||||
|
||||
$this->assertEquals(201, $team['headers']['status-code']);
|
||||
$this->assertNotEmpty($team['body']['$id']);
|
||||
|
||||
$project = $this->client->call(Client::METHOD_POST, '/projects', array_merge([
|
||||
'content-type' => 'application/json',
|
||||
'x-appwrite-project' => $this->getProject()['$id'],
|
||||
'cookie' => 'a_session_console=' . $this->getRoot()['session'],
|
||||
]), [
|
||||
'projectId' => ID::unique(),
|
||||
'name' => 'Project Test',
|
||||
'teamId' => $team['body']['$id'],
|
||||
'region' => System::getEnv('_APP_REGION', 'default')
|
||||
]);
|
||||
|
||||
$this->assertEquals(201, $project['headers']['status-code']);
|
||||
$this->assertNotEmpty($project['body']['$id']);
|
||||
|
||||
$id = $project['body']['$id'];
|
||||
|
||||
// Bulk disable should no longer work
|
||||
$response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/api/all', array_merge([
|
||||
'content-type' => 'application/json',
|
||||
'x-appwrite-project' => $this->getProject()['$id'],
|
||||
'x-appwrite-response-format' => '1.9.0',
|
||||
'cookie' => 'a_session_console=' . $this->getRoot()['session'],
|
||||
]), [
|
||||
'status' => false,
|
||||
]);
|
||||
|
||||
$this->assertEquals(405, $response['headers']['status-code']);
|
||||
$this->assertEquals('general_not_implemented', $response['body']['type']);
|
||||
|
||||
// Bulk enable should no longer work
|
||||
$response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $id . '/api/all', array_merge([
|
||||
'content-type' => 'application/json',
|
||||
'x-appwrite-project' => $this->getProject()['$id'],
|
||||
'x-appwrite-response-format' => '1.9.0',
|
||||
'cookie' => 'a_session_console=' . $this->getRoot()['session'],
|
||||
]), [
|
||||
'status' => true,
|
||||
]);
|
||||
|
||||
$this->assertEquals(405, $response['headers']['status-code']);
|
||||
$this->assertEquals('general_not_implemented', $response['body']['type']);
|
||||
}
|
||||
|
||||
public function testUpdateProjectApiStatus(): void
|
||||
{
|
||||
$team = $this->client->call(Client::METHOD_POST, '/teams', array_merge([
|
||||
@@ -4053,58 +3941,6 @@ class ProjectsConsoleClientTest extends Scope
|
||||
$this->assertEmpty($response['body']);
|
||||
}
|
||||
|
||||
// JWT Keys
|
||||
|
||||
public function testJWTKey(): void
|
||||
{
|
||||
$data = $this->setupProjectData();
|
||||
$id = $data['projectId'];
|
||||
|
||||
// Create JWT key
|
||||
$response = $this->client->call(Client::METHOD_POST, '/projects/' . $id . '/jwts', array_merge([
|
||||
'content-type' => 'application/json',
|
||||
'x-appwrite-project' => $this->getProject()['$id'],
|
||||
], $this->getHeaders()), [
|
||||
'duration' => 5,
|
||||
'scopes' => ['users.read'],
|
||||
]);
|
||||
|
||||
$this->assertEquals(201, $response['headers']['status-code']);
|
||||
$this->assertNotEmpty($response['body']['jwt']);
|
||||
|
||||
$jwt = $response['body']['jwt'];
|
||||
|
||||
// Ensure JWT key works
|
||||
$response = $this->client->call(Client::METHOD_GET, '/users', [
|
||||
'content-type' => 'application/json',
|
||||
'x-appwrite-project' => $id,
|
||||
'x-appwrite-key' => $jwt,
|
||||
]);
|
||||
|
||||
$this->assertEquals(200, $response['headers']['status-code']);
|
||||
$this->assertArrayHasKey('users', $response['body']);
|
||||
|
||||
// Ensure JWT key respect scopes
|
||||
$response = $this->client->call(Client::METHOD_GET, '/functions', [
|
||||
'content-type' => 'application/json',
|
||||
'x-appwrite-project' => $id,
|
||||
'x-appwrite-key' => $jwt,
|
||||
]);
|
||||
|
||||
$this->assertEquals(401, $response['headers']['status-code']);
|
||||
|
||||
// Ensure JWT key expires
|
||||
\sleep(10);
|
||||
|
||||
$response = $this->client->call(Client::METHOD_GET, '/users', [
|
||||
'content-type' => 'application/json',
|
||||
'x-appwrite-project' => $id,
|
||||
'x-appwrite-key' => $jwt,
|
||||
]);
|
||||
|
||||
$this->assertEquals(401, $response['headers']['status-code']);
|
||||
}
|
||||
|
||||
// Platforms
|
||||
|
||||
public function testCreateProjectPlatform(): void
|
||||
|
||||
@@ -10,6 +10,7 @@ use Utopia\System\System;
|
||||
|
||||
class ProjectsCustomServerTest extends Scope
|
||||
{
|
||||
use ProjectsBase;
|
||||
use ProjectCustom;
|
||||
use SideServer;
|
||||
|
||||
|
||||
@@ -14,6 +14,65 @@ class TeamsConsoleClientTest extends Scope
|
||||
use ProjectConsole;
|
||||
use SideClient;
|
||||
|
||||
public function testConsoleMembershipPrivacyDefaults(): void
|
||||
{
|
||||
$teamData = $this->createTeamHelper();
|
||||
$membershipData = $this->createAndAcceptMembershipHelper($teamData['teamUid'], $teamData['teamName']);
|
||||
|
||||
$teamUid = $teamData['teamUid'];
|
||||
$projectId = $this->getProject()['$id'];
|
||||
$owner = $this->getUser();
|
||||
$memberHeaders = [
|
||||
'origin' => 'http://localhost',
|
||||
'content-type' => 'application/json',
|
||||
'x-appwrite-project' => $projectId,
|
||||
'cookie' => 'a_session_' . $projectId . '=' . $membershipData['session'],
|
||||
];
|
||||
|
||||
$ownerMemberships = $this->client->call(Client::METHOD_GET, '/teams/' . $teamUid . '/memberships', array_merge([
|
||||
'content-type' => 'application/json',
|
||||
'x-appwrite-project' => $projectId,
|
||||
], $this->getHeaders()));
|
||||
|
||||
$this->assertEquals(200, $ownerMemberships['headers']['status-code']);
|
||||
$this->assertEquals(2, $ownerMemberships['body']['total']);
|
||||
|
||||
$ownerMembershipsByUser = [];
|
||||
foreach ($ownerMemberships['body']['memberships'] as $membership) {
|
||||
$ownerMembershipsByUser[$membership['userId']] = $membership;
|
||||
}
|
||||
|
||||
$this->assertArrayHasKey($owner['$id'], $ownerMembershipsByUser);
|
||||
$this->assertContains('owner', $ownerMembershipsByUser[$owner['$id']]['roles']);
|
||||
|
||||
$this->assertArrayHasKey($membershipData['userUid'], $ownerMembershipsByUser);
|
||||
$this->assertNotContains('owner', $ownerMembershipsByUser[$membershipData['userUid']]['roles']);
|
||||
$this->assertSame($membershipData['userUid'], $ownerMembershipsByUser[$membershipData['userUid']]['userId']);
|
||||
$this->assertSame($membershipData['name'], $ownerMembershipsByUser[$membershipData['userUid']]['userName']);
|
||||
$this->assertSame($membershipData['email'], $ownerMembershipsByUser[$membershipData['userUid']]['userEmail']);
|
||||
$this->assertFalse($ownerMembershipsByUser[$membershipData['userUid']]['mfa']);
|
||||
|
||||
$memberMemberships = $this->client->call(Client::METHOD_GET, '/teams/' . $teamUid . '/memberships', $memberHeaders);
|
||||
|
||||
$this->assertEquals(200, $memberMemberships['headers']['status-code']);
|
||||
$this->assertEquals(2, $memberMemberships['body']['total']);
|
||||
|
||||
$memberMembershipsByUser = [];
|
||||
foreach ($memberMemberships['body']['memberships'] as $membership) {
|
||||
$memberMembershipsByUser[$membership['userId']] = $membership;
|
||||
}
|
||||
|
||||
$this->assertArrayHasKey($owner['$id'], $memberMembershipsByUser);
|
||||
$this->assertSame($owner['$id'], $memberMembershipsByUser[$owner['$id']]['userId']);
|
||||
$this->assertSame($owner['name'], $memberMembershipsByUser[$owner['$id']]['userName']);
|
||||
$this->assertSame($owner['email'], $memberMembershipsByUser[$owner['$id']]['userEmail']);
|
||||
$this->assertFalse($memberMembershipsByUser[$owner['$id']]['mfa']);
|
||||
$this->assertContains('owner', $memberMembershipsByUser[$owner['$id']]['roles']);
|
||||
|
||||
$this->assertArrayHasKey($membershipData['userUid'], $memberMembershipsByUser);
|
||||
$this->assertNotContains('owner', $memberMembershipsByUser[$membershipData['userUid']]['roles']);
|
||||
}
|
||||
|
||||
public function testTeamCreateMembershipConsole(): void
|
||||
{
|
||||
$teamData = $this->createTeamHelper();
|
||||
|
||||
Reference in New Issue
Block a user