diff --git a/src/.env.example b/src/.env.example index 6da75f74..ff90e3a4 100644 --- a/src/.env.example +++ b/src/.env.example @@ -1,175 +1,176 @@ APP_NAME=Kolab APP_ENV=local APP_KEY= APP_DEBUG=true APP_URL=http://127.0.0.1:8000 #APP_PASSPHRASE= APP_PUBLIC_URL= APP_DOMAIN=kolabnow.com APP_WEBSITE_DOMAIN=kolabnow.com APP_THEME=default APP_TENANT_ID=5 APP_LOCALE=en APP_LOCALES= APP_WITH_ADMIN=1 APP_WITH_RESELLER=1 APP_WITH_SERVICES=1 +APP_WITH_FILES=1 APP_HEADER_CSP="connect-src 'self'; child-src 'self'; font-src 'self'; form-action 'self' data:; frame-ancestors 'self'; img-src blob: data: 'self' *; media-src 'self'; object-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-eval' 'unsafe-inline'; default-src 'self';" APP_HEADER_XFO=sameorigin SIGNUP_LIMIT_EMAIL=0 SIGNUP_LIMIT_IP=0 ASSET_URL=http://127.0.0.1:8000 WEBMAIL_URL=/apps SUPPORT_URL=/support SUPPORT_EMAIL= LOG_CHANNEL=stack LOG_SLOW_REQUESTS=5 LOG_DEPRECATIONS_CHANNEL=null LOG_LEVEL=debug DB_CONNECTION=mysql DB_DATABASE=kolabdev DB_HOST=127.0.0.1 DB_PASSWORD=kolab DB_PORT=3306 DB_USERNAME=kolabdev BROADCAST_DRIVER=redis CACHE_DRIVER=redis QUEUE_CONNECTION=redis SESSION_DRIVER=file SESSION_LIFETIME=120 OPENEXCHANGERATES_API_KEY="from openexchangerates.org" MFA_DSN=mysql://roundcube:Welcome2KolabSystems@127.0.0.1/roundcube MFA_TOTP_DIGITS=6 MFA_TOTP_INTERVAL=30 MFA_TOTP_DIGEST=sha1 IMAP_URI=ssl://127.0.0.1:11993 IMAP_ADMIN_LOGIN=cyrus-admin IMAP_ADMIN_PASSWORD=Welcome2KolabSystems IMAP_VERIFY_HOST=false IMAP_VERIFY_PEER=false LDAP_BASE_DN="dc=mgmt,dc=com" LDAP_DOMAIN_BASE_DN="ou=Domains,dc=mgmt,dc=com" LDAP_HOSTS=127.0.0.1 LDAP_PORT=389 LDAP_SERVICE_BIND_DN="uid=kolab-service,ou=Special Users,dc=mgmt,dc=com" LDAP_SERVICE_BIND_PW="Welcome2KolabSystems" LDAP_USE_SSL=false LDAP_USE_TLS=false # Administrative LDAP_ADMIN_BIND_DN="cn=Directory Manager" LDAP_ADMIN_BIND_PW="Welcome2KolabSystems" LDAP_ADMIN_ROOT_DN="dc=mgmt,dc=com" # Hosted (public registration) LDAP_HOSTED_BIND_DN="uid=hosted-kolab-service,ou=Special Users,dc=mgmt,dc=com" LDAP_HOSTED_BIND_PW="Welcome2KolabSystems" LDAP_HOSTED_ROOT_DN="dc=hosted,dc=com" COTURN_PUBLIC_IP=127.0.0.1 COTURN_STATIC_SECRET="Welcome2KolabSystems" MEET_WEBHOOK_TOKEN=Welcome2KolabSystems MEET_SERVER_TOKEN=Welcome2KolabSystems MEET_SERVER_URLS=https://localhost:12443/meetmedia/api/ MEET_SERVER_VERIFY_TLS=true MEET_WEBRTC_LISTEN_IP= MEET_PUBLIC_DOMAIN=127.0.0.1:12443 MEET_TURN_SERVER='turn:127.0.0.1:3478?transport=tcp' PGP_ENABLED= PGP_BINARY= PGP_AGENT= PGP_GPGCONF= PGP_LENGTH= # Set these to IP addresses you serve WOAT with. # Have the domain owner point _woat. NS RRs refer to ns0{1,2}. WOAT_NS1=ns01.domain.tld WOAT_NS2=ns02.domain.tld REDIS_HOST=127.0.0.1 REDIS_PASSWORD=null REDIS_PORT=6379 OCTANE_HTTP_HOST=127.0.0.1 SWOOLE_PACKAGE_MAX_LENGTH=10485760 PAYMENT_PROVIDER= MOLLIE_KEY= STRIPE_KEY= STRIPE_PUBLIC_KEY= STRIPE_WEBHOOK_SECRET= MAIL_MAILER=smtp MAIL_HOST=smtp.mailtrap.io MAIL_PORT=2525 MAIL_USERNAME=null MAIL_PASSWORD=null MAIL_ENCRYPTION=null MAIL_FROM_ADDRESS="noreply@example.com" MAIL_FROM_NAME="Example.com" MAIL_REPLYTO_ADDRESS="replyto@example.com" MAIL_REPLYTO_NAME=null DNS_TTL=3600 DNS_SPF="v=spf1 mx -all" DNS_STATIC="%s. MX 10 ext-mx01.mykolab.com." DNS_COPY_FROM=null AWS_ACCESS_KEY_ID= AWS_SECRET_ACCESS_KEY= AWS_DEFAULT_REGION=us-east-1 AWS_BUCKET= AWS_USE_PATH_STYLE_ENDPOINT=false PUSHER_APP_ID= PUSHER_APP_KEY= PUSHER_APP_SECRET= PUSHER_APP_CLUSTER=mt1 MIX_ASSET_PATH='/' MIX_PUSHER_APP_KEY="${PUSHER_APP_KEY}" MIX_PUSHER_APP_CLUSTER="${PUSHER_APP_CLUSTER}" # Generate with ./artisan passport:client --password #PASSPORT_PROXY_OAUTH_CLIENT_ID= #PASSPORT_PROXY_OAUTH_CLIENT_SECRET= # Generate with ./artisan passport:client --password #PASSPORT_COMPANIONAPP_OAUTH_CLIENT_ID= #PASSPORT_COMPANIONAPP_OAUTH_CLIENT_SECRET= PASSPORT_PRIVATE_KEY= PASSPORT_PUBLIC_KEY= PASSWORD_POLICY= COMPANY_NAME= COMPANY_ADDRESS= COMPANY_DETAILS= COMPANY_EMAIL= COMPANY_LOGO= COMPANY_FOOTER= VAT_COUNTRIES=CH,LI VAT_RATE=7.7 KB_ACCOUNT_DELETE= KB_ACCOUNT_SUSPENDED= KB_PAYMENT_SYSTEM= diff --git a/src/app/Handlers/Beta/Base.php b/src/app/Handlers/Beta/Base.php deleted file mode 100644 index 7d32c9e0..00000000 --- a/src/app/Handlers/Beta/Base.php +++ /dev/null @@ -1,91 +0,0 @@ -active) { - return $object->hasSku('beta'); - } else { - if ($object->entitlements()->where('sku_id', $sku->id)->first()) { - return true; - } - } - - return false; - } - - /** - * SKU handler metadata. - * - * @param \App\Sku $sku The SKU object - * - * @return array - */ - public static function metadata(\App\Sku $sku): array - { - $data = parent::metadata($sku); - - $data['required'] = ['Beta']; - - return $data; - } - - /** - * Prerequisites for the Entitlement to be applied to the object. - * - * @param \App\Entitlement $entitlement - * @param mixed $object - * - * @return bool - */ - public static function preReq($entitlement, $object): bool - { - if (!parent::preReq($entitlement, $object)) { - return false; - } - - // TODO: User has to have the "beta" entitlement - - return true; - } - - /** - * The priority that specifies the order of SKUs in UI. - * Higher number means higher on the list. - * - * @return int - */ - public static function priority(): int - { - return 10; - } -} diff --git a/src/app/Handlers/Beta/Distlists.php b/src/app/Handlers/Beta/Distlists.php deleted file mode 100644 index 5b6cb5a9..00000000 --- a/src/app/Handlers/Beta/Distlists.php +++ /dev/null @@ -1,28 +0,0 @@ -wallet()->entitlements() - ->where('entitleable_type', \App\Domain::class)->count() > 0; - } - - return false; - } -} diff --git a/src/app/Handlers/Beta/Resources.php b/src/app/Handlers/Beta/Resources.php deleted file mode 100644 index 9091bb5d..00000000 --- a/src/app/Handlers/Beta/Resources.php +++ /dev/null @@ -1,28 +0,0 @@ -wallet()->entitlements() - ->where('entitleable_type', \App\Domain::class)->count() > 0; - } - - return false; - } -} diff --git a/src/app/Handlers/Beta/SharedFolders.php b/src/app/Handlers/Beta/SharedFolders.php deleted file mode 100644 index 3801e155..00000000 --- a/src/app/Handlers/Beta/SharedFolders.php +++ /dev/null @@ -1,28 +0,0 @@ -wallet()->entitlements() - ->where('entitleable_type', \App\Domain::class)->count() > 0; - } - - return false; - } -} diff --git a/src/app/Handlers/Files.php b/src/app/Handlers/Files.php deleted file mode 100644 index b0f53269..00000000 --- a/src/app/Handlers/Files.php +++ /dev/null @@ -1,7 +0,0 @@ -guard()->user(); $search = trim(request()->input('search')); $page = intval(request()->input('page')) ?: 1; $pageSize = 20; $hasMore = false; $result = $user->users(); // Search by user email, alias or name if (strlen($search) > 0) { // thanks to cloning we skip some extra queries in $user->users() $allUsers1 = clone $result; $allUsers2 = clone $result; $result->whereLike('email', $search) ->union( $allUsers1->join('user_aliases', 'users.id', '=', 'user_aliases.user_id') ->whereLike('alias', $search) ) ->union( $allUsers2->join('user_settings', 'users.id', '=', 'user_settings.user_id') ->whereLike('value', $search) ->whereIn('key', ['first_name', 'last_name']) ); } $result = $result->orderBy('email') ->limit($pageSize + 1) ->offset($pageSize * ($page - 1)) ->get(); if (count($result) > $pageSize) { $result->pop(); $hasMore = true; } // Process the result $result = $result->map( function ($user) { return $this->objectToClient($user); } ); $result = [ 'list' => $result, 'count' => count($result), 'hasMore' => $hasMore, ]; return response()->json($result); } /** * Display information on the user account specified by $id. * * @param string $id The account to show information for. * * @return \Illuminate\Http\JsonResponse */ public function show($id) { $user = User::find($id); if (!$this->checkTenant($user)) { return $this->errorResponse(404); } if (!$this->guard()->user()->canRead($user)) { return $this->errorResponse(403); } $response = $this->userResponse($user); $response['skus'] = \App\Entitlement::objectEntitlementsSummary($user); $response['config'] = $user->getConfig(); $response['aliases'] = $user->aliases()->pluck('alias')->all(); $code = $user->verificationcodes()->where('active', true) ->where('expires_at', '>', \Carbon\Carbon::now()) ->first(); if ($code) { $response['passwordLinkCode'] = $code->short_code . '-' . $code->code; } return response()->json($response); } /** * User status (extended) information * * @param \App\User $user User object * * @return array Status information */ public static function statusInfo($user): array { $process = self::processStateInfo( $user, [ 'user-new' => true, 'user-ldap-ready' => $user->isLdapReady(), 'user-imap-ready' => $user->isImapReady(), ] ); // Check if the user is a controller of his wallet $isController = $user->canDelete($user); $hasCustomDomain = $user->wallet()->entitlements() ->where('entitleable_type', Domain::class) ->count() > 0; // Get user's entitlements titles $skus = $user->entitlements()->select('skus.title') ->join('skus', 'skus.id', '=', 'entitlements.sku_id') ->get() ->pluck('title') ->sort() ->unique() ->values() ->all(); + $hasBeta = in_array('beta', $skus); + $result = [ 'skus' => $skus, // TODO: This will change when we enable all users to create domains 'enableDomains' => $isController && $hasCustomDomain, // TODO: Make 'enableDistlists' working for wallet controllers that aren't account owners - 'enableDistlists' => $isController && $hasCustomDomain && in_array('beta-distlists', $skus), - 'enableFiles' => in_array('files', $skus), + 'enableDistlists' => $isController && $hasCustomDomain && $hasBeta, + 'enableFiles' => !$isDegraded && $hasBeta && \config('app.with_files'), // TODO: Make 'enableFolders' working for wallet controllers that aren't account owners - 'enableFolders' => $isController && $hasCustomDomain && in_array('beta-shared-folders', $skus), + 'enableFolders' => $isController && $hasCustomDomain && $hasBeta, // TODO: Make 'enableResources' working for wallet controllers that aren't account owners - 'enableResources' => $isController && $hasCustomDomain && in_array('beta-resources', $skus), + 'enableResources' => $isController && $hasCustomDomain && $hasBeta, + 'enableRooms' => !$isDegraded, 'enableSettings' => $isController, 'enableUsers' => $isController, 'enableWallets' => $isController, - 'enableCompanionapps' => $isController && in_array('beta', $skus), + 'enableCompanionapps' => $hasBeta, ]; return array_merge($process, $result); } /** * Create a new user record. * * @param \Illuminate\Http\Request $request The API request. * * @return \Illuminate\Http\JsonResponse The response */ public function store(Request $request) { $current_user = $this->guard()->user(); $owner = $current_user->walletOwner(); if ($owner->id != $current_user->id) { return $this->errorResponse(403); } $this->deleteBeforeCreate = null; if ($error_response = $this->validateUserRequest($request, null, $settings)) { return $error_response; } if (empty($request->package) || !($package = \App\Package::withEnvTenantContext()->find($request->package))) { $errors = ['package' => \trans('validation.packagerequired')]; return response()->json(['status' => 'error', 'errors' => $errors], 422); } if ($package->isDomain()) { $errors = ['package' => \trans('validation.packageinvalid')]; return response()->json(['status' => 'error', 'errors' => $errors], 422); } DB::beginTransaction(); // @phpstan-ignore-next-line if ($this->deleteBeforeCreate) { $this->deleteBeforeCreate->forceDelete(); } // Create user record $user = User::create([ 'email' => $request->email, 'password' => $request->password, ]); $this->activatePassCode($user); $owner->assignPackage($package, $user); if (!empty($settings)) { $user->setSettings($settings); } if (!empty($request->aliases)) { $user->setAliases($request->aliases); } DB::commit(); return response()->json([ 'status' => 'success', 'message' => \trans('app.user-create-success'), ]); } /** * Update user data. * * @param \Illuminate\Http\Request $request The API request. * @param string $id User identifier * * @return \Illuminate\Http\JsonResponse The response */ public function update(Request $request, $id) { $user = User::withEnvTenantContext()->find($id); if (empty($user)) { return $this->errorResponse(404); } $current_user = $this->guard()->user(); // TODO: Decide what attributes a user can change on his own profile if (!$current_user->canUpdate($user)) { return $this->errorResponse(403); } if ($error_response = $this->validateUserRequest($request, $user, $settings)) { return $error_response; } // Entitlements, only controller can do that if ($request->skus !== null && !$current_user->canDelete($user)) { return $this->errorResponse(422, "You have no permission to change entitlements"); } DB::beginTransaction(); $this->updateEntitlements($user, $request->skus); if (!empty($settings)) { $user->setSettings($settings); } if (!empty($request->password)) { $user->password = $request->password; $user->save(); } $this->activatePassCode($user); if (isset($request->aliases)) { $user->setAliases($request->aliases); } DB::commit(); $response = [ 'status' => 'success', 'message' => \trans('app.user-update-success'), ]; // For self-update refresh the statusInfo in the UI if ($user->id == $current_user->id) { $response['statusInfo'] = self::statusInfo($user); } return response()->json($response); } /** * Update user entitlements. * * @param \App\User $user The user * @param array $rSkus List of SKU IDs requested for the user in the form [id=>qty] */ protected function updateEntitlements(User $user, $rSkus) { if (!is_array($rSkus)) { return; } // list of skus, [id=>obj] $skus = Sku::withEnvTenantContext()->get()->mapWithKeys( function ($sku) { return [$sku->id => $sku]; } ); // existing entitlement's SKUs $eSkus = []; $user->entitlements()->groupBy('sku_id') ->selectRaw('count(*) as total, sku_id')->each( function ($e) use (&$eSkus) { $eSkus[$e->sku_id] = $e->total; } ); foreach ($skus as $skuID => $sku) { $e = array_key_exists($skuID, $eSkus) ? $eSkus[$skuID] : 0; $r = array_key_exists($skuID, $rSkus) ? $rSkus[$skuID] : 0; if ($sku->handler_class == \App\Handlers\Mailbox::class) { if ($r != 1) { throw new \Exception("Invalid quantity of mailboxes"); } } if ($e > $r) { // remove those entitled more than existing $user->removeSku($sku, ($e - $r)); } elseif ($e < $r) { // add those requested more than entitled $user->assignSku($sku, ($r - $e)); } } } /** * Create a response data array for specified user. * * @param \App\User $user User object * * @return array Response data */ public static function userResponse(User $user): array { $response = array_merge($user->toArray(), self::objectState($user)); // Settings $response['settings'] = []; foreach ($user->settings()->whereIn('key', self::USER_SETTINGS)->get() as $item) { $response['settings'][$item->key] = $item->value; } // Status info $response['statusInfo'] = self::statusInfo($user); // Add more info to the wallet object output $map_func = function ($wallet) use ($user) { $result = $wallet->toArray(); if ($wallet->discount) { $result['discount'] = $wallet->discount->discount; $result['discount_description'] = $wallet->discount->description; } if ($wallet->user_id != $user->id) { $result['user_email'] = $wallet->owner->email; } $provider = \App\Providers\PaymentProvider::factory($wallet); $result['provider'] = $provider->name(); return $result; }; // Information about wallets and accounts for access checks $response['wallets'] = $user->wallets->map($map_func)->toArray(); $response['accounts'] = $user->accounts->map($map_func)->toArray(); $response['wallet'] = $map_func($user->wallet()); return $response; } /** * Prepare user statuses for the UI * * @param \App\User $user User object * * @return array Statuses array */ protected static function objectState($user): array { $state = parent::objectState($user); $state['isAccountDegraded'] = $user->isDegraded(true); return $state; } /** * Validate user input * * @param \Illuminate\Http\Request $request The API request. * @param \App\User|null $user User identifier * @param array $settings User settings (from the request) * * @return \Illuminate\Http\JsonResponse|null The error response on error */ protected function validateUserRequest(Request $request, $user, &$settings = []) { $rules = [ 'external_email' => 'nullable|email', 'phone' => 'string|nullable|max:64|regex:/^[0-9+() -]+$/', 'first_name' => 'string|nullable|max:128', 'last_name' => 'string|nullable|max:128', 'organization' => 'string|nullable|max:512', 'billing_address' => 'string|nullable|max:1024', 'country' => 'string|nullable|alpha|size:2', 'currency' => 'string|nullable|alpha|size:3', 'aliases' => 'array|nullable', ]; $controller = ($user ?: $this->guard()->user())->walletOwner(); // Handle generated password reset code if ($code = $request->input('passwordLinkCode')) { // Accept - input if (strpos($code, '-')) { $code = explode('-', $code)[1]; } $this->passCode = $this->guard()->user()->verificationcodes() ->where('code', $code)->where('active', false)->first(); // Generate a password for a new user with password reset link // FIXME: Should/can we have a user with no password set? if ($this->passCode && empty($user)) { $request->password = $request->password_confirmation = Str::random(16); $ignorePassword = true; } } if (empty($user) || !empty($request->password) || !empty($request->password_confirmation)) { if (empty($ignorePassword)) { $rules['password'] = ['required', 'confirmed', new Password($controller)]; } } $errors = []; // Validate input $v = Validator::make($request->all(), $rules); if ($v->fails()) { $errors = $v->errors()->toArray(); } // For new user validate email address if (empty($user)) { $email = $request->email; if (empty($email)) { $errors['email'] = \trans('validation.required', ['attribute' => 'email']); } elseif ($error = self::validateEmail($email, $controller, $this->deleteBeforeCreate)) { $errors['email'] = $error; } } // Validate aliases input if (isset($request->aliases)) { $aliases = []; $existing_aliases = $user ? $user->aliases()->get()->pluck('alias')->toArray() : []; foreach ($request->aliases as $idx => $alias) { if (is_string($alias) && !empty($alias)) { // Alias cannot be the same as the email address (new user) if (!empty($email) && Str::lower($alias) == Str::lower($email)) { continue; } // validate new aliases if ( !in_array($alias, $existing_aliases) && ($error = self::validateAlias($alias, $controller)) ) { if (!isset($errors['aliases'])) { $errors['aliases'] = []; } $errors['aliases'][$idx] = $error; continue; } $aliases[] = $alias; } } $request->aliases = $aliases; } if (!empty($errors)) { return response()->json(['status' => 'error', 'errors' => $errors], 422); } // Update user settings $settings = $request->only(array_keys($rules)); unset($settings['password'], $settings['aliases'], $settings['email']); return null; } /** * Execute (synchronously) specified step in a user setup process. * * @param \App\User $user User object * @param string $step Step identifier (as in self::statusInfo()) * * @return bool|null True if the execution succeeded, False if not, Null when * the job has been sent to the worker (result unknown) */ public static function execProcessStep(User $user, string $step): ?bool { try { if (strpos($step, 'domain-') === 0) { list ($local, $domain) = explode('@', $user->email); $domain = Domain::where('namespace', $domain)->first(); return DomainsController::execProcessStep($domain, $step); } switch ($step) { case 'user-ldap-ready': // User not in LDAP, create it $job = new \App\Jobs\User\CreateJob($user->id); $job->handle(); $user->refresh(); return $user->isLdapReady(); case 'user-imap-ready': // User not in IMAP? Verify again // Do it synchronously if the imap admin credentials are available // otherwise let the worker do the job if (!\config('imap.admin_password')) { \App\Jobs\User\VerifyJob::dispatch($user->id); return null; } $job = new \App\Jobs\User\VerifyJob($user->id); $job->handle(); $user->refresh(); return $user->isImapReady(); } } catch (\Exception $e) { \Log::error($e); } return false; } /** * Email address validation for use as a user mailbox (login). * * @param string $email Email address * @param \App\User $user The account owner * @param null|\App\User|\App\Group $deleted Filled with an instance of a deleted user or group * with the specified email address, if exists * * @return ?string Error message on validation error */ public static function validateEmail(string $email, \App\User $user, &$deleted = null): ?string { $deleted = null; if (strpos($email, '@') === false) { return \trans('validation.entryinvalid', ['attribute' => 'email']); } list($login, $domain) = explode('@', Str::lower($email)); if (strlen($login) === 0 || strlen($domain) === 0) { return \trans('validation.entryinvalid', ['attribute' => 'email']); } // Check if domain exists $domain = Domain::withObjectTenantContext($user)->where('namespace', $domain)->first(); if (empty($domain)) { return \trans('validation.domaininvalid'); } // Validate login part alone $v = Validator::make( ['email' => $login], ['email' => ['required', new UserEmailLocal(!$domain->isPublic())]] ); if ($v->fails()) { return $v->errors()->toArray()['email'][0]; } // Check if it is one of domains available to the user if (!$domain->isPublic() && $user->id != $domain->walletOwner()->id) { return \trans('validation.entryexists', ['attribute' => 'domain']); } // Check if a user/group/resource/shared folder with specified address already exists if ( ($existing = User::emailExists($email, true)) || ($existing = \App\Group::emailExists($email, true)) || ($existing = \App\Resource::emailExists($email, true)) || ($existing = \App\SharedFolder::emailExists($email, true)) ) { // If this is a deleted user/group/resource/folder in the same custom domain // we'll force delete it before creating the target user if (!$domain->isPublic() && $existing->trashed()) { $deleted = $existing; } else { return \trans('validation.entryexists', ['attribute' => 'email']); } } // Check if an alias with specified address already exists. if (User::aliasExists($email) || \App\SharedFolder::aliasExists($email)) { return \trans('validation.entryexists', ['attribute' => 'email']); } return null; } /** * Email address validation for use as an alias. * * @param string $email Email address * @param \App\User $user The account owner * * @return ?string Error message on validation error */ public static function validateAlias(string $email, \App\User $user): ?string { if (strpos($email, '@') === false) { return \trans('validation.entryinvalid', ['attribute' => 'alias']); } list($login, $domain) = explode('@', Str::lower($email)); if (strlen($login) === 0 || strlen($domain) === 0) { return \trans('validation.entryinvalid', ['attribute' => 'alias']); } // Check if domain exists $domain = Domain::withObjectTenantContext($user)->where('namespace', $domain)->first(); if (empty($domain)) { return \trans('validation.domaininvalid'); } // Validate login part alone $v = Validator::make( ['alias' => $login], ['alias' => ['required', new UserEmailLocal(!$domain->isPublic())]] ); if ($v->fails()) { return $v->errors()->toArray()['alias'][0]; } // Check if it is one of domains available to the user if (!$domain->isPublic() && $user->id != $domain->walletOwner()->id) { return \trans('validation.entryexists', ['attribute' => 'domain']); } // Check if a user with specified address already exists if ($existing_user = User::emailExists($email, true)) { // Allow an alias in a custom domain to an address that was a user before if ($domain->isPublic() || !$existing_user->trashed()) { return \trans('validation.entryexists', ['attribute' => 'alias']); } } // Check if a group/resource/shared folder with specified address already exists if ( \App\Group::emailExists($email) || \App\Resource::emailExists($email) || \App\SharedFolder::emailExists($email) ) { return \trans('validation.entryexists', ['attribute' => 'alias']); } // Check if an alias with specified address already exists if (User::aliasExists($email) || \App\SharedFolder::aliasExists($email)) { // Allow assigning the same alias to a user in the same group account, // but only for non-public domains if ($domain->isPublic()) { return \trans('validation.entryexists', ['attribute' => 'alias']); } } return null; } /** * Activate password reset code (if set), and assign it to a user. * * @param \App\User $user The user */ protected function activatePassCode(User $user): void { // Activate the password reset code if ($this->passCode) { $this->passCode->user_id = $user->id; $this->passCode->active = true; $this->passCode->save(); } } } diff --git a/src/database/migrations/2022_03_02_100000_create_filesystem_tables.php b/src/database/migrations/2022_03_02_100000_create_filesystem_tables.php index bb500b67..03314f35 100644 --- a/src/database/migrations/2022_03_02_100000_create_filesystem_tables.php +++ b/src/database/migrations/2022_03_02_100000_create_filesystem_tables.php @@ -1,93 +1,91 @@ string('id', 36)->primary(); $table->bigInteger('user_id')->index(); $table->integer('type')->unsigned()->default(0); $table->timestamps(); $table->softDeletes(); $table->foreign('user_id')->references('id')->on('users') ->onUpdate('cascade')->onDelete('cascade'); } ); Schema::create( 'fs_properties', function (Blueprint $table) { $table->bigIncrements('id'); $table->string('item_id', 36); $table->string('key')->index(); $table->text('value'); $table->timestamps(); $table->unique(['item_id', 'key']); $table->foreign('item_id')->references('id')->on('fs_items') ->onDelete('cascade')->onUpdate('cascade'); } ); Schema::create( 'fs_chunks', function (Blueprint $table) { $table->bigIncrements('id'); $table->string('item_id', 36); $table->string('chunk_id', 36); $table->integer('sequence')->default(0); $table->integer('size')->unsigned()->default(0); $table->timestamps(); $table->softDeletes(); $table->unique(['item_id', 'chunk_id']); // $table->unique(['item_id', 'sequence', 'deleted_at']); $table->foreign('item_id')->references('id')->on('fs_items') ->onUpdate('cascade')->onDelete('cascade'); } ); if (\config('app.with_files') && !\App\Sku::where('title', 'files')->first()) { \App\Sku::create([ 'title' => 'files', 'name' => 'File storage', 'description' => 'Access to file storage', 'cost' => 0, 'units_free' => 0, 'period' => 'monthly', 'handler_class' => 'App\Handlers\Files', 'active' => true, ]); } } /** * Reverse the migrations. */ public function down(): void { - if (\App\Sku::where('title', 'files')->first()) { - \App\Sku::where('title', 'files')->delete(); - } + \App\Sku::where('title', 'files')->delete(); Schema::dropIfExists('fs_properties'); Schema::dropIfExists('fs_chunks'); Schema::dropIfExists('fs_items'); } }; diff --git a/src/database/migrations/2022_06_03_100000_drop_beta_skus.php b/src/database/migrations/2022_06_03_100000_drop_beta_skus.php new file mode 100644 index 00000000..cda5e82d --- /dev/null +++ b/src/database/migrations/2022_06_03_100000_drop_beta_skus.php @@ -0,0 +1,67 @@ +delete(); + + \App\Sku::where('title', 'beta')->update(['description' => 'Access to the private beta program features']); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + \App\Sku::create([ + 'title' => 'beta-distlists', + 'name' => 'Distribution lists', + 'description' => 'Access to mail distribution lists', + 'cost' => 0, + 'units_free' => 0, + 'period' => 'monthly', + 'handler_class' => 'App\Handlers\Beta\Distlists', + 'active' => true, + ]); + \App\Sku::create([ + 'title' => 'beta-resources', + 'name' => 'Calendaring resources', + 'description' => 'Access to calendaring resources', + 'cost' => 0, + 'units_free' => 0, + 'period' => 'monthly', + 'handler_class' => 'App\Handlers\Beta\Resources', + 'active' => true, + ]); + \App\Sku::create([ + 'title' => 'beta-shared-folders', + 'name' => 'Shared folders', + 'description' => 'Access to shared folders', + 'cost' => 0, + 'units_free' => 0, + 'period' => 'monthly', + 'handler_class' => 'App\Handlers\Beta\SharedFolders', + 'active' => true, + ]); + \App\Sku::create([ + 'title' => 'files', + 'name' => 'File storage', + 'description' => 'Access to file storage', + 'cost' => 0, + 'units_free' => 0, + 'period' => 'monthly', + 'handler_class' => 'App\Handlers\Files', + 'active' => true, + ]); + } +}; diff --git a/src/database/seeds/local/SkuSeeder.php b/src/database/seeds/local/SkuSeeder.php index cf009f2f..fb6e09e2 100644 --- a/src/database/seeds/local/SkuSeeder.php +++ b/src/database/seeds/local/SkuSeeder.php @@ -1,380 +1,314 @@ 'mailbox', 'name' => 'User Mailbox', 'description' => 'Just a mailbox', 'cost' => 500, 'units_free' => 0, 'period' => 'monthly', 'handler_class' => 'App\Handlers\Mailbox', 'active' => true, ] ); Sku::create( [ 'title' => 'domain', 'name' => 'Hosted Domain', 'description' => 'Somewhere to place a mailbox', 'cost' => 100, 'period' => 'monthly', 'handler_class' => 'App\Handlers\Domain', 'active' => false, ] ); Sku::create( [ 'title' => 'domain-registration', 'name' => 'Domain Registration', 'description' => 'Register a domain with us', 'cost' => 101, 'period' => 'yearly', 'handler_class' => 'App\Handlers\DomainRegistration', 'active' => false, ] ); Sku::create( [ 'title' => 'domain-hosting', 'name' => 'External Domain', 'description' => 'Host a domain that is externally registered', 'cost' => 100, 'units_free' => 1, 'period' => 'monthly', 'handler_class' => 'App\Handlers\DomainHosting', 'active' => true, ] ); Sku::create( [ 'title' => 'domain-relay', 'name' => 'Domain Relay', 'description' => 'A domain you host at home, for which we relay email', 'cost' => 103, 'period' => 'monthly', 'handler_class' => 'App\Handlers\DomainRelay', 'active' => false, ] ); Sku::create( [ 'title' => 'storage', 'name' => 'Storage Quota', 'description' => 'Some wiggle room', 'cost' => 25, 'units_free' => 5, 'period' => 'monthly', 'handler_class' => 'App\Handlers\Storage', 'active' => true, ] ); Sku::create( [ 'title' => 'groupware', 'name' => 'Groupware Features', 'description' => 'Groupware functions like Calendar, Tasks, Notes, etc.', 'cost' => 490, 'units_free' => 0, 'period' => 'monthly', 'handler_class' => 'App\Handlers\Groupware', 'active' => true, ] ); Sku::create( [ 'title' => 'resource', 'name' => 'Resource', 'description' => 'Reservation taker', 'cost' => 101, 'period' => 'monthly', 'handler_class' => 'App\Handlers\Resource', 'active' => true, ] ); Sku::create( [ 'title' => 'shared-folder', 'name' => 'Shared Folder', 'description' => 'A shared folder', 'cost' => 89, 'period' => 'monthly', 'handler_class' => 'App\Handlers\SharedFolder', 'active' => true, ] ); Sku::create( [ 'title' => '2fa', 'name' => '2-Factor Authentication', 'description' => 'Two factor authentication for webmail and administration panel', 'cost' => 0, 'units_free' => 0, 'period' => 'monthly', 'handler_class' => 'App\Handlers\Auth2F', 'active' => true, ] ); Sku::create( [ 'title' => 'activesync', 'name' => 'Activesync', 'description' => 'Mobile synchronization', 'cost' => 0, 'units_free' => 0, 'period' => 'monthly', 'handler_class' => 'App\Handlers\Activesync', 'active' => true, ] ); // Check existence because migration might have added this already $sku = Sku::where(['title' => 'beta', 'tenant_id' => \config('app.tenant_id')])->first(); if (!$sku) { Sku::create( [ 'title' => 'beta', 'name' => 'Private Beta (invitation only)', - 'description' => 'Access to the private beta program subscriptions', + 'description' => 'Access to the private beta program features', 'cost' => 0, 'units_free' => 0, 'period' => 'monthly', 'handler_class' => 'App\Handlers\Beta', 'active' => false, ] ); } // Check existence because migration might have added this already $sku = Sku::where(['title' => 'meet', 'tenant_id' => \config('app.tenant_id')])->first(); if (!$sku) { Sku::create( [ 'title' => 'meet', 'name' => 'Voice & Video Conferencing (public beta)', 'description' => 'Video conferencing tool', 'cost' => 0, 'units_free' => 0, 'period' => 'monthly', 'handler_class' => 'App\Handlers\Meet', 'active' => true, ] ); } // Check existence because migration might have added this already $sku = Sku::where(['title' => 'group', 'tenant_id' => \config('app.tenant_id')])->first(); if (!$sku) { Sku::create( [ 'title' => 'group', 'name' => 'Group', 'description' => 'Distribution list', 'cost' => 0, 'units_free' => 0, 'period' => 'monthly', 'handler_class' => 'App\Handlers\Group', 'active' => true, ] ); } - // Check existence because migration might have added this already - $sku = Sku::where(['title' => 'beta-distlists', 'tenant_id' => \config('app.tenant_id')])->first(); - - if (!$sku) { - Sku::create( - [ - 'title' => 'beta-distlists', - 'name' => 'Distribution lists', - 'description' => 'Access to mail distribution lists', - 'cost' => 0, - 'units_free' => 0, - 'period' => 'monthly', - 'handler_class' => 'App\Handlers\Beta\Distlists', - 'active' => true, - ] - ); - } - - // Check existence because migration might have added this already - $sku = Sku::where(['title' => 'beta-resources', 'tenant_id' => \config('app.tenant_id')])->first(); - - if (!$sku) { - Sku::create([ - 'title' => 'beta-resources', - 'name' => 'Calendaring resources', - 'description' => 'Access to calendaring resources', - 'cost' => 0, - 'units_free' => 0, - 'period' => 'monthly', - 'handler_class' => 'App\Handlers\Beta\Resources', - 'active' => true, - ]); - } - - // Check existence because migration might have added this already - $sku = Sku::where(['title' => 'beta-shared-folders', 'tenant_id' => \config('app.tenant_id')])->first(); - - if (!$sku) { - Sku::create([ - 'title' => 'beta-shared-folders', - 'name' => 'Shared folders', - 'description' => 'Access to shared folders', - 'cost' => 0, - 'units_free' => 0, - 'period' => 'monthly', - 'handler_class' => 'App\Handlers\Beta\SharedFolders', - 'active' => true, - ]); - } - - // Check existence because migration might have added this already - $sku = Sku::where(['title' => 'files', 'tenant_id' => \config('app.tenant_id')])->first(); - - if (!$sku) { - Sku::create([ - 'title' => 'files', - 'name' => 'File storage', - 'description' => 'Access to file storage', - 'cost' => 0, - 'units_free' => 0, - 'period' => 'monthly', - 'handler_class' => 'App\Handlers\Files', - 'active' => true, - ]); - } - // for tenants that are not the configured tenant id $tenants = \App\Tenant::where('id', '!=', \config('app.tenant_id'))->get(); foreach ($tenants as $tenant) { $sku = Sku::create( [ 'title' => 'mailbox', 'name' => 'User Mailbox', 'description' => 'Just a mailbox', 'cost' => 500, 'fee' => 333, 'units_free' => 0, 'period' => 'monthly', 'handler_class' => 'App\Handlers\Mailbox', 'active' => true, ] ); $sku->tenant_id = $tenant->id; $sku->save(); $sku = Sku::create( [ 'title' => 'storage', 'name' => 'Storage Quota', 'description' => 'Some wiggle room', 'cost' => 25, 'fee' => 16, 'units_free' => 5, 'period' => 'monthly', 'handler_class' => 'App\Handlers\Storage', 'active' => true, ] ); $sku->tenant_id = $tenant->id; $sku->save(); $sku = Sku::create( [ 'title' => 'domain-hosting', 'name' => 'External Domain', 'description' => 'Host a domain that is externally registered', 'cost' => 100, 'fee' => 66, 'units_free' => 1, 'period' => 'monthly', 'handler_class' => 'App\Handlers\DomainHosting', 'active' => true, ] ); $sku->tenant_id = $tenant->id; $sku->save(); $sku = Sku::create( [ 'title' => 'groupware', 'name' => 'Groupware Features', 'description' => 'Groupware functions like Calendar, Tasks, Notes, etc.', 'cost' => 490, 'fee' => 327, 'units_free' => 0, 'period' => 'monthly', 'handler_class' => 'App\Handlers\Groupware', 'active' => true, ] ); $sku->tenant_id = $tenant->id; $sku->save(); $sku = Sku::create( [ 'title' => '2fa', 'name' => '2-Factor Authentication', 'description' => 'Two factor authentication for webmail and administration panel', 'cost' => 0, 'units_free' => 0, 'period' => 'monthly', 'handler_class' => 'App\Handlers\Auth2F', 'active' => true, ] ); $sku->tenant_id = $tenant->id; $sku->save(); $sku = Sku::create( [ 'title' => 'activesync', 'name' => 'Activesync', 'description' => 'Mobile synchronization', 'cost' => 0, 'units_free' => 0, 'period' => 'monthly', 'handler_class' => 'App\Handlers\Activesync', 'active' => true, ] ); $sku->tenant_id = $tenant->id; $sku->save(); } } } diff --git a/src/database/seeds/production/SkuSeeder.php b/src/database/seeds/production/SkuSeeder.php index 3a505ca2..f239d16e 100644 --- a/src/database/seeds/production/SkuSeeder.php +++ b/src/database/seeds/production/SkuSeeder.php @@ -1,259 +1,203 @@ 'mailbox', 'name' => 'User Mailbox', 'description' => 'Just a mailbox', 'cost' => 444, 'units_free' => 0, 'period' => 'monthly', 'handler_class' => 'App\Handlers\Mailbox', 'active' => true, ] ); Sku::create( [ 'title' => 'domain', 'name' => 'Hosted Domain', 'description' => 'Somewhere to place a mailbox', 'cost' => 100, 'period' => 'monthly', 'handler_class' => 'App\Handlers\Domain', 'active' => false, ] ); Sku::create( [ 'title' => 'domain-registration', 'name' => 'Domain Registration', 'description' => 'Register a domain with us', 'cost' => 101, 'period' => 'yearly', 'handler_class' => 'App\Handlers\DomainRegistration', 'active' => false, ] ); Sku::create( [ 'title' => 'domain-hosting', 'name' => 'External Domain', 'description' => 'Host a domain that is externally registered', 'cost' => 100, 'units_free' => 1, 'period' => 'monthly', 'handler_class' => 'App\Handlers\DomainHosting', 'active' => true, ] ); Sku::create( [ 'title' => 'domain-relay', 'name' => 'Domain Relay', 'description' => 'A domain you host at home, for which we relay email', 'cost' => 103, 'period' => 'monthly', 'handler_class' => 'App\Handlers\DomainRelay', 'active' => false, ] ); Sku::create( [ 'title' => 'storage', 'name' => 'Storage Quota', 'description' => 'Some wiggle room', 'cost' => 50, 'units_free' => 2, 'period' => 'monthly', 'handler_class' => 'App\Handlers\Storage', 'active' => true, ] ); Sku::create( [ 'title' => 'groupware', 'name' => 'Groupware Features', 'description' => 'Groupware functions like Calendar, Tasks, Notes, etc.', 'cost' => 555, 'units_free' => 0, 'period' => 'monthly', 'handler_class' => 'App\Handlers\Groupware', 'active' => true, ] ); Sku::create( [ 'title' => 'resource', 'name' => 'Resource', 'description' => 'Reservation taker', 'cost' => 0, 'period' => 'monthly', 'handler_class' => 'App\Handlers\Resource', 'active' => true, ] ); Sku::create( [ 'title' => 'shared-folder', 'name' => 'Shared Folder', 'description' => 'A shared folder', 'cost' => 89, 'period' => 'monthly', 'handler_class' => 'App\Handlers\SharedFolder', 'active' => false, ] ); Sku::create( [ 'title' => '2fa', 'name' => '2-Factor Authentication', 'description' => 'Two factor authentication for webmail and administration panel', 'cost' => 0, 'units_free' => 0, 'period' => 'monthly', 'handler_class' => 'App\Handlers\Auth2F', 'active' => true, ] ); Sku::create( [ 'title' => 'activesync', 'name' => 'Activesync', 'description' => 'Mobile synchronization', 'cost' => 100, 'units_free' => 0, 'period' => 'monthly', 'handler_class' => 'App\Handlers\Activesync', 'active' => true, ] ); // Check existence because migration might have added this already if (!Sku::where('title', 'beta')->first()) { Sku::create( [ 'title' => 'beta', 'name' => 'Private Beta (invitation only)', 'description' => 'Access to the private beta program subscriptions', 'cost' => 0, 'units_free' => 0, 'period' => 'monthly', 'handler_class' => 'App\Handlers\Beta', 'active' => false, ] ); } // Check existence because migration might have added this already if (!Sku::where('title', 'meet')->first()) { Sku::create( [ 'title' => 'meet', 'name' => 'Voice & Video Conferencing (public beta)', 'description' => 'Video conferencing tool', 'cost' => 0, 'units_free' => 0, 'period' => 'monthly', 'handler_class' => 'App\Handlers\Meet', 'active' => true, ] ); } // Check existence because migration might have added this already if (!Sku::where('title', 'group')->first()) { Sku::create( [ 'title' => 'group', 'name' => 'Group', 'description' => 'Distribution list', 'cost' => 0, 'units_free' => 0, 'period' => 'monthly', 'handler_class' => 'App\Handlers\Group', 'active' => true, ] ); } - - // Check existence because migration might have added this already - if (!Sku::where('title', 'beta-distlists')->first()) { - Sku::create([ - 'title' => 'beta-distlists', - 'name' => 'Distribution lists', - 'description' => 'Access to mail distribution lists', - 'cost' => 0, - 'units_free' => 0, - 'period' => 'monthly', - 'handler_class' => 'App\Handlers\Beta\Distlists', - 'active' => true, - ]); - } - - // Check existence because migration might have added this already - if (!Sku::where('title', 'beta-resources')->first()) { - Sku::create([ - 'title' => 'beta-resources', - 'name' => 'Calendaring resources', - 'description' => 'Access to calendaring resources', - 'cost' => 0, - 'units_free' => 0, - 'period' => 'monthly', - 'handler_class' => 'App\Handlers\Beta\Resources', - 'active' => true, - ]); - } - - // Check existence because migration might have added this already - if (!Sku::where('title', 'beta-shared-folders')->first()) { - Sku::create([ - 'title' => 'beta-shared-folders', - 'name' => 'Shared folders', - 'description' => 'Access to shared folders', - 'cost' => 0, - 'units_free' => 0, - 'period' => 'monthly', - 'handler_class' => 'App\Handlers\Beta\SharedFolders', - 'active' => true, - ]); - } - - // Check existence because migration might have added this already - if (!Sku::where('title', 'files')->first()) { - Sku::create([ - 'title' => 'files', - 'name' => 'File storage', - 'description' => 'Access to file storage', - 'cost' => 0, - 'units_free' => 0, - 'period' => 'monthly', - 'handler_class' => 'App\Handlers\Files', - 'active' => true, - ]); - } } } diff --git a/src/routes/api.php b/src/routes/api.php index e93c84e1..16184780 100644 --- a/src/routes/api.php +++ b/src/routes/api.php @@ -1,261 +1,263 @@ 'api', 'prefix' => 'auth' ], function () { Route::post('login', [API\AuthController::class, 'login']); Route::group( ['middleware' => 'auth:api'], function () { Route::get('info', [API\AuthController::class, 'info']); Route::post('info', [API\AuthController::class, 'info']); Route::post('logout', [API\AuthController::class, 'logout']); Route::post('refresh', [API\AuthController::class, 'refresh']); } ); } ); Route::group( [ 'domain' => \config('app.website_domain'), 'middleware' => 'api', 'prefix' => 'auth' ], function () { Route::post('password-policy/check', [API\PasswordPolicyController::class, 'check']); Route::post('password-reset/init', [API\PasswordResetController::class, 'init']); Route::post('password-reset/verify', [API\PasswordResetController::class, 'verify']); Route::post('password-reset', [API\PasswordResetController::class, 'reset']); Route::post('signup/init', [API\SignupController::class, 'init']); Route::get('signup/invitations/{id}', [API\SignupController::class, 'invitation']); Route::get('signup/plans', [API\SignupController::class, 'plans']); Route::post('signup/verify', [API\SignupController::class, 'verify']); Route::post('signup', [API\SignupController::class, 'signup']); } ); Route::group( [ 'domain' => \config('app.website_domain'), 'middleware' => 'auth:api', 'prefix' => 'v4' ], function () { Route::post('companion/register', [API\V4\CompanionAppsController::class, 'register']); Route::post('auth-attempts/{id}/confirm', [API\V4\AuthAttemptsController::class, 'confirm']); Route::post('auth-attempts/{id}/deny', [API\V4\AuthAttemptsController::class, 'deny']); Route::get('auth-attempts/{id}/details', [API\V4\AuthAttemptsController::class, 'details']); Route::get('auth-attempts', [API\V4\AuthAttemptsController::class, 'index']); Route::get('companion/pairing', [API\V4\CompanionAppsController::class, 'pairing']); Route::apiResource('companion', API\V4\CompanionAppsController::class); Route::post('companion/register', [API\V4\CompanionAppsController::class, 'register']); Route::post('companion/revoke', [API\V4\CompanionAppsController::class, 'revokeAll']); Route::apiResource('domains', API\V4\DomainsController::class); Route::get('domains/{id}/confirm', [API\V4\DomainsController::class, 'confirm']); Route::get('domains/{id}/skus', [API\V4\SkusController::class, 'domainSkus']); Route::get('domains/{id}/status', [API\V4\DomainsController::class, 'status']); Route::post('domains/{id}/config', [API\V4\DomainsController::class, 'setConfig']); - Route::apiResource('files', API\V4\FilesController::class); - Route::get('files/{fileId}/permissions', [API\V4\FilesController::class, 'getPermissions']); - Route::post('files/{fileId}/permissions', [API\V4\FilesController::class, 'createPermission']); - Route::put('files/{fileId}/permissions/{id}', [API\V4\FilesController::class, 'updatePermission']); - Route::delete('files/{fileId}/permissions/{id}', [API\V4\FilesController::class, 'deletePermission']); - Route::post('files/uploads/{id}', [API\V4\FilesController::class, 'upload']) - ->withoutMiddleware(['auth:api']) - ->middleware(['api']); - Route::get('files/downloads/{id}', [API\V4\FilesController::class, 'download']) - ->withoutMiddleware(['auth:api']); + if (\config('app.with_files')) { + Route::apiResource('files', API\V4\FilesController::class); + Route::get('files/{fileId}/permissions', [API\V4\FilesController::class, 'getPermissions']); + Route::post('files/{fileId}/permissions', [API\V4\FilesController::class, 'createPermission']); + Route::put('files/{fileId}/permissions/{id}', [API\V4\FilesController::class, 'updatePermission']); + Route::delete('files/{fileId}/permissions/{id}', [API\V4\FilesController::class, 'deletePermission']); + Route::post('files/uploads/{id}', [API\V4\FilesController::class, 'upload']) + ->withoutMiddleware(['auth:api']) + ->middleware(['api']); + Route::get('files/downloads/{id}', [API\V4\FilesController::class, 'download']) + ->withoutMiddleware(['auth:api']); + } Route::apiResource('groups', API\V4\GroupsController::class); Route::get('groups/{id}/status', [API\V4\GroupsController::class, 'status']); Route::post('groups/{id}/config', [API\V4\GroupsController::class, 'setConfig']); Route::apiResource('packages', API\V4\PackagesController::class); Route::get('meet/rooms', [API\V4\MeetController::class, 'index']); Route::post('meet/rooms/{id}/config', [API\V4\MeetController::class, 'setRoomConfig']); Route::post('meet/rooms/{id}', [API\V4\MeetController::class, 'joinRoom']) ->withoutMiddleware(['auth:api']); Route::apiResource('resources', API\V4\ResourcesController::class); Route::get('resources/{id}/status', [API\V4\ResourcesController::class, 'status']); Route::post('resources/{id}/config', [API\V4\ResourcesController::class, 'setConfig']); Route::apiResource('shared-folders', API\V4\SharedFoldersController::class); Route::get('shared-folders/{id}/status', [API\V4\SharedFoldersController::class, 'status']); Route::post('shared-folders/{id}/config', [API\V4\SharedFoldersController::class, 'setConfig']); Route::apiResource('skus', API\V4\SkusController::class); Route::apiResource('users', API\V4\UsersController::class); Route::post('users/{id}/config', [API\V4\UsersController::class, 'setConfig']); Route::get('users/{id}/skus', [API\V4\SkusController::class, 'userSkus']); Route::get('users/{id}/status', [API\V4\UsersController::class, 'status']); Route::apiResource('wallets', API\V4\WalletsController::class); Route::get('wallets/{id}/transactions', [API\V4\WalletsController::class, 'transactions']); Route::get('wallets/{id}/receipts', [API\V4\WalletsController::class, 'receipts']); Route::get('wallets/{id}/receipts/{receipt}', [API\V4\WalletsController::class, 'receiptDownload']); Route::get('password-policy', [API\PasswordPolicyController::class, 'index']); Route::post('password-reset/code', [API\PasswordResetController::class, 'codeCreate']); Route::delete('password-reset/code/{id}', [API\PasswordResetController::class, 'codeDelete']); Route::post('payments', [API\V4\PaymentsController::class, 'store']); //Route::delete('payments', [API\V4\PaymentsController::class, 'cancel']); Route::get('payments/mandate', [API\V4\PaymentsController::class, 'mandate']); Route::post('payments/mandate', [API\V4\PaymentsController::class, 'mandateCreate']); Route::put('payments/mandate', [API\V4\PaymentsController::class, 'mandateUpdate']); Route::delete('payments/mandate', [API\V4\PaymentsController::class, 'mandateDelete']); Route::get('payments/methods', [API\V4\PaymentsController::class, 'paymentMethods']); Route::get('payments/pending', [API\V4\PaymentsController::class, 'payments']); Route::get('payments/has-pending', [API\V4\PaymentsController::class, 'hasPayments']); Route::post('support/request', [API\V4\SupportController::class, 'request']) ->withoutMiddleware(['auth:api']) ->middleware(['api']); } ); Route::group( [ 'domain' => \config('app.website_domain'), 'prefix' => 'webhooks' ], function () { Route::post('payment/{provider}', [API\V4\PaymentsController::class, 'webhook']); Route::post('meet', [API\V4\MeetController::class, 'webhook']); } ); if (\config('app.with_services')) { Route::group( [ 'domain' => 'services.' . \config('app.website_domain'), 'prefix' => 'webhooks' ], function () { Route::get('nginx', [API\V4\NGINXController::class, 'authenticate']); Route::get('nginx-httpauth', [API\V4\NGINXController::class, 'httpauth']); Route::post('policy/greylist', [API\V4\PolicyController::class, 'greylist']); Route::post('policy/ratelimit', [API\V4\PolicyController::class, 'ratelimit']); Route::post('policy/spf', [API\V4\PolicyController::class, 'senderPolicyFramework']); } ); } if (\config('app.with_admin')) { Route::group( [ 'domain' => 'admin.' . \config('app.website_domain'), 'middleware' => ['auth:api', 'admin'], 'prefix' => 'v4', ], function () { Route::apiResource('domains', API\V4\Admin\DomainsController::class); Route::get('domains/{id}/skus', [API\V4\Admin\SkusController::class, 'domainSkus']); Route::post('domains/{id}/suspend', [API\V4\Admin\DomainsController::class, 'suspend']); Route::post('domains/{id}/unsuspend', [API\V4\Admin\DomainsController::class, 'unsuspend']); Route::apiResource('groups', API\V4\Admin\GroupsController::class); Route::post('groups/{id}/suspend', [API\V4\Admin\GroupsController::class, 'suspend']); Route::post('groups/{id}/unsuspend', [API\V4\Admin\GroupsController::class, 'unsuspend']); Route::apiResource('resources', API\V4\Admin\ResourcesController::class); Route::apiResource('shared-folders', API\V4\Admin\SharedFoldersController::class); Route::apiResource('skus', API\V4\Admin\SkusController::class); Route::apiResource('users', API\V4\Admin\UsersController::class); Route::get('users/{id}/discounts', [API\V4\Reseller\DiscountsController::class, 'userDiscounts']); Route::post('users/{id}/reset2FA', [API\V4\Admin\UsersController::class, 'reset2FA']); Route::get('users/{id}/skus', [API\V4\Admin\SkusController::class, 'userSkus']); Route::post('users/{id}/skus/{sku}', [API\V4\Admin\UsersController::class, 'setSku']); Route::post('users/{id}/suspend', [API\V4\Admin\UsersController::class, 'suspend']); Route::post('users/{id}/unsuspend', [API\V4\Admin\UsersController::class, 'unsuspend']); Route::apiResource('wallets', API\V4\Admin\WalletsController::class); Route::post('wallets/{id}/one-off', [API\V4\Admin\WalletsController::class, 'oneOff']); Route::get('wallets/{id}/transactions', [API\V4\Admin\WalletsController::class, 'transactions']); Route::get('stats/chart/{chart}', [API\V4\Admin\StatsController::class, 'chart']); } ); } if (\config('app.with_reseller')) { Route::group( [ 'domain' => 'reseller.' . \config('app.website_domain'), 'middleware' => ['auth:api', 'reseller'], 'prefix' => 'v4', ], function () { Route::apiResource('domains', API\V4\Reseller\DomainsController::class); Route::get('domains/{id}/skus', [API\V4\Reseller\SkusController::class, 'domainSkus']); Route::post('domains/{id}/suspend', [API\V4\Reseller\DomainsController::class, 'suspend']); Route::post('domains/{id}/unsuspend', [API\V4\Reseller\DomainsController::class, 'unsuspend']); Route::apiResource('groups', API\V4\Reseller\GroupsController::class); Route::post('groups/{id}/suspend', [API\V4\Reseller\GroupsController::class, 'suspend']); Route::post('groups/{id}/unsuspend', [API\V4\Reseller\GroupsController::class, 'unsuspend']); Route::apiResource('invitations', API\V4\Reseller\InvitationsController::class); Route::post('invitations/{id}/resend', [API\V4\Reseller\InvitationsController::class, 'resend']); Route::post('payments', [API\V4\Reseller\PaymentsController::class, 'store']); Route::get('payments/mandate', [API\V4\Reseller\PaymentsController::class, 'mandate']); Route::post('payments/mandate', [API\V4\Reseller\PaymentsController::class, 'mandateCreate']); Route::put('payments/mandate', [API\V4\Reseller\PaymentsController::class, 'mandateUpdate']); Route::delete('payments/mandate', [API\V4\Reseller\PaymentsController::class, 'mandateDelete']); Route::get('payments/methods', [API\V4\Reseller\PaymentsController::class, 'paymentMethods']); Route::get('payments/pending', [API\V4\Reseller\PaymentsController::class, 'payments']); Route::get('payments/has-pending', [API\V4\Reseller\PaymentsController::class, 'hasPayments']); Route::apiResource('resources', API\V4\Reseller\ResourcesController::class); Route::apiResource('shared-folders', API\V4\Reseller\SharedFoldersController::class); Route::apiResource('skus', API\V4\Reseller\SkusController::class); Route::apiResource('users', API\V4\Reseller\UsersController::class); Route::get('users/{id}/discounts', [API\V4\Reseller\DiscountsController::class, 'userDiscounts']); Route::post('users/{id}/reset2FA', [API\V4\Reseller\UsersController::class, 'reset2FA']); Route::get('users/{id}/skus', [API\V4\Reseller\SkusController::class, 'userSkus']); Route::post('users/{id}/skus/{sku}', [API\V4\Admin\UsersController::class, 'setSku']); Route::post('users/{id}/suspend', [API\V4\Reseller\UsersController::class, 'suspend']); Route::post('users/{id}/unsuspend', [API\V4\Reseller\UsersController::class, 'unsuspend']); Route::apiResource('wallets', API\V4\Reseller\WalletsController::class); Route::post('wallets/{id}/one-off', [API\V4\Reseller\WalletsController::class, 'oneOff']); Route::get('wallets/{id}/receipts', [API\V4\Reseller\WalletsController::class, 'receipts']); Route::get('wallets/{id}/receipts/{receipt}', [API\V4\Reseller\WalletsController::class, 'receiptDownload']); Route::get('wallets/{id}/transactions', [API\V4\Reseller\WalletsController::class, 'transactions']); Route::get('stats/chart/{chart}', [API\V4\Reseller\StatsController::class, 'chart']); } ); } diff --git a/src/tests/Browser/DegradedAccountTest.php b/src/tests/Browser/DegradedAccountTest.php index 04387825..5a28631a 100644 --- a/src/tests/Browser/DegradedAccountTest.php +++ b/src/tests/Browser/DegradedAccountTest.php @@ -1,125 +1,125 @@ getTestUser('john@kolab.org'); if (!$john->isDegraded()) { $john->status |= User::STATUS_DEGRADED; User::where('id', $john->id)->update(['status' => $john->status]); } $this->clearBetaEntitlements(); } /** * {@inheritDoc} */ public function tearDown(): void { $john = $this->getTestUser('john@kolab.org'); if ($john->isDegraded()) { $john->status ^= User::STATUS_DEGRADED; User::where('id', $john->id)->update(['status' => $john->status]); } $this->clearBetaEntitlements(); parent::tearDown(); } /** * Test acting as an owner of a degraded account */ public function testDegradedAccountOwner(): void { // Add beta+distlist entitlements $john = $this->getTestUser('john@kolab.org'); - $this->addBetaEntitlement($john, ['beta-distlists', 'beta-resources', 'beta-shared-folders']); + $this->addBetaEntitlement($john); $this->browse(function (Browser $browser) { $browser->visit(new Home()) ->submitLogon('john@kolab.org', 'simple123', true) ->on(new Dashboard()) ->assertSeeIn('#status-degraded p.alert', 'The account is degraded') ->assertSeeIn('#status-degraded p.alert', 'Please, make a payment'); // Goto /users and assert that the warning is also displayed there $browser->visit(new UserList()) ->assertSeeIn('#status-degraded p.alert', 'The account is degraded') ->assertSeeIn('#status-degraded p.alert', 'Please, make a payment') ->whenAvailable('@table', function (Browser $browser) { $browser->waitFor('tbody tr') ->assertVisible('tbody tr:nth-child(1) td:first-child svg.text-warning') // Jack ->assertText('tbody tr:nth-child(2) td:first-child svg.text-warning title', 'Degraded') ->assertVisible('tbody tr:nth-child(3) td:first-child svg.text-warning') // John ->assertText('tbody tr:nth-child(3) td:first-child svg.text-warning title', 'Degraded'); }) ->assertMissing('button.user-new'); // Goto /domains and assert that the warning is also displayed there $browser->visit(new DomainList()) ->assertSeeIn('#status-degraded p.alert', 'The account is degraded') ->assertSeeIn('#status-degraded p.alert', 'Please, make a payment') ->assertMissing('button.domain-new'); // Goto /distlists and assert that the warning is also displayed there $browser->visit(new DistlistList()) ->assertSeeIn('#status-degraded p.alert', 'The account is degraded') ->assertSeeIn('#status-degraded p.alert', 'Please, make a payment') ->assertMissing('button.distlist-new'); // Goto /resources and assert that the warning is also displayed there $browser->visit(new ResourceList()) ->assertSeeIn('#status-degraded p.alert', 'The account is degraded') ->assertSeeIn('#status-degraded p.alert', 'Please, make a payment') ->assertMissing('button.resource-new'); // Goto /shared-folders and assert that the warning is also displayed there $browser->visit(new SharedFolderList()) ->assertSeeIn('#status-degraded p.alert', 'The account is degraded') ->assertSeeIn('#status-degraded p.alert', 'Please, make a payment') ->assertMissing('button.shared-folder-new'); // Test that /rooms is not accessible $browser->visit('/rooms') ->waitFor('#app > #error-page') ->assertSeeIn('#error-page .code', '403'); }); } /** * Test acting as non-owner of a degraded account */ public function testDegradedAccountUser(): void { $this->browse(function (Browser $browser) { $browser->visit(new Home()) ->submitLogon('jack@kolab.org', 'simple123', true) ->on(new Dashboard()) ->assertSeeIn('#status-degraded p.alert', 'The account is degraded') ->assertDontSeeIn('#status-degraded p.alert', 'Please, make a payment'); }); } } diff --git a/src/tests/Browser/DistlistTest.php b/src/tests/Browser/DistlistTest.php index 5928d594..9ae9adec 100644 --- a/src/tests/Browser/DistlistTest.php +++ b/src/tests/Browser/DistlistTest.php @@ -1,314 +1,314 @@ deleteTestGroup('group-test@kolab.org'); $this->clearBetaEntitlements(); } /** * {@inheritDoc} */ public function tearDown(): void { $this->deleteTestGroup('group-test@kolab.org'); $this->clearBetaEntitlements(); parent::tearDown(); } /** * Test distlist info page (unauthenticated) */ public function testInfoUnauth(): void { // Test that the page requires authentication $this->browse(function (Browser $browser) { $browser->visit('/distlist/abc')->on(new Home()); }); } /** * Test distlist list page (unauthenticated) */ public function testListUnauth(): void { // Test that the page requires authentication $this->browse(function (Browser $browser) { $browser->visit('/distlists')->on(new Home()); }); } /** * Test distlist list page */ public function testList(): void { // Log on the user $this->browse(function (Browser $browser) { $browser->visit(new Home()) ->submitLogon('john@kolab.org', 'simple123', true) ->on(new Dashboard()) ->assertMissing('@links .link-distlists'); }); - // Test that Distribution lists page is not accessible without the 'beta-distlists' entitlement + // Test that Distribution lists page is not accessible without the 'beta' entitlement $this->browse(function (Browser $browser) { $browser->visit('/distlists') ->assertErrorPage(403); }); - // Create a single group, add beta+distlist entitlements + // Create a single group, add beta entitlement $john = $this->getTestUser('john@kolab.org'); - $this->addBetaEntitlement($john, 'beta-distlists'); + $this->addBetaEntitlement($john); $group = $this->getTestGroup('group-test@kolab.org', ['name' => 'Test Group']); $group->assignToWallet($john->wallets->first()); // Test distribution lists page $this->browse(function (Browser $browser) { $browser->visit(new Dashboard()) ->assertSeeIn('@links .link-distlists', 'Distribution lists') ->click('@links .link-distlists') ->on(new DistlistList()) ->whenAvailable('@table', function (Browser $browser) { $browser->waitFor('tbody tr') ->assertSeeIn('thead tr th:nth-child(1)', 'Name') ->assertSeeIn('thead tr th:nth-child(2)', 'Email') ->assertElementsCount('tbody tr', 1) ->assertSeeIn('tbody tr:nth-child(1) td:nth-child(1) a', 'Test Group') ->assertText('tbody tr:nth-child(1) td:nth-child(1) svg.text-danger title', 'Not Ready') ->assertSeeIn('tbody tr:nth-child(1) td:nth-child(2) a', 'group-test@kolab.org') ->assertMissing('tfoot'); }); }); } /** * Test distlist creation/editing/deleting * * @depends testList */ public function testCreateUpdateDelete(): void { - // Test that the page is not available accessible without the 'beta-distlists' entitlement + // Test that the page is not available accessible without the 'beta' entitlement $this->browse(function (Browser $browser) { $browser->visit('/distlist/new') ->assertErrorPage(403); }); // Add beta+distlist entitlements $john = $this->getTestUser('john@kolab.org'); - $this->addBetaEntitlement($john, 'beta-distlists'); + $this->addBetaEntitlement($john); $this->browse(function (Browser $browser) { // Create a group $browser->visit(new DistlistList()) ->assertSeeIn('button.distlist-new', 'Create list') ->click('button.distlist-new') ->on(new DistlistInfo()) ->assertSeeIn('#distlist-info .card-title', 'New distribution list') ->assertSeeIn('@nav #tab-general', 'General') ->assertMissing('@nav #tab-settings') ->with('@general', function (Browser $browser) { // Assert form content $browser->assertMissing('#status') ->assertFocused('#name') ->assertSeeIn('div.row:nth-child(1) label', 'Name') ->assertValue('div.row:nth-child(1) input[type=text]', '') ->assertSeeIn('div.row:nth-child(2) label', 'Email') ->assertValue('div.row:nth-child(2) input[type=text]', '') ->assertSeeIn('div.row:nth-child(3) label', 'Recipients') ->assertVisible('div.row:nth-child(3) .list-input') ->with(new ListInput('#members'), function (Browser $browser) { $browser->assertListInputValue([]) ->assertValue('@input', ''); }) ->assertSeeIn('button[type=submit]', 'Submit'); }) // Test error conditions ->type('#name', str_repeat('A', 192)) ->type('#email', 'group-test@kolabnow.com') ->click('@general button[type=submit]') ->waitFor('#members + .invalid-feedback') ->assertSeeIn('#email + .invalid-feedback', 'The specified domain is not available.') ->assertSeeIn('#name + .invalid-feedback', 'The name may not be greater than 191 characters.') ->assertSeeIn('#members + .invalid-feedback', 'At least one recipient is required.') ->assertFocused('#name') ->assertToast(Toast::TYPE_ERROR, 'Form validation error') // Test successful group creation ->type('#name', 'Test Group') ->type('#email', 'group-test@kolab.org') ->with(new ListInput('#members'), function (Browser $browser) { $browser->addListEntry('test1@gmail.com') ->addListEntry('test2@gmail.com'); }) ->click('@general button[type=submit]') ->assertToast(Toast::TYPE_SUCCESS, 'Distribution list created successfully.') ->on(new DistlistList()) ->assertElementsCount('@table tbody tr', 1); // Test group update $browser->click('@table tr:nth-child(1) td:first-child a') ->on(new DistlistInfo()) ->assertSeeIn('#distlist-info .card-title', 'Distribution list') ->with('@general', function (Browser $browser) { // Assert form content $browser->assertFocused('#name') ->assertSeeIn('div.row:nth-child(1) label', 'Status') ->assertSeeIn('div.row:nth-child(1) span.text-danger', 'Not Ready') ->assertSeeIn('div.row:nth-child(2) label', 'Name') ->assertValue('div.row:nth-child(2) input[type=text]', 'Test Group') ->assertSeeIn('div.row:nth-child(3) label', 'Email') ->assertValue('div.row:nth-child(3) input[type=text]:disabled', 'group-test@kolab.org') ->assertSeeIn('div.row:nth-child(4) label', 'Recipients') ->assertVisible('div.row:nth-child(4) .list-input') ->with(new ListInput('#members'), function (Browser $browser) { $browser->assertListInputValue(['test1@gmail.com', 'test2@gmail.com']) ->assertValue('@input', ''); }) ->assertSeeIn('button[type=submit]', 'Submit'); }) // Test error handling ->with(new ListInput('#members'), function (Browser $browser) { $browser->addListEntry('invalid address'); }) ->click('@general button[type=submit]') ->waitFor('#members + .invalid-feedback') ->assertSeeIn('#members + .invalid-feedback', 'The specified email address is invalid.') ->assertVisible('#members .input-group:nth-child(4) input.is-invalid') ->assertToast(Toast::TYPE_ERROR, 'Form validation error') // Test successful update ->with(new ListInput('#members'), function (Browser $browser) { $browser->removeListEntry(3)->removeListEntry(2); }) ->click('@general button[type=submit]') ->assertToast(Toast::TYPE_SUCCESS, 'Distribution list updated successfully.') ->assertMissing('.invalid-feedback') ->on(new DistlistList()) ->assertElementsCount('@table tbody tr', 1); $group = Group::where('email', 'group-test@kolab.org')->first(); $this->assertSame(['test1@gmail.com'], $group->members); // Test group deletion $browser->click('@table tr:nth-child(1) td:first-child a') ->on(new DistlistInfo()) ->assertSeeIn('button.button-delete', 'Delete list') ->click('button.button-delete') ->assertToast(Toast::TYPE_SUCCESS, 'Distribution list deleted successfully.') ->on(new DistlistList()) ->assertElementsCount('@table tbody tr', 0) ->assertVisible('@table tfoot'); $this->assertNull(Group::where('email', 'group-test@kolab.org')->first()); }); } /** * Test distribution list status * * @depends testList */ public function testStatus(): void { $john = $this->getTestUser('john@kolab.org'); - $this->addBetaEntitlement($john, 'beta-distlists'); + $this->addBetaEntitlement($john, 'beta'); $group = $this->getTestGroup('group-test@kolab.org'); $group->assignToWallet($john->wallets->first()); $group->status = Group::STATUS_NEW | Group::STATUS_ACTIVE; $group->save(); $this->assertFalse($group->isLdapReady()); $this->browse(function ($browser) use ($group) { // Test auto-refresh $browser->visit('/distlist/' . $group->id) ->on(new DistlistInfo()) ->with(new Status(), function ($browser) { $browser->assertSeeIn('@body', 'We are preparing the distribution list') ->assertProgress(83, 'Creating a distribution list...', 'pending') ->assertMissing('@refresh-button') ->assertMissing('@refresh-text') ->assertMissing('#status-link') ->assertMissing('#status-verify'); }); $group->status |= Group::STATUS_LDAP_READY; $group->save(); // Test Verify button $browser->waitUntilMissing('@status', 10); }); // TODO: Test all group statuses on the list } /** * Test distribution list settings */ public function testSettings(): void { $john = $this->getTestUser('john@kolab.org'); - $this->addBetaEntitlement($john, 'beta-distlists'); + $this->addBetaEntitlement($john); $group = $this->getTestGroup('group-test@kolab.org'); $group->assignToWallet($john->wallets->first()); $group->status = Group::STATUS_NEW | Group::STATUS_ACTIVE; $group->save(); $this->browse(function ($browser) use ($group) { // Test auto-refresh $browser->visit('/distlist/' . $group->id) ->on(new DistlistInfo()) ->assertSeeIn('@nav #tab-general', 'General') ->assertSeeIn('@nav #tab-settings', 'Settings') ->click('@nav #tab-settings') ->with('@settings form', function (Browser $browser) { // Assert form content $browser->assertSeeIn('div.row:nth-child(1) label', 'Sender Access List') ->assertVisible('div.row:nth-child(1) .list-input') ->with(new ListInput('#sender-policy'), function (Browser $browser) { $browser->assertListInputValue([]) ->assertValue('@input', ''); }) ->assertSeeIn('button[type=submit]', 'Submit'); }) // Test error handling ->with(new ListInput('#sender-policy'), function (Browser $browser) { $browser->addListEntry('test.com'); }) ->click('@settings button[type=submit]') ->assertToast(Toast::TYPE_SUCCESS, 'Distribution list settings updated successfully.') ->assertMissing('.invalid-feedback') ->refresh() ->on(new DistlistInfo()) ->click('@nav #tab-settings') ->with('@settings form', function (Browser $browser) { $browser->with(new ListInput('#sender-policy'), function (Browser $browser) { $browser->assertListInputValue(['test.com']) ->assertValue('@input', ''); }); }); }); } } diff --git a/src/tests/Browser/ResourceTest.php b/src/tests/Browser/ResourceTest.php index fba04676..374af761 100644 --- a/src/tests/Browser/ResourceTest.php +++ b/src/tests/Browser/ResourceTest.php @@ -1,301 +1,301 @@ delete(); $this->clearBetaEntitlements(); } /** * {@inheritDoc} */ public function tearDown(): void { Resource::whereNotIn('email', ['resource-test1@kolab.org', 'resource-test2@kolab.org'])->delete(); $this->clearBetaEntitlements(); parent::tearDown(); } /** * Test resource info page (unauthenticated) */ public function testInfoUnauth(): void { // Test that the page requires authentication $this->browse(function (Browser $browser) { $browser->visit('/resource/abc')->on(new Home()); }); } /** * Test resource list page (unauthenticated) */ public function testListUnauth(): void { // Test that the page requires authentication $this->browse(function (Browser $browser) { $browser->visit('/resources')->on(new Home()); }); } /** * Test resources list page */ public function testList(): void { // Log on the user $this->browse(function (Browser $browser) { $browser->visit(new Home()) ->submitLogon('john@kolab.org', 'simple123', true) ->on(new Dashboard()) ->assertMissing('@links .link-resources'); }); - // Test that Resources lists page is not accessible without the 'beta-resources' entitlement + // Test that Resources lists page is not accessible without the 'beta' entitlement $this->browse(function (Browser $browser) { $browser->visit('/resources') ->assertErrorPage(403); }); - // Add beta+beta-resources entitlements + // Add beta entitlements $john = $this->getTestUser('john@kolab.org'); - $this->addBetaEntitlement($john, 'beta-resources'); + $this->addBetaEntitlement($john); // Make sure the first resource is active $resource = $this->getTestResource('resource-test1@kolab.org'); $resource->status = Resource::STATUS_NEW | Resource::STATUS_ACTIVE | Resource::STATUS_LDAP_READY | Resource::STATUS_IMAP_READY; $resource->save(); // Test resources lists page $this->browse(function (Browser $browser) { $browser->visit(new Dashboard()) ->assertSeeIn('@links .link-resources', 'Resources') ->click('@links .link-resources') ->on(new ResourceList()) ->whenAvailable('@table', function (Browser $browser) { $browser->waitFor('tbody tr') ->assertSeeIn('thead tr th:nth-child(1)', 'Name') ->assertSeeIn('thead tr th:nth-child(2)', 'Email Address') ->assertElementsCount('tbody tr', 2) ->assertSeeIn('tbody tr:nth-child(1) td:nth-child(1) a', 'Conference Room #1') ->assertText('tbody tr:nth-child(1) td:nth-child(1) svg.text-success title', 'Active') ->assertSeeIn('tbody tr:nth-child(1) td:nth-child(2) a', 'kolab.org') ->assertMissing('tfoot'); }); }); } /** * Test resource creation/editing/deleting * * @depends testList */ public function testCreateUpdateDelete(): void { - // Test that the page is not available accessible without the 'beta-resources' entitlement + // Test that the page is not available accessible without the 'beta' entitlement $this->browse(function (Browser $browser) { $browser->visit('/resource/new') ->assertErrorPage(403); }); - // Add beta+beta-resource entitlements + // Add beta entitlements $john = $this->getTestUser('john@kolab.org'); - $this->addBetaEntitlement($john, 'beta-resources'); + $this->addBetaEntitlement($john); $this->browse(function (Browser $browser) { // Create a resource $browser->visit(new ResourceList()) ->assertSeeIn('button.resource-new', 'Create resource') ->click('button.resource-new') ->on(new ResourceInfo()) ->assertSeeIn('#resource-info .card-title', 'New resource') ->assertSeeIn('@nav #tab-general', 'General') ->assertMissing('@nav #tab-settings') ->with('@general', function (Browser $browser) { // Assert form content $browser->assertMissing('#status') ->assertFocused('#name') ->assertSeeIn('div.row:nth-child(1) label', 'Name') ->assertValue('div.row:nth-child(1) input[type=text]', '') ->assertSeeIn('div.row:nth-child(2) label', 'Domain') ->assertSelectHasOptions('div.row:nth-child(2) select', ['kolab.org']) ->assertValue('div.row:nth-child(2) select', 'kolab.org') ->assertSeeIn('button[type=submit]', 'Submit'); }) // Test error conditions ->type('#name', str_repeat('A', 192)) ->click('@general button[type=submit]') ->waitFor('#name + .invalid-feedback') ->assertSeeIn('#name + .invalid-feedback', 'The name may not be greater than 191 characters.') ->assertFocused('#name') ->assertToast(Toast::TYPE_ERROR, 'Form validation error') // Test successful resource creation ->type('#name', 'Test Resource') ->click('@general button[type=submit]') ->assertToast(Toast::TYPE_SUCCESS, 'Resource created successfully.') ->on(new ResourceList()) ->assertElementsCount('@table tbody tr', 3); // Test resource update $browser->click('@table tr:nth-child(3) td:first-child a') ->on(new ResourceInfo()) ->assertSeeIn('#resource-info .card-title', 'Resource') ->with('@general', function (Browser $browser) { // Assert form content $browser->assertFocused('#name') ->assertSeeIn('div.row:nth-child(1) label', 'Status') ->assertSeeIn('div.row:nth-child(1) span.text-danger', 'Not Ready') ->assertSeeIn('div.row:nth-child(2) label', 'Name') ->assertValue('div.row:nth-child(2) input[type=text]', 'Test Resource') ->assertSeeIn('div.row:nth-child(3) label', 'Email') ->assertAttributeRegExp( 'div.row:nth-child(3) input[type=text]:disabled', 'value', '/^resource-[0-9]+@kolab\.org$/' ) ->assertSeeIn('button[type=submit]', 'Submit'); }) // Test error handling ->type('#name', str_repeat('A', 192)) ->click('@general button[type=submit]') ->waitFor('#name + .invalid-feedback') ->assertSeeIn('#name + .invalid-feedback', 'The name may not be greater than 191 characters.') ->assertVisible('#name.is-invalid') ->assertFocused('#name') ->assertToast(Toast::TYPE_ERROR, 'Form validation error') // Test successful update ->type('#name', 'Test Resource Update') ->click('@general button[type=submit]') ->assertToast(Toast::TYPE_SUCCESS, 'Resource updated successfully.') ->on(new ResourceList()) ->assertElementsCount('@table tbody tr', 3) ->assertSeeIn('@table tr:nth-child(3) td:first-child a', 'Test Resource Update'); $this->assertSame(1, Resource::where('name', 'Test Resource Update')->count()); // Test resource deletion $browser->click('@table tr:nth-child(3) td:first-child a') ->on(new ResourceInfo()) ->assertSeeIn('button.button-delete', 'Delete resource') ->click('button.button-delete') ->assertToast(Toast::TYPE_SUCCESS, 'Resource deleted successfully.') ->on(new ResourceList()) ->assertElementsCount('@table tbody tr', 2); $this->assertNull(Resource::where('name', 'Test Resource Update')->first()); }); } /** * Test resource status * * @depends testList */ public function testStatus(): void { $john = $this->getTestUser('john@kolab.org'); - $this->addBetaEntitlement($john, 'beta-resources'); + $this->addBetaEntitlement($john); $resource = $this->getTestResource('resource-test2@kolab.org'); $resource->status = Resource::STATUS_NEW | Resource::STATUS_ACTIVE | Resource::STATUS_LDAP_READY; $resource->created_at = \now(); $resource->save(); $this->assertFalse($resource->isImapReady()); $this->browse(function ($browser) use ($resource) { // Test auto-refresh $browser->visit('/resource/' . $resource->id) ->on(new ResourceInfo()) ->with(new Status(), function ($browser) { $browser->assertSeeIn('@body', 'We are preparing the resource') ->assertProgress(85, 'Creating a shared folder...', 'pending') ->assertMissing('@refresh-button') ->assertMissing('@refresh-text') ->assertMissing('#status-link') ->assertMissing('#status-verify'); }); $resource->status |= Resource::STATUS_IMAP_READY; $resource->save(); // Test Verify button $browser->waitUntilMissing('@status', 10); }); // TODO: Test all resource statuses on the list } /** * Test resource settings */ public function testSettings(): void { $john = $this->getTestUser('john@kolab.org'); - $this->addBetaEntitlement($john, 'beta-resources'); + $this->addBetaEntitlement($john); $resource = $this->getTestResource('resource-test2@kolab.org'); $resource->setSetting('invitation_policy', null); $this->browse(function ($browser) use ($resource) { // Test auto-refresh $browser->visit('/resource/' . $resource->id) ->on(new ResourceInfo()) ->assertSeeIn('@nav #tab-general', 'General') ->assertSeeIn('@nav #tab-settings', 'Settings') ->click('@nav #tab-settings') ->with('@settings form', function (Browser $browser) { // Assert form content $browser->assertSeeIn('div.row:nth-child(1) label', 'Invitation policy') ->assertSelectHasOptions('div.row:nth-child(1) select', ['accept', 'manual', 'reject']) ->assertValue('div.row:nth-child(1) select', 'accept') ->assertMissing('div.row:nth-child(1) input') ->assertSeeIn('div.row:nth-child(1) small', 'manual acceptance') ->assertSeeIn('button[type=submit]', 'Submit'); }) // Test error handling ->select('#invitation_policy', 'manual') ->waitFor('#invitation_policy + input') ->type('#invitation_policy + input', 'kolab.org') ->click('@settings button[type=submit]') ->waitFor('#invitation_policy + input + .invalid-feedback') ->assertSeeIn( '#invitation_policy + input + .invalid-feedback', 'The specified email address is invalid.' ) ->assertVisible('#invitation_policy + input.is-invalid') ->assertFocused('#invitation_policy + input') ->assertToast(Toast::TYPE_ERROR, 'Form validation error') ->type('#invitation_policy + input', 'jack@kolab.org') ->click('@settings button[type=submit]') ->assertToast(Toast::TYPE_SUCCESS, 'Resource settings updated successfully.') ->assertMissing('.invalid-feedback') ->refresh() ->on(new ResourceInfo()) ->click('@nav #tab-settings') ->with('@settings form', function (Browser $browser) { $browser->assertValue('div.row:nth-child(1) select', 'manual') ->assertVisible('div.row:nth-child(1) input') ->assertValue('div.row:nth-child(1) input', 'jack@kolab.org'); }); }); } } diff --git a/src/tests/Browser/SharedFolderTest.php b/src/tests/Browser/SharedFolderTest.php index 350fa817..36d485fd 100644 --- a/src/tests/Browser/SharedFolderTest.php +++ b/src/tests/Browser/SharedFolderTest.php @@ -1,397 +1,397 @@ delete(); $this->clearBetaEntitlements(); } /** * {@inheritDoc} */ public function tearDown(): void { SharedFolder::whereNotIn('email', ['folder-event@kolab.org', 'folder-contact@kolab.org'])->delete(); $this->clearBetaEntitlements(); parent::tearDown(); } /** * Test shared folder info page (unauthenticated) */ public function testInfoUnauth(): void { // Test that the page requires authentication $this->browse(function (Browser $browser) { $browser->visit('/shared-folder/abc')->on(new Home()); }); } /** * Test shared folder list page (unauthenticated) */ public function testListUnauth(): void { // Test that the page requires authentication $this->browse(function (Browser $browser) { $browser->visit('/shared-folders')->on(new Home()); }); } /** * Test shared folders list page */ public function testList(): void { // Log on the user $this->browse(function (Browser $browser) { $browser->visit(new Home()) ->submitLogon('john@kolab.org', 'simple123', true) ->on(new Dashboard()) ->assertMissing('@links .link-shared-folders'); }); - // Test that shared folders lists page is not accessible without the 'beta-shared-folders' entitlement + // Test that shared folders lists page is not accessible without the 'beta' entitlement $this->browse(function (Browser $browser) { $browser->visit('/shared-folders') ->assertErrorPage(403); }); - // Add beta+beta-shared-folders entitlements + // Add beta entitlement $john = $this->getTestUser('john@kolab.org'); - $this->addBetaEntitlement($john, 'beta-shared-folders'); + $this->addBetaEntitlement($john); // Make sure the first folder is active $folder = $this->getTestSharedFolder('folder-event@kolab.org'); $folder->status = SharedFolder::STATUS_NEW | SharedFolder::STATUS_ACTIVE | SharedFolder::STATUS_LDAP_READY | SharedFolder::STATUS_IMAP_READY; $folder->save(); // Test shared folders lists page $this->browse(function (Browser $browser) { $browser->visit(new Dashboard()) ->assertSeeIn('@links .link-shared-folders', 'Shared folders') ->click('@links .link-shared-folders') ->on(new SharedFolderList()) ->whenAvailable('@table', function (Browser $browser) { $browser->waitFor('tbody tr') ->assertElementsCount('thead th', 2) ->assertSeeIn('thead tr th:nth-child(1)', 'Name') ->assertSeeIn('thead tr th:nth-child(2)', 'Type') ->assertElementsCount('tbody tr', 2) ->assertSeeIn('tbody tr:nth-child(1) td:nth-child(1) a', 'Calendar') ->assertSeeIn('tbody tr:nth-child(1) td:nth-child(2)', 'Calendar') ->assertText('tbody tr:nth-child(1) td:nth-child(1) svg.text-success title', 'Active') ->assertSeeIn('tbody tr:nth-child(2) td:nth-child(1) a', 'Contacts') ->assertSeeIn('tbody tr:nth-child(2) td:nth-child(2)', 'Address Book') ->assertMissing('tfoot'); }); }); } /** * Test shared folder creation/editing/deleting * * @depends testList */ public function testCreateUpdateDelete(): void { - // Test that the page is not available accessible without the 'beta-shared-folders' entitlement + // Test that the page is not available accessible without the 'beta' entitlement $this->browse(function (Browser $browser) { $browser->visit('/shared-folder/new') ->assertErrorPage(403); }); - // Add beta+beta-shared-folders entitlements + // Add beta entitlements $john = $this->getTestUser('john@kolab.org'); - $this->addBetaEntitlement($john, 'beta-shared-folders'); + $this->addBetaEntitlement($john); $this->browse(function (Browser $browser) { // Create a folder $browser->visit(new SharedFolderList()) ->assertSeeIn('button.shared-folder-new', 'Create folder') ->click('button.shared-folder-new') ->on(new SharedFolderInfo()) ->assertSeeIn('#folder-info .card-title', 'New shared folder') ->assertSeeIn('@nav #tab-general', 'General') ->assertMissing('@nav #tab-settings') ->with('@general', function (Browser $browser) { // Assert form content $browser->assertMissing('#status') ->assertFocused('#name') ->assertSeeIn('div.row:nth-child(1) label', 'Name') ->assertValue('div.row:nth-child(1) input[type=text]', '') ->assertSeeIn('div.row:nth-child(2) label', 'Type') ->assertSelectHasOptions( 'div.row:nth-child(2) select', ['mail', 'event', 'task', 'contact', 'note', 'file'] ) ->assertValue('div.row:nth-child(2) select', 'mail') ->assertSeeIn('div.row:nth-child(3) label', 'Domain') ->assertSelectHasOptions('div.row:nth-child(3) select', ['kolab.org']) ->assertValue('div.row:nth-child(3) select', 'kolab.org') ->assertSeeIn('div.row:nth-child(4) label', 'Email Addresses') ->with(new ListInput('#aliases'), function (Browser $browser) { $browser->assertListInputValue([]) ->assertValue('@input', ''); }) ->assertSeeIn('button[type=submit]', 'Submit'); }) // Test error conditions ->type('#name', str_repeat('A', 192)) ->select('#type', 'event') ->assertMissing('#aliases') ->click('@general button[type=submit]') ->waitFor('#name + .invalid-feedback') ->assertSeeIn('#name + .invalid-feedback', 'The name may not be greater than 191 characters.') ->assertFocused('#name') ->assertToast(Toast::TYPE_ERROR, 'Form validation error') // Test error handling on aliases input ->type('#name', 'Test Folder') ->select('#type', 'mail') ->with(new ListInput('#aliases'), function (Browser $browser) { $browser->addListEntry('folder-alias@unknown'); }) ->click('@general button[type=submit]') ->assertMissing('#name + .invalid-feedback') ->waitFor('#aliases + .invalid-feedback') ->with(new ListInput('#aliases'), function (Browser $browser) { $browser->assertFormError(1, "The specified domain is invalid.", true); }) ->assertToast(Toast::TYPE_ERROR, 'Form validation error') // Test successful folder creation ->select('#type', 'event') ->click('@general button[type=submit]') ->assertToast(Toast::TYPE_SUCCESS, 'Shared folder created successfully.') ->on(new SharedFolderList()) ->assertElementsCount('@table tbody tr', 3); $this->assertSame(1, SharedFolder::where('name', 'Test Folder')->count()); $this->assertSame(0, SharedFolder::where('name', 'Test Folder')->first()->aliases()->count()); // Test folder update $browser->click('@table tr:nth-child(3) td:first-child a') ->on(new SharedFolderInfo()) ->assertSeeIn('#folder-info .card-title', 'Shared folder') ->with('@general', function (Browser $browser) { // Assert form content $browser->assertFocused('#name') ->assertSeeIn('div.row:nth-child(1) label', 'Status') ->assertSeeIn('div.row:nth-child(1) span.text-danger', 'Not Ready') ->assertSeeIn('div.row:nth-child(2) label', 'Name') ->assertValue('div.row:nth-child(2) input[type=text]', 'Test Folder') ->assertSeeIn('div.row:nth-child(3) label', 'Type') ->assertSelected('div.row:nth-child(3) select:disabled', 'event') ->assertMissing('#aliases') ->assertSeeIn('button[type=submit]', 'Submit'); }) // Test error handling ->type('#name', str_repeat('A', 192)) ->click('@general button[type=submit]') ->waitFor('#name + .invalid-feedback') ->assertSeeIn('#name + .invalid-feedback', 'The name may not be greater than 191 characters.') ->assertVisible('#name.is-invalid') ->assertFocused('#name') ->assertToast(Toast::TYPE_ERROR, 'Form validation error') // Test successful update ->type('#name', 'Test Folder Update') ->click('@general button[type=submit]') ->assertToast(Toast::TYPE_SUCCESS, 'Shared folder updated successfully.') ->on(new SharedFolderList()) ->assertElementsCount('@table tbody tr', 3) ->assertSeeIn('@table tr:nth-child(3) td:first-child a', 'Test Folder Update'); $this->assertSame(1, SharedFolder::where('name', 'Test Folder Update')->count()); // Test folder deletion $browser->click('@table tr:nth-child(3) td:first-child a') ->on(new SharedFolderInfo()) ->assertSeeIn('button.button-delete', 'Delete folder') ->click('button.button-delete') ->assertToast(Toast::TYPE_SUCCESS, 'Shared folder deleted successfully.') ->on(new SharedFolderList()) ->assertElementsCount('@table tbody tr', 2); $this->assertNull(SharedFolder::where('name', 'Test Folder Update')->first()); }); // Test creation/updating a mail folder with mail aliases $this->browse(function (Browser $browser) { $browser->on(new SharedFolderList()) ->click('button.shared-folder-new') ->on(new SharedFolderInfo()) ->type('#name', 'Test Folder2') ->with(new ListInput('#aliases'), function (Browser $browser) { $browser->addListEntry('folder-alias1@kolab.org') ->addListEntry('folder-alias2@kolab.org'); }) ->click('@general button[type=submit]') ->assertToast(Toast::TYPE_SUCCESS, 'Shared folder created successfully.') ->on(new SharedFolderList()) ->assertElementsCount('@table tbody tr', 3); $folder = SharedFolder::where('name', 'Test Folder2')->first(); $this->assertSame( ['folder-alias1@kolab.org', 'folder-alias2@kolab.org'], $folder->aliases()->pluck('alias')->all() ); // Test folder update $browser->click('@table tr:nth-child(3) td:first-child a') ->on(new SharedFolderInfo()) ->with('@general', function (Browser $browser) { // Assert form content $browser->assertFocused('#name') ->assertValue('div.row:nth-child(2) input[type=text]', 'Test Folder2') ->assertSelected('div.row:nth-child(3) select:disabled', 'mail') ->with(new ListInput('#aliases'), function (Browser $browser) { $browser->assertListInputValue(['folder-alias1@kolab.org', 'folder-alias2@kolab.org']) ->assertValue('@input', ''); }); }) // change folder name, and remove one alias ->type('#name', 'Test Folder Update2') ->with(new ListInput('#aliases'), function (Browser $browser) { $browser->removeListEntry(2); }) ->click('@general button[type=submit]') ->assertToast(Toast::TYPE_SUCCESS, 'Shared folder updated successfully.'); $folder->refresh(); $this->assertSame('Test Folder Update2', $folder->name); $this->assertSame(['folder-alias1@kolab.org'], $folder->aliases()->pluck('alias')->all()); }); } /** * Test shared folder status * * @depends testList */ public function testStatus(): void { $john = $this->getTestUser('john@kolab.org'); - $this->addBetaEntitlement($john, 'beta-shared-folders'); + $this->addBetaEntitlement($john); $folder = $this->getTestSharedFolder('folder-event@kolab.org'); $folder->status = SharedFolder::STATUS_NEW | SharedFolder::STATUS_ACTIVE | SharedFolder::STATUS_LDAP_READY; $folder->created_at = \now(); $folder->save(); $this->assertFalse($folder->isImapReady()); $this->browse(function ($browser) use ($folder) { // Test auto-refresh $browser->visit('/shared-folder/' . $folder->id) ->on(new SharedFolderInfo()) ->with(new Status(), function ($browser) { $browser->assertSeeIn('@body', 'We are preparing the shared folder') ->assertProgress(85, 'Creating a shared folder...', 'pending') ->assertMissing('@refresh-button') ->assertMissing('@refresh-text') ->assertMissing('#status-link') ->assertMissing('#status-verify'); }); $folder->status |= SharedFolder::STATUS_IMAP_READY; $folder->save(); // Test Verify button $browser->waitUntilMissing('@status', 10); }); // TODO: Test all shared folder statuses on the list } /** * Test shared folder settings */ public function testSettings(): void { $john = $this->getTestUser('john@kolab.org'); - $this->addBetaEntitlement($john, 'beta-shared-folders'); + $this->addBetaEntitlement($john); $folder = $this->getTestSharedFolder('folder-event@kolab.org'); $folder->setSetting('acl', null); $this->browse(function ($browser) use ($folder) { $aclInput = new AclInput('@settings #acl'); // Test auto-refresh $browser->visit('/shared-folder/' . $folder->id) ->on(new SharedFolderInfo()) ->assertSeeIn('@nav #tab-general', 'General') ->assertSeeIn('@nav #tab-settings', 'Settings') ->click('@nav #tab-settings') ->with('@settings form', function (Browser $browser) { // Assert form content $browser->assertSeeIn('div.row:nth-child(1) label', 'Access rights') ->assertSeeIn('div.row:nth-child(1) #acl-hint', 'permissions') ->assertSeeIn('button[type=submit]', 'Submit'); }) // Test the AclInput widget ->with($aclInput, function (Browser $browser) { $browser->assertAclValue([]) ->addAclEntry('anyone, read-only') ->addAclEntry('test, read-write') ->addAclEntry('john@kolab.org, full') ->assertAclValue([ 'anyone, read-only', 'test, read-write', 'john@kolab.org, full', ]); }) // Test error handling ->click('@settings button[type=submit]') ->with($aclInput, function (Browser $browser) { $browser->assertFormError(2, 'The specified email address is invalid.'); }) ->assertToast(Toast::TYPE_ERROR, 'Form validation error') // Test successful update ->with($aclInput, function (Browser $browser) { $browser->removeAclEntry(2) ->assertAclValue([ 'anyone, read-only', 'john@kolab.org, full', ]) ->updateAclEntry(2, 'jack@kolab.org, read-write') ->assertAclValue([ 'anyone, read-only', 'jack@kolab.org, read-write', ]); }) ->click('@settings button[type=submit]') ->assertToast(Toast::TYPE_SUCCESS, 'Shared folder settings updated successfully.') ->assertMissing('.invalid-feedback') // Refresh the page and check if everything was saved ->refresh() ->on(new SharedFolderInfo()) ->click('@nav #tab-settings') ->with($aclInput, function (Browser $browser) { $browser->assertAclValue([ 'anyone, read-only', 'jack@kolab.org, read-write', ]); }); }); } } diff --git a/src/tests/Browser/UsersTest.php b/src/tests/Browser/UsersTest.php index e75a2c43..295f828e 100644 --- a/src/tests/Browser/UsersTest.php +++ b/src/tests/Browser/UsersTest.php @@ -1,905 +1,784 @@ 'John', 'last_name' => 'Doe', 'organization' => 'Kolab Developers', ]; /** * {@inheritDoc} */ public function setUp(): void { parent::setUp(); $this->deleteTestUser('julia.roberts@kolab.org'); $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(); $activesync_sku = Sku::withEnvTenantContext()->where('title', 'activesync')->first(); $storage_sku = Sku::withEnvTenantContext()->where('title', 'storage')->first(); Entitlement::where('entitleable_id', $john->id)->where('sku_id', $activesync_sku->id)->delete(); Entitlement::where('cost', '>=', 5000)->delete(); Entitlement::where('cost', '=', 25)->where('sku_id', $storage_sku->id)->delete(); $wallet = $john->wallets()->first(); $wallet->discount()->dissociate(); $wallet->currency = 'CHF'; $wallet->save(); $this->clearBetaEntitlements(); $this->clearMeetEntitlements(); } /** * {@inheritDoc} */ public function tearDown(): void { $this->deleteTestUser('julia.roberts@kolab.org'); $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(); $activesync_sku = Sku::withEnvTenantContext()->where('title', 'activesync')->first(); $storage_sku = Sku::withEnvTenantContext()->where('title', 'storage')->first(); Entitlement::where('entitleable_id', $john->id)->where('sku_id', $activesync_sku->id)->delete(); Entitlement::where('cost', '>=', 5000)->delete(); Entitlement::where('cost', '=', 25)->where('sku_id', $storage_sku->id)->delete(); $wallet = $john->wallets()->first(); $wallet->discount()->dissociate(); $wallet->save(); $this->clearBetaEntitlements(); $this->clearMeetEntitlements(); parent::tearDown(); } /** * Test user account editing page (not profile page) */ public function testInfo(): void { $this->browse(function (Browser $browser) { $john = $this->getTestUser('john@kolab.org'); $jack = $this->getTestUser('jack@kolab.org'); $john->verificationcodes()->delete(); $jack->verificationcodes()->delete(); $john->setSetting('password_policy', 'min:10,upper,digit'); // Test that the page requires authentication $browser->visit('/user/' . $john->id) ->on(new Home()) ->submitLogon('john@kolab.org', 'simple123', false) ->on(new UserInfo()) ->assertSeeIn('#user-info .card-title', 'User account') ->with('@general', 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#password', '') ->assertValue('div.row:nth-child(7) input#password_confirmation', '') ->assertAttribute('#password', 'placeholder', 'Password') ->assertAttribute('#password_confirmation', 'placeholder', 'Confirm Password') ->assertMissing('div.row:nth-child(7) .btn-group') ->assertMissing('div.row:nth-child(7) #password-link') ->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('@general', function (Browser $browser) { // Test error handling (password) $browser->type('#password', 'aaaaaA') ->vueClear('#password_confirmation') ->whenAvailable('#password_policy', function (Browser $browser) { $browser->assertElementsCount('li', 3) ->assertMissing('li:nth-child(1) svg.text-success') ->assertSeeIn('li:nth-child(1) small', "Minimum password length: 10 characters") ->waitFor('li:nth-child(2) svg.text-success') ->assertSeeIn('li:nth-child(2) small', "Password contains an upper-case character") ->assertMissing('li:nth-child(3) svg.text-success') ->assertSeeIn('li:nth-child(3) small', "Password contains a digit"); }) ->click('button[type=submit]') ->waitFor('#password_confirmation + .invalid-feedback') ->assertSeeIn( '#password_confirmation + .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'); }) ->scrollTo('button[type=submit]')->pause(500) ->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); }); // 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()); $alias = $john->aliases()->where('alias', 'john.test@kolab.org')->first(); $this->assertTrue(!empty($alias)); // Test subscriptions $browser->with('@general', function (Browser $browser) { $browser->assertSeeIn('div.row:nth-child(8) label', 'Subscriptions') ->assertVisible('@skus.row:nth-child(8)') ->with('@skus', function ($browser) { $browser->assertElementsCount('tbody tr', 6) // Mailbox SKU ->assertSeeIn('tbody tr:nth-child(1) td.name', 'User Mailbox') ->assertSeeIn('tbody tr:nth-child(1) td.price', '5,00 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(5)->setQuotaValue(6); }) ->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', '4,90 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', '0,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' ) // Meet SKU ->assertSeeIn('tbody tr:nth-child(6) td.name', 'Voice & Video Conferencing (public beta)') ->assertSeeIn('tbody tr:nth-child(6) td.price', '0,00 CHF/month') ->assertNotChecked('tbody tr:nth-child(6) td.selection input') ->assertEnabled('tbody tr:nth-child(6) td.selection input') ->assertTip( 'tbody tr:nth-child(6) td.buttons button', 'Video conferencing tool' ) ->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', 'storage', 'storage', 'storage']; $this->assertEntitlements($john->fresh(), $expected); // Test subscriptions interaction $browser->with('@general', 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 'meet', expect an alert ->click('#sku-input-meet') ->assertDialogOpened('Voice & Video Conferencing (public beta) requires Groupware Features.') ->acceptDialog() ->assertNotChecked('#sku-input-meet') // 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'); }); }); // Test password reset link delete and create $code = new \App\VerificationCode(['mode' => 'password-reset']); $jack->verificationcodes()->save($code); $browser->visit('/user/' . $jack->id) ->on(new UserInfo()) ->with('@general', function (Browser $browser) use ($jack, $john, $code) { // Test displaying an existing password reset link $link = Browser::$baseUrl . '/password-reset/' . $code->short_code . '-' . $code->code; $browser->assertSeeIn('div.row:nth-child(7) label', 'Password') ->assertMissing('#password') ->assertMissing('#password_confirmation') ->assertMissing('#pass-mode-link:checked') ->assertMissing('#pass-mode-input:checked') ->assertSeeIn('#password-link code', $link) ->assertVisible('#password-link button.text-danger') ->assertVisible('#password-link button:not(.text-danger)') ->assertAttribute('#password-link button:not(.text-danger)', 'title', 'Copy') ->assertAttribute('#password-link button.text-danger', 'title', 'Delete') ->assertMissing('#password-link div.form-text'); // Test deleting an existing password reset link $browser->click('#password-link button.text-danger') ->assertToast(Toast::TYPE_SUCCESS, 'Password reset code deleted successfully.') ->assertMissing('#password-link') ->assertMissing('#pass-mode-link:checked') ->assertMissing('#pass-mode-input:checked') ->assertMissing('#password'); $this->assertSame(0, $jack->verificationcodes()->count()); // Test creating a password reset link $link = preg_replace('|/[a-z0-9A-Z-]+$|', '', $link) . '/'; $browser->click('#pass-mode-link + label') ->assertMissing('#password') ->assertMissing('#password_confirmation') ->waitFor('#password-link code') ->assertSeeIn('#password-link code', $link) ->assertSeeIn('#password-link div.form-text', "Press Submit to activate the link"); // Test copy to clipboard /* TODO: Figure out how to give permission to do this operation $code = $john->verificationcodes()->first(); $link .= $code->short_code . '-' . $code->code; $browser->assertMissing('#password-link button.text-danger') ->click('#password-link button:not(.text-danger)') ->keys('#organization', ['{left_control}', 'v']) ->assertAttribute('#organization', 'value', $link) ->vueClear('#organization'); */ // Finally submit the form $browser->click('button[type=submit]') ->assertToast(Toast::TYPE_SUCCESS, 'User data updated successfully.'); $this->assertSame(1, $jack->verificationcodes()->where('active', true)->count()); $this->assertSame(0, $john->verificationcodes()->count()); }); }); } /** * Test user settings tab * * @depends testInfo */ public function testUserSettings(): void { $john = $this->getTestUser('john@kolab.org'); $john->setSetting('greylist_enabled', null); $this->browse(function (Browser $browser) use ($john) { $browser->visit('/user/' . $john->id) ->on(new UserInfo()) ->assertElementsCount('@nav a', 2) ->assertSeeIn('@nav #tab-general', 'General') ->assertSeeIn('@nav #tab-settings', 'Settings') ->click('@nav #tab-settings') ->with('#settings form', function (Browser $browser) { $browser->assertSeeIn('div.row:nth-child(1) label', 'Greylisting') ->click('div.row:nth-child(1) input[type=checkbox]:checked') ->click('button[type=submit]') ->assertToast(Toast::TYPE_SUCCESS, 'User settings updated successfully.'); }); }); $this->assertSame('false', $john->fresh()->getSetting('greylist_enabled')); } /** * Test user adding page * * @depends testInfo */ public function testNewUser(): void { $john = $this->getTestUser('john@kolab.org'); $john->setSetting('password_policy', null); $this->browse(function (Browser $browser) { $browser->visit(new UserList()) ->assertSeeIn('button.user-new', 'Create user') ->click('button.user-new') ->on(new UserInfo()) ->assertSeeIn('#user-info .card-title', 'New user account') ->with('@general', 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#password', '') ->assertValue('div.row:nth-child(6) input#password_confirmation', '') ->assertAttribute('#password', 'placeholder', 'Password') ->assertAttribute('#password_confirmation', 'placeholder', 'Confirm Password') ->assertSeeIn('div.row:nth-child(6) .btn-group input:first-child + label', 'Enter password') ->assertSeeIn('div.row:nth-child(6) .btn-group input:not(:first-child) + label', 'Set via link') ->assertChecked('div.row:nth-child(6) .btn-group input:first-child') ->assertMissing('div.row:nth-child(6) #password-link') ->assertSeeIn('div.row:nth-child(7) 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,90 CHF/month') ->assertSeeIn('tbody tr:nth-child(2) .price', '5,00 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') ->type('#password', 'simple123') ->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_confirmation + .invalid-feedback', 'The password confirmation does not match.' ); }); // Test form error handling (aliases) $browser->with('@general', 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('@general', 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.') // check redirection to users list ->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->assertEntitlements($julia, ['mailbox', 'storage', 'storage', 'storage', '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('@table 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') ->keys('.input-group:nth-child(2) input', '{enter}'); }) // TODO: Investigate why this click does not work, for now we // submit the form with Enter key above //->click('@general 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') ->keys('.input-group:nth-child(3) input', '{enter}'); }) // TODO: Investigate why this click does not work, for now we // submit the form with Enter key above //->click('@general 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) use ($julia) { $browser->visit('/user/' . $julia->id) ->on(new UserInfo()) ->assertSeeIn('button.button-delete', 'Delete user') ->click('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'); }) ->waitUntilMissing('#delete-warning') ->click('button.button-delete') ->with(new Dialog('#delete-warning'), function (Browser $browser) { $browser->click('@button-action'); }) ->waitUntilMissing('#delete-warning') ->assertToast(Toast::TYPE_SUCCESS, 'User deleted successfully.') ->on(new UserList()) ->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 that non-controller user cannot see/delete himself on the users list $this->browse(function (Browser $browser) { $browser->visit('/logout') ->on(new Home()) ->submitLogon('jack@kolab.org', 'simple123', true) ->visit('/users') ->assertErrorPage(403); }); // Test that controller user (Ned) can see 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); }); // 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') // joe@kolab.org ->on(new UserInfo()) ->with('@general', 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', 6) // Mailbox SKU ->assertSeeIn('tbody tr:nth-child(1) td.price', '4,50 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,37 CHF/month¹') // groupware SKU ->assertSeeIn('tbody tr:nth-child(3) td.price', '4,41 CHF/month¹') // ActiveSync SKU ->assertSeeIn('tbody tr:nth-child(4) td.price', '0,00 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.user-new') ->on(new UserInfo()) ->with('@general', function (Browser $browser) { $browser->whenAvailable('@packages', function (Browser $browser) { $browser->assertElementsCount('tbody tr', 2) ->assertSeeIn('tbody tr:nth-child(1) .price', '8,91 CHF/month¹') // Groupware ->assertSeeIn('tbody tr:nth-child(2) .price', '4,50 CHF/month¹'); // Lite }) ->assertSeeIn('@packages table + .hint', '¹ applied discount: 10% - Test voucher'); }); }); // Test using entitlement cost instead of the SKU cost $this->browse(function (Browser $browser) use ($wallet) { $joe = User::where('email', 'joe@kolab.org')->first(); $beta_sku = Sku::withEnvTenantContext()->where('title', 'beta')->first(); $storage_sku = Sku::withEnvTenantContext()->where('title', 'storage')->first(); // Add an extra storage and beta entitlement with different prices Entitlement::create([ 'wallet_id' => $wallet->id, 'sku_id' => $beta_sku->id, 'cost' => 5010, 'entitleable_id' => $joe->id, 'entitleable_type' => User::class ]); Entitlement::create([ 'wallet_id' => $wallet->id, 'sku_id' => $storage_sku->id, 'cost' => 5000, 'entitleable_id' => $joe->id, 'entitleable_type' => User::class ]); $browser->visit('/user/' . $joe->id) ->on(new UserInfo()) ->with('@general', function (Browser $browser) { $browser->whenAvailable('@skus', function (Browser $browser) { $quota_input = new QuotaInput('tbody tr:nth-child(2) .range-input'); $browser->waitFor('tbody tr') // Beta SKU ->assertSeeIn('tbody tr:nth-child(7) td.price', '45,09 CHF/month¹') // Storage SKU ->assertSeeIn('tr:nth-child(2) td.price', '45,00 CHF/month¹') ->with($quota_input, function (Browser $browser) { $browser->setQuotaValue(7); }) ->assertSeeIn('tr:nth-child(2) td.price', '45,22 CHF/month¹') ->with($quota_input, function (Browser $browser) { $browser->setQuotaValue(5); }) ->assertSeeIn('tr:nth-child(2) td.price', '0,00 CHF/month¹'); }) ->assertSeeIn('@skus table + .hint', '¹ applied discount: 10% - Test voucher'); }); }); } /** * Test non-default currency in the UI */ public function testCurrency(): void { // Add 10% discount $john = User::where('email', 'john@kolab.org')->first(); $wallet = $john->wallet(); $wallet->balance = -1000; $wallet->currency = 'EUR'; $wallet->save(); // On Dashboard and the wallet page $this->browse(function (Browser $browser) { $browser->visit('/logout') ->on(new Home()) ->submitLogon('john@kolab.org', 'simple123', true) ->on(new Dashboard()) ->assertSeeIn('@links .link-wallet .badge', '-10,00 €') ->click('@links .link-wallet') ->on(new WalletPage()) ->assertSeeIn('#wallet .card-title', 'Account balance -10,00 €'); }); // SKUs on user edit page $this->browse(function (Browser $browser) { $browser->visit(new UserList()) ->waitFor('@table tr:nth-child(2)') ->click('@table tr:nth-child(2) a') // joe@kolab.org ->on(new UserInfo()) ->with('@general', 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', 6) // Mailbox SKU ->assertSeeIn('tbody tr:nth-child(1) td.price', '5,00 €/month') // Storage SKU ->assertSeeIn('tr:nth-child(2) td.price', '0,00 €/month') ->with($quota_input, function (Browser $browser) { $browser->setQuotaValue(100); }) ->assertSeeIn('tr:nth-child(2) td.price', '23,75 €/month'); }); }); }); // Packages on new user page $this->browse(function (Browser $browser) { $browser->visit(new UserList()) ->click('button.user-new') ->on(new UserInfo()) ->with('@general', function (Browser $browser) { $browser->whenAvailable('@packages', function (Browser $browser) { $browser->assertElementsCount('tbody tr', 2) ->assertSeeIn('tbody tr:nth-child(1) .price', '9,90 €/month') // Groupware ->assertSeeIn('tbody tr:nth-child(2) .price', '5,00 €/month'); // Lite }); }); }); } - - /** - * Test beta entitlements - * - * @depends testInfo - */ - public function testBetaEntitlements(): void - { - $this->browse(function (Browser $browser) { - $john = User::where('email', 'john@kolab.org')->first(); - $sku = Sku::withEnvTenantContext()->where('title', 'beta')->first(); - $john->assignSku($sku); - - $browser->visit('/user/' . $john->id) - ->on(new UserInfo()) - ->with('@skus', function ($browser) { - $browser->assertElementsCount('tbody tr', 11) - // Meet SKU - ->assertSeeIn('tbody tr:nth-child(6) td.name', 'Voice & Video Conferencing (public beta)') - ->assertSeeIn('tr:nth-child(6) td.price', '0,00 CHF/month') - ->assertNotChecked('tbody tr:nth-child(6) td.selection input') - ->assertEnabled('tbody tr:nth-child(6) td.selection input') - ->assertTip( - 'tbody tr:nth-child(6) td.buttons button', - 'Video conferencing tool' - ) - // Beta SKU - ->assertSeeIn('tbody tr:nth-child(7) td.name', 'Private Beta (invitation only)') - ->assertSeeIn('tbody tr:nth-child(7) td.price', '0,00 CHF/month') - ->assertChecked('tbody tr:nth-child(7) td.selection input') - ->assertEnabled('tbody tr:nth-child(7) td.selection input') - ->assertTip( - 'tbody tr:nth-child(7) td.buttons button', - 'Access to the private beta program subscriptions' - ) - // Distlists SKU - ->assertSeeIn('tbody tr:nth-child(8) td.name', 'Distribution lists') - ->assertSeeIn('tr:nth-child(8) td.price', '0,00 CHF/month') - ->assertNotChecked('tbody tr:nth-child(8) td.selection input') - ->assertEnabled('tbody tr:nth-child(8) td.selection input') - ->assertTip( - 'tbody tr:nth-child(8) td.buttons button', - 'Access to mail distribution lists' - ) - // Resources SKU - ->assertSeeIn('tbody tr:nth-child(9) td.name', 'Calendaring resources') - ->assertSeeIn('tr:nth-child(9) td.price', '0,00 CHF/month') - ->assertNotChecked('tbody tr:nth-child(9) td.selection input') - ->assertEnabled('tbody tr:nth-child(9) td.selection input') - ->assertTip( - 'tbody tr:nth-child(9) td.buttons button', - 'Access to calendaring resources' - ) - // Shared folders SKU - ->scrollTo('tbody tr:nth-child(10)')->pause(250) - ->assertSeeIn('tbody tr:nth-child(10) td.name', 'Shared folders') - ->assertSeeIn('tr:nth-child(10) td.price', '0,00 CHF/month') - ->assertNotChecked('tbody tr:nth-child(10) td.selection input') - ->assertEnabled('tbody tr:nth-child(10) td.selection input') - ->assertTip( - 'tbody tr:nth-child(10) td.buttons button', - 'Access to shared folders' - ) - // Files SKU - ->scrollTo('tbody tr:nth-child(11)')->pause(250) - ->assertSeeIn('tbody tr:nth-child(11) td.name', 'File storage') - ->assertSeeIn('tr:nth-child(11) td.price', '0,00 CHF/month') - ->assertNotChecked('tbody tr:nth-child(11) td.selection input') - ->assertEnabled('tbody tr:nth-child(11) td.selection input') - ->assertTip( - 'tbody tr:nth-child(11) td.buttons button', - 'Access to file storage' - ) - // Check Distlist, Uncheck Beta, expect Distlist unchecked - ->click('#sku-input-beta-distlists') - ->click('#sku-input-beta') - ->assertNotChecked('#sku-input-beta') - ->assertNotChecked('#sku-input-beta-distlists') - // Click Distlists expect an alert - ->click('#sku-input-beta-distlists') - ->assertDialogOpened('Distribution lists requires Private Beta (invitation only).') - ->acceptDialog() - // Enable Beta and Distlist and submit - ->click('#sku-input-beta') - ->click('#sku-input-beta-distlists'); - }) - ->scrollTo('@general button[type=submit]') - ->click('@general button[type=submit]') - ->assertToast(Toast::TYPE_SUCCESS, 'User data updated successfully.'); - - $expected = [ - 'beta', - 'beta-distlists', - 'groupware', - 'mailbox', - 'storage', 'storage', 'storage', 'storage', 'storage' - ]; - - $this->assertEntitlements($john, $expected); - - $browser->visit('/user/' . $john->id) - ->on(new UserInfo()) - ->waitFor('#sku-input-beta') - ->click('#sku-input-beta') - ->scrollTo('@general button[type=submit]')->pause(500) - ->click('@general button[type=submit]') - ->assertToast(Toast::TYPE_SUCCESS, 'User data updated successfully.'); - - $expected = [ - 'groupware', - 'mailbox', - 'storage', 'storage', 'storage', 'storage', 'storage' - ]; - - $this->assertEntitlements($john, $expected); - }); - - // TODO: Test that the Distlists SKU is not available for users that aren't a group account owners - // TODO: Test that entitlements change has immediate effect on the available items in dashboard - // i.e. does not require a page reload nor re-login. - } } diff --git a/src/tests/Feature/Controller/Admin/SkusTest.php b/src/tests/Feature/Controller/Admin/SkusTest.php index f7678b54..ab836444 100644 --- a/src/tests/Feature/Controller/Admin/SkusTest.php +++ b/src/tests/Feature/Controller/Admin/SkusTest.php @@ -1,124 +1,124 @@ delete(); $this->clearBetaEntitlements(); $this->clearMeetEntitlements(); } /** * {@inheritDoc} */ public function tearDown(): void { Sku::where('title', 'test')->delete(); $this->clearBetaEntitlements(); $this->clearMeetEntitlements(); parent::tearDown(); } /** * Test fetching SKUs list for a domain (GET /domains//skus) */ public function testDomainSkus(): void { $admin = $this->getTestUser('jeroen@jeroen.jeroen'); $user = $this->getTestUser('john@kolab.org'); $domain = $this->getTestDOmain('kolab.org'); // Unauth access not allowed $response = $this->get("api/v4/domains/{$domain->id}/skus"); $response->assertStatus(401); // Non-admin access not allowed $response = $this->actingAs($user)->get("api/v4/domains/{$domain->id}/skus"); $response->assertStatus(403); $response = $this->actingAs($admin)->get("api/v4/domains/{$domain->id}/skus"); $response->assertStatus(200); $json = $response->json(); $this->assertCount(1, $json); // Note: Details are tested where we test API\V4\SkusController } /** * Test fetching SKUs list */ public function testIndex(): void { $admin = $this->getTestUser('jeroen@jeroen.jeroen'); $user = $this->getTestUser('john@kolab.org'); $sku = Sku::withEnvTenantContext()->where('title', 'mailbox')->first(); // Unauth access not allowed $response = $this->get("api/v4/skus"); $response->assertStatus(401); // User access not allowed on admin API $response = $this->actingAs($user)->get("api/v4/skus"); $response->assertStatus(403); $response = $this->actingAs($admin)->get("api/v4/skus"); $response->assertStatus(200); $json = $response->json(); - $this->assertCount(14, $json); + $this->assertCount(10, $json); $this->assertSame(100, $json[0]['prio']); $this->assertSame($sku->id, $json[0]['id']); $this->assertSame($sku->title, $json[0]['title']); $this->assertSame($sku->name, $json[0]['name']); $this->assertSame($sku->description, $json[0]['description']); $this->assertSame($sku->cost, $json[0]['cost']); $this->assertSame($sku->units_free, $json[0]['units_free']); $this->assertSame($sku->period, $json[0]['period']); $this->assertSame($sku->active, $json[0]['active']); $this->assertSame('user', $json[0]['type']); $this->assertSame('Mailbox', $json[0]['handler']); } /** * Test fetching SKUs list for a user (GET /users//skus) */ public function testUserSkus(): void { $admin = $this->getTestUser('jeroen@jeroen.jeroen'); $user = $this->getTestUser('john@kolab.org'); // Unauth access not allowed $response = $this->get("api/v4/users/{$user->id}/skus"); $response->assertStatus(401); // Non-admin access not allowed $response = $this->actingAs($user)->get("api/v4/users/{$user->id}/skus"); $response->assertStatus(403); $response = $this->actingAs($admin)->get("api/v4/users/{$user->id}/skus"); $response->assertStatus(200); $json = $response->json(); $this->assertCount(6, $json); // Note: Details are tested where we test API\V4\SkusController } } diff --git a/src/tests/Feature/Controller/Reseller/SkusTest.php b/src/tests/Feature/Controller/Reseller/SkusTest.php index 97195fec..801a9abe 100644 --- a/src/tests/Feature/Controller/Reseller/SkusTest.php +++ b/src/tests/Feature/Controller/Reseller/SkusTest.php @@ -1,173 +1,173 @@ delete(); $this->clearBetaEntitlements(); $this->clearMeetEntitlements(); } /** * {@inheritDoc} */ public function tearDown(): void { Sku::where('title', 'test')->delete(); $this->clearBetaEntitlements(); $this->clearMeetEntitlements(); parent::tearDown(); } /** * Test fetching SKUs list for a domain (GET /domains//skus) */ public function testDomainSkus(): void { $reseller1 = $this->getTestUser('reseller@' . \config('app.domain')); $reseller2 = $this->getTestUser('reseller@sample-tenant.dev-local'); $admin = $this->getTestUser('jeroen@jeroen.jeroen'); $user = $this->getTestUser('john@kolab.org'); $domain = $this->getTestDomain('kolab.org'); // Unauth access not allowed $response = $this->get("api/v4/domains/{$domain->id}/skus"); $response->assertStatus(401); // User access not allowed $response = $this->actingAs($user)->get("api/v4/domains/{$domain->id}/skus"); $response->assertStatus(403); // Admin access not allowed $response = $this->actingAs($admin)->get("api/v4/domains/{$domain->id}/skus"); $response->assertStatus(403); // Reseller from another tenant $response = $this->actingAs($reseller2)->get("api/v4/domains/{$domain->id}/skus"); $response->assertStatus(404); // Reseller access $response = $this->actingAs($reseller1)->get("api/v4/domains/{$domain->id}/skus"); $response->assertStatus(200); $json = $response->json(); $this->assertCount(1, $json); // Note: Details are tested where we test API\V4\SkusController } /** * Test fetching SKUs list */ public function testIndex(): void { $reseller1 = $this->getTestUser('reseller@' . \config('app.domain')); $reseller2 = $this->getTestUser('reseller@sample-tenant.dev-local'); $admin = $this->getTestUser('jeroen@jeroen.jeroen'); $user = $this->getTestUser('john@kolab.org'); $sku = Sku::withEnvTenantContext()->where('title', 'mailbox')->first(); // Unauth access not allowed $response = $this->get("api/v4/skus"); $response->assertStatus(401); // User access not allowed $response = $this->actingAs($user)->get("api/v4/skus"); $response->assertStatus(403); // Admin access not allowed $response = $this->actingAs($admin)->get("api/v4/skus"); $response->assertStatus(403); $response = $this->actingAs($reseller1)->get("api/v4/skus"); $response->assertStatus(200); $json = $response->json(); - $this->assertCount(14, $json); + $this->assertCount(10, $json); $this->assertSame(100, $json[0]['prio']); $this->assertSame($sku->id, $json[0]['id']); $this->assertSame($sku->title, $json[0]['title']); $this->assertSame($sku->name, $json[0]['name']); $this->assertSame($sku->description, $json[0]['description']); $this->assertSame($sku->cost, $json[0]['cost']); $this->assertSame($sku->units_free, $json[0]['units_free']); $this->assertSame($sku->period, $json[0]['period']); $this->assertSame($sku->active, $json[0]['active']); $this->assertSame('user', $json[0]['type']); $this->assertSame('Mailbox', $json[0]['handler']); // Test with another tenant $sku = Sku::where('title', 'mailbox')->where('tenant_id', $reseller2->tenant_id)->first(); $response = $this->actingAs($reseller2)->get("api/v4/skus"); $response->assertStatus(200); $json = $response->json(); $this->assertCount(6, $json); $this->assertSame(100, $json[0]['prio']); $this->assertSame($sku->id, $json[0]['id']); $this->assertSame($sku->title, $json[0]['title']); $this->assertSame($sku->name, $json[0]['name']); $this->assertSame($sku->description, $json[0]['description']); $this->assertSame($sku->cost, $json[0]['cost']); $this->assertSame($sku->units_free, $json[0]['units_free']); $this->assertSame($sku->period, $json[0]['period']); $this->assertSame($sku->active, $json[0]['active']); $this->assertSame('user', $json[0]['type']); $this->assertSame('Mailbox', $json[0]['handler']); } /** * Test fetching SKUs list for a user (GET /users//skus) */ public function testUserSkus(): void { $reseller1 = $this->getTestUser('reseller@' . \config('app.domain')); $reseller2 = $this->getTestUser('reseller@sample-tenant.dev-local'); $admin = $this->getTestUser('jeroen@jeroen.jeroen'); $user = $this->getTestUser('john@kolab.org'); // Unauth access not allowed $response = $this->get("api/v4/users/{$user->id}/skus"); $response->assertStatus(401); // User access not allowed $response = $this->actingAs($user)->get("api/v4/users/{$user->id}/skus"); $response->assertStatus(403); // Admin access not allowed $response = $this->actingAs($admin)->get("api/v4/users/{$user->id}/skus"); $response->assertStatus(403); // Reseller from another tenant $response = $this->actingAs($reseller2)->get("api/v4/users/{$user->id}/skus"); $response->assertStatus(404); // Reseller access $response = $this->actingAs($reseller1)->get("api/v4/users/{$user->id}/skus"); $response->assertStatus(200); $json = $response->json(); $this->assertCount(6, $json); // Note: Details are tested where we test API\V4\SkusController } } diff --git a/src/tests/Feature/Controller/SkusTest.php b/src/tests/Feature/Controller/SkusTest.php index 3ee2ddf1..10d61052 100644 --- a/src/tests/Feature/Controller/SkusTest.php +++ b/src/tests/Feature/Controller/SkusTest.php @@ -1,291 +1,255 @@ clearBetaEntitlements(); $this->clearMeetEntitlements(); Sku::where('title', 'test')->delete(); } /** * {@inheritDoc} */ public function tearDown(): void { $this->clearBetaEntitlements(); $this->clearMeetEntitlements(); Sku::where('title', 'test')->delete(); parent::tearDown(); } /** * Test fetching SKUs list for a domain (GET /domains//skus) */ public function testDomainSkus(): void { $user = $this->getTestUser('john@kolab.org'); $domain = $this->getTestDomain('kolab.org'); // Unauth access not allowed $response = $this->get("api/v4/domains/{$domain->id}/skus"); $response->assertStatus(401); // Create an sku for another tenant, to make sure it is not included in the result $nsku = Sku::create([ 'title' => 'test', 'name' => 'Test', 'description' => '', 'active' => true, 'cost' => 100, 'handler_class' => 'App\Handlers\Domain', ]); $tenant = Tenant::whereNotIn('id', [\config('app.tenant_id')])->first(); $nsku->tenant_id = $tenant->id; $nsku->save(); $response = $this->actingAs($user)->get("api/v4/domains/{$domain->id}/skus"); $response->assertStatus(200); $json = $response->json(); $this->assertCount(1, $json); $this->assertSkuElement('domain-hosting', $json[0], [ 'prio' => 0, 'type' => 'domain', 'handler' => 'DomainHosting', 'enabled' => false, 'readonly' => false, ]); } /** * Test fetching SKUs list */ public function testIndex(): void { // Unauth access not allowed $response = $this->get("api/v4/skus"); $response->assertStatus(401); $user = $this->getTestUser('john@kolab.org'); $sku = Sku::withEnvTenantContext()->where('title', 'mailbox')->first(); // Create an sku for another tenant, to make sure it is not included in the result $nsku = Sku::create([ 'title' => 'test', 'name' => 'Test', 'description' => '', 'active' => true, 'cost' => 100, 'handler_class' => 'App\Handlers\Mailbox', ]); $tenant = Tenant::whereNotIn('id', [\config('app.tenant_id')])->first(); $nsku->tenant_id = $tenant->id; $nsku->save(); $response = $this->actingAs($user)->get("api/v4/skus"); $response->assertStatus(200); $json = $response->json(); - $this->assertCount(14, $json); + $this->assertCount(10, $json); $this->assertSame(100, $json[0]['prio']); $this->assertSame($sku->id, $json[0]['id']); $this->assertSame($sku->title, $json[0]['title']); $this->assertSame($sku->name, $json[0]['name']); $this->assertSame($sku->description, $json[0]['description']); $this->assertSame($sku->cost, $json[0]['cost']); $this->assertSame($sku->units_free, $json[0]['units_free']); $this->assertSame($sku->period, $json[0]['period']); $this->assertSame($sku->active, $json[0]['active']); $this->assertSame('user', $json[0]['type']); $this->assertSame('Mailbox', $json[0]['handler']); } /** * Test fetching SKUs list for a user (GET /users//skus) */ public function testUserSkus(): void { $user = $this->getTestUser('john@kolab.org'); // Unauth access not allowed $response = $this->get("api/v4/users/{$user->id}/skus"); $response->assertStatus(401); // Create an sku for another tenant, to make sure it is not included in the result $nsku = Sku::create([ 'title' => 'test', 'name' => 'Test', 'description' => '', 'active' => true, 'cost' => 100, 'handler_class' => 'Mailbox', ]); $tenant = Tenant::whereNotIn('id', [\config('app.tenant_id')])->first(); $nsku->tenant_id = $tenant->id; $nsku->save(); $response = $this->actingAs($user)->get("api/v4/users/{$user->id}/skus"); $response->assertStatus(200); $json = $response->json(); $this->assertCount(6, $json); $this->assertSkuElement('mailbox', $json[0], [ 'prio' => 100, 'type' => 'user', 'handler' => 'Mailbox', 'enabled' => true, 'readonly' => true, ]); $this->assertSkuElement('storage', $json[1], [ 'prio' => 90, 'type' => 'user', 'handler' => 'Storage', 'enabled' => true, 'readonly' => true, 'range' => [ 'min' => 5, 'max' => 100, 'unit' => 'GB', ] ]); $this->assertSkuElement('groupware', $json[2], [ 'prio' => 80, 'type' => 'user', 'handler' => 'Groupware', 'enabled' => false, 'readonly' => false, ]); $this->assertSkuElement('activesync', $json[3], [ 'prio' => 70, 'type' => 'user', 'handler' => 'Activesync', 'enabled' => false, 'readonly' => false, 'required' => ['Groupware'], ]); $this->assertSkuElement('2fa', $json[4], [ 'prio' => 60, 'type' => 'user', 'handler' => 'Auth2F', 'enabled' => false, 'readonly' => false, 'forbidden' => ['Activesync'], ]); $this->assertSkuElement('meet', $json[5], [ 'prio' => 50, 'type' => 'user', 'handler' => 'Meet', 'enabled' => false, 'readonly' => false, 'required' => ['Groupware'], ]); // Test inclusion of beta SKUs $sku = Sku::withEnvTenantContext()->where('title', 'beta')->first(); $user->assignSku($sku); $response = $this->actingAs($user)->get("api/v4/users/{$user->id}/skus"); $response->assertStatus(200); $json = $response->json(); - $this->assertCount(11, $json); + $this->assertCount(6, $json); $this->assertSkuElement('beta', $json[6], [ 'prio' => 10, 'type' => 'user', 'handler' => 'Beta', 'enabled' => false, 'readonly' => false, ]); - - $this->assertSkuElement('beta-distlists', $json[7], [ - 'prio' => 10, - 'type' => 'user', - 'handler' => 'Beta\Distlists', - 'enabled' => false, - 'readonly' => false, - 'required' => ['Beta'], - ]); - - $this->assertSkuElement('beta-resources', $json[8], [ - 'prio' => 10, - 'type' => 'user', - 'handler' => 'Beta\Resources', - 'enabled' => false, - 'readonly' => false, - 'required' => ['Beta'], - ]); - - $this->assertSkuElement('beta-shared-folders', $json[9], [ - 'prio' => 10, - 'type' => 'user', - 'handler' => 'Beta\SharedFolders', - 'enabled' => false, - 'readonly' => false, - 'required' => ['Beta'], - ]); - - $this->assertSkuElement('files', $json[10], [ - 'prio' => 10, - 'type' => 'user', - 'handler' => 'Files', - 'enabled' => false, - 'readonly' => false, - 'required' => ['Beta'], - ]); } /** * Assert content of the SKU element in an API response * * @param string $sku_title The SKU title * @param array $result The result to assert * @param array $other Other items the SKU itself does not include */ protected function assertSkuElement($sku_title, $result, $other = []): void { $sku = Sku::withEnvTenantContext()->where('title', $sku_title)->first(); $this->assertSame($sku->id, $result['id']); $this->assertSame($sku->title, $result['title']); $this->assertSame($sku->name, $result['name']); $this->assertSame($sku->description, $result['description']); $this->assertSame($sku->cost, $result['cost']); $this->assertSame($sku->units_free, $result['units_free']); $this->assertSame($sku->period, $result['period']); $this->assertSame($sku->active, $result['active']); foreach ($other as $key => $value) { $this->assertSame($value, $result[$key]); } $this->assertCount(8 + count($other), $result); } } diff --git a/src/tests/TestCaseTrait.php b/src/tests/TestCaseTrait.php index 0afd21fa..4a3ae509 100644 --- a/src/tests/TestCaseTrait.php +++ b/src/tests/TestCaseTrait.php @@ -1,670 +1,667 @@ 'John', 'last_name' => 'Doe', 'organization' => 'Test Domain Owner', ]; /** * Some users for the hosted domain, ultimately including the owner. * * @var \App\User[] */ protected $domainUsers = []; /** * A specific user that is a regular user in the hosted domain. * * @var ?\App\User */ protected $jack; /** * A specific user that is a controller on the wallet to which the hosted domain is charged. * * @var ?\App\User */ protected $jane; /** * A specific user that has a second factor configured. * * @var ?\App\User */ protected $joe; /** * One of the domains that is available for public registration. * * @var ?\App\Domain */ protected $publicDomain; /** * A newly generated user in a public domain. * * @var ?\App\User */ protected $publicDomainUser; /** * A placeholder for a password that can be generated. * * Should be generated with `\App\Utils::generatePassphrase()`. * * @var ?string */ protected $userPassword; /** * Register the beta entitlement for a user */ protected function addBetaEntitlement($user, $titles = []): void { // Add beta + $title entitlements $beta_sku = Sku::withEnvTenantContext()->where('title', 'beta')->first(); $user->assignSku($beta_sku); if (!empty($titles)) { Sku::withEnvTenantContext()->whereIn('title', (array) $titles)->get() ->each(function ($sku) use ($user) { $user->assignSku($sku); }); } } /** * Assert that the entitlements for the user match the expected list of entitlements. * * @param \App\User|\App\Domain $object The object for which the entitlements need to be pulled. * @param array $expected An array of expected \App\Sku titles. */ protected function assertEntitlements($object, $expected) { // Assert the user entitlements $skus = $object->entitlements()->get() ->map(function ($ent) { return $ent->sku->title; }) ->toArray(); sort($skus); Assert::assertSame($expected, $skus); } protected function backdateEntitlements($entitlements, $targetDate, $targetCreatedDate = null) { $wallets = []; $ids = []; foreach ($entitlements as $entitlement) { $ids[] = $entitlement->id; $wallets[] = $entitlement->wallet_id; } \App\Entitlement::whereIn('id', $ids)->update([ 'created_at' => $targetCreatedDate ?: $targetDate, 'updated_at' => $targetDate, ]); if (!empty($wallets)) { $wallets = array_unique($wallets); $owners = \App\Wallet::whereIn('id', $wallets)->pluck('user_id')->all(); \App\User::whereIn('id', $owners)->update([ 'created_at' => $targetCreatedDate ?: $targetDate ]); } } /** * Removes all beta entitlements from the database */ protected function clearBetaEntitlements(): void { $beta_handlers = [ 'App\Handlers\Beta', - 'App\Handlers\Beta\Distlists', - 'App\Handlers\Beta\Resources', - 'App\Handlers\Beta\SharedFolders', ]; $betas = Sku::whereIn('handler_class', $beta_handlers)->pluck('id')->all(); \App\Entitlement::whereIn('sku_id', $betas)->delete(); } /** * Creates the application. * * @return \Illuminate\Foundation\Application */ public function createApplication() { $app = require __DIR__ . '/../bootstrap/app.php'; $app->make(Kernel::class)->bootstrap(); return $app; } /** * Create a set of transaction log entries for a wallet */ protected function createTestTransactions($wallet) { $result = []; $date = Carbon::now(); $debit = 0; $entitlementTransactions = []; foreach ($wallet->entitlements as $entitlement) { if ($entitlement->cost) { $debit += $entitlement->cost; $entitlementTransactions[] = $entitlement->createTransaction( Transaction::ENTITLEMENT_BILLED, $entitlement->cost ); } } $transaction = Transaction::create( [ 'user_email' => 'jeroen@jeroen.jeroen', 'object_id' => $wallet->id, 'object_type' => \App\Wallet::class, 'type' => Transaction::WALLET_DEBIT, 'amount' => $debit * -1, 'description' => 'Payment', ] ); $result[] = $transaction; Transaction::whereIn('id', $entitlementTransactions)->update(['transaction_id' => $transaction->id]); $transaction = Transaction::create( [ 'user_email' => null, 'object_id' => $wallet->id, 'object_type' => \App\Wallet::class, 'type' => Transaction::WALLET_CREDIT, 'amount' => 2000, 'description' => 'Payment', ] ); $transaction->created_at = $date->next(Carbon::MONDAY); $transaction->save(); $result[] = $transaction; $types = [ Transaction::WALLET_AWARD, Transaction::WALLET_PENALTY, ]; // The page size is 10, so we generate so many to have at least two pages $loops = 10; while ($loops-- > 0) { $type = $types[count($result) % count($types)]; $transaction = Transaction::create([ 'user_email' => 'jeroen.@jeroen.jeroen', 'object_id' => $wallet->id, 'object_type' => \App\Wallet::class, 'type' => $type, 'amount' => 11 * (count($result) + 1) * ($type == Transaction::WALLET_PENALTY ? -1 : 1), 'description' => 'TRANS' . $loops, ]); $transaction->created_at = $date->next(Carbon::MONDAY); $transaction->save(); $result[] = $transaction; } return $result; } /** * Delete a test domain whatever it takes. * * @coversNothing */ protected function deleteTestDomain($name) { Queue::fake(); $domain = Domain::withTrashed()->where('namespace', $name)->first(); if (!$domain) { return; } $job = new \App\Jobs\Domain\DeleteJob($domain->id); $job->handle(); $domain->forceDelete(); } /** * Delete a test group whatever it takes. * * @coversNothing */ protected function deleteTestGroup($email) { Queue::fake(); $group = Group::withTrashed()->where('email', $email)->first(); if (!$group) { return; } LDAP::deleteGroup($group); $group->forceDelete(); } /** * Delete a test resource whatever it takes. * * @coversNothing */ protected function deleteTestResource($email) { Queue::fake(); $resource = Resource::withTrashed()->where('email', $email)->first(); if (!$resource) { return; } LDAP::deleteResource($resource); $resource->forceDelete(); } /** * Delete a test shared folder whatever it takes. * * @coversNothing */ protected function deleteTestSharedFolder($email) { Queue::fake(); $folder = SharedFolder::withTrashed()->where('email', $email)->first(); if (!$folder) { return; } LDAP::deleteSharedFolder($folder); $folder->forceDelete(); } /** * Delete a test user whatever it takes. * * @coversNothing */ protected function deleteTestUser($email) { Queue::fake(); $user = User::withTrashed()->where('email', $email)->first(); if (!$user) { return; } LDAP::deleteUser($user); $user->forceDelete(); } /** * Delete a test companion app whatever it takes. * * @coversNothing */ protected function deleteTestCompanionApp($deviceId) { Queue::fake(); $companionApp = CompanionApp::where('device_id', $deviceId)->first(); if (!$companionApp) { return; } $companionApp->forceDelete(); } /** * Helper to access protected property of an object */ protected static function getObjectProperty($object, $property_name) { $reflection = new \ReflectionClass($object); $property = $reflection->getProperty($property_name); $property->setAccessible(true); return $property->getValue($object); } /** * Get Domain object by namespace, create it if needed. * Skip LDAP jobs. * * @coversNothing */ protected function getTestDomain($name, $attrib = []) { // Disable jobs (i.e. skip LDAP oprations) Queue::fake(); return Domain::firstOrCreate(['namespace' => $name], $attrib); } /** * Get Group object by email, create it if needed. * Skip LDAP jobs. */ protected function getTestGroup($email, $attrib = []) { // Disable jobs (i.e. skip LDAP oprations) Queue::fake(); return Group::firstOrCreate(['email' => $email], $attrib); } /** * Get Resource object by email, create it if needed. * Skip LDAP jobs. */ protected function getTestResource($email, $attrib = []) { // Disable jobs (i.e. skip LDAP oprations) Queue::fake(); $resource = Resource::where('email', $email)->first(); if (!$resource) { list($local, $domain) = explode('@', $email, 2); $resource = new Resource(); $resource->email = $email; $resource->domainName = $domain; if (!isset($attrib['name'])) { $resource->name = $local; } } foreach ($attrib as $key => $val) { $resource->{$key} = $val; } $resource->save(); return $resource; } /** * Get SharedFolder object by email, create it if needed. * Skip LDAP jobs. */ protected function getTestSharedFolder($email, $attrib = []) { // Disable jobs (i.e. skip LDAP oprations) Queue::fake(); $folder = SharedFolder::where('email', $email)->first(); if (!$folder) { list($local, $domain) = explode('@', $email, 2); $folder = new SharedFolder(); $folder->email = $email; $folder->domainName = $domain; if (!isset($attrib['name'])) { $folder->name = $local; } } foreach ($attrib as $key => $val) { $folder->{$key} = $val; } $folder->save(); return $folder; } /** * Get User object by email, create it if needed. * Skip LDAP jobs. * * @coversNothing */ protected function getTestUser($email, $attrib = []) { // Disable jobs (i.e. skip LDAP oprations) Queue::fake(); $user = User::firstOrCreate(['email' => $email], $attrib); if ($user->trashed()) { // Note: we do not want to use user restore here User::where('id', $user->id)->forceDelete(); $user = User::create(['email' => $email] + $attrib); } return $user; } /** * Get CompanionApp object by deviceId, create it if needed. * Skip LDAP jobs. * * @coversNothing */ protected function getTestCompanionApp($deviceId, $user, $attrib = []) { // Disable jobs (i.e. skip LDAP oprations) Queue::fake(); $companionApp = CompanionApp::firstOrCreate( [ 'device_id' => $deviceId, 'user_id' => $user->id, 'notification_token' => '', 'mfa_enabled' => 1 ], $attrib ); return $companionApp; } /** * Call protected/private method of a class. * * @param object $object Instantiated object that we will run method on. * @param string $methodName Method name to call * @param array $parameters Array of parameters to pass into method. * * @return mixed Method return. */ protected function invokeMethod($object, $methodName, array $parameters = []) { $reflection = new \ReflectionClass(get_class($object)); $method = $reflection->getMethod($methodName); $method->setAccessible(true); return $method->invokeArgs($object, $parameters); } /** * Extract content of an email message. * * @param \Illuminate\Mail\Mailable $mail Mailable object * * @return array Parsed message data: * - 'plain': Plain text body * - 'html: HTML body * - 'subject': Mail subject */ protected function renderMail(\Illuminate\Mail\Mailable $mail): array { $mail->build(); // @phpstan-ignore-line $result = $this->invokeMethod($mail, 'renderForAssertions'); return [ 'plain' => $result[1], 'html' => $result[0], 'subject' => $mail->subject, ]; } protected function setUpTest() { $this->userPassword = \App\Utils::generatePassphrase(); $this->domainHosted = $this->getTestDomain( 'test.domain', [ 'type' => \App\Domain::TYPE_EXTERNAL, 'status' => \App\Domain::STATUS_ACTIVE | \App\Domain::STATUS_CONFIRMED | \App\Domain::STATUS_VERIFIED ] ); $this->getTestDomain( 'test2.domain2', [ 'type' => \App\Domain::TYPE_EXTERNAL, 'status' => \App\Domain::STATUS_ACTIVE | \App\Domain::STATUS_CONFIRMED | \App\Domain::STATUS_VERIFIED ] ); $packageKolab = \App\Package::where('title', 'kolab')->first(); $this->domainOwner = $this->getTestUser('john@test.domain', ['password' => $this->userPassword]); $this->domainOwner->assignPackage($packageKolab); $this->domainOwner->setSettings($this->domainOwnerSettings); $this->domainOwner->setAliases(['alias1@test2.domain2']); // separate for regular user $this->jack = $this->getTestUser('jack@test.domain', ['password' => $this->userPassword]); // separate for wallet controller $this->jane = $this->getTestUser('jane@test.domain', ['password' => $this->userPassword]); $this->joe = $this->getTestUser('joe@test.domain', ['password' => $this->userPassword]); $this->domainUsers[] = $this->jack; $this->domainUsers[] = $this->jane; $this->domainUsers[] = $this->joe; $this->domainUsers[] = $this->getTestUser('jill@test.domain', ['password' => $this->userPassword]); foreach ($this->domainUsers as $user) { $this->domainOwner->assignPackage($packageKolab, $user); } $this->domainUsers[] = $this->domainOwner; // assign second factor to joe $this->joe->assignSku(Sku::where('title', '2fa')->first()); \App\Auth\SecondFactor::seed($this->joe->email); usort( $this->domainUsers, function ($a, $b) { return $a->email > $b->email; } ); $this->domainHosted->assignPackage( \App\Package::where('title', 'domain-hosting')->first(), $this->domainOwner ); $wallet = $this->domainOwner->wallets()->first(); $wallet->addController($this->jane); $this->publicDomain = \App\Domain::where('type', \App\Domain::TYPE_PUBLIC)->first(); $this->publicDomainUser = $this->getTestUser( 'john@' . $this->publicDomain->namespace, ['password' => $this->userPassword] ); $this->publicDomainUser->assignPackage($packageKolab); } public function tearDown(): void { foreach ($this->domainUsers as $user) { if ($user == $this->domainOwner) { continue; } $this->deleteTestUser($user->email); } if ($this->domainOwner) { $this->deleteTestUser($this->domainOwner->email); } if ($this->domainHosted) { $this->deleteTestDomain($this->domainHosted->namespace); } if ($this->publicDomainUser) { $this->deleteTestUser($this->publicDomainUser->email); } parent::tearDown(); } }