diff --git a/src/app/Providers/HorizonServiceProvider.php b/src/app/Providers/HorizonServiceProvider.php new file mode 100644 index 00000000..dac99e5e --- /dev/null +++ b/src/app/Providers/HorizonServiceProvider.php @@ -0,0 +1,45 @@ +role == "admin"; + } + ); + */ + } +} diff --git a/src/app/Utils.php b/src/app/Utils.php index 1f48f22b..b595ed80 100644 --- a/src/app/Utils.php +++ b/src/app/Utils.php @@ -1,152 +1,181 @@ diffInDays($end) + 1; } + /** + * Generate a passphrase. Not intended for use in production, so limited to environments that are not production. + * + * @return string + * + * @throws \Exception + */ + public static function generatePassphrase() + { + if (\config('app.env') == "production") { + throw new \Exception("Thou shall not pass"); + } + + $alphaLow = 'abcdefghijklmnopqrstuvwxyz'; + $alphaUp = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'; + $num = '0123456789'; + $stdSpecial = '~`!@#$%^&*()-_+=[{]}\\|\'";:/?.>,<'; + + $source = $alphaLow . $alphaUp . $num . $stdSpecial; + + $result = ''; + + for ($x = 0; $x < 16; $x++) { + $result .= substr($source, rand(0, (strlen($source) - 1)), 1); + } + + return $result; + } + /** * Provide all unique combinations of elements in $input, with order and duplicates irrelevant. * * @param array $input The input array of elements. * * @return array[] */ public static function powerSet(array $input): array { $output = []; for ($x = 0; $x < count($input); $x++) { self::combine($input, $x + 1, 0, [], 0, $output); } return $output; } /** * Returns the current user's email address or null. * * @return string */ public static function userEmailOrNull(): ?string { $user = Auth::user(); if (!$user) { return null; } return $user->email; } /** * Returns a UUID in the form of an integer. * * @return integer */ public static function uuidInt(): int { $hex = Uuid::uuid4(); $bin = pack('h*', str_replace('-', '', $hex)); $ids = unpack('L', $bin); $id = array_shift($ids); return $id; } /** * Returns a UUID in the form of a string. * * @return string */ public static function uuidStr(): string { return Uuid::uuid4()->toString(); } private static function combine($input, $r, $index, $data, $i, &$output): void { $n = count($input); // Current cobination is ready if ($index == $r) { $output[] = array_slice($data, 0, $r); return; } // When no more elements are there to put in data[] if ($i >= $n) { return; } // current is included, put next at next location $data[$index] = $input[$i]; self::combine($input, $r, $index + 1, $data, $i + 1, $output); // current is excluded, replace it with next (Note that i+1 // is passed, but index is not changed) self::combine($input, $r, $index, $data, $i + 1, $output); } /** * Create self URL * * @param string $route Route/Path * * @return string Full URL */ public static function serviceUrl(string $route): string { $url = \config('app.public_url'); if (!$url) { $url = \config('app.url'); } return rtrim(trim($url, '/') . '/' . ltrim($route, '/'), '/'); } /** * Create a configuration/environment data to be passed to * the UI * * @todo For a lack of better place this is put here for now * * @return array Configuration data */ public static function uiEnv(): array { $opts = ['app.name', 'app.url', 'app.domain']; $env = \app('config')->getMany($opts); $countries = include resource_path('countries.php'); $env['countries'] = $countries ?: []; $isAdmin = strpos(request()->getHttpHost(), 'admin.') === 0; $env['jsapp'] = $isAdmin ? 'admin.js' : 'user.js'; $env['paymentProvider'] = \config('services.payment_provider'); $env['stripePK'] = \config('services.stripe.public_key'); return $env; } } diff --git a/src/composer.json b/src/composer.json index 7e9c78b7..92068a62 100644 --- a/src/composer.json +++ b/src/composer.json @@ -1,89 +1,90 @@ { "name": "laravel/laravel", "type": "project", "description": "The Laravel Framework.", "keywords": [ "framework", "laravel" ], "license": "MIT", "repositories": [ { "type": "vcs", "url": "https://git.kolab.org/diffusion/PNL/php-net_ldap3.git" } ], "require": { "php": "^7.1.3", "barryvdh/laravel-dompdf": "^0.8.6", "doctrine/dbal": "^2.9", "fideloper/proxy": "^4.0", "geoip2/geoip2": "^2.9", "iatstuti/laravel-nullable-fields": "*", "kolab/net_ldap3": "dev-master", "laravel/framework": "6.*", + "laravel/horizon": "^3", "laravel/tinker": "^2.4", "mollie/laravel-mollie": "^2.9", "morrislaptop/laravel-queue-clear": "^1.2", "silviolleite/laravelpwa": "^1.0", "spatie/laravel-translatable": "^4.2", "spomky-labs/otphp": "~4.0.0", "stripe/stripe-php": "^7.29", "swooletw/laravel-swoole": "^2.6", "torann/currency": "^1.0", "torann/geoip": "^1.0", "tymon/jwt-auth": "^1.0" }, "require-dev": { "beyondcode/laravel-dump-server": "^1.0", "beyondcode/laravel-er-diagram-generator": "^1.3", "code-lts/doctum": "^5.1", "filp/whoops": "^2.0", "fzaninotto/faker": "^1.4", "kirschbaum-development/mail-intercept": "^0.2.4", "laravel/dusk": "~5.11.0", "mockery/mockery": "^1.0", "nunomaduro/larastan": "^0.6", "phpstan/phpstan": "^0.12", "phpunit/phpunit": "^8" }, "config": { "optimize-autoloader": true, "preferred-install": "dist", "sort-packages": true }, "extra": { "laravel": { "dont-discover": [] } }, "autoload": { "psr-4": { "App\\": "app/" }, "classmap": [ "database/seeds", "database/factories", "include" ] }, "autoload-dev": { "psr-4": { "Tests\\": "tests/" } }, "minimum-stability": "dev", "prefer-stable": true, "scripts": { "post-autoload-dump": [ "Illuminate\\Foundation\\ComposerScripts::postAutoloadDump", "@php artisan package:discover --ansi" ], "post-root-package-install": [ "@php -r \"file_exists('.env') || copy('.env.example', '.env');\"" ], "post-create-project-cmd": [ "@php artisan key:generate --ansi" ] } } diff --git a/src/config/app.php b/src/config/app.php index e8c1be22..7612df7f 100644 --- a/src/config/app.php +++ b/src/config/app.php @@ -1,262 +1,263 @@ env('APP_NAME', 'Laravel'), /* |-------------------------------------------------------------------------- | Application Environment |-------------------------------------------------------------------------- | | This value determines the "environment" your application is currently | running in. This may determine how you prefer to configure various | services the application utilizes. Set this in your ".env" file. | */ 'env' => env('APP_ENV', 'production'), /* |-------------------------------------------------------------------------- | Application Debug Mode |-------------------------------------------------------------------------- | | When your application is in debug mode, detailed error messages with | stack traces will be shown on every error that occurs within your | application. If disabled, a simple generic error page is shown. | */ 'debug' => env('APP_DEBUG', false), /* |-------------------------------------------------------------------------- | Application URL |-------------------------------------------------------------------------- | | This URL is used by the console to properly generate URLs when using | the Artisan command line tool. You should set this to the root of | your application so that it is used when running Artisan tasks. */ 'url' => env('APP_URL', 'http://localhost'), 'public_url' => env('APP_PUBLIC_URL', env('APP_URL', 'http://localhost')), 'asset_url' => env('ASSET_URL', null), 'support_url' => env('SUPPORT_URL', null), /* |-------------------------------------------------------------------------- | Application Domain |-------------------------------------------------------------------------- | | System domain used for user signup (kolab identity) */ 'domain' => env('APP_DOMAIN', 'domain.tld'), /* |-------------------------------------------------------------------------- | Application Timezone |-------------------------------------------------------------------------- | | Here you may specify the default timezone for your application, which | will be used by the PHP date and date-time functions. We have gone | ahead and set this to a sensible default for you out of the box. | */ 'timezone' => 'UTC', /* |-------------------------------------------------------------------------- | Application Locale Configuration |-------------------------------------------------------------------------- | | The application locale determines the default locale that will be used | by the translation service provider. You are free to set this value | to any of the locales which will be supported by the application. | */ 'locale' => 'en', /* |-------------------------------------------------------------------------- | Application Fallback Locale |-------------------------------------------------------------------------- | | The fallback locale determines the locale to use when the current one | is not available. You may change the value to correspond to any of | the language folders that are provided through your application. | */ 'fallback_locale' => 'en', /* |-------------------------------------------------------------------------- | Faker Locale |-------------------------------------------------------------------------- | | This locale will be used by the Faker PHP library when generating fake | data for your database seeds. For example, this will be used to get | localized telephone numbers, street address information and more. | */ 'faker_locale' => 'en_US', /* |-------------------------------------------------------------------------- | Encryption Key |-------------------------------------------------------------------------- | | This key is used by the Illuminate encrypter service and should be set | to a random, 32 character string, otherwise these encrypted strings | will not be safe. Please do this before deploying an application! | */ 'key' => env('APP_KEY'), 'cipher' => 'AES-256-CBC', /* |-------------------------------------------------------------------------- | Autoloaded Service Providers |-------------------------------------------------------------------------- | | The service providers listed here will be automatically loaded on the | request to your application. Feel free to add your own services to | this array to grant expanded functionality to your applications. | */ 'providers' => [ /* * Laravel Framework Service Providers... */ Illuminate\Auth\AuthServiceProvider::class, Illuminate\Broadcasting\BroadcastServiceProvider::class, Illuminate\Bus\BusServiceProvider::class, Illuminate\Cache\CacheServiceProvider::class, Illuminate\Foundation\Providers\ConsoleSupportServiceProvider::class, Illuminate\Cookie\CookieServiceProvider::class, Illuminate\Database\DatabaseServiceProvider::class, Illuminate\Encryption\EncryptionServiceProvider::class, Illuminate\Filesystem\FilesystemServiceProvider::class, Illuminate\Foundation\Providers\FoundationServiceProvider::class, Illuminate\Hashing\HashServiceProvider::class, Illuminate\Mail\MailServiceProvider::class, Illuminate\Notifications\NotificationServiceProvider::class, Illuminate\Pagination\PaginationServiceProvider::class, Illuminate\Pipeline\PipelineServiceProvider::class, Illuminate\Queue\QueueServiceProvider::class, Illuminate\Redis\RedisServiceProvider::class, Illuminate\Auth\Passwords\PasswordResetServiceProvider::class, Illuminate\Session\SessionServiceProvider::class, Illuminate\Translation\TranslationServiceProvider::class, Illuminate\Validation\ValidationServiceProvider::class, Illuminate\View\ViewServiceProvider::class, /* * Package Service Providers... */ Barryvdh\DomPDF\ServiceProvider::class, /* * Application Service Providers... */ App\Providers\AppServiceProvider::class, App\Providers\AuthServiceProvider::class, // App\Providers\BroadcastServiceProvider::class, App\Providers\EventServiceProvider::class, + App\Providers\HorizonServiceProvider::class, App\Providers\RouteServiceProvider::class, ], /* |-------------------------------------------------------------------------- | Class Aliases |-------------------------------------------------------------------------- | | This array of class aliases will be registered when this application | is started. However, feel free to register as many as you wish as | the aliases are "lazy" loaded so they don't hinder performance. | */ 'aliases' => [ 'App' => Illuminate\Support\Facades\App::class, 'Arr' => Illuminate\Support\Arr::class, 'Artisan' => Illuminate\Support\Facades\Artisan::class, 'Auth' => Illuminate\Support\Facades\Auth::class, 'Blade' => Illuminate\Support\Facades\Blade::class, 'Broadcast' => Illuminate\Support\Facades\Broadcast::class, 'Bus' => Illuminate\Support\Facades\Bus::class, 'Cache' => Illuminate\Support\Facades\Cache::class, 'Config' => Illuminate\Support\Facades\Config::class, 'Cookie' => Illuminate\Support\Facades\Cookie::class, 'Crypt' => Illuminate\Support\Facades\Crypt::class, 'DB' => Illuminate\Support\Facades\DB::class, 'Eloquent' => Illuminate\Database\Eloquent\Model::class, 'Event' => Illuminate\Support\Facades\Event::class, 'File' => Illuminate\Support\Facades\File::class, 'Gate' => Illuminate\Support\Facades\Gate::class, 'Hash' => Illuminate\Support\Facades\Hash::class, 'Lang' => Illuminate\Support\Facades\Lang::class, 'Log' => Illuminate\Support\Facades\Log::class, 'Mail' => Illuminate\Support\Facades\Mail::class, 'Notification' => Illuminate\Support\Facades\Notification::class, 'Password' => Illuminate\Support\Facades\Password::class, 'PDF' => Barryvdh\DomPDF\Facade::class, 'Queue' => Illuminate\Support\Facades\Queue::class, 'Redirect' => Illuminate\Support\Facades\Redirect::class, 'Redis' => Illuminate\Support\Facades\Redis::class, 'Request' => Illuminate\Support\Facades\Request::class, 'Response' => Illuminate\Support\Facades\Response::class, 'Route' => Illuminate\Support\Facades\Route::class, 'Schema' => Illuminate\Support\Facades\Schema::class, 'Session' => Illuminate\Support\Facades\Session::class, 'Storage' => Illuminate\Support\Facades\Storage::class, 'Str' => Illuminate\Support\Str::class, 'URL' => Illuminate\Support\Facades\URL::class, 'Validator' => Illuminate\Support\Facades\Validator::class, 'View' => Illuminate\Support\Facades\View::class, ], // Locations of knowledge base articles 'kb' => [ // An article about suspended accounts 'account_suspended' => env('KB_ACCOUNT_SUSPENDED'), // An article about a way to delete an owned account 'account_delete' => env('KB_ACCOUNT_DELETE'), ], 'company' => [ 'name' => env('COMPANY_NAME'), 'address' => env('COMPANY_ADDRESS'), 'details' => env('COMPANY_DETAILS'), 'email' => env('COMPANY_EMAIL'), 'logo' => env('COMPANY_LOGO'), 'footer' => env('COMPANY_FOOTER', env('COMPANY_DETAILS')), ], 'vat' => [ 'countries' => env('VAT_COUNTRIES'), 'rate' => (float) env('VAT_RATE'), ], ]; diff --git a/src/config/horizon.php b/src/config/horizon.php new file mode 100644 index 00000000..d0adac15 --- /dev/null +++ b/src/config/horizon.php @@ -0,0 +1,166 @@ + 'admin.' . \config('app.domain'), + + /* + |-------------------------------------------------------------------------- + | Horizon Path + |-------------------------------------------------------------------------- + | + | This is the URI path where Horizon will be accessible from. Feel free + | to change this path to anything you like. Note that the URI will not + | affect the paths of its internal API that aren't exposed to users. + | + */ + + 'path' => 'horizon', + + /* + |-------------------------------------------------------------------------- + | Horizon Redis Connection + |-------------------------------------------------------------------------- + | + | This is the name of the Redis connection where Horizon will store the + | meta information required for it to function. It includes the list + | of supervisors, failed jobs, job metrics, and other information. + | + */ + + 'use' => 'default', + + /* + |-------------------------------------------------------------------------- + | Horizon Redis Prefix + |-------------------------------------------------------------------------- + | + | This prefix will be used when storing all Horizon data in Redis. You + | may modify the prefix when you are running multiple installations + | of Horizon on the same server so that they don't have problems. + | + */ + + 'prefix' => env('HORIZON_PREFIX', 'horizon:'), + + /* + |-------------------------------------------------------------------------- + | Horizon Route Middleware + |-------------------------------------------------------------------------- + | + | These middleware will get attached onto each Horizon route, giving you + | the chance to add your own middleware to this list or change any of + | the existing middleware. Or, you can simply stick with this list. + | + */ + + 'middleware' => ['web'], + + /* + |-------------------------------------------------------------------------- + | Queue Wait Time Thresholds + |-------------------------------------------------------------------------- + | + | This option allows you to configure when the LongWaitDetected event + | will be fired. Every connection / queue combination may have its + | own, unique threshold (in seconds) before this event is fired. + | + */ + + 'waits' => [ + 'redis:default' => 60, + ], + + /* + |-------------------------------------------------------------------------- + | Job Trimming Times + |-------------------------------------------------------------------------- + | + | Here you can configure for how long (in minutes) you desire Horizon to + | persist the recent and failed jobs. Typically, recent jobs are kept + | for one hour while all failed jobs are stored for an entire week. + | + */ + + 'trim' => [ + 'recent' => 60, + 'completed' => 60, + 'recent_failed' => 10080, + 'failed' => 10080, + 'monitored' => 10080, + ], + + /* + |-------------------------------------------------------------------------- + | Fast Termination + |-------------------------------------------------------------------------- + | + | When this option is enabled, Horizon's "terminate" command will not + | wait on all of the workers to terminate unless the --wait option + | is provided. Fast termination can shorten deployment delay by + | allowing a new instance of Horizon to start while the last + | instance will continue to terminate each of its workers. + | + */ + + 'fast_termination' => false, + + /* + |-------------------------------------------------------------------------- + | Memory Limit (MB) + |-------------------------------------------------------------------------- + | + | This value describes the maximum amount of memory the Horizon worker + | may consume before it is terminated and restarted. You should set + | this value according to the resources available to your server. + | + */ + + 'memory_limit' => 64, + + /* + |-------------------------------------------------------------------------- + | Queue Worker Configuration + |-------------------------------------------------------------------------- + | + | Here you may define the queue worker settings used by your application + | in all environments. These supervisors and settings handle all your + | queued jobs and will be provisioned by Horizon during deployment. + | + */ + + 'environments' => [ + 'production' => [ + 'supervisor-1' => [ + 'connection' => 'redis', + 'queue' => ['default'], + 'balance' => 'auto', + 'maxProcesses' => 10, + 'minProcesses' => 0, + 'tries' => 1, + ], + ], + + 'local' => [ + 'supervisor-1' => [ + 'connection' => 'redis', + 'queue' => ['default'], + 'balance' => 'auto', + 'maxProcesses' => 10, + 'minProcesses' => 0, + 'tries' => 1, + ], + ], + ], +]; diff --git a/src/config/swoole_http.php b/src/config/swoole_http.php index f73a2c59..bad1052e 100644 --- a/src/config/swoole_http.php +++ b/src/config/swoole_http.php @@ -1,137 +1,137 @@ [ 'host' => env('SWOOLE_HTTP_HOST', '127.0.0.1'), 'port' => env('SWOOLE_HTTP_PORT', '1215'), 'public_path' => base_path('public'), // Determine if to use swoole to respond request for static files 'handle_static_files' => env('SWOOLE_HANDLE_STATIC', true), 'access_log' => env('SWOOLE_HTTP_ACCESS_LOG', false), // You must add --enable-openssl while compiling Swoole // Put `SWOOLE_SOCK_TCP | SWOOLE_SSL` if you want to enable SSL 'socket_type' => SWOOLE_SOCK_TCP, 'process_type' => SWOOLE_PROCESS, 'options' => [ 'pid_file' => env('SWOOLE_HTTP_PID_FILE', base_path('storage/logs/swoole_http.pid')), 'log_file' => env('SWOOLE_HTTP_LOG_FILE', base_path('storage/logs/swoole_http.log')), 'daemonize' => env('SWOOLE_HTTP_DAEMONIZE', false), // Normally this value should be 1~4 times larger according to your cpu cores. 'reactor_num' => env('SWOOLE_HTTP_REACTOR_NUM', swoole_cpu_num()), 'worker_num' => env('SWOOLE_HTTP_WORKER_NUM', swoole_cpu_num()), 'task_worker_num' => env('SWOOLE_HTTP_TASK_WORKER_NUM', swoole_cpu_num()), // The data to receive can't be larger than buffer_output_size. 'package_max_length' => 20 * 1024 * 1024, // The data to send can't be larger than buffer_output_size. 'buffer_output_size' => 10 * 1024 * 1024, // Max buffer size for socket connections 'socket_buffer_size' => 128 * 1024 * 1024, // Worker will restart after processing this number of requests 'max_request' => 3000, // Enable coroutine send 'send_yield' => true, // You must add --enable-openssl while compiling Swoole 'ssl_cert_file' => null, 'ssl_key_file' => null, ], ], /* |-------------------------------------------------------------------------- | Enable to turn on websocket server. |-------------------------------------------------------------------------- */ 'websocket' => [ 'enabled' => env('SWOOLE_HTTP_WEBSOCKET', false), ], /* |-------------------------------------------------------------------------- | Hot reload configuration |-------------------------------------------------------------------------- */ 'hot_reload' => [ 'enabled' => env('SWOOLE_HOT_RELOAD_ENABLE', false), 'recursively' => env('SWOOLE_HOT_RELOAD_RECURSIVELY', true), 'directory' => env('SWOOLE_HOT_RELOAD_DIRECTORY', base_path()), 'log' => env('SWOOLE_HOT_RELOAD_LOG', true), 'filter' => env('SWOOLE_HOT_RELOAD_FILTER', '.php'), ], /* |-------------------------------------------------------------------------- | Console output will be transferred to response content if enabled. |-------------------------------------------------------------------------- */ - 'ob_output' => env('SWOOLE_OB_OUTPUT', true), + 'ob_output' => env('SWOOLE_OB_OUTPUT', false), /* |-------------------------------------------------------------------------- | Pre-resolved instances here will be resolved when sandbox created. |-------------------------------------------------------------------------- */ 'pre_resolved' => [ 'view', 'files', 'session', 'session.store', 'routes', 'db', 'db.factory', 'cache', 'cache.store', 'config', 'cookie', 'encrypter', 'hash', 'router', 'translator', 'url', 'log', ], /* |-------------------------------------------------------------------------- | Instances here will be cleared on every request. |-------------------------------------------------------------------------- */ 'instances' => [ // ], /* |-------------------------------------------------------------------------- | Providers here will be registered on every request. |-------------------------------------------------------------------------- */ 'providers' => [ Illuminate\Pagination\PaginationServiceProvider::class, ], /* |-------------------------------------------------------------------------- | Resetters for sandbox app. |-------------------------------------------------------------------------- */ 'resetters' => [ SwooleTW\Http\Server\Resetters\ResetConfig::class, SwooleTW\Http\Server\Resetters\ResetSession::class, SwooleTW\Http\Server\Resetters\ResetCookie::class, SwooleTW\Http\Server\Resetters\ClearInstances::class, SwooleTW\Http\Server\Resetters\BindRequest::class, SwooleTW\Http\Server\Resetters\RebindKernelContainer::class, SwooleTW\Http\Server\Resetters\RebindRouterContainer::class, SwooleTW\Http\Server\Resetters\RebindViewContainer::class, SwooleTW\Http\Server\Resetters\ResetProviders::class, ], /* |-------------------------------------------------------------------------- | Define your swoole tables here. | | @see https://www.swoole.co.uk/docs/modules/swoole-table |-------------------------------------------------------------------------- */ 'tables' => [ // 'table_name' => [ // 'size' => 1024, // 'columns' => [ // ['name' => 'column_name', 'type' => Table::TYPE_STRING, 'size' => 1024], // ] // ], ], ]; diff --git a/src/tests/Browser/UsersTest.php b/src/tests/Browser/UsersTest.php index 419b777f..68ae4107 100644 --- a/src/tests/Browser/UsersTest.php +++ b/src/tests/Browser/UsersTest.php @@ -1,586 +1,681 @@ 'John', 'last_name' => 'Doe', - 'organization' => 'Kolab Developers', + 'organization' => 'Test Domain Owner', ]; + private $users = []; + /** * {@inheritDoc} */ public function setUp(): void { parent::setUp(); - $this->deleteTestUser('julia.roberts@kolab.org'); + $this->password = \App\Utils::generatePassphrase(); - $john = User::where('email', 'john@kolab.org')->first(); - $john->setSettings($this->profile); - UserAlias::where('user_id', $john->id) - ->where('alias', 'john.test@kolab.org')->delete(); + $this->domain = $this->getTestDomain( + 'test.domain', + [ + 'type' => \App\Domain::TYPE_EXTERNAL, + 'status' => \App\Domain::STATUS_ACTIVE | \App\Domain::STATUS_CONFIRMED | \App\Domain::STATUS_VERIFIED + ] + ); - Entitlement::where('entitleable_id', $john->id)->whereIn('cost', [25, 100])->delete(); + $packageKolab = \App\Package::where('title', 'kolab')->first(); - $wallet = $john->wallets()->first(); - $wallet->discount()->dissociate(); - $wallet->save(); + $this->owner = $this->getTestUser('john@test.domain', ['password' => $this->password]); + $this->owner->assignPackage($packageKolab); + $this->owner->setSettings($this->profile); + + $this->users[] = $this->getTestUser('jack@test.domain', ['password' => $this->password]); + $this->users[] = $this->getTestUser('jane@test.domain', ['password' => $this->password]); + $this->users[] = $this->getTestUser('jill@test.domain', ['password' => $this->password]); + $this->users[] = $this->getTestUser('joe@test.domain', ['password' => $this->password]); + + foreach ($this->users as $user) { + $this->owner->assignPackage($packageKolab, $user); + } + + $this->users[] = $this->owner; + + usort( + $this->users, + function ($a, $b) { + return $a->email > $b->email; + } + ); + + $this->domain->assignPackage(\App\Package::where('title', 'domain-hosting')->first(), $this->owner); } /** * {@inheritDoc} */ public function tearDown(): void { - $this->deleteTestUser('julia.roberts@kolab.org'); + foreach ($this->users as $user) { + if ($user == $this->owner) { + continue; + } - $john = User::where('email', 'john@kolab.org')->first(); - $john->setSettings($this->profile); - UserAlias::where('user_id', $john->id) - ->where('alias', 'john.test@kolab.org')->delete(); + $this->deleteTestUser($user->email); + } - Entitlement::where('entitleable_id', $john->id)->whereIn('cost', [25, 100])->delete(); - - $wallet = $john->wallets()->first(); - $wallet->discount()->dissociate(); - $wallet->save(); + $this->deleteTestUser('john@test.domain'); + $this->deleteTestDomain('test.domain'); parent::tearDown(); } /** - * Test user info page (unauthenticated) + * Verify that a user page requires authentication. */ - public function testInfoUnauth(): void + public function testUserPageRequiresAuth(): void { // Test that the page requires authentication - $this->browse(function (Browser $browser) { - $user = User::where('email', 'john@kolab.org')->first(); - - $browser->visit('/user/' . $user->id)->on(new Home()); - }); + $this->browse( + function (Browser $browser) { + $browser->visit('/user/' . $this->owner->id)->on(new Home()); + } + ); } /** - * Test users list page (unauthenticated) + * VErify that the page with a list of users requires authentication */ - public function testListUnauth(): void + public function testUserListPageRequiresAuthentication(): void { // Test that the page requires authentication - $this->browse(function (Browser $browser) { - $browser->visit('/users')->on(new Home()); - }); + $this->browse( + function (Browser $browser) { + $browser->visit('/users')->on(new Home()); + } + ); } /** * Test users list page */ - public function testList(): void + public function testUsersListPageAsOwner(): void { // Test that the page requires authentication - $this->browse(function (Browser $browser) { - $browser->visit(new Home()) - ->submitLogon('john@kolab.org', 'simple123', true) - ->on(new Dashboard()) - ->assertSeeIn('@links .link-users', 'User accounts') - ->click('@links .link-users') - ->on(new UserList()) - ->whenAvailable('@table', function (Browser $browser) { - $browser->waitFor('tbody tr') - ->assertElementsCount('tbody tr', 4) - ->assertSeeIn('tbody tr:nth-child(1) a', 'jack@kolab.org') - ->assertSeeIn('tbody tr:nth-child(2) a', 'joe@kolab.org') - ->assertSeeIn('tbody tr:nth-child(3) a', 'john@kolab.org') - ->assertSeeIn('tbody tr:nth-child(4) a', 'ned@kolab.org') - ->assertVisible('tbody tr:nth-child(1) button.button-delete') - ->assertVisible('tbody tr:nth-child(2) button.button-delete') - ->assertVisible('tbody tr:nth-child(3) button.button-delete') - ->assertVisible('tbody tr:nth-child(4) button.button-delete') - ->assertMissing('tfoot'); - }); - }); + $this->browse( + function (Browser $browser) { + $browser->visit(new Home()); + $browser->submitLogon($this->owner->email, $this->password, true); + $browser->on(new Dashboard()); + $browser->assertSeeIn('@links .link-users', 'User accounts'); + $browser->click('@links .link-users'); + $browser->on(new UserList()); + $browser->whenAvailable( + '@table', + function (Browser $browser) { + $browser->waitFor('tbody tr'); + $browser->assertElementsCount('tbody tr', sizeof($this->users)); + + foreach ($this->users as $user) { + $arrayPosition = array_search($user, $this->users); + $listPosition = $arrayPosition + 1; + + $browser->assertSeeIn("tbody tr:nth-child({$listPosition}) a", $user->email); + $browser->assertVisible("tbody tr:nth-child({$listPosition}) button.button-delete"); + } + + $browser->assertMissing('tfoot'); + } + ); + } + ); } /** * Test user account editing page (not profile page) * - * @depends testList + * @depends testUsersListPageAsOwner */ - public function testInfo(): void + public function testUserInfoPageAsOwner(): void { - $this->browse(function (Browser $browser) { - $browser->on(new UserList()) - ->click('@table tr:nth-child(3) a') - ->on(new UserInfo()) - ->assertSeeIn('#user-info .card-title', 'User account') - ->with('@form', function (Browser $browser) { - // Assert form content - $browser->assertSeeIn('div.row:nth-child(1) label', 'Status') - ->assertSeeIn('div.row:nth-child(1) #status', 'Active') - ->assertFocused('div.row:nth-child(2) input') - ->assertSeeIn('div.row:nth-child(2) label', 'First name') - ->assertValue('div.row:nth-child(2) input[type=text]', $this->profile['first_name']) - ->assertSeeIn('div.row:nth-child(3) label', 'Last name') - ->assertValue('div.row:nth-child(3) input[type=text]', $this->profile['last_name']) - ->assertSeeIn('div.row:nth-child(4) label', 'Organization') - ->assertValue('div.row:nth-child(4) input[type=text]', $this->profile['organization']) - ->assertSeeIn('div.row:nth-child(5) label', 'Email') - ->assertValue('div.row:nth-child(5) input[type=text]', 'john@kolab.org') - ->assertDisabled('div.row:nth-child(5) input[type=text]') - ->assertSeeIn('div.row:nth-child(6) label', 'Email aliases') - ->assertVisible('div.row:nth-child(6) .list-input') - ->with(new ListInput('#aliases'), function (Browser $browser) { - $browser->assertListInputValue(['john.doe@kolab.org']) - ->assertValue('@input', ''); - }) - ->assertSeeIn('div.row:nth-child(7) label', 'Password') - ->assertValue('div.row:nth-child(7) input[type=password]', '') - ->assertSeeIn('div.row:nth-child(8) label', 'Confirm password') - ->assertValue('div.row:nth-child(8) input[type=password]', '') - ->assertSeeIn('button[type=submit]', 'Submit') - // Clear some fields and submit - ->vueClear('#first_name') - ->vueClear('#last_name') - ->click('button[type=submit]'); - }) - ->assertToast(Toast::TYPE_SUCCESS, 'User data updated successfully.') - ->on(new UserList()) - ->click('@table tr:nth-child(3) a') - ->on(new UserInfo()) - ->assertSeeIn('#user-info .card-title', 'User account') - ->with('@form', function (Browser $browser) { - // Test error handling (password) - $browser->type('#password', 'aaaaaa') - ->vueClear('#password_confirmation') - ->click('button[type=submit]') - ->waitFor('#password + .invalid-feedback') - ->assertSeeIn('#password + .invalid-feedback', 'The password confirmation does not match.') - ->assertFocused('#password') - ->assertToast(Toast::TYPE_ERROR, 'Form validation error'); - - // TODO: Test password change - - // Test form error handling (aliases) - $browser->vueClear('#password') - ->vueClear('#password_confirmation') - ->with(new ListInput('#aliases'), function (Browser $browser) { - $browser->addListEntry('invalid address'); - }) - ->click('button[type=submit]') - ->assertToast(Toast::TYPE_ERROR, 'Form validation error'); - - $browser->with(new ListInput('#aliases'), function (Browser $browser) { - $browser->assertFormError(2, 'The specified alias is invalid.', false); - }); + $this->browse( + function (Browser $browser) { + $browser->on(new UserList()); + $browser->click('@table tr:nth-child(' . (array_search($this->owner, $this->users) + 1) . ') a'); + $browser->on(new UserInfo()); + $browser->assertSeeIn('#user-info .card-title', 'User account'); + $browser->with( + '@form', + function (Browser $browser) { + // Assert form content + $browser->assertSeeIn('div.row:nth-child(1) label', 'Status'); + $browser->assertSeeIn('div.row:nth-child(1) #status', 'Active'); + $browser->assertFocused('div.row:nth-child(2) input'); + $browser->assertSeeIn('div.row:nth-child(2) label', 'First name'); + $browser->assertValue('div.row:nth-child(2) input[type=text]', $this->profile['first_name']); + $browser->assertSeeIn('div.row:nth-child(3) label', 'Last name'); + $browser->assertValue('div.row:nth-child(3) input[type=text]', $this->profile['last_name']); + $browser->assertSeeIn('div.row:nth-child(4) label', 'Organization'); + $browser->assertValue('div.row:nth-child(4) input[type=text]', $this->profile['organization']); + $browser->assertSeeIn('div.row:nth-child(5) label', 'Email'); + $browser->assertValue('div.row:nth-child(5) input[type=text]', 'john@kolab.org'); + $browser->assertDisabled('div.row:nth-child(5) input[type=text]'); + $browser->assertSeeIn('div.row:nth-child(6) label', 'Email aliases'); + $browser->assertVisible('div.row:nth-child(6) .list-input'); + + $browser->with( + new ListInput('#aliases'), + function (Browser $browser) { + $browser->assertListInputValue(['john.doe@' . $this->domain->namespace]) + ->assertValue('@input', ''); + } + ); + + $browser->assertSeeIn('div.row:nth-child(7) label', 'Password'); + $browser->assertValue('div.row:nth-child(7) input[type=password]', ''); + $browser->assertSeeIn('div.row:nth-child(8) label', 'Confirm password'); + $browser->assertValue('div.row:nth-child(8) input[type=password]', ''); + $browser->assertSeeIn('button[type=submit]', 'Submit'); - // Test adding aliases - $browser->with(new ListInput('#aliases'), function (Browser $browser) { - $browser->removeListEntry(2) - ->addListEntry('john.test@kolab.org'); - }) - ->click('button[type=submit]') - ->assertToast(Toast::TYPE_SUCCESS, 'User data updated successfully.'); - }) - ->on(new UserList()) - ->click('@table tr:nth-child(3) a') - ->on(new UserInfo()); - - $john = User::where('email', 'john@kolab.org')->first(); - $alias = UserAlias::where('user_id', $john->id)->where('alias', 'john.test@kolab.org')->first(); - $this->assertTrue(!empty($alias)); - - // Test subscriptions - $browser->with('@form', function (Browser $browser) { - $browser->assertSeeIn('div.row:nth-child(9) label', 'Subscriptions') - ->assertVisible('@skus.row:nth-child(9)') - ->with('@skus', function ($browser) { - $browser->assertElementsCount('tbody tr', 5) - // Mailbox SKU - ->assertSeeIn('tbody tr:nth-child(1) td.name', 'User Mailbox') - ->assertSeeIn('tbody tr:nth-child(1) td.price', '4,44 CHF/month') - ->assertChecked('tbody tr:nth-child(1) td.selection input') - ->assertDisabled('tbody tr:nth-child(1) td.selection input') - ->assertTip( - 'tbody tr:nth-child(1) td.buttons button', - 'Just a mailbox' - ) - // Storage SKU - ->assertSeeIn('tbody tr:nth-child(2) td.name', 'Storage Quota') - ->assertSeeIn('tr:nth-child(2) td.price', '0,00 CHF/month') - ->assertChecked('tbody tr:nth-child(2) td.selection input') - ->assertDisabled('tbody tr:nth-child(2) td.selection input') - ->assertTip( - 'tbody tr:nth-child(2) td.buttons button', - 'Some wiggle room' - ) - ->with(new QuotaInput('tbody tr:nth-child(2) .range-input'), function ($browser) { - $browser->assertQuotaValue(2)->setQuotaValue(3); - }) - ->assertSeeIn('tr:nth-child(2) td.price', '0,25 CHF/month') - // groupware SKU - ->assertSeeIn('tbody tr:nth-child(3) td.name', 'Groupware Features') - ->assertSeeIn('tbody tr:nth-child(3) td.price', '5,55 CHF/month') - ->assertChecked('tbody tr:nth-child(3) td.selection input') - ->assertEnabled('tbody tr:nth-child(3) td.selection input') - ->assertTip( - 'tbody tr:nth-child(3) td.buttons button', - 'Groupware functions like Calendar, Tasks, Notes, etc.' - ) - // ActiveSync SKU - ->assertSeeIn('tbody tr:nth-child(4) td.name', 'Activesync') - ->assertSeeIn('tbody tr:nth-child(4) td.price', '1,00 CHF/month') - ->assertNotChecked('tbody tr:nth-child(4) td.selection input') - ->assertEnabled('tbody tr:nth-child(4) td.selection input') - ->assertTip( - 'tbody tr:nth-child(4) td.buttons button', - 'Mobile synchronization' - ) - // 2FA SKU - ->assertSeeIn('tbody tr:nth-child(5) td.name', '2-Factor Authentication') - ->assertSeeIn('tbody tr:nth-child(5) td.price', '0,00 CHF/month') - ->assertNotChecked('tbody tr:nth-child(5) td.selection input') - ->assertEnabled('tbody tr:nth-child(5) td.selection input') - ->assertTip( - 'tbody tr:nth-child(5) td.buttons button', - 'Two factor authentication for webmail and administration panel' - ) - ->click('tbody tr:nth-child(4) td.selection input'); - }) - ->assertMissing('@skus table + .hint') - ->click('button[type=submit]') - ->assertToast(Toast::TYPE_SUCCESS, 'User data updated successfully.'); - }) - ->on(new UserList()) - ->click('@table tr:nth-child(3) a') - ->on(new UserInfo()); - - $expected = ['activesync', 'groupware', 'mailbox', 'storage', 'storage', 'storage']; - $this->assertUserEntitlements($john, $expected); - - // Test subscriptions interaction - $browser->with('@form', function (Browser $browser) { - $browser->with('@skus', function ($browser) { - // Uncheck 'groupware', expect activesync unchecked - $browser->click('#sku-input-groupware') - ->assertNotChecked('#sku-input-groupware') - ->assertNotChecked('#sku-input-activesync') - ->assertEnabled('#sku-input-activesync') - ->assertNotReadonly('#sku-input-activesync') - // Check 'activesync', expect an alert - ->click('#sku-input-activesync') - ->assertDialogOpened('Activesync requires Groupware Features.') - ->acceptDialog() - ->assertNotChecked('#sku-input-activesync') - // Check '2FA', expect 'activesync' unchecked and readonly - ->click('#sku-input-2fa') - ->assertChecked('#sku-input-2fa') - ->assertNotChecked('#sku-input-activesync') - ->assertReadonly('#sku-input-activesync') - // Uncheck '2FA' - ->click('#sku-input-2fa') - ->assertNotChecked('#sku-input-2fa') - ->assertNotReadonly('#sku-input-activesync'); - }); - }); - }); + // Clear some fields and submit + $browser->vueClear('#first_name'); + $browser->vueClear('#last_name'); + $browser->click('button[type=submit]'); + } + ); + $browser->assertToast(Toast::TYPE_SUCCESS, 'User data updated successfully.'); + $browser->on(new UserList()); + $browser->click('@table tr:nth-child(3) a'); + $browser->on(new UserInfo()); + $browser->assertSeeIn('#user-info .card-title', 'User account'); + $browser->with( + '@form', + function (Browser $browser) { + // Test error handling (password) + $browser->type('#password', 'aaaaaa'); + $browser->vueClear('#password_confirmation'); + $browser->click('button[type=submit]'); + $browser->waitFor('#password + .invalid-feedback'); + $browser->assertSeeIn( + '#password + .invalid-feedback', + 'The password confirmation does not match.' + ); + + $browser->assertFocused('#password'); + $browser->assertToast(Toast::TYPE_ERROR, 'Form validation error'); + + // TODO: Test password change + + // Test form error handling (aliases) + $browser->vueClear('#password'); + $browser->vueClear('#password_confirmation'); + + $browser->with( + new ListInput('#aliases'), + function (Browser $browser) { + $browser->addListEntry('invalid address'); + } + ); + + $browser->click('button[type=submit]'); + $browser->assertToast(Toast::TYPE_ERROR, 'Form validation error'); + + $browser->with(new ListInput('#aliases'), function (Browser $browser) { + $browser->assertFormError(2, 'The specified alias is invalid.', false); + }); + + // Test adding aliases + $browser->with( + new ListInput('#aliases'), + function (Browser $browser) { + $browser->removeListEntry(2); + $browser->addListEntry('john.test@' . $this->domain->namespace); + } + ); + + $browser->click('button[type=submit]'); + $browser->assertToast(Toast::TYPE_SUCCESS, 'User data updated successfully.'); + } + ); + + $browser->on(new UserList()); + $browser->click('@table tr:nth-child(' . (array_search($this->owner, $this->users) + 1) . ') a'); + $browser->on(new UserInfo()); + + $alias = $this->owner->aliases(); + $this->assertTrue(!empty($alias)); + + // Test subscriptions + $browser->with( + '@form', + function (Browser $browser) { + $browser->assertSeeIn('div.row:nth-child(9) label', 'Subscriptions'); + $browser->assertVisible('@skus.row:nth-child(9)'); + $browser->with( + '@skus', + function ($browser) { + $browser->assertElementsCount('tbody tr', 5); + // Mailbox SKU + $browser->assertSeeIn('tbody tr:nth-child(1) td.name', 'User Mailbox'); + $browser->assertSeeIn('tbody tr:nth-child(1) td.price', '4,44 CHF/month'); + $browser->assertChecked('tbody tr:nth-child(1) td.selection input'); + $browser->assertDisabled('tbody tr:nth-child(1) td.selection input'); + $browser->assertTip( + 'tbody tr:nth-child(1) td.buttons button', + 'Just a mailbox' + ); + + // Storage SKU + $browser->assertSeeIn('tbody tr:nth-child(2) td.name', 'Storage Quota'); + $browser->assertSeeIn('tr:nth-child(2) td.price', '0,00 CHF/month'); + $browser->assertChecked('tbody tr:nth-child(2) td.selection input'); + $browser->assertDisabled('tbody tr:nth-child(2) td.selection input'); + $browser->assertTip( + 'tbody tr:nth-child(2) td.buttons button', + 'Some wiggle room' + ); + + $browser->with( + new QuotaInput('tbody tr:nth-child(2) .range-input'), + function ($browser) { + $browser->assertQuotaValue(2)->setQuotaValue(3); + } + ); + + $browser->assertSeeIn('tr:nth-child(2) td.price', '0,25 CHF/month'); + + // groupware SKU + $browser->assertSeeIn('tbody tr:nth-child(3) td.name', 'Groupware Features'); + $browser->assertSeeIn('tbody tr:nth-child(3) td.price', '5,55 CHF/month'); + $browser->assertChecked('tbody tr:nth-child(3) td.selection input'); + $browser->assertEnabled('tbody tr:nth-child(3) td.selection input'); + $browser->assertTip( + 'tbody tr:nth-child(3) td.buttons button', + 'Groupware functions like Calendar, Tasks, Notes, etc.' + ); + + // ActiveSync SKU + $browser->assertSeeIn('tbody tr:nth-child(4) td.name', 'Activesync'); + $browser->assertSeeIn('tbody tr:nth-child(4) td.price', '1,00 CHF/month'); + $browser->assertNotChecked('tbody tr:nth-child(4) td.selection input'); + $browser->assertEnabled('tbody tr:nth-child(4) td.selection input'); + $browser->assertTip( + 'tbody tr:nth-child(4) td.buttons button', + 'Mobile synchronization' + ); + + // 2FA SKU + $browser->assertSeeIn('tbody tr:nth-child(5) td.name', '2-Factor Authentication'); + $browser->assertSeeIn('tbody tr:nth-child(5) td.price', '0,00 CHF/month'); + $browser->assertNotChecked('tbody tr:nth-child(5) td.selection input'); + $browser->assertEnabled('tbody tr:nth-child(5) td.selection input'); + $browser->assertTip( + 'tbody tr:nth-child(5) td.buttons button', + 'Two factor authentication for webmail and administration panel' + ); + + $browser->click('tbody tr:nth-child(4) td.selection input'); + } + ); + + $browser->assertMissing('@skus table + .hint'); + $browser->click('button[type=submit]'); + $browser->assertToast(Toast::TYPE_SUCCESS, 'User data updated successfully.'); + } + ); + + $browser->on(new UserList()); + $browser->click('@table tr:nth-child(' (array_search($this->owner, $this->users) + 1) . ') a'); + $browser->on(new UserInfo()); + + $expected = ['activesync', 'groupware', 'mailbox', 'storage', 'storage', 'storage']; + $this->assertUserEntitlements($john, $expected); + + // Test subscriptions interaction + $browser->with( + '@form', + function (Browser $browser) { + $browser->with( + '@skus', + function ($browser) { + // Uncheck 'groupware', expect activesync unchecked + $browser->click('#sku-input-groupware'); + $browser->assertNotChecked('#sku-input-groupware'); + $browser->assertNotChecked('#sku-input-activesync'); + $browser->assertEnabled('#sku-input-activesync'); + $browser->assertNotReadonly('#sku-input-activesync'); + + // Check 'activesync', expect an alert + $browser->click('#sku-input-activesync'); + $browser->assertDialogOpened('Activesync requires Groupware Features.'); + $browser->acceptDialog(); + $browser->assertNotChecked('#sku-input-activesync'); + + // Check '2FA', expect 'activesync' unchecked and readonly + $browser->click('#sku-input-2fa'); + $browser->assertChecked('#sku-input-2fa'); + $browser->assertNotChecked('#sku-input-activesync'); + $browser->assertReadonly('#sku-input-activesync'); + + // Uncheck '2FA' + $browser->click('#sku-input-2fa'); + $browser->assertNotChecked('#sku-input-2fa'); + $browser->assertNotReadonly('#sku-input-activesync'); + } + ); + } + ); + } + ); } /** * Test user adding page * - * @depends testList + * @depends testUsersListPageAsOwner */ public function testNewUser(): void { $this->browse(function (Browser $browser) { $browser->visit(new UserList()) ->assertSeeIn('button.create-user', 'Create user') ->click('button.create-user') ->on(new UserInfo()) ->assertSeeIn('#user-info .card-title', 'New user account') ->with('@form', function (Browser $browser) { // Assert form content $browser->assertFocused('div.row:nth-child(1) input') ->assertSeeIn('div.row:nth-child(1) label', 'First name') ->assertValue('div.row:nth-child(1) input[type=text]', '') ->assertSeeIn('div.row:nth-child(2) label', 'Last name') ->assertValue('div.row:nth-child(2) input[type=text]', '') ->assertSeeIn('div.row:nth-child(3) label', 'Organization') ->assertValue('div.row:nth-child(3) input[type=text]', '') ->assertSeeIn('div.row:nth-child(4) label', 'Email') ->assertValue('div.row:nth-child(4) input[type=text]', '') ->assertEnabled('div.row:nth-child(4) input[type=text]') ->assertSeeIn('div.row:nth-child(5) label', 'Email aliases') ->assertVisible('div.row:nth-child(5) .list-input') ->with(new ListInput('#aliases'), function (Browser $browser) { $browser->assertListInputValue([]) ->assertValue('@input', ''); }) ->assertSeeIn('div.row:nth-child(6) label', 'Password') ->assertValue('div.row:nth-child(6) input[type=password]', '') ->assertSeeIn('div.row:nth-child(7) label', 'Confirm password') ->assertValue('div.row:nth-child(7) input[type=password]', '') ->assertSeeIn('div.row:nth-child(8) label', 'Package') // assert packages list widget, select "Lite Account" ->with('@packages', function ($browser) { $browser->assertElementsCount('tbody tr', 2) ->assertSeeIn('tbody tr:nth-child(1)', 'Groupware Account') ->assertSeeIn('tbody tr:nth-child(2)', 'Lite Account') ->assertSeeIn('tbody tr:nth-child(1) .price', '9,99 CHF/month') ->assertSeeIn('tbody tr:nth-child(2) .price', '4,44 CHF/month') ->assertChecked('tbody tr:nth-child(1) input') ->click('tbody tr:nth-child(2) input') ->assertNotChecked('tbody tr:nth-child(1) input') ->assertChecked('tbody tr:nth-child(2) input'); }) ->assertMissing('@packages table + .hint') ->assertSeeIn('button[type=submit]', 'Submit'); // Test browser-side required fields and error handling $browser->click('button[type=submit]') ->assertFocused('#email') ->type('#email', 'invalid email') ->click('button[type=submit]') ->assertFocused('#password') ->type('#password', 'simple123') ->click('button[type=submit]') ->assertFocused('#password_confirmation') ->type('#password_confirmation', 'simple') ->click('button[type=submit]') ->assertToast(Toast::TYPE_ERROR, 'Form validation error') ->assertSeeIn('#email + .invalid-feedback', 'The specified email is invalid.') ->assertSeeIn('#password + .invalid-feedback', 'The password confirmation does not match.'); }); // Test form error handling (aliases) $browser->with('@form', function (Browser $browser) { $browser->type('#email', 'julia.roberts@kolab.org') ->type('#password_confirmation', 'simple123') ->with(new ListInput('#aliases'), function (Browser $browser) { $browser->addListEntry('invalid address'); }) ->click('button[type=submit]') ->assertToast(Toast::TYPE_ERROR, 'Form validation error') ->with(new ListInput('#aliases'), function (Browser $browser) { $browser->assertFormError(1, 'The specified alias is invalid.', false); }); }); // Successful account creation - $browser->with('@form', function (Browser $browser) { - $browser->type('#first_name', 'Julia') - ->type('#last_name', 'Roberts') - ->type('#organization', 'Test Org') - ->with(new ListInput('#aliases'), function (Browser $browser) { - $browser->removeListEntry(1) - ->addListEntry('julia.roberts2@kolab.org'); - }) - ->click('button[type=submit]'); - }) - ->assertToast(Toast::TYPE_SUCCESS, 'User created successfully.') + $browser->with( + '@form', + function (Browser $browser) { + $browser->type('#first_name', 'Julia'); + $browser->type('#last_name', 'Roberts'); + $browser->type('#organization', 'Test Org'); + $browser->with( + new ListInput('#aliases'), + function (Browser $browser) { + $browser->removeListEntry(1)->addListEntry('julia.roberts2@kolab.org'); + } + ); + + $browser->click('button[type=submit]'); + } + ); + + $browser->assertToast(Toast::TYPE_SUCCESS, 'User created successfully.'); + // check redirection to users list - ->on(new UserList()) + $browser->on(new UserList()) ->whenAvailable('@table', function (Browser $browser) { $browser->assertElementsCount('tbody tr', 5) ->assertSeeIn('tbody tr:nth-child(4) a', 'julia.roberts@kolab.org'); }); $julia = User::where('email', 'julia.roberts@kolab.org')->first(); $alias = UserAlias::where('user_id', $julia->id)->where('alias', 'julia.roberts2@kolab.org')->first(); $this->assertTrue(!empty($alias)); $this->assertUserEntitlements($julia, ['mailbox', 'storage', 'storage']); $this->assertSame('Julia', $julia->getSetting('first_name')); $this->assertSame('Roberts', $julia->getSetting('last_name')); $this->assertSame('Test Org', $julia->getSetting('organization')); // Some additional tests for the list input widget $browser->click('tbody tr:nth-child(4) a') ->on(new UserInfo()) ->with(new ListInput('#aliases'), function (Browser $browser) { $browser->assertListInputValue(['julia.roberts2@kolab.org']) ->addListEntry('invalid address') ->type('.input-group:nth-child(2) input', '@kolab.org'); }) ->click('button[type=submit]') ->assertToast(Toast::TYPE_ERROR, 'Form validation error') ->with(new ListInput('#aliases'), function (Browser $browser) { $browser->assertVisible('.input-group:nth-child(2) input.is-invalid') ->assertVisible('.input-group:nth-child(3) input.is-invalid') ->type('.input-group:nth-child(2) input', 'julia.roberts3@kolab.org') ->type('.input-group:nth-child(3) input', 'julia.roberts4@kolab.org'); }) ->click('button[type=submit]') ->assertToast(Toast::TYPE_SUCCESS, 'User data updated successfully.'); $julia = User::where('email', 'julia.roberts@kolab.org')->first(); $aliases = $julia->aliases()->orderBy('alias')->get()->pluck('alias')->all(); $this->assertSame(['julia.roberts3@kolab.org', 'julia.roberts4@kolab.org'], $aliases); }); } /** * Test user delete * * @depends testNewUser */ public function testDeleteUser(): void { // First create a new user $john = $this->getTestUser('john@kolab.org'); $julia = $this->getTestUser('julia.roberts@kolab.org'); $package_kolab = \App\Package::where('title', 'kolab')->first(); $john->assignPackage($package_kolab, $julia); // Test deleting non-controller user $this->browse(function (Browser $browser) { $browser->visit(new UserList()) ->whenAvailable('@table', function (Browser $browser) { $browser->assertElementsCount('tbody tr', 5) ->assertSeeIn('tbody tr:nth-child(4) a', 'julia.roberts@kolab.org') ->click('tbody tr:nth-child(4) button.button-delete'); }) ->with(new Dialog('#delete-warning'), function (Browser $browser) { $browser->assertSeeIn('@title', 'Delete julia.roberts@kolab.org') ->assertFocused('@button-cancel') ->assertSeeIn('@button-cancel', 'Cancel') ->assertSeeIn('@button-action', 'Delete') ->click('@button-cancel'); }) ->whenAvailable('@table', function (Browser $browser) { $browser->click('tbody tr:nth-child(4) button.button-delete'); }) ->with(new Dialog('#delete-warning'), function (Browser $browser) { $browser->click('@button-action'); }) ->assertToast(Toast::TYPE_SUCCESS, 'User deleted successfully.') ->with('@table', function (Browser $browser) { $browser->assertElementsCount('tbody tr', 4) ->assertSeeIn('tbody tr:nth-child(1) a', 'jack@kolab.org') ->assertSeeIn('tbody tr:nth-child(2) a', 'joe@kolab.org') ->assertSeeIn('tbody tr:nth-child(3) a', 'john@kolab.org') ->assertSeeIn('tbody tr:nth-child(4) a', 'ned@kolab.org'); }); $julia = User::where('email', 'julia.roberts@kolab.org')->first(); $this->assertTrue(empty($julia)); // Test clicking Delete on the controller record redirects to /profile/delete $browser ->with('@table', function (Browser $browser) { $browser->click('tbody tr:nth-child(3) button.button-delete'); }) ->waitForLocation('/profile/delete'); }); // Test that non-controller user cannot see/delete himself on the users list // Note: Access to /profile/delete page is tested in UserProfileTest.php $this->browse(function (Browser $browser) { $browser->visit('/logout') ->on(new Home()) ->submitLogon('jack@kolab.org', 'simple123', true) ->visit(new UserList()) ->whenAvailable('@table', function (Browser $browser) { $browser->assertElementsCount('tbody tr', 0) ->assertSeeIn('tfoot td', 'There are no users in this account.'); }); }); // Test that controller user (Ned) can see/delete all the users ??? $this->browse(function (Browser $browser) { $browser->visit('/logout') ->on(new Home()) ->submitLogon('ned@kolab.org', 'simple123', true) ->visit(new UserList()) ->whenAvailable('@table', function (Browser $browser) { $browser->assertElementsCount('tbody tr', 4) ->assertElementsCount('tbody button.button-delete', 4); }); // TODO: Test the delete action in details }); // TODO: Test what happens with the logged in user session after he's been deleted by another user } /** * Test discounted sku/package prices in the UI */ public function testDiscountedPrices(): void { // Add 10% discount $discount = Discount::where('code', 'TEST')->first(); $john = User::where('email', 'john@kolab.org')->first(); $wallet = $john->wallet(); $wallet->discount()->associate($discount); $wallet->save(); // SKUs on user edit page $this->browse(function (Browser $browser) { $browser->visit('/logout') ->on(new Home()) ->submitLogon('john@kolab.org', 'simple123', true) ->visit(new UserList()) ->waitFor('@table tr:nth-child(2)') ->click('@table tr:nth-child(2) a') ->on(new UserInfo()) ->with('@form', function (Browser $browser) { $browser->whenAvailable('@skus', function (Browser $browser) { $quota_input = new QuotaInput('tbody tr:nth-child(2) .range-input'); $browser->waitFor('tbody tr') ->assertElementsCount('tbody tr', 5) // Mailbox SKU ->assertSeeIn('tbody tr:nth-child(1) td.price', '3,99 CHF/month¹') // Storage SKU ->assertSeeIn('tr:nth-child(2) td.price', '0,00 CHF/month¹') ->with($quota_input, function (Browser $browser) { $browser->setQuotaValue(100); }) ->assertSeeIn('tr:nth-child(2) td.price', '21,56 CHF/month¹') // groupware SKU ->assertSeeIn('tbody tr:nth-child(3) td.price', '4,99 CHF/month¹') // ActiveSync SKU ->assertSeeIn('tbody tr:nth-child(4) td.price', '0,90 CHF/month¹') // 2FA SKU ->assertSeeIn('tbody tr:nth-child(5) td.price', '0,00 CHF/month¹'); }) ->assertSeeIn('@skus table + .hint', '¹ applied discount: 10% - Test voucher'); }); }); // Packages on new user page $this->browse(function (Browser $browser) { $browser->visit(new UserList()) ->click('button.create-user') ->on(new UserInfo()) ->with('@form', function (Browser $browser) { $browser->whenAvailable('@packages', function (Browser $browser) { $browser->assertElementsCount('tbody tr', 2) ->assertSeeIn('tbody tr:nth-child(1) .price', '8,99 CHF/month¹') // Groupware ->assertSeeIn('tbody tr:nth-child(2) .price', '3,99 CHF/month¹'); // Lite }) ->assertSeeIn('@packages table + .hint', '¹ applied discount: 10% - Test voucher'); }); }); } } diff --git a/src/tests/Feature/Jobs/UserUpdateTest.php b/src/tests/Feature/Jobs/UserUpdateTest.php index ab57a564..2ec60bee 100644 --- a/src/tests/Feature/Jobs/UserUpdateTest.php +++ b/src/tests/Feature/Jobs/UserUpdateTest.php @@ -1,87 +1,87 @@ deleteTestUser('new-job-user@' . \config('app.domain')); } /** * {@inheritDoc} */ public function tearDown(): void { $this->deleteTestUser('new-job-user@' . \config('app.domain')); parent::tearDown(); } /** * Test job handle * * @group ldap */ public function testHandle(): void { // Ignore any jobs created here (e.g. on setAliases() use) Queue::fake(); $user = $this->getTestUser('new-job-user@' . \config('app.domain')); // Create the user in LDAP $job = new \App\Jobs\User\CreateJob($user->id); $job->handle(); // Test setting two aliases $aliases = [ 'new-job-user1@' . \config('app.domain'), 'new-job-user2@' . \config('app.domain'), ]; $user->setAliases($aliases); $job = new \App\Jobs\User\UpdateJob($user->id); $job->handle(); $ldap_user = LDAP::getUser('new-job-user@' . \config('app.domain')); - $this->assertSame($aliases, $ldap_user['alias']); + $this->assertSame($aliases, $ldap_user['alias'], var_export($ldap_user, true)); // Test updating aliases list $aliases = [ 'new-job-user1@' . \config('app.domain'), ]; $user->setAliases($aliases); $job = new \App\Jobs\User\UpdateJob($user->id); $job->handle(); $ldap_user = LDAP::getUser('new-job-user@' . \config('app.domain')); $this->assertSame($aliases, (array) $ldap_user['alias']); // Test unsetting aliases list $aliases = []; $user->setAliases($aliases); $job = new \App\Jobs\User\UpdateJob($user->id); $job->handle(); $ldap_user = LDAP::getUser('new-job-user@' . \config('app.domain')); $this->assertTrue(empty($ldap_user['alias'])); } }