diff --git a/.env b/.env index 4d7c038a6b..64fc7ef10f 100644 --- a/.env +++ b/.env @@ -21,13 +21,13 @@ _APP_OPTIONS_ROUTER_PROTECTION=disabled _APP_OPTIONS_FORCE_HTTPS=disabled _APP_OPTIONS_ROUTER_FORCE_HTTPS=disabled _APP_OPENSSL_KEY_V1=your-secret-key -_APP_DNS=8.8.8.8 -_APP_DOMAIN=traefik +_APP_DNS=172.16.238.100 # CoreDNS +_APP_DOMAIN=appwrite.test _APP_CONSOLE_DOMAIN=localhost _APP_DOMAIN_FUNCTIONS=functions.localhost _APP_DOMAIN_SITES=sites.localhost -_APP_DOMAIN_TARGET_CNAME=test.localhost -_APP_DOMAIN_TARGET_A=127.0.0.1 +_APP_DOMAIN_TARGET_CNAME=cname.localhost +_APP_DOMAIN_TARGET_A=203.0.0.1 _APP_DOMAIN_TARGET_AAAA=::1 _APP_DOMAIN_TARGET_CAA=digicert.com _APP_RULES_FORMAT=md5 @@ -103,6 +103,7 @@ _APP_MAINTENANCE_RETENTION_USAGE_HOURLY=8640000 _APP_MAINTENANCE_RETENTION_SCHEDULES=86400 _APP_USAGE_STATS=enabled _APP_LOGGING_CONFIG= +_APP_LOGGING_CONFIG_REALTIME= _APP_GRAPHQL_MAX_BATCH_SIZE=10 _APP_GRAPHQL_MAX_COMPLEXITY=250 _APP_GRAPHQL_MAX_DEPTH=4 @@ -123,4 +124,5 @@ _APP_MESSAGE_PUSH_TEST_DSN= _APP_WEBHOOK_MAX_FAILED_ATTEMPTS=10 _APP_PROJECT_REGIONS=default _APP_FUNCTIONS_CREATION_ABUSE_LIMIT=5000 -_APP_STATS_USAGE_DUAL_WRITING_DBS=database_db_main \ No newline at end of file +_APP_STATS_USAGE_DUAL_WRITING_DBS=database_db_main +_APP_TRUSTED_HEADERS=x-forwarded-for \ No newline at end of file diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index c78156ca04..180eb5428d 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -42,6 +42,7 @@ jobs: with: context: . platforms: linux/amd64,linux/arm64 + target: production build-args: | VERSION=${{ steps.meta.outputs.version }} VITE_APPWRITE_GROWTH_ENDPOINT=https://growth.appwrite.io/v1 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 862d669466..5426f53583 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -20,10 +20,10 @@ jobs: submodules: recursive - name: Set up QEMU - uses: docker/setup-qemu-action@v2 + uses: docker/setup-qemu-action@v3 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v2 + uses: docker/setup-buildx-action@v3 - name: Login to Docker Hub uses: docker/login-action@v2 @@ -46,6 +46,7 @@ jobs: with: context: . platforms: linux/amd64,linux/arm64 + target: production build-args: | VERSION=${{ steps.meta.outputs.version }} push: true diff --git a/Dockerfile b/Dockerfile index 2a3e176838..e146008222 100755 --- a/Dockerfile +++ b/Dockerfile @@ -12,7 +12,7 @@ RUN composer install --ignore-platform-reqs --optimize-autoloader \ --no-plugins --no-scripts --prefer-dist \ `if [ "$TESTING" != "true" ]; then echo "--no-dev"; fi` -FROM appwrite/base:0.10.4 AS final +FROM appwrite/base:0.10.6 AS base LABEL maintainer="team@appwrite.io" @@ -24,11 +24,9 @@ ENV _APP_VERSION=$VERSION \ _APP_HOME=https://appwrite.io RUN \ - if [ "$DEBUG" == "true" ]; then \ + if [ "$DEBUG" == "true" ]; then \ apk add boost boost-dev; \ - fi - -RUN apk add libwebp + fi WORKDIR /usr/src/code @@ -38,9 +36,7 @@ COPY --from=composer /usr/local/src/vendor /usr/src/code/vendor COPY ./app /usr/src/code/app COPY ./public /usr/src/code/public COPY ./bin /usr/local/bin -COPY ./docs /usr/src/code/docs COPY ./src /usr/src/code/src -COPY ./dev /usr/src/code/dev # Set Volumes RUN mkdir -p /storage/uploads && \ @@ -92,14 +88,30 @@ RUN chmod +x /usr/local/bin/doctor && \ chmod +x /usr/local/bin/stats-resources && \ chmod +x /usr/local/bin/worker-stats-resources -# Letsencrypt Permissions RUN mkdir -p /etc/letsencrypt/live/ && chmod -Rf 755 /etc/letsencrypt/live/ -# Enable Extensions -RUN if [ "$DEBUG" = "true" ]; then cp /usr/src/code/dev/xdebug.ini /usr/local/etc/php/conf.d/xdebug.ini; fi -RUN if [ "$DEBUG" = "true" ]; then mkdir -p /tmp/xdebug; fi -RUN if [ "$DEBUG" = "false" ]; then rm -rf /usr/src/code/dev; fi -RUN if [ "$DEBUG" = "false" ]; then rm -f /usr/local/lib/php/extensions/no-debug-non-zts-20230831/xdebug.so; fi +FROM base AS production + +RUN rm -rf /usr/src/code/app/config/specs && \ + rm -f /usr/local/lib/php/extensions/no-debug-non-zts-20240924/xdebug.so && \ + find /usr -name '*.a' -delete 2>/dev/null || true && \ + find /usr -type d -name '__pycache__' -exec rm -rf {} + 2>/dev/null || true && \ + find /usr -name '*.pyc' -delete 2>/dev/null || true + +EXPOSE 80 + +CMD [ "php", "app/http.php" ] + +FROM base AS development + +COPY ./docs /usr/src/code/docs +COPY ./dev /usr/src/code/dev + +RUN if [ "$DEBUG" = "true" ]; then \ + cp /usr/src/code/dev/xdebug.ini /usr/local/etc/php/conf.d/xdebug.ini && \ + mkdir -p /tmp/xdebug && \ + apk add --update --no-cache openssh-client github-cli; \ + fi EXPOSE 80 diff --git a/app/assets/dbip/dbip-country-lite-2024-09.mmdb b/app/assets/dbip/dbip-country-lite-2024-09.mmdb deleted file mode 100644 index 43d6bcdeea..0000000000 Binary files a/app/assets/dbip/dbip-country-lite-2024-09.mmdb and /dev/null differ diff --git a/app/assets/dbip/dbip-country-lite-2025-12.mmdb b/app/assets/dbip/dbip-country-lite-2025-12.mmdb new file mode 100644 index 0000000000..4ecabbf735 Binary files /dev/null and b/app/assets/dbip/dbip-country-lite-2025-12.mmdb differ diff --git a/app/cli.php b/app/cli.php index 71b6464cb9..7b65ab8fa5 100644 --- a/app/cli.php +++ b/app/cli.php @@ -9,6 +9,7 @@ use Appwrite\Event\StatsResources; use Appwrite\Event\StatsUsage; use Appwrite\Platform\Appwrite; use Appwrite\Runtimes\Runtimes; +use Appwrite\Utopia\Database\Documents\User; use Executor\Executor; use Swoole\Runtime; use Swoole\Timer; @@ -40,8 +41,6 @@ Config::setParam('runtimes', (new Runtimes('v5'))->getAll(supported: false)); // require controllers after overwriting runtimes require_once __DIR__ . '/controllers/general.php'; -Authorization::disable(); - CLI::setResource('register', fn () => $register); CLI::setResource('cache', function ($pools) { @@ -59,7 +58,13 @@ CLI::setResource('pools', function (Registry $register) { return $register->get('pools'); }, ['register']); -CLI::setResource('dbForPlatform', function ($pools, $cache) { +CLI::setResource('authorization', function () { + $authorization = new Authorization(); + $authorization->disable(); + return $authorization; +}, []); + +CLI::setResource('dbForPlatform', function ($pools, $cache, $authorization) { $sleep = 3; $maxAttempts = 5; $attempts = 0; @@ -73,9 +78,11 @@ CLI::setResource('dbForPlatform', function ($pools, $cache) { $dbForPlatform = new Database($adapter, $cache); $dbForPlatform + ->setAuthorization($authorization) ->setNamespace('_console') ->setMetadata('host', \gethostname()) ->setMetadata('project', 'console'); + $dbForPlatform->setDocumentType('users', User::class); // Ensure tables exist $collections = Config::getParam('collections', [])['console']; @@ -97,7 +104,7 @@ CLI::setResource('dbForPlatform', function ($pools, $cache) { } return $dbForPlatform; -}, ['pools', 'cache']); +}, ['pools', 'cache', 'authorization']); CLI::setResource('console', function () { return new Document(Config::getParam('console')); @@ -108,10 +115,10 @@ CLI::setResource( fn () => fn (Document $project, string $resourceType, ?string $resourceId) => false ); -CLI::setResource('getProjectDB', function (Group $pools, Database $dbForPlatform, $cache) { +CLI::setResource('getProjectDB', function (Group $pools, Database $dbForPlatform, $cache, $authorization) { $databases = []; // TODO: @Meldiron This should probably be responsibility of utopia-php/pools - return function (Document $project) use ($pools, $dbForPlatform, $cache, &$databases) { + return function (Document $project) use ($pools, $dbForPlatform, $cache, $authorization, &$databases) { if ($project->isEmpty() || $project->getId() === 'console') { return $dbForPlatform; } @@ -144,6 +151,7 @@ CLI::setResource('getProjectDB', function (Group $pools, Database $dbForPlatform $adapter = new DatabasePool($pools->get($dsn->getHost())); $database = new Database($adapter, $cache); + $databases[$dsn->getHost()] = $database; $sharedTables = \explode(',', System::getEnv('_APP_DATABASE_SHARED_TABLES', '')); @@ -160,17 +168,18 @@ CLI::setResource('getProjectDB', function (Group $pools, Database $dbForPlatform } $database + ->setAuthorization($authorization) ->setMetadata('host', \gethostname()) ->setMetadata('project', $project->getId()); return $database; }; -}, ['pools', 'dbForPlatform', 'cache']); +}, ['pools', 'dbForPlatform', 'cache', 'authorization']); -CLI::setResource('getLogsDB', function (Group $pools, Cache $cache) { +CLI::setResource('getLogsDB', function (Group $pools, Cache $cache, Authorization $authorization) { $database = null; - return function (?Document $project = null) use ($pools, $cache, $database) { + return function (?Document $project = null) use ($pools, $cache, $database, $authorization) { if ($database !== null && $project !== null && !$project->isEmpty() && $project->getId() !== 'console') { $database->setTenant((int)$project->getSequence()); return $database; @@ -180,6 +189,7 @@ CLI::setResource('getLogsDB', function (Group $pools, Cache $cache) { $database = new Database($adapter, $cache); $database + ->setAuthorization($authorization) ->setSharedTables(true) ->setNamespace('logsV1') ->setTimeout(APP_DATABASE_TIMEOUT_MILLISECONDS_TASK) @@ -192,7 +202,7 @@ CLI::setResource('getLogsDB', function (Group $pools, Cache $cache) { return $database; }; -}, ['pools', 'cache']); +}, ['pools', 'cache', 'authorization']); CLI::setResource('publisher', function (Group $pools) { return new BrokerPool(publisher: $pools->get('publisher')); }, ['pools']); diff --git a/app/config/collections/common.php b/app/config/collections/common.php index 6de7eb224b..a364a0a866 100644 --- a/app/config/collections/common.php +++ b/app/config/collections/common.php @@ -1,6 +1,6 @@ 256, 'signed' => true, 'required' => false, - 'default' => Auth::DEFAULT_ALGO, + 'default' => (new Argon2())->getName(), 'array' => false, 'filters' => [], ], @@ -184,7 +184,7 @@ return [ 'size' => 65535, 'signed' => true, 'required' => false, - 'default' => Auth::DEFAULT_ALGO_OPTIONS, + 'default' => (new Argon2())->getOptions(), 'array' => false, 'filters' => ['json'], ], @@ -364,6 +364,61 @@ return [ 'array' => false, 'filters' => ['datetime'], ], + [ + '$id' => ID::custom('emailCanonical'), + 'type' => Database::VAR_STRING, + 'format' => '', + 'size' => 320, + 'signed' => true, + 'required' => false, + 'default' => null, + 'array' => false, + 'filters' => [], + ], + [ + '$id' => ID::custom('emailIsFree'), + 'type' => Database::VAR_BOOLEAN, + 'format' => '', + 'size' => 0, + 'signed' => true, + 'required' => false, + 'default' => null, + 'array' => false, + 'filters' => [], + ], + [ + '$id' => ID::custom('emailIsDisposable'), + 'type' => Database::VAR_BOOLEAN, + 'format' => '', + 'size' => 0, + 'signed' => true, + 'required' => false, + 'default' => null, + 'array' => false, + 'filters' => [], + ], + [ + '$id' => ID::custom('emailIsCorporate'), + 'type' => Database::VAR_BOOLEAN, + 'format' => '', + 'size' => 0, + 'signed' => true, + 'required' => false, + 'default' => null, + 'array' => false, + 'filters' => [], + ], + [ + '$id' => ID::custom('emailIsCanonical'), + 'type' => Database::VAR_BOOLEAN, + 'format' => '', + 'size' => 0, + 'signed' => true, + 'required' => false, + 'default' => null, + 'array' => false, + 'filters' => [], + ], ], 'indexes' => [ [ @@ -1527,6 +1582,17 @@ return [ 'required' => true, 'array' => false, ], + [ + '$id' => ID::custom('transformations'), + 'type' => Database::VAR_BOOLEAN, + 'signed' => true, + 'size' => 0, + 'format' => '', + 'filters' => [], + 'required' => false, + 'array' => false, + 'default' => true, + ], [ '$id' => ID::custom('search'), 'type' => Database::VAR_STRING, diff --git a/app/config/collections/platform.php b/app/config/collections/platform.php index b839e51622..d44d9b725c 100644 --- a/app/config/collections/platform.php +++ b/app/config/collections/platform.php @@ -1317,6 +1317,17 @@ return [ 'array' => false, 'filters' => [], ], + [ + '$id' => ID::custom('logs'), + 'type' => Database::VAR_STRING, + 'format' => '', + 'size' => 1000000, + 'signed' => true, + 'required' => false, + 'default' => '', + 'array' => false, + 'filters' => [], + ], ], 'indexes' => [ [ diff --git a/app/config/collections/projects.php b/app/config/collections/projects.php index bf0cee3527..dae0337dc9 100644 --- a/app/config/collections/projects.php +++ b/app/config/collections/projects.php @@ -2345,7 +2345,7 @@ return [ '$id' => ID::custom('errors'), 'type' => Database::VAR_STRING, 'format' => '', - 'size' => 65535, + 'size' => 1_000_000, 'signed' => true, 'required' => true, 'default' => null, diff --git a/app/config/console.php b/app/config/console.php index f8f68a8039..5c4bf87614 100644 --- a/app/config/console.php +++ b/app/config/console.php @@ -4,7 +4,6 @@ * Initializes console project document. */ -use Appwrite\Auth\Auth; use Appwrite\Network\Platform; use Utopia\Database\Helpers\ID; use Utopia\System\System; @@ -38,7 +37,7 @@ $console = [ '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 - 'duration' => Auth::TOKEN_EXPIRATION_LOGIN_LONG, // 1 Year in seconds + 'duration' => TOKEN_EXPIRATION_LOGIN_LONG, // 1 Year in seconds 'sessionAlerts' => System::getEnv('_APP_CONSOLE_SESSION_ALERTS', 'disabled') === 'enabled', 'invalidateSessions' => true ], diff --git a/app/config/errors.php b/app/config/errors.php index 2e18f05797..6d747e4eb1 100644 --- a/app/config/errors.php +++ b/app/config/errors.php @@ -522,6 +522,11 @@ return [ 'description' => 'The requested file is not publicly readable.', 'code' => 403, ], + Exception::STORAGE_BUCKET_TRANSFORMATIONS_DISABLED => [ + 'name' => Exception::STORAGE_BUCKET_TRANSFORMATIONS_DISABLED, + 'description' => 'Image transformations are disabled for the requested bucket.', + 'code' => 403, + ], /** Tokens */ Exception::TOKEN_NOT_FOUND => [ @@ -683,12 +688,12 @@ return [ /** Databases */ Exception::DATABASE_NOT_FOUND => [ 'name' => Exception::DATABASE_NOT_FOUND, - 'description' => 'Database not found', + 'description' => 'Database with the requested ID \'%s\' could not be found.', 'code' => 404 ], Exception::DATABASE_ALREADY_EXISTS => [ 'name' => Exception::DATABASE_ALREADY_EXISTS, - 'description' => 'Database already exists', + 'description' => 'Database with the requested ID \'%s\' already exists. Try again with a different ID or use ID.unique() to generate a unique ID.', 'code' => 409 ], Exception::DATABASE_TIMEOUT => [ @@ -705,41 +710,41 @@ return [ /** Collections */ Exception::COLLECTION_NOT_FOUND => [ 'name' => Exception::COLLECTION_NOT_FOUND, - 'description' => 'Collection with the requested ID could not be found.', + 'description' => 'Collection with the requested ID \'%s\' could not be found.', 'code' => 404, ], Exception::COLLECTION_ALREADY_EXISTS => [ 'name' => Exception::COLLECTION_ALREADY_EXISTS, - 'description' => 'A collection with the requested ID already exists. Try again with a different ID or use ID.unique() to generate a unique ID.', + 'description' => 'A collection with the requested ID \'%s\' already exists. Try again with a different ID or use ID.unique() to generate a unique ID.', 'code' => 409, ], Exception::COLLECTION_LIMIT_EXCEEDED => [ 'name' => Exception::COLLECTION_LIMIT_EXCEEDED, - 'description' => 'The maximum number of collections has been reached.', + 'description' => 'The maximum number of collections for database \'%s\' has been reached.', 'code' => 400, ], /** Tables */ Exception::TABLE_NOT_FOUND => [ 'name' => Exception::TABLE_NOT_FOUND, - 'description' => 'Table with the requested ID could not be found.', + 'description' => 'Table with the requested ID \'%s\' could not be found.', 'code' => 404, ], Exception::TABLE_ALREADY_EXISTS => [ 'name' => Exception::TABLE_ALREADY_EXISTS, - 'description' => 'A table with the requested ID already exists. Try again with a different ID or use ID.unique() to generate a unique ID.', + 'description' => 'A table with the requested ID \'%s\' already exists. Try again with a different ID or use ID.unique() to generate a unique ID.', 'code' => 409, ], Exception::TABLE_LIMIT_EXCEEDED => [ 'name' => Exception::TABLE_LIMIT_EXCEEDED, - 'description' => 'The maximum number of tables has been reached.', + 'description' => 'The maximum number of tables for database \'%s\' has been reached.', 'code' => 400, ], /** Documents */ Exception::DOCUMENT_NOT_FOUND => [ 'name' => Exception::DOCUMENT_NOT_FOUND, - 'description' => 'Document with the requested ID could not be found.', + 'description' => 'Document with the requested ID \'%s\' could not be found.', 'code' => 404, ], Exception::DOCUMENT_INVALID_STRUCTURE => [ @@ -759,7 +764,7 @@ return [ ], Exception::DOCUMENT_ALREADY_EXISTS => [ 'name' => Exception::DOCUMENT_ALREADY_EXISTS, - 'description' => 'Document with the requested ID already exists. Try again with a different ID or use ID.unique() to generate a unique ID.', + 'description' => 'Document with the requested ID \'%s\' already exists. Try again with a different ID or use ID.unique() to generate a unique ID.', 'code' => 409, ], Exception::DOCUMENT_UPDATE_CONFLICT => [ @@ -776,7 +781,7 @@ return [ /** Rows */ Exception::ROW_NOT_FOUND => [ 'name' => Exception::ROW_NOT_FOUND, - 'description' => 'Row with the requested ID could not be found.', + 'description' => 'Row with the requested ID \'%s\' could not be found.', 'code' => 404, ], Exception::ROW_INVALID_STRUCTURE => [ @@ -786,7 +791,7 @@ return [ ], Exception::ROW_MISSING_DATA => [ 'name' => Exception::ROW_MISSING_DATA, - 'description' => 'The row data is missing. Try again with row data populated', + 'description' => 'The row data is missing. Try again with row data populated.', 'code' => 400, ], Exception::ROW_MISSING_PAYLOAD => [ @@ -796,7 +801,7 @@ return [ ], Exception::ROW_ALREADY_EXISTS => [ 'name' => Exception::ROW_ALREADY_EXISTS, - 'description' => 'Row with the requested ID already exists. Try again with a different ID or use ID.unique() to generate a unique ID.', + 'description' => 'Row with the requested ID \'%s\' already exists. Try again with a different ID or use ID.unique() to generate a unique ID.', 'code' => 409, ], Exception::ROW_UPDATE_CONFLICT => [ @@ -813,17 +818,17 @@ return [ /** Attributes */ Exception::ATTRIBUTE_NOT_FOUND => [ 'name' => Exception::ATTRIBUTE_NOT_FOUND, - 'description' => 'Attribute with the requested ID could not be found.', + 'description' => 'Attribute with the requested key \'%s\' could not be found.', 'code' => 404, ], Exception::ATTRIBUTE_UNKNOWN => [ 'name' => Exception::ATTRIBUTE_UNKNOWN, - 'description' => 'The attribute required for the index could not be found. Please confirm all your attributes are in the available state.', + 'description' => 'The attribute \'%s\' required for the index could not be found. Please confirm all your attributes are in the available state.', 'code' => 400, ], Exception::ATTRIBUTE_NOT_AVAILABLE => [ 'name' => Exception::ATTRIBUTE_NOT_AVAILABLE, - 'description' => 'The requested attribute is not yet available. Please try again later.', + 'description' => 'The requested attribute \'%s\' is not yet available. Please try again later.', 'code' => 400, ], Exception::ATTRIBUTE_FORMAT_UNSUPPORTED => [ @@ -838,12 +843,12 @@ return [ ], Exception::ATTRIBUTE_ALREADY_EXISTS => [ 'name' => Exception::ATTRIBUTE_ALREADY_EXISTS, - 'description' => 'Attribute with the requested key already exists. Attribute keys must be unique, try again with a different key.', + 'description' => 'Attribute with the requested key \'%s\' already exists. Attribute keys must be unique, try again with a different key.', 'code' => 409, ], Exception::ATTRIBUTE_LIMIT_EXCEEDED => [ 'name' => Exception::ATTRIBUTE_LIMIT_EXCEEDED, - 'description' => 'The maximum number or size of attributes for this collection has been reached.', + 'description' => 'The maximum number or size of attributes for collection \'%s\' has been reached.', 'code' => 400, ], Exception::ATTRIBUTE_VALUE_INVALID => [ @@ -853,7 +858,7 @@ return [ ], Exception::ATTRIBUTE_TYPE_INVALID => [ 'name' => Exception::ATTRIBUTE_TYPE_INVALID, - 'description' => 'The attribute type is invalid.', + 'description' => 'The attribute \'%s\' type is invalid.', 'code' => 400, ], Exception::ATTRIBUTE_INVALID_RESIZE => [ @@ -864,7 +869,7 @@ return [ Exception::ATTRIBUTE_TYPE_NOT_SUPPORTED => [ 'name' => Exception::ATTRIBUTE_TYPE_NOT_SUPPORTED, - 'description' => 'Attribute type is not supported.', + 'description' => 'Attribute type \'%s\' is not supported.', 'code' => 400, ], @@ -878,17 +883,17 @@ return [ /** Columns */ Exception::COLUMN_NOT_FOUND => [ 'name' => Exception::COLUMN_NOT_FOUND, - 'description' => 'Column with the requested ID could not be found.', + 'description' => 'Column with the requested key \'%s\' could not be found.', 'code' => 404, ], Exception::COLUMN_UNKNOWN => [ 'name' => Exception::COLUMN_UNKNOWN, - 'description' => 'The column required for the index could not be found. Please confirm all your columns are in the available state.', + 'description' => 'The column \'%s\' required for the index could not be found. Please confirm all your columns are in the available state.', 'code' => 400, ], Exception::COLUMN_NOT_AVAILABLE => [ 'name' => Exception::COLUMN_NOT_AVAILABLE, - 'description' => 'The requested column is not yet available. Please try again later.', + 'description' => 'The requested column \'%s\' is not yet available. Please try again later.', 'code' => 400, ], Exception::COLUMN_FORMAT_UNSUPPORTED => [ @@ -903,12 +908,12 @@ return [ ], Exception::COLUMN_ALREADY_EXISTS => [ 'name' => Exception::COLUMN_ALREADY_EXISTS, - 'description' => 'Column with the requested key already exists. Column keys must be unique, try again with a different key.', + 'description' => 'Column with the requested key \'%s\' already exists. Column keys must be unique, try again with a different key.', 'code' => 409, ], Exception::COLUMN_LIMIT_EXCEEDED => [ 'name' => Exception::COLUMN_LIMIT_EXCEEDED, - 'description' => 'The maximum number or size of columns for this table has been reached.', + 'description' => 'The maximum number or size of columns for table \'%s\' has been reached.', 'code' => 400, ], Exception::COLUMN_VALUE_INVALID => [ @@ -918,7 +923,7 @@ return [ ], Exception::COLUMN_TYPE_INVALID => [ 'name' => Exception::COLUMN_TYPE_INVALID, - 'description' => 'The column type is invalid.', + 'description' => 'The column \'%s\' type is invalid.', 'code' => 400, ], Exception::COLUMN_INVALID_RESIZE => [ @@ -928,24 +933,24 @@ return [ ], Exception::COLUMN_TYPE_NOT_SUPPORTED => [ 'name' => Exception::COLUMN_TYPE_NOT_SUPPORTED, - 'description' => 'Column type is not supported.', + 'description' => 'Column type \'%s\' is not supported.', 'code' => 400, ], /** Indexes */ Exception::INDEX_NOT_FOUND => [ 'name' => Exception::INDEX_NOT_FOUND, - 'description' => 'Index with the requested ID could not be found.', + 'description' => 'Index with the requested key \'%s\' could not be found.', 'code' => 404, ], Exception::INDEX_LIMIT_EXCEEDED => [ 'name' => Exception::INDEX_LIMIT_EXCEEDED, - 'description' => 'The maximum number of indexes has been reached.', + 'description' => 'The maximum number of indexes for collection \'%s\' has been reached.', 'code' => 400, ], Exception::INDEX_ALREADY_EXISTS => [ 'name' => Exception::INDEX_ALREADY_EXISTS, - 'description' => 'Index with the requested key already exists. Try again with a different key.', + 'description' => 'Index with the requested key \'%s\' already exists. Try again with a different key.', 'code' => 409, ], Exception::INDEX_INVALID => [ @@ -955,24 +960,24 @@ return [ ], Exception::INDEX_DEPENDENCY => [ 'name' => Exception::INDEX_DEPENDENCY, - 'description' => 'Attribute cannot be renamed or deleted. Please remove the associated index first.', + 'description' => 'Attribute \'%s\' cannot be renamed or deleted. Please remove the associated index first.', 'code' => 409, ], /** Column Indexes, same as Indexes but with different type */ Exception::COLUMN_INDEX_NOT_FOUND => [ 'name' => Exception::COLUMN_INDEX_NOT_FOUND, - 'description' => 'Index with the requested ID could not be found.', + 'description' => 'Index with the requested key \'%s\' could not be found.', 'code' => 404, ], Exception::COLUMN_INDEX_LIMIT_EXCEEDED => [ 'name' => Exception::COLUMN_INDEX_LIMIT_EXCEEDED, - 'description' => 'The maximum number of indexes has been reached.', + 'description' => 'The maximum number of indexes for table \'%s\' has been reached.', 'code' => 400, ], Exception::COLUMN_INDEX_ALREADY_EXISTS => [ 'name' => Exception::COLUMN_INDEX_ALREADY_EXISTS, - 'description' => 'Index with the requested key already exists. Try again with a different key.', + 'description' => 'Index with the requested key \'%s\' already exists. Try again with a different key.', 'code' => 409, ], Exception::COLUMN_INDEX_INVALID => [ @@ -982,19 +987,19 @@ return [ ], Exception::COLUMN_INDEX_DEPENDENCY => [ 'name' => Exception::COLUMN_INDEX_DEPENDENCY, - 'description' => 'Column cannot be renamed or deleted. Please remove the associated index first.', + 'description' => 'Column \'%s\' cannot be renamed or deleted. Please remove the associated index first.', 'code' => 409, ], /** Transactions */ Exception::TRANSACTION_NOT_FOUND => [ 'name' => Exception::TRANSACTION_NOT_FOUND, - 'description' => 'Transaction with the requested ID could not be found.', + 'description' => 'Transaction with the requested ID \'%s\' could not be found.', 'code' => 404, ], Exception::TRANSACTION_ALREADY_EXISTS => [ 'name' => Exception::TRANSACTION_ALREADY_EXISTS, - 'description' => 'Transaction with the requested ID already exists. Try again with a different ID or use ID.unique() to generate a unique ID.', + 'description' => 'Transaction with the requested ID \'%s\' already exists. Try again with a different ID or use ID.unique() to generate a unique ID.', 'code' => 409, ], Exception::TRANSACTION_INVALID => [ diff --git a/app/config/frameworks.php b/app/config/frameworks.php index f4d8ec7ffa..6078c53c63 100644 --- a/app/config/frameworks.php +++ b/app/config/frameworks.php @@ -8,20 +8,13 @@ use Utopia\Config\Config; $templateRuntimes = Config::getParam('template-runtimes'); -function getVersions(array $versions, string $prefix) -{ - return array_map(function ($version) use ($prefix) { - return $prefix . '-' . $version; - }, $versions); -} - return [ 'analog' => [ 'key' => 'analog', 'name' => 'Analog', 'screenshotSleep' => 3000, 'buildRuntime' => 'node-22', - 'runtimes' => getVersions($templateRuntimes['NODE']['versions'], 'node'), + 'runtimes' => $templateRuntimes['NODE'], 'bundleCommand' => 'bash /usr/local/server/helpers/analog/bundle.sh', 'envCommand' => 'source /usr/local/server/helpers/analog/env.sh', 'adapters' => [ @@ -47,7 +40,7 @@ return [ 'name' => 'Angular', 'screenshotSleep' => 3000, 'buildRuntime' => 'node-22', - 'runtimes' => getVersions($templateRuntimes['NODE']['versions'], 'node'), + 'runtimes' => $templateRuntimes['NODE'], 'bundleCommand' => 'bash /usr/local/server/helpers/angular/bundle.sh', 'envCommand' => 'source /usr/local/server/helpers/angular/env.sh', 'adapters' => [ @@ -73,7 +66,7 @@ return [ 'name' => 'Next.js', 'screenshotSleep' => 3000, 'buildRuntime' => 'node-22', - 'runtimes' => getVersions($templateRuntimes['NODE']['versions'], 'node'), + 'runtimes' => $templateRuntimes['NODE'], 'bundleCommand' => 'bash /usr/local/server/helpers/next-js/bundle.sh', 'envCommand' => 'source /usr/local/server/helpers/next-js/env.sh', 'adapters' => [ @@ -98,7 +91,7 @@ return [ 'name' => 'React', 'screenshotSleep' => 3000, 'buildRuntime' => 'node-22', - 'runtimes' => getVersions($templateRuntimes['NODE']['versions'], 'node'), + 'runtimes' => $templateRuntimes['NODE'], 'adapters' => [ 'static' => [ 'key' => 'static', @@ -115,7 +108,7 @@ return [ 'name' => 'Nuxt', 'screenshotSleep' => 3000, 'buildRuntime' => 'node-22', - 'runtimes' => getVersions($templateRuntimes['NODE']['versions'], 'node'), + 'runtimes' => $templateRuntimes['NODE'], 'bundleCommand' => 'bash /usr/local/server/helpers/nuxt/bundle.sh', 'envCommand' => 'source /usr/local/server/helpers/nuxt/env.sh', 'adapters' => [ @@ -140,7 +133,7 @@ return [ 'name' => 'Vue.js', 'screenshotSleep' => 5000, 'buildRuntime' => 'node-22', - 'runtimes' => getVersions($templateRuntimes['NODE']['versions'], 'node'), + 'runtimes' => $templateRuntimes['NODE'], 'adapters' => [ 'static' => [ 'key' => 'static', @@ -157,7 +150,7 @@ return [ 'name' => 'SvelteKit', 'screenshotSleep' => 3000, 'buildRuntime' => 'node-22', - 'runtimes' => getVersions($templateRuntimes['NODE']['versions'], 'node'), + 'runtimes' => $templateRuntimes['NODE'], 'bundleCommand' => 'bash /usr/local/server/helpers/sveltekit/bundle.sh', 'envCommand' => 'source /usr/local/server/helpers/sveltekit/env.sh', 'adapters' => [ @@ -182,7 +175,7 @@ return [ 'name' => 'Astro', 'screenshotSleep' => 3000, 'buildRuntime' => 'node-22', - 'runtimes' => getVersions($templateRuntimes['NODE']['versions'], 'node'), + 'runtimes' => $templateRuntimes['NODE'], 'bundleCommand' => 'bash /usr/local/server/helpers/astro/bundle.sh', 'envCommand' => 'source /usr/local/server/helpers/astro/env.sh', 'adapters' => [ @@ -202,12 +195,37 @@ return [ ] ] ], + 'tanstack-start' => [ + 'key' => 'tanstack-start', + 'name' => 'TanStack Start', + 'screenshotSleep' => 3000, + 'buildRuntime' => 'node-22', + 'runtimes' => $templateRuntimes['NODE'], + 'bundleCommand' => 'bash /usr/local/server/helpers/tanstack-start/bundle.sh', + 'envCommand' => 'source /usr/local/server/helpers/tanstack-start/env.sh', + 'adapters' => [ + 'ssr' => [ + 'key' => 'ssr', + 'buildCommand' => 'npm run build', + 'installCommand' => 'npm install', + 'outputDirectory' => './.output', + 'startCommand' => 'bash helpers/tanstack-start/server.sh', + ], + 'static' => [ + 'key' => 'static', + 'buildCommand' => 'npm run build', + 'installCommand' => 'npm install', + 'outputDirectory' => './dist/client', + 'startCommand' => 'bash helpers/server.sh', + ] + ] + ], 'remix' => [ 'key' => 'remix', 'name' => 'Remix', 'screenshotSleep' => 3000, 'buildRuntime' => 'node-22', - 'runtimes' => getVersions($templateRuntimes['NODE']['versions'], 'node'), + 'runtimes' => $templateRuntimes['NODE'], 'bundleCommand' => 'bash /usr/local/server/helpers/remix/bundle.sh', 'envCommand' => 'source /usr/local/server/helpers/remix/env.sh', 'adapters' => [ @@ -232,7 +250,7 @@ return [ 'name' => 'Lynx', 'screenshotSleep' => 5000, 'buildRuntime' => 'node-22', - 'runtimes' => getVersions($templateRuntimes['NODE']['versions'], 'node'), + 'runtimes' => $templateRuntimes['NODE'], 'adapters' => [ 'static' => [ 'key' => 'static', @@ -248,8 +266,8 @@ return [ 'key' => 'flutter', 'name' => 'Flutter', 'screenshotSleep' => 5000, - 'buildRuntime' => 'flutter-3.29', - 'runtimes' => getVersions($templateRuntimes['FLUTTER']['versions'], 'flutter'), + 'buildRuntime' => 'flutter-3.35', + 'runtimes' => $templateRuntimes['FLUTTER'], 'adapters' => [ 'static' => [ 'key' => 'static', @@ -257,6 +275,7 @@ return [ 'installCommand' => 'flutter pub get', 'outputDirectory' => './build/web', 'startCommand' => 'bash helpers/server.sh', + 'fallbackFile' => 'index.html' ], ], ], @@ -265,7 +284,7 @@ return [ 'name' => 'React Native', 'screenshotSleep' => 3000, 'buildRuntime' => 'node-22', - 'runtimes' => getVersions($templateRuntimes['NODE']['versions'], 'node'), + 'runtimes' => $templateRuntimes['NODE'], 'adapters' => [ 'static' => [ 'key' => 'static', @@ -282,7 +301,7 @@ return [ 'name' => 'Vite', 'screenshotSleep' => 3000, 'buildRuntime' => 'node-22', - 'runtimes' => getVersions($templateRuntimes['NODE']['versions'], 'node'), + 'runtimes' => $templateRuntimes['NODE'], 'adapters' => [ 'static' => [ 'key' => 'static', @@ -298,7 +317,7 @@ return [ 'name' => 'Other', 'screenshotSleep' => 3000, 'buildRuntime' => 'node-22', - 'runtimes' => getVersions($templateRuntimes['NODE']['versions'], 'node'), + 'runtimes' => $templateRuntimes['NODE'], 'adapters' => [ 'static' => [ 'key' => 'static', diff --git a/app/config/locale/templates/email-base-styled.tpl b/app/config/locale/templates/email-base-styled.tpl index 37ca630d43..81964b968f 100644 --- a/app/config/locale/templates/email-base-styled.tpl +++ b/app/config/locale/templates/email-base-styled.tpl @@ -2,6 +2,38 @@ + + + - + +
+

Themed website

+

Adaptive light and dark mode showcase

+
Appwrite Sites
+
+ diff --git a/tests/resources/sites/static-themed/screenshot-dark.png b/tests/resources/sites/static-themed/screenshot-dark.png new file mode 100644 index 0000000000..199a09b902 Binary files /dev/null and b/tests/resources/sites/static-themed/screenshot-dark.png differ diff --git a/tests/resources/sites/static-themed/screenshot-light.png b/tests/resources/sites/static-themed/screenshot-light.png new file mode 100644 index 0000000000..4eae73c5b7 Binary files /dev/null and b/tests/resources/sites/static-themed/screenshot-light.png differ diff --git a/tests/unit/Auth/AuthTest.php b/tests/unit/Auth/AuthTest.php deleted file mode 100644 index 705da42879..0000000000 --- a/tests/unit/Auth/AuthTest.php +++ /dev/null @@ -1,502 +0,0 @@ -toString()); - } - - public function testCookieName(): void - { - $name = 'cookie-name'; - - $this->assertEquals(Auth::setCookieName($name), $name); - $this->assertEquals(Auth::$cookieName, $name); - } - - public function testEncodeDecodeSession(): void - { - $id = 'id'; - $secret = 'secret'; - $session = 'eyJpZCI6ImlkIiwic2VjcmV0Ijoic2VjcmV0In0='; - - $this->assertEquals(Auth::encodeSession($id, $secret), $session); - $this->assertEquals(Auth::decodeSession($session), ['id' => $id, 'secret' => $secret]); - } - - public function testHash(): void - { - $secret = 'secret'; - $this->assertEquals(Auth::hash($secret), '2bb80d537b1da3e38bd30361aa855686bde0eacd7162fef6a25fe97bf527a25b'); - } - - public function testPassword(): void - { - /* - General tests, using pre-defined hashes generated by online tools - */ - - // Bcrypt - Version Y - $plain = 'secret'; - $hash = '$2y$08$PDbMtV18J1KOBI9tIYabBuyUwBrtXPGhLxCy9pWP6xkldVOKLrLKy'; - $generatedHash = Auth::passwordHash($plain, 'bcrypt'); - $this->assertEquals(true, Auth::passwordVerify($plain, $generatedHash, 'bcrypt')); - $this->assertEquals(true, Auth::passwordVerify($plain, $hash, 'bcrypt')); - $this->assertEquals(false, Auth::passwordVerify('wrongPassword', $hash, 'bcrypt')); - - // Bcrypt - Version A - $plain = 'test123'; - $hash = '$2a$12$3f2ZaARQ1AmhtQWx2nmQpuXcWfTj1YV2/Hl54e8uKxIzJe3IfwLiu'; - $generatedHash = Auth::passwordHash($plain, 'bcrypt'); - $this->assertEquals(true, Auth::passwordVerify($plain, $generatedHash, 'bcrypt')); - $this->assertEquals(true, Auth::passwordVerify($plain, $hash, 'bcrypt')); - $this->assertEquals(false, Auth::passwordVerify('wrongPassword', $hash, 'bcrypt')); - - // Bcrypt - Cost 5 - $plain = 'hello-world'; - $hash = '$2a$05$IjrtSz6SN7UJ6Sh3l.b5jODEvEG2LMJTPAHIaLWRvlWx7if3VMkFO'; - $generatedHash = Auth::passwordHash($plain, 'bcrypt'); - $this->assertEquals(true, Auth::passwordVerify($plain, $generatedHash, 'bcrypt')); - $this->assertEquals(true, Auth::passwordVerify($plain, $hash, 'bcrypt')); - $this->assertEquals(false, Auth::passwordVerify('wrongPassword', $hash, 'bcrypt')); - - // Bcrypt - Cost 15 - $plain = 'super-secret-password'; - $hash = '$2a$15$DS0ZzbsFZYumH/E4Qj5oeOHnBcM3nCCsCA2m4Goigat/0iMVQC4Na'; - $generatedHash = Auth::passwordHash($plain, 'bcrypt'); - $this->assertEquals(true, Auth::passwordVerify($plain, $generatedHash, 'bcrypt')); - $this->assertEquals(true, Auth::passwordVerify($plain, $hash, 'bcrypt')); - $this->assertEquals(false, Auth::passwordVerify('wrongPassword', $hash, 'bcrypt')); - - // MD5 - Short - $plain = 'appwrite'; - $hash = '144fa7eaa4904e8ee120651997f70dcc'; - $generatedHash = Auth::passwordHash($plain, 'md5'); - $this->assertEquals(true, Auth::passwordVerify($plain, $generatedHash, 'md5')); - $this->assertEquals(true, Auth::passwordVerify($plain, $hash, 'md5')); - $this->assertEquals(false, Auth::passwordVerify('wrongPassword', $hash, 'md5')); - - // MD5 - Long - $plain = 'AppwriteIsAwesomeBackendAsAServiceThatIsAlsoOpenSourced'; - $hash = '8410e96cf7ac64e0b84c3f8517a82616'; - $generatedHash = Auth::passwordHash($plain, 'md5'); - $this->assertEquals(true, Auth::passwordVerify($plain, $generatedHash, 'md5')); - $this->assertEquals(true, Auth::passwordVerify($plain, $hash, 'md5')); - $this->assertEquals(false, Auth::passwordVerify('wrongPassword', $hash, 'md5')); - - // PHPass - $plain = 'pass123'; - $hash = '$P$BVKPmJBZuLch27D4oiMRTEykGLQ9tX0'; - $generatedHash = Auth::passwordHash($plain, 'phpass'); - $this->assertEquals(true, Auth::passwordVerify($plain, $generatedHash, 'phpass')); - $this->assertEquals(true, Auth::passwordVerify($plain, $hash, 'phpass')); - $this->assertEquals(false, Auth::passwordVerify('wrongPassword', $hash, 'phpass')); - - // SHA - $plain = 'developersAreAwesome!'; - $hash = '2455118438cb125354b89bb5888346e9bd23355462c40df393fab514bf2220b5a08e4e2d7b85d7327595a450d0ac965cc6661152a46a157c66d681bed20a4735'; - $generatedHash = Auth::passwordHash($plain, 'sha'); - $this->assertEquals(true, Auth::passwordVerify($plain, $generatedHash, 'sha')); - $this->assertEquals(true, Auth::passwordVerify($plain, $hash, 'sha')); - $this->assertEquals(false, Auth::passwordVerify('wrongPassword', $hash, 'sha')); - - // Argon2 - $plain = 'safe-argon-password'; - $hash = '$argon2id$v=19$m=2048,t=3,p=4$MWc5NWRmc2QxZzU2$41mp7rSgBZ49YxLbbxIac7aRaxfp5/e1G45ckwnK0g8'; - $generatedHash = Auth::passwordHash($plain, 'argon2'); - $this->assertEquals(true, Auth::passwordVerify($plain, $generatedHash, 'argon2')); - $this->assertEquals(true, Auth::passwordVerify($plain, $hash, 'argon2')); - $this->assertEquals(false, Auth::passwordVerify('wrongPassword', $hash, 'argon2')); - - // Scrypt - $plain = 'some-scrypt-password'; - $hash = 'b448ad7ba88b653b5b56b8053a06806724932d0751988bc9cd0ef7ff059e8ba8a020e1913b7069a650d3f99a1559aba0221f2c277826919513a054e76e339028'; - $generatedHash = Auth::passwordHash($plain, 'scrypt', [ 'salt' => 'some-salt', 'length' => 64, 'costCpu' => 16384, 'costMemory' => 12, 'costParallel' => 2]); - - $this->assertEquals(true, Auth::passwordVerify($plain, $generatedHash, 'scrypt', [ 'salt' => 'some-salt', 'length' => 64, 'costCpu' => 16384, 'costMemory' => 12, 'costParallel' => 2])); - $this->assertEquals(true, Auth::passwordVerify($plain, $hash, 'scrypt', [ 'salt' => 'some-salt', 'length' => 64, 'costCpu' => 16384, 'costMemory' => 12, 'costParallel' => 2])); - $this->assertEquals(false, Auth::passwordVerify($plain, $hash, 'scrypt', [ 'salt' => 'some-wrong-salt', 'length' => 64, 'costCpu' => 16384, 'costMemory' => 12, 'costParallel' => 2])); - $this->assertEquals(false, Auth::passwordVerify($plain, $hash, 'scrypt', [ 'salt' => 'some-salt', 'length' => 64, 'costCpu' => 16384, 'costMemory' => 10, 'costParallel' => 2])); - $this->assertEquals(false, Auth::passwordVerify('wrongPassword', $hash, 'scrypt', [ 'salt' => 'some-salt', 'length' => 64, 'costCpu' => 16384, 'costMemory' => 12, 'costParallel' => 2])); - - // ScryptModified tested are in provider-specific tests below - - /* - Provider-specific tests, ensuring functionality of specific use-cases - */ - - // Provider #1 (Database) - $plain = 'example-password'; - $hash = '$2a$10$3bIGRWUes86CICsuchGLj.e.BqdCdg2/1Ud9LvBhJr0j7Dze8PBdS'; - $generatedHash = Auth::passwordHash($plain, 'bcrypt'); - $this->assertEquals(true, Auth::passwordVerify($plain, $generatedHash, 'bcrypt')); - $this->assertEquals(true, Auth::passwordVerify($plain, $hash, 'bcrypt')); - $this->assertEquals(false, Auth::passwordVerify('wrongPassword', $hash, 'bcrypt')); - - // Provider #2 (Blog) - $plain = 'your-password'; - $hash = '$P$BkiNDJTpAWXtpaMhEUhUdrv7M0I1g6.'; - $generatedHash = Auth::passwordHash($plain, 'phpass'); - $this->assertEquals(true, Auth::passwordVerify($plain, $generatedHash, 'phpass')); - $this->assertEquals(true, Auth::passwordVerify($plain, $hash, 'phpass')); - $this->assertEquals(false, Auth::passwordVerify('wrongPassword', $hash, 'phpass')); - - // Provider #2 (Google) - $plain = 'users-password'; - $hash = 'EPKgfALpS9Tvgr/y1ki7ubY4AEGJeWL3teakrnmOacN4XGiyD00lkzEHgqCQ71wGxoi/zb7Y9a4orOtvMV3/Jw=='; - $salt = '56dFqW+kswqktw=='; - $saltSeparator = 'Bw=='; - $signerKey = 'XyEKE9RcTDeLEsL/RjwPDBv/RqDl8fb3gpYEOQaPihbxf1ZAtSOHCjuAAa7Q3oHpCYhXSN9tizHgVOwn6krflQ=='; - - $options = [ 'salt' => $salt, 'saltSeparator' => $saltSeparator, 'signerKey' => $signerKey ]; - $generatedHash = Auth::passwordHash($plain, 'scryptMod', $options); - $this->assertEquals(true, Auth::passwordVerify($plain, $generatedHash, 'scryptMod', $options)); - $this->assertEquals(true, Auth::passwordVerify($plain, $hash, 'scryptMod', $options)); - $this->assertEquals(false, Auth::passwordVerify('wrongPassword', $hash, 'scryptMod', $options)); - } - - public function testUnknownAlgo() - { - $this->expectExceptionMessage('Hashing algorithm \'md8\' is not supported.'); - - // Bcrypt - Cost 5 - $plain = 'whatIsMd8?!?'; - $generatedHash = Auth::passwordHash($plain, 'md8'); - $this->assertEquals(true, Auth::passwordVerify($plain, $generatedHash, 'md8')); - } - - public function testPasswordGenerator(): void - { - $this->assertEquals(\mb_strlen(Auth::passwordGenerator()), 40); - $this->assertEquals(\mb_strlen(Auth::passwordGenerator(5)), 10); - } - - public function testTokenGenerator(): void - { - $this->assertEquals(\strlen(Auth::tokenGenerator()), 256); - $this->assertEquals(\strlen(Auth::tokenGenerator(5)), 5); - } - - public function testCodeGenerator(): void - { - $this->assertEquals(6, \strlen(Auth::codeGenerator())); - $this->assertEquals(\mb_strlen(Auth::codeGenerator(256)), 256); - $this->assertEquals(\mb_strlen(Auth::codeGenerator(10)), 10); - $this->assertTrue(is_numeric(Auth::codeGenerator(5))); - } - - public function testSessionVerify(): void - { - $expireTime1 = 60 * 60 * 24; - - $secret = 'secret1'; - $hash = Auth::hash($secret); - $tokens1 = [ - new Document([ - '$id' => ID::custom('token1'), - 'secret' => $hash, - 'provider' => Auth::SESSION_PROVIDER_EMAIL, - 'providerUid' => 'test@example.com', - 'expire' => DateTime::addSeconds(new \DateTime(), $expireTime1), - ]), - new Document([ - '$id' => ID::custom('token2'), - 'secret' => 'secret2', - 'provider' => Auth::SESSION_PROVIDER_EMAIL, - 'providerUid' => 'test@example.com', - 'expire' => DateTime::addSeconds(new \DateTime(), $expireTime1), - ]), - ]; - - $expireTime2 = -60 * 60 * 24; - - $tokens2 = [ - new Document([ // Correct secret and type time, wrong expire time - '$id' => ID::custom('token1'), - 'secret' => $hash, - 'provider' => Auth::SESSION_PROVIDER_EMAIL, - 'providerUid' => 'test@example.com', - 'expire' => DateTime::addSeconds(new \DateTime(), $expireTime2), - ]), - new Document([ - '$id' => ID::custom('token2'), - 'secret' => 'secret2', - 'provider' => Auth::SESSION_PROVIDER_EMAIL, - 'providerUid' => 'test@example.com', - 'expire' => DateTime::addSeconds(new \DateTime(), $expireTime2), - ]), - ]; - - $this->assertEquals(Auth::sessionVerify($tokens1, $secret), 'token1'); - $this->assertEquals(Auth::sessionVerify($tokens1, 'false-secret'), false); - $this->assertEquals(Auth::sessionVerify($tokens2, $secret), false); - $this->assertEquals(Auth::sessionVerify($tokens2, 'false-secret'), false); - } - - public function testTokenVerify(): void - { - $secret = 'secret1'; - $hash = Auth::hash($secret); - $tokens1 = [ - new Document([ - '$id' => ID::custom('token1'), - 'type' => Auth::TOKEN_TYPE_RECOVERY, - 'expire' => DateTime::formatTz(DateTime::addSeconds(new \DateTime(), 60 * 60 * 24)), - 'secret' => $hash, - ]), - new Document([ - '$id' => ID::custom('token2'), - 'type' => Auth::TOKEN_TYPE_RECOVERY, - 'expire' => DateTime::formatTz(DateTime::addSeconds(new \DateTime(), -60 * 60 * 24)), - 'secret' => 'secret2', - ]), - ]; - - $tokens2 = [ - new Document([ // Correct secret and type time, wrong expire time - '$id' => ID::custom('token1'), - 'type' => Auth::TOKEN_TYPE_RECOVERY, - 'expire' => DateTime::formatTz(DateTime::addSeconds(new \DateTime(), -60 * 60 * 24)), - 'secret' => $hash, - ]), - new Document([ - '$id' => ID::custom('token2'), - 'type' => Auth::TOKEN_TYPE_RECOVERY, - 'expire' => DateTime::formatTz(DateTime::addSeconds(new \DateTime(), -60 * 60 * 24)), - 'secret' => 'secret2', - ]), - ]; - - $tokens3 = [ // Correct secret and expire time, wrong type - new Document([ - '$id' => ID::custom('token1'), - 'type' => Auth::TOKEN_TYPE_INVITE, - 'expire' => DateTime::formatTz(DateTime::addSeconds(new \DateTime(), 60 * 60 * 24)), - 'secret' => $hash, - ]), - new Document([ - '$id' => ID::custom('token2'), - 'type' => Auth::TOKEN_TYPE_RECOVERY, - 'expire' => DateTime::formatTz(DateTime::addSeconds(new \DateTime(), -60 * 60 * 24)), - 'secret' => 'secret2', - ]), - ]; - - $this->assertEquals(Auth::tokenVerify($tokens1, Auth::TOKEN_TYPE_RECOVERY, $secret), $tokens1[0]); - $this->assertEquals(Auth::tokenVerify($tokens1, null, $secret), $tokens1[0]); - $this->assertEquals(Auth::tokenVerify($tokens1, Auth::TOKEN_TYPE_RECOVERY, 'false-secret'), false); - $this->assertEquals(Auth::tokenVerify($tokens2, Auth::TOKEN_TYPE_RECOVERY, $secret), false); - $this->assertEquals(Auth::tokenVerify($tokens2, Auth::TOKEN_TYPE_RECOVERY, 'false-secret'), false); - $this->assertEquals(Auth::tokenVerify($tokens3, Auth::TOKEN_TYPE_RECOVERY, $secret), false); - $this->assertEquals(Auth::tokenVerify($tokens3, Auth::TOKEN_TYPE_RECOVERY, 'false-secret'), false); - } - - public function testIsPrivilegedUser(): void - { - $this->assertEquals(false, Auth::isPrivilegedUser([])); - $this->assertEquals(false, Auth::isPrivilegedUser([Role::guests()->toString()])); - $this->assertEquals(false, Auth::isPrivilegedUser([Role::users()->toString()])); - $this->assertEquals(true, Auth::isPrivilegedUser([Auth::USER_ROLE_ADMIN])); - $this->assertEquals(true, Auth::isPrivilegedUser([Auth::USER_ROLE_DEVELOPER])); - $this->assertEquals(true, Auth::isPrivilegedUser([Auth::USER_ROLE_OWNER])); - $this->assertEquals(false, Auth::isPrivilegedUser([Auth::USER_ROLE_APPS])); - $this->assertEquals(false, Auth::isPrivilegedUser([Auth::USER_ROLE_SYSTEM])); - - $this->assertEquals(false, Auth::isPrivilegedUser([Auth::USER_ROLE_APPS, Auth::USER_ROLE_APPS])); - $this->assertEquals(false, Auth::isPrivilegedUser([Auth::USER_ROLE_APPS, Role::guests()->toString()])); - $this->assertEquals(true, Auth::isPrivilegedUser([Auth::USER_ROLE_OWNER, Role::guests()->toString()])); - $this->assertEquals(true, Auth::isPrivilegedUser([Auth::USER_ROLE_OWNER, Auth::USER_ROLE_ADMIN, Auth::USER_ROLE_DEVELOPER])); - } - - public function testIsAppUser(): void - { - $this->assertEquals(false, Auth::isAppUser([])); - $this->assertEquals(false, Auth::isAppUser([Role::guests()->toString()])); - $this->assertEquals(false, Auth::isAppUser([Role::users()->toString()])); - $this->assertEquals(false, Auth::isAppUser([Auth::USER_ROLE_ADMIN])); - $this->assertEquals(false, Auth::isAppUser([Auth::USER_ROLE_DEVELOPER])); - $this->assertEquals(false, Auth::isAppUser([Auth::USER_ROLE_OWNER])); - $this->assertEquals(true, Auth::isAppUser([Auth::USER_ROLE_APPS])); - $this->assertEquals(false, Auth::isAppUser([Auth::USER_ROLE_SYSTEM])); - - $this->assertEquals(true, Auth::isAppUser([Auth::USER_ROLE_APPS, Auth::USER_ROLE_APPS])); - $this->assertEquals(true, Auth::isAppUser([Auth::USER_ROLE_APPS, Role::guests()->toString()])); - $this->assertEquals(false, Auth::isAppUser([Auth::USER_ROLE_OWNER, Role::guests()->toString()])); - $this->assertEquals(false, Auth::isAppUser([Auth::USER_ROLE_OWNER, Auth::USER_ROLE_ADMIN, Auth::USER_ROLE_DEVELOPER])); - } - - public function testGuestRoles(): void - { - $user = new Document([ - '$id' => '' - ]); - - $roles = Auth::getRoles($user); - $this->assertCount(1, $roles); - $this->assertContains(Role::guests()->toString(), $roles); - } - - public function testUserRoles(): void - { - $user = new Document([ - '$id' => ID::custom('123'), - 'labels' => [ - 'vip', - 'admin' - ], - 'emailVerification' => true, - 'phoneVerification' => true, - 'memberships' => [ - [ - '$id' => ID::custom('456'), - 'teamId' => ID::custom('abc'), - 'confirm' => true, - 'roles' => [ - 'administrator', - 'moderator' - ] - ], - [ - '$id' => ID::custom('abc'), - 'teamId' => ID::custom('def'), - 'confirm' => true, - 'roles' => [ - 'guest' - ] - ] - ] - ]); - - $roles = Auth::getRoles($user); - - $this->assertCount(13, $roles); - $this->assertContains(Role::users()->toString(), $roles); - $this->assertContains(Role::user(ID::custom('123'))->toString(), $roles); - $this->assertContains(Role::users(Roles::DIMENSION_VERIFIED)->toString(), $roles); - $this->assertContains(Role::user(ID::custom('123'), Roles::DIMENSION_VERIFIED)->toString(), $roles); - $this->assertContains(Role::team(ID::custom('abc'))->toString(), $roles); - $this->assertContains(Role::team(ID::custom('abc'), 'administrator')->toString(), $roles); - $this->assertContains(Role::team(ID::custom('abc'), 'moderator')->toString(), $roles); - $this->assertContains(Role::team(ID::custom('def'))->toString(), $roles); - $this->assertContains(Role::team(ID::custom('def'), 'guest')->toString(), $roles); - $this->assertContains(Role::member(ID::custom('456'))->toString(), $roles); - $this->assertContains(Role::member(ID::custom('abc'))->toString(), $roles); - $this->assertContains('label:vip', $roles); - $this->assertContains('label:admin', $roles); - - // Disable all verification - $user['emailVerification'] = false; - $user['phoneVerification'] = false; - - $roles = Auth::getRoles($user); - $this->assertContains(Role::users(Roles::DIMENSION_UNVERIFIED)->toString(), $roles); - $this->assertContains(Role::user(ID::custom('123'), Roles::DIMENSION_UNVERIFIED)->toString(), $roles); - - // Enable single verification type - $user['emailVerification'] = true; - - $roles = Auth::getRoles($user); - $this->assertContains(Role::users(Roles::DIMENSION_VERIFIED)->toString(), $roles); - $this->assertContains(Role::user(ID::custom('123'), Roles::DIMENSION_VERIFIED)->toString(), $roles); - } - - public function testPrivilegedUserRoles(): void - { - Authorization::setRole(Auth::USER_ROLE_OWNER); - $user = new Document([ - '$id' => ID::custom('123'), - 'emailVerification' => true, - 'phoneVerification' => true, - 'memberships' => [ - [ - '$id' => ID::custom('def'), - 'teamId' => ID::custom('abc'), - 'confirm' => true, - 'roles' => [ - 'administrator', - 'moderator' - ] - ], - [ - '$id' => ID::custom('abc'), - 'teamId' => ID::custom('def'), - 'confirm' => true, - 'roles' => [ - 'guest' - ] - ] - ] - ]); - - $roles = Auth::getRoles($user); - - $this->assertCount(7, $roles); - $this->assertNotContains(Role::users()->toString(), $roles); - $this->assertNotContains(Role::user(ID::custom('123'))->toString(), $roles); - $this->assertNotContains(Role::users(Roles::DIMENSION_VERIFIED)->toString(), $roles); - $this->assertNotContains(Role::user(ID::custom('123'), Roles::DIMENSION_VERIFIED)->toString(), $roles); - $this->assertContains(Role::team(ID::custom('abc'))->toString(), $roles); - $this->assertContains(Role::team(ID::custom('abc'), 'administrator')->toString(), $roles); - $this->assertContains(Role::team(ID::custom('abc'), 'moderator')->toString(), $roles); - $this->assertContains(Role::team(ID::custom('def'))->toString(), $roles); - $this->assertContains(Role::team(ID::custom('def'), 'guest')->toString(), $roles); - $this->assertContains(Role::member(ID::custom('def'))->toString(), $roles); - $this->assertContains(Role::member(ID::custom('abc'))->toString(), $roles); - } - - public function testAppUserRoles(): void - { - Authorization::setRole(Auth::USER_ROLE_APPS); - $user = new Document([ - '$id' => ID::custom('123'), - 'memberships' => [ - [ - '$id' => ID::custom('def'), - 'teamId' => ID::custom('abc'), - 'confirm' => true, - 'roles' => [ - 'administrator', - 'moderator' - ] - ], - [ - '$id' => ID::custom('abc'), - 'teamId' => ID::custom('def'), - 'confirm' => true, - 'roles' => [ - 'guest' - ] - ] - ] - ]); - - $roles = Auth::getRoles($user); - - $this->assertCount(7, $roles); - $this->assertNotContains(Role::users()->toString(), $roles); - $this->assertNotContains(Role::user(ID::custom('123'))->toString(), $roles); - $this->assertContains(Role::team(ID::custom('abc'))->toString(), $roles); - $this->assertContains(Role::team(ID::custom('abc'), 'administrator')->toString(), $roles); - $this->assertContains(Role::team(ID::custom('abc'), 'moderator')->toString(), $roles); - $this->assertContains(Role::team(ID::custom('def'))->toString(), $roles); - $this->assertContains(Role::team(ID::custom('def'), 'guest')->toString(), $roles); - $this->assertContains(Role::member(ID::custom('def'))->toString(), $roles); - $this->assertContains(Role::member(ID::custom('abc'))->toString(), $roles); - } -} diff --git a/tests/unit/Auth/KeyTest.php b/tests/unit/Auth/KeyTest.php index 8ae2114697..920608e82f 100644 --- a/tests/unit/Auth/KeyTest.php +++ b/tests/unit/Auth/KeyTest.php @@ -3,8 +3,8 @@ namespace Tests\Unit\Auth; use Ahc\Jwt\JWT; -use Appwrite\Auth\Auth; use Appwrite\Auth\Key; +use Appwrite\Utopia\Database\Documents\User; use PHPUnit\Framework\TestCase; use Utopia\Config\Config; use Utopia\Database\Document; @@ -21,7 +21,7 @@ class KeyTest extends TestCase 'collections.read', 'documents.read', ]; - $roleScopes = Config::getParam('roles', [])[Auth::USER_ROLE_APPS]['scopes']; + $roleScopes = Config::getParam('roles', [])[User::ROLE_APPS]['scopes']; $key = static::generateKey($projectId, $usage, $scopes); $project = new Document(['$id' => $projectId,]); @@ -29,7 +29,7 @@ class KeyTest extends TestCase $this->assertEquals($projectId, $decoded->getProjectId()); $this->assertEquals(API_KEY_DYNAMIC, $decoded->getType()); - $this->assertEquals(Auth::USER_ROLE_APPS, $decoded->getRole()); + $this->assertEquals(User::ROLE_APPS, $decoded->getRole()); $this->assertEquals(\array_merge($scopes, $roleScopes), $decoded->getScopes()); } diff --git a/tests/unit/Messaging/MessagingChannelsTest.php b/tests/unit/Messaging/MessagingChannelsTest.php index 8ba0374093..7df5b8d1e6 100644 --- a/tests/unit/Messaging/MessagingChannelsTest.php +++ b/tests/unit/Messaging/MessagingChannelsTest.php @@ -2,12 +2,12 @@ namespace Tests\Unit\Messaging; -use Appwrite\Auth\Auth; use Appwrite\Messaging\Adapter\Realtime; +use Appwrite\Utopia\Database\Documents\User; use PHPUnit\Framework\TestCase; -use Utopia\Database\Document; use Utopia\Database\Helpers\ID; use Utopia\Database\Helpers\Role; +use Utopia\Database\Validator\Authorization; class MessagingChannelsTest extends TestCase { @@ -34,6 +34,19 @@ class MessagingChannelsTest extends TestCase 'functions.1', ]; + + private $authorization; + + public function getAuthorization(): Authorization + { + if (isset($this->authorization)) { + return $this->authorization; + } + + $this->authorization = new Authorization(); + return $this->authorization; + } + public function setUp(): void { /** @@ -50,7 +63,7 @@ class MessagingChannelsTest extends TestCase */ for ($i = 0; $i < $this->connectionsPerChannel; $i++) { foreach ($this->allChannels as $index => $channel) { - $user = new Document([ + $user = new User([ '$id' => ID::custom('user' . $this->connectionsCount), 'memberships' => [ [ @@ -59,14 +72,14 @@ class MessagingChannelsTest extends TestCase 'confirm' => true, 'roles' => [ empty($index % 2) - ? Auth::USER_ROLE_ADMIN + ? User::ROLE_ADMIN : 'member', ] ] ] ]); - $roles = Auth::getRoles($user); + $roles = $user->getRoles($this->getAuthorization()); $parsedChannels = Realtime::convertChannels([0 => $channel], $user->getId()); @@ -86,11 +99,11 @@ class MessagingChannelsTest extends TestCase */ for ($i = 0; $i < $this->connectionsPerChannel; $i++) { foreach ($this->allChannels as $index => $channel) { - $user = new Document([ + $user = new User([ '$id' => '' ]); - $roles = Auth::getRoles($user); + $roles = $user->getRoles($this->getAuthorization()); $parsedChannels = Realtime::convertChannels([0 => $channel], $user->getId()); @@ -294,7 +307,7 @@ class MessagingChannelsTest extends TestCase } $role = empty($index % 2) - ? Auth::USER_ROLE_ADMIN + ? User::ROLE_ADMIN : 'member'; $permissions = [ diff --git a/tests/unit/Network/CorsTest.php b/tests/unit/Network/CorsTest.php new file mode 100644 index 0000000000..986e48ebb5 --- /dev/null +++ b/tests/unit/Network/CorsTest.php @@ -0,0 +1,165 @@ +expectException(InvalidArgumentException::class); + + new Cors( + allowedHosts: ['*'], + allowedMethods: ['GET'], + allowedHeaders: ['X-Test'], + exposedHeaders: [], + allowCredentials: true + ); + } + + public function testWildcardAllowsAnyOrigin(): void + { + $cors = new Cors( + allowedHosts: ['*'], + allowedMethods: ['GET'], + allowedHeaders: ['X-Test'], + exposedHeaders: [], + allowCredentials: false + ); + + $result = $cors->headers('https://foo.com'); + + $this->assertSame('https://foo.com', $result[Cors::HEADER_ALLOW_ORIGIN]); + } + + public function testSubdomainWildcardAllowsAnySubdomain(): void + { + $cors = new Cors( + allowedHosts: ['*.example.com'], + allowedMethods: ['GET'], + allowedHeaders: ['X-Test'], + exposedHeaders: [], + allowCredentials: false + ); + + $result = $cors->headers('https://foo.example.com'); + + $this->assertSame('https://foo.example.com', $result[Cors::HEADER_ALLOW_ORIGIN]); + } + + public function testEmptyOriginReturnsStaticHeadersOnly(): void + { + $cors = new Cors( + allowedHosts: ['example.com'], + allowedMethods: ['GET'], + allowedHeaders: ['X-Test'], + exposedHeaders: [], + allowCredentials: false + ); + + $result = $cors->headers(''); + + $this->assertArrayNotHasKey(Cors::HEADER_ALLOW_ORIGIN, $result); + $this->assertSame('false', $result[Cors::HEADER_ALLOW_CREDENTIALS]); + $this->assertSame('GET', $result[Cors::HEADER_ALLOW_METHODS]); + } + + public function testInvalidOriginReturnsStaticHeadersOnly(): void + { + $cors = new Cors( + allowedHosts: ['example.com'], + allowedMethods: ['GET'], + allowedHeaders: ['X-Test'], + exposedHeaders: [], + allowCredentials: false + ); + + $result = $cors->headers('%%%not-a-url%%%'); + + $this->assertArrayNotHasKey(Cors::HEADER_ALLOW_ORIGIN, $result); + } + + public function testUnlistedOriginReturnsStaticHeadersOnly(): void + { + $cors = new Cors( + allowedHosts: ['allowed.com'], + allowedMethods: ['GET'], + allowedHeaders: ['X-Test'], + exposedHeaders: [], + allowCredentials: false + ); + + $result = $cors->headers('https://forbidden.com'); + + $this->assertArrayNotHasKey(Cors::HEADER_ALLOW_ORIGIN, $result); + } + + public function testAllowedOriginIsReturned(): void + { + $cors = new Cors( + allowedHosts: ['example.com'], + allowedMethods: ['POST'], + allowedHeaders: ['X-Test'], + exposedHeaders: [], + allowCredentials: true + ); + + $result = $cors->headers('https://example.com'); + + $this->assertSame('https://example.com', $result[Cors::HEADER_ALLOW_ORIGIN]); + } + + public function testOriginIsLowercasedForMatching(): void + { + $cors = new Cors( + allowedHosts: ['example.com'], + allowedMethods: ['GET'], + allowedHeaders: ['X-Test'], + exposedHeaders: [], + allowCredentials: false + ); + + $result = $cors->headers('HTTPS://EXAMPLE.COM'); + + // Lowercase logic is in the class + $this->assertSame('https://example.com', $result[Cors::HEADER_ALLOW_ORIGIN]); + } + + public function testHeaderFormatting(): void + { + $cors = new Cors( + allowedHosts: ['example.com'], + allowedMethods: ['GET', 'POST'], + allowedHeaders: ['X-A', 'X-B'], + exposedHeaders: ['E1', 'E2'], + allowCredentials: true + ); + + $result = $cors->headers('https://example.com'); + + $this->assertSame('GET, POST', $result[Cors::HEADER_ALLOW_METHODS]); + $this->assertSame('X-A, X-B', $result[Cors::HEADER_ALLOW_HEADERS]); + $this->assertSame('E1, E2', $result[Cors::HEADER_EXPOSE_HEADERS]); + $this->assertSame('true', $result[Cors::HEADER_ALLOW_CREDENTIALS]); + } + + public function testMaxAgeIncluded(): void + { + $cors = new Cors( + allowedHosts: ['example.com'], + allowedMethods: ['GET'], + allowedHeaders: ['X-Test'], + exposedHeaders: [], + allowCredentials: false, + maxAge: 999 + ); + + $result = $cors->headers('https://example.com'); + + $this->assertSame(999, $result[Cors::HEADER_MAX_AGE]); + } +} diff --git a/tests/unit/Network/Validators/DNSTest.php b/tests/unit/Network/Validators/DNSTest.php index c3e819e7dc..6e4a78022f 100644 --- a/tests/unit/Network/Validators/DNSTest.php +++ b/tests/unit/Network/Validators/DNSTest.php @@ -3,106 +3,51 @@ namespace Tests\Unit\Network\Validators; use Appwrite\Network\Validator\DNS; -use Appwrite\Tests\Retry; use PHPUnit\Framework\TestCase; - -/* -DNS Setup (on Appwrite Labs digital ocean team, network tab): - -certainly.caa.appwrite.org: CAA 0 issue "certainly.com" -certainly-full.caa.appwrite.org: CAA 128 issuewild "certainly.com;account=123456;validationmethods=dns-01" -letsencrypt.certainly.caa.appwrite.org: CAA 0 issue "letsencrypt.org" - -*/ +use Utopia\DNS\Message\Record; class DNSTest extends TestCase { - public function setUp(): void + public function testSingleDNSServer(): void { + $validator = new DNS('appwrite.io', Record::TYPE_CNAME, ['8.8.8.8']); + $this->assertEquals(false, $validator->isValid('')); + $this->assertEquals(false, $validator->isValid(null)); + $this->assertEquals('string', $validator->getType()); } - public function tearDown(): void + public function testMultipleDNSServers(): void { + $validator = new DNS('appwrite.io', Record::TYPE_CNAME, ['8.8.8.8', '1.1.1.1']); + + $this->assertEquals(false, $validator->isValid('')); + $this->assertEquals(false, $validator->isValid(null)); + $this->assertEquals('string', $validator->getType()); } - public function testCNAME(): void + public function testValidationFailure(): void { - $validator = new DNS('appwrite.io', DNS::RECORD_CNAME); - $this->assertEquals($validator->isValid(''), false); - $this->assertEquals($validator->isValid(null), false); - $this->assertEquals($validator->isValid(false), false); - $this->assertEquals($validator->isValid('cname-unit-test.appwrite.org'), true); - $this->assertEquals($validator->isValid('test1.appwrite.org'), false); + $validator = new DNS('invalid-target.example.com', Record::TYPE_CNAME, ['8.8.8.8', '1.1.1.1']); + + $result = $validator->isValid('nonexistent-domain-' . \uniqid() . '.com'); + + $this->assertEquals(false, $result); + $this->assertIsInt($validator->count); + $this->assertIsString($validator->value); + $this->assertIsArray($validator->records); + $this->assertIsString($validator->getDescription()); } - public function testA(): void + public function testCoreDNSFailure(): void { - // IPv4 for documentation purposes - $validator = new DNS('203.0.113.1', DNS::RECORD_A); - $this->assertEquals($validator->isValid(''), false); - $this->assertEquals($validator->isValid(null), false); - $this->assertEquals($validator->isValid(false), false); - $this->assertEquals($validator->isValid('a-unit-test.appwrite.org'), true); - $this->assertEquals($validator->isValid('test1.appwrite.org'), false); - } + // CoreDNS is configured to return cname.localhost. for stage.webapp.com + $validator = new DNS('cname.localhost.', Record::TYPE_CNAME, ['172.16.238.100', '8.8.8.8']); - public function testAAAA(): void - { - // IPv6 for documentation purposes - $validator = new DNS('2001:db8::1', DNS::RECORD_AAAA); - $this->assertEquals($validator->isValid(''), false); - $this->assertEquals($validator->isValid(null), false); - $this->assertEquals($validator->isValid(false), false); - $this->assertEquals($validator->isValid('aaaa-unit-test.appwrite.org'), true); - $this->assertEquals($validator->isValid('test1.appwrite.org'), false); - } + $result = $validator->isValid('stage.webapp.com'); + $this->assertEquals(false, $result); - #[Retry(count: 5)] - public function testCAA(): void - { - $certainly = new DNS('certainly.com', DNS::RECORD_CAA, 'ns1.digitalocean.com'); - $letsencrypt = new DNS('letsencrypt.org', DNS::RECORD_CAA, 'ns1.digitalocean.com'); - - // No CAA record succeeds on main domain & subdomains for any issuer - $this->assertEquals($certainly->isValid('caa.appwrite.org'), true); - $this->assertEquals($certainly->isValid('sub.caa.appwrite.org'), true); - $this->assertEquals($certainly->isValid('sub.sub.caa.appwrite.org'), true); - - $this->assertEquals($letsencrypt->isValid('caa.appwrite.org'), true); - $this->assertEquals($letsencrypt->isValid('sub.caa.appwrite.org'), true); - $this->assertEquals($letsencrypt->isValid('sub.sub.caa.appwrite.org'), true); - - // Custom flags and tag is allowed, but only for Certainly - $this->assertEquals($certainly->isValid('certainly-full.caa.appwrite.org'), true); - $this->assertEquals($letsencrypt->isValid('certainly-full.caa.appwrite.org'), false); - - // Custom flags&tag are not allowed if validator includes specific flags&tag - $certainlyFull = new DNS('0 issue "certainly.com"', DNS::RECORD_CAA); - $this->assertEquals($certainlyFull->isValid('certainly-full.caa.appwrite.org'), false); - - // Custom flags&tag still allows if they match exactly - $certainlyFull = new DNS('128 issuewild "certainly.com;account=123456;validationmethods=dns-01"', DNS::RECORD_CAA); - $this->assertEquals($certainlyFull->isValid('certainly-full.caa.appwrite.org'), true); - - // Certainly CAA allows Certainly, but not LetsEncrypt; Same for subdomains - $this->assertEquals($certainly->isValid('certainly.caa.appwrite.org'), true); - $this->assertEquals($letsencrypt->isValid('certainly.caa.appwrite.org'), false); - - $this->assertEquals($certainly->isValid('sub.certainly.caa.appwrite.org'), true); - $this->assertEquals($letsencrypt->isValid('sub.certainly.caa.appwrite.org'), false); - - $this->assertEquals($certainly->isValid('sub.sub.certainly.caa.appwrite.org'), true); - $this->assertEquals($letsencrypt->isValid('sub.sub.certainly.caa.appwrite.org'), false); - - // LetsEncrypt CAA on subdomain with parent allowing Certainly. Only LetsEncrypt is allowed; Same for subdomains - $this->assertEquals($certainly->isValid('letsencrypt.certainly.caa.appwrite.org'), false); - $this->assertEquals($letsencrypt->isValid('letsencrypt.certainly.caa.appwrite.org'), true); - - $this->assertEquals($certainly->isValid('sub.letsencrypt.certainly.caa.appwrite.org'), false); - $this->assertEquals($letsencrypt->isValid('sub.letsencrypt.certainly.caa.appwrite.org'), true); - - $this->assertEquals($certainly->isValid('sub.sub.letsencrypt.certainly.caa.appwrite.org'), false); - $this->assertEquals($letsencrypt->isValid('sub.sub.letsencrypt.certainly.caa.appwrite.org'), true); + $result = $validator->isValid('stage-wrong-cname.webapp.com'); + $this->assertEquals(false, $result); } } diff --git a/tests/unit/Network/Validators/OriginTest.php b/tests/unit/Network/Validators/OriginTest.php index d312f8c5a5..a4c235f755 100644 --- a/tests/unit/Network/Validators/OriginTest.php +++ b/tests/unit/Network/Validators/OriginTest.php @@ -2,53 +2,17 @@ namespace Tests\Unit\Network\Validators; -use Appwrite\Network\Platform; use Appwrite\Network\Validator\Origin; use PHPUnit\Framework\TestCase; -use Utopia\Database\Helpers\ID; class OriginTest extends TestCase { public function testValues(): void { - $validator = new Origin([ - [ - '$collection' => ID::custom('platforms'), - 'name' => 'Production', - 'type' => Platform::TYPE_WEB, - 'hostname' => 'appwrite.io', - ], - [ - '$collection' => ID::custom('platforms'), - 'name' => 'Development', - 'type' => Platform::TYPE_WEB, - 'hostname' => 'appwrite.test', - ], - [ - '$collection' => ID::custom('platforms'), - 'name' => 'Localhost', - 'type' => Platform::TYPE_WEB, - 'hostname' => 'localhost', - ], - [ - '$collection' => ID::custom('platforms'), - 'name' => 'Flutter', - 'type' => Platform::TYPE_FLUTTER_WEB, - 'hostname' => 'appwrite.flutter', - ], - [ - '$collection' => ID::custom('platforms'), - 'name' => 'Expo', - 'type' => Platform::TYPE_SCHEME, - 'key' => 'exp', - ], - [ - '$collection' => ID::custom('platforms'), - 'name' => 'Appwrite Callback', - 'type' => Platform::TYPE_SCHEME, - 'key' => 'appwrite-callback-123', - ], - ]); + $validator = new Origin( + allowedHostnames: ['appwrite.io', 'appwrite.test', 'localhost', 'appwrite.flutter'], + allowedSchemes: ['exp', 'appwrite-callback-123'] + ); $this->assertEquals(false, $validator->isValid('')); $this->assertEquals(false, $validator->isValid('/')); diff --git a/tests/unit/Platform/Modules/Compute/Validator/SpecificationTest.php b/tests/unit/Platform/Modules/Compute/Validator/SpecificationTest.php new file mode 100644 index 0000000000..0505494e17 --- /dev/null +++ b/tests/unit/Platform/Modules/Compute/Validator/SpecificationTest.php @@ -0,0 +1,75 @@ +specifications = Config::getParam('specifications', []); + } + + public function testGetAllowedSpecificationsNoLimits(): void + { + $validator = new Specification( + plan: [], + specifications: $this->specifications, + maxCpus: 0, + maxMemory: 0 + ); + + $allowed = $validator->getAllowedSpecifications(); + $this->assertCount(count($this->specifications), $allowed); + $this->assertEquals( + $this->specifications[array_key_last($this->specifications)]['slug'], + $allowed[array_key_last($allowed)] + ); + } + + public function testGetAllowedSpecificationsWithMaxCpusAndMemory(): void + { + $validator = new Specification( + plan: [], + specifications: $this->specifications, + maxCpus: 2, + maxMemory: 2048 + ); + + $allowed = $validator->getAllowedSpecifications(); + $this->assertCount(4, $allowed); + $this->assertEquals( + SpecificationConstants::S_2VCPU_2GB, + $allowed[array_key_last($allowed)] + ); + } + + public function testGetAllowedSpecificationsWithPlanLimits(): void + { + $plan = [ + 'runtimeSpecifications' => [ + SpecificationConstants::S_05VCPU_512MB, + SpecificationConstants::S_1VCPU_512MB + ] + ]; + $validator = new Specification( + plan: $plan, + specifications: $this->specifications, + maxCpus: 0, + maxMemory: 0 + ); + + $allowed = $validator->getAllowedSpecifications(); + $this->assertCount(2, $allowed); + $this->assertContains(SpecificationConstants::S_05VCPU_512MB, $allowed); + $this->assertContains(SpecificationConstants::S_1VCPU_512MB, $allowed); + } +} diff --git a/tests/unit/Utopia/Database/Documents/UserTest.php b/tests/unit/Utopia/Database/Documents/UserTest.php new file mode 100644 index 0000000000..d5706e7bec --- /dev/null +++ b/tests/unit/Utopia/Database/Documents/UserTest.php @@ -0,0 +1,363 @@ +authorization)) { + return $this->authorization; + } + + $this->authorization = new Authorization(); + return $this->authorization; + } + + /** + * Reset Roles + */ + public function tearDown(): void + { + $this->getAuthorization()->cleanRoles(); + $this->getAuthorization()->addRole(Role::any()->toString()); + } + + public function testSessionVerify(): void + { + $proofForToken = new Token(); + $expireTime1 = 60 * 60 * 24; + + $secret = 'secret1'; + $hash = $proofForToken->hash($secret); + $tokens1 = [ + new Document([ + '$id' => ID::custom('token1'), + 'secret' => $hash, + 'provider' => SESSION_PROVIDER_EMAIL, + 'providerUid' => 'test@example.com', + 'expire' => DateTime::addSeconds(new \DateTime(), $expireTime1), + ]), + new Document([ + '$id' => ID::custom('token2'), + 'secret' => 'secret2', + 'provider' => SESSION_PROVIDER_EMAIL, + 'providerUid' => 'test@example.com', + 'expire' => DateTime::addSeconds(new \DateTime(), $expireTime1), + ]), + ]; + + $expireTime2 = -60 * 60 * 24; + + $tokens2 = [ + new Document([ // Correct secret and type time, wrong expire time + '$id' => ID::custom('token1'), + 'secret' => $hash, + 'provider' => SESSION_PROVIDER_EMAIL, + 'providerUid' => 'test@example.com', + 'expire' => DateTime::addSeconds(new \DateTime(), $expireTime2), + ]), + new Document([ + '$id' => ID::custom('token2'), + 'secret' => 'secret2', + 'provider' => SESSION_PROVIDER_EMAIL, + 'providerUid' => 'test@example.com', + 'expire' => DateTime::addSeconds(new \DateTime(), $expireTime2), + ]), + ]; + + $user1 = new User([ + '$id' => ID::custom('user1'), + 'sessions' => $tokens1, + + ]); + + $user2 = new User([ + '$id' => ID::custom('user2'), + 'sessions' => $tokens2, + ]); + + $this->assertEquals('token1', $user1->sessionVerify($secret, $proofForToken)); + $this->assertEquals($user1->sessionVerify('false-secret', $proofForToken), false); + $this->assertEquals($user2->sessionVerify($secret, $proofForToken), false); + $this->assertEquals($user2->sessionVerify('false-secret', $proofForToken), false); + } + + public function testTokenVerify(): void + { + $proofForToken = new Token(); + $secret = 'secret1'; + $hash = $proofForToken->hash($secret); + $tokens1 = [ + new Document([ + '$id' => ID::custom('token1'), + 'type' => TOKEN_TYPE_RECOVERY, + 'expire' => DateTime::formatTz(DateTime::addSeconds(new \DateTime(), 60 * 60 * 24)), + 'secret' => $hash, + ]), + new Document([ + '$id' => ID::custom('token2'), + 'type' => TOKEN_TYPE_RECOVERY, + 'expire' => DateTime::formatTz(DateTime::addSeconds(new \DateTime(), -60 * 60 * 24)), + 'secret' => 'secret2', + ]), + ]; + + $tokens2 = [ + new Document([ // Correct secret and type time, wrong expire time + '$id' => ID::custom('token1'), + 'type' => TOKEN_TYPE_RECOVERY, + 'expire' => DateTime::formatTz(DateTime::addSeconds(new \DateTime(), -60 * 60 * 24)), + 'secret' => $hash, + ]), + new Document([ + '$id' => ID::custom('token2'), + 'type' => TOKEN_TYPE_RECOVERY, + 'expire' => DateTime::formatTz(DateTime::addSeconds(new \DateTime(), -60 * 60 * 24)), + 'secret' => 'secret2', + ]), + ]; + + $tokens3 = [ // Correct secret and expire time, wrong type + new Document([ + '$id' => ID::custom('token1'), + 'type' => TOKEN_TYPE_INVITE, + 'expire' => DateTime::formatTz(DateTime::addSeconds(new \DateTime(), 60 * 60 * 24)), + 'secret' => $hash, + ]), + new Document([ + '$id' => ID::custom('token2'), + 'type' => TOKEN_TYPE_RECOVERY, + 'expire' => DateTime::formatTz(DateTime::addSeconds(new \DateTime(), -60 * 60 * 24)), + 'secret' => 'secret2', + ]), + ]; + + $user1 = new User([ + '$id' => ID::custom('user1'), + 'tokens' => $tokens1, + ]); + + $user2 = new User([ + '$id' => ID::custom('user2'), + 'tokens' => $tokens2, + ]); + + $user3 = new User([ + '$id' => ID::custom('user3'), + 'tokens' => $tokens3, + ]); + + $this->assertEquals($user1->tokenVerify(TOKEN_TYPE_RECOVERY, $secret, $proofForToken), $tokens1[0]); + $this->assertEquals($user1->tokenVerify(null, $secret, $proofForToken), $tokens1[0]); + $this->assertEquals($user1->tokenVerify(TOKEN_TYPE_RECOVERY, 'false-secret', $proofForToken), false); + $this->assertEquals($user2->tokenVerify(TOKEN_TYPE_RECOVERY, $secret, $proofForToken), false); + $this->assertEquals($user2->tokenVerify(TOKEN_TYPE_RECOVERY, 'false-secret', $proofForToken), false); + $this->assertEquals($user3->tokenVerify(TOKEN_TYPE_RECOVERY, $secret, $proofForToken), false); + $this->assertEquals($user3->tokenVerify(TOKEN_TYPE_RECOVERY, 'false-secret', $proofForToken), false); + } + + public function testIsPrivilegedUser(): void + { + $this->assertEquals(false, User::isPrivileged([])); + $this->assertEquals(false, User::isPrivileged([Role::guests()->toString()])); + $this->assertEquals(false, User::isPrivileged([Role::users()->toString()])); + $this->assertEquals(true, User::isPrivileged([User::ROLE_ADMIN])); + $this->assertEquals(true, User::isPrivileged([User::ROLE_DEVELOPER])); + $this->assertEquals(true, User::isPrivileged([User::ROLE_OWNER])); + $this->assertEquals(false, User::isPrivileged([User::ROLE_APPS])); + $this->assertEquals(false, User::isPrivileged([User::ROLE_SYSTEM])); + + $this->assertEquals(false, User::isPrivileged([User::ROLE_APPS, User::ROLE_APPS])); + $this->assertEquals(false, User::isPrivileged([User::ROLE_APPS, Role::guests()->toString()])); + $this->assertEquals(true, User::isPrivileged([User::ROLE_OWNER, Role::guests()->toString()])); + $this->assertEquals(true, User::isPrivileged([User::ROLE_OWNER, User::ROLE_ADMIN, User::ROLE_DEVELOPER])); + } + + public function testIsAppUser(): void + { + $this->assertEquals(false, User::isApp([])); + $this->assertEquals(false, User::isApp([Role::guests()->toString()])); + $this->assertEquals(false, User::isApp([Role::users()->toString()])); + $this->assertEquals(false, User::isApp([User::ROLE_ADMIN])); + $this->assertEquals(false, User::isApp([User::ROLE_DEVELOPER])); + $this->assertEquals(false, User::isApp([User::ROLE_OWNER])); + $this->assertEquals(true, User::isApp([User::ROLE_APPS])); + $this->assertEquals(false, User::isApp([User::ROLE_SYSTEM])); + + $this->assertEquals(true, User::isApp([User::ROLE_APPS, User::ROLE_APPS])); + $this->assertEquals(true, User::isApp([User::ROLE_APPS, Role::guests()->toString()])); + $this->assertEquals(false, User::isApp([User::ROLE_OWNER, Role::guests()->toString()])); + $this->assertEquals(false, User::isApp([User::ROLE_OWNER, User::ROLE_ADMIN, User::ROLE_DEVELOPER])); + } + + public function testGuestRoles(): void + { + $user = new User([ + '$id' => '' + ]); + + $roles = $user->getRoles($this->getAuthorization()); + $this->assertCount(1, $roles); + $this->assertContains(Role::guests()->toString(), $roles); + } + + public function testUserRoles(): void + { + $user = new User([ + '$id' => ID::custom('123'), + 'labels' => [ + 'vip', + 'admin' + ], + 'emailVerification' => true, + 'phoneVerification' => true, + 'memberships' => [ + [ + '$id' => ID::custom('456'), + 'teamId' => ID::custom('abc'), + 'confirm' => true, + 'roles' => [ + 'administrator', + 'moderator' + ] + ], + [ + '$id' => ID::custom('abc'), + 'teamId' => ID::custom('def'), + 'confirm' => true, + 'roles' => [ + 'guest' + ] + ] + ] + ]); + + $roles = $user->getRoles($this->getAuthorization()); + + $this->assertCount(13, $roles); + $this->assertContains(Role::users()->toString(), $roles); + $this->assertContains(Role::user(ID::custom('123'))->toString(), $roles); + $this->assertContains(Role::users(Roles::DIMENSION_VERIFIED)->toString(), $roles); + $this->assertContains(Role::user(ID::custom('123'), Roles::DIMENSION_VERIFIED)->toString(), $roles); + $this->assertContains(Role::team(ID::custom('abc'))->toString(), $roles); + $this->assertContains(Role::team(ID::custom('abc'), 'administrator')->toString(), $roles); + $this->assertContains(Role::team(ID::custom('abc'), 'moderator')->toString(), $roles); + $this->assertContains(Role::team(ID::custom('def'))->toString(), $roles); + $this->assertContains(Role::team(ID::custom('def'), 'guest')->toString(), $roles); + $this->assertContains(Role::member(ID::custom('456'))->toString(), $roles); + $this->assertContains(Role::member(ID::custom('abc'))->toString(), $roles); + $this->assertContains('label:vip', $roles); + $this->assertContains('label:admin', $roles); + + // Disable all verification + $user['emailVerification'] = false; + $user['phoneVerification'] = false; + + $roles = $user->getRoles($this->getAuthorization()); + $this->assertContains(Role::users(Roles::DIMENSION_UNVERIFIED)->toString(), $roles); + $this->assertContains(Role::user(ID::custom('123'), Roles::DIMENSION_UNVERIFIED)->toString(), $roles); + + // Enable single verification type + $user['emailVerification'] = true; + + $roles = $user->getRoles($this->getAuthorization()); + $this->assertContains(Role::users(Roles::DIMENSION_VERIFIED)->toString(), $roles); + $this->assertContains(Role::user(ID::custom('123'), Roles::DIMENSION_VERIFIED)->toString(), $roles); + } + + public function testPrivilegedUserRoles(): void + { + $this->getAuthorization()->addRole(User::ROLE_OWNER); + $user = new User([ + '$id' => ID::custom('123'), + 'emailVerification' => true, + 'phoneVerification' => true, + 'memberships' => [ + [ + '$id' => ID::custom('def'), + 'teamId' => ID::custom('abc'), + 'confirm' => true, + 'roles' => [ + 'administrator', + 'moderator' + ] + ], + [ + '$id' => ID::custom('abc'), + 'teamId' => ID::custom('def'), + 'confirm' => true, + 'roles' => [ + 'guest' + ] + ] + ] + ]); + $roles = $user->getRoles($this->getAuthorization()); + + $this->assertCount(7, $roles); + $this->assertNotContains(Role::users()->toString(), $roles); + $this->assertNotContains(Role::user(ID::custom('123'))->toString(), $roles); + $this->assertNotContains(Role::users(Roles::DIMENSION_VERIFIED)->toString(), $roles); + $this->assertNotContains(Role::user(ID::custom('123'), Roles::DIMENSION_VERIFIED)->toString(), $roles); + $this->assertContains(Role::team(ID::custom('abc'))->toString(), $roles); + $this->assertContains(Role::team(ID::custom('abc'), 'administrator')->toString(), $roles); + $this->assertContains(Role::team(ID::custom('abc'), 'moderator')->toString(), $roles); + $this->assertContains(Role::team(ID::custom('def'))->toString(), $roles); + $this->assertContains(Role::team(ID::custom('def'), 'guest')->toString(), $roles); + $this->assertContains(Role::member(ID::custom('def'))->toString(), $roles); + $this->assertContains(Role::member(ID::custom('abc'))->toString(), $roles); + } + + public function testAppUserRoles(): void + { + $this->getAuthorization()->addRole(User::ROLE_APPS); + $user = new User([ + '$id' => ID::custom('123'), + 'memberships' => [ + [ + '$id' => ID::custom('def'), + 'teamId' => ID::custom('abc'), + 'confirm' => true, + 'roles' => [ + 'administrator', + 'moderator' + ] + ], + [ + '$id' => ID::custom('abc'), + 'teamId' => ID::custom('def'), + 'confirm' => true, + 'roles' => [ + 'guest' + ] + ] + ] + ]); + + $roles = $user->getRoles($this->getAuthorization()); + + $this->assertCount(7, $roles); + $this->assertNotContains(Role::users()->toString(), $roles); + $this->assertNotContains(Role::user(ID::custom('123'))->toString(), $roles); + $this->assertContains(Role::team(ID::custom('abc'))->toString(), $roles); + $this->assertContains(Role::team(ID::custom('abc'), 'administrator')->toString(), $roles); + $this->assertContains(Role::team(ID::custom('abc'), 'moderator')->toString(), $roles); + $this->assertContains(Role::team(ID::custom('def'))->toString(), $roles); + $this->assertContains(Role::team(ID::custom('def'), 'guest')->toString(), $roles); + $this->assertContains(Role::member(ID::custom('def'))->toString(), $roles); + $this->assertContains(Role::member(ID::custom('abc'))->toString(), $roles); + } +}