diff --git a/src/app/Domain.php b/src/app/Domain.php index ad03ffbe..f87249d9 100644 --- a/src/app/Domain.php +++ b/src/app/Domain.php @@ -1,157 +1,169 @@ morphOne('App\Entitlement', 'entitleable'); } + /** + * Return list of public+active domain names + */ + public static function getPublicDomains(): array + { + $where = sprintf('(type & %s) AND (status & %s)', Domain::TYPE_PUBLIC, Domain::STATUS_ACTIVE); + + return self::whereRaw($where)->get(['namespace'])->map(function ($domain) { + return $domain->namespace; + })->toArray(); + } + /** * Returns whether this domain is active. * * @return bool */ public function isActive(): bool { return $this->status & self::STATUS_ACTIVE; } /** * Returns whether this domain is confirmed the ownership of. * * @return bool */ public function isConfirmed(): bool { return $this->status & self::STATUS_CONFIRMED; } /** * Returns whether this domain is deleted. * * @return bool */ public function isDeleted(): bool { return $this->status & self::STATUS_DELETED; } /** * Returns whether this domain is registered with us. * * @return bool */ public function isExternal(): bool { return $this->type & self::TYPE_EXTERNAL; } /** * Returns whether this domain is hosted with us. * * @return bool */ public function isHosted(): bool { return $this->type & self::TYPE_HOSTED; } /** * Returns whether this domain is new. * * @return bool */ public function isNew(): bool { return $this->status & self::STATUS_NEW; } /** * Returns whether this domain is public. * * @return bool */ public function isPublic(): bool { return $this->type & self::TYPE_PUBLIC; } /** * Returns whether this domain is suspended. * * @return bool */ public function isSuspended(): bool { return $this->status & self::STATUS_SUSPENDED; } /* public function setStatusAttribute($status) { $_status = $this->status; switch ($status) { case "new": $_status += self::STATUS_NEW; break; case "active": $_status += self::STATUS_ACTIVE; $_status -= self::STATUS_NEW; break; case "confirmed": $_status += self::STATUS_CONFIRMED; $_status -= self::STATUS_NEW; break; case "suspended": $_status += self::STATUS_SUSPENDED; break; case "deleted": $_status += self::STATUS_DELETED; break; default: $_status = $status; //throw new \Exception("Invalid domain status: {$status}"); break; } $this->status = $_status; } */ } diff --git a/src/app/Handlers/Domain.php b/src/app/Handlers/Domain.php index c8db9f67..231a2ecb 100644 --- a/src/app/Handlers/Domain.php +++ b/src/app/Handlers/Domain.php @@ -1,17 +1,23 @@ sku_id)->active) { + \Log::error("Sku not active"); return false; } return true; } } diff --git a/src/app/Handlers/DomainHosting.php b/src/app/Handlers/DomainHosting.php index 78c1d647..2ec21573 100644 --- a/src/app/Handlers/DomainHosting.php +++ b/src/app/Handlers/DomainHosting.php @@ -1,17 +1,23 @@ sku_id)->active) { + \Log::error("Sku not active"); return false; } - return false; + return true; } } diff --git a/src/app/Handlers/DomainRegistration.php b/src/app/Handlers/DomainRegistration.php index ff5494a5..367c3db9 100644 --- a/src/app/Handlers/DomainRegistration.php +++ b/src/app/Handlers/DomainRegistration.php @@ -1,17 +1,23 @@ sku_id)->active) { + \Log::error("Sku not active"); return false; } - return false; + return true; } } diff --git a/src/app/Handlers/Groupware.php b/src/app/Handlers/Groupware.php index fd6d97dc..5f6f443d 100644 --- a/src/app/Handlers/Groupware.php +++ b/src/app/Handlers/Groupware.php @@ -1,17 +1,23 @@ sku_id)->active) { + \Log::error("Sku not active"); return false; } return true; } } diff --git a/src/app/Handlers/Mailbox.php b/src/app/Handlers/Mailbox.php index 3c63c604..b6ce7b8b 100644 --- a/src/app/Handlers/Mailbox.php +++ b/src/app/Handlers/Mailbox.php @@ -1,32 +1,43 @@ sku_id)->active) { - \Log::info("Sku not active"); + \Log::error("Sku not active"); return false; } +/* + FIXME: This code prevents from creating initial mailbox SKU + on signup of group account, because User::domains() + does not return the new domain. + Either we make sure to create domain entitlement before mailbox + entitlement or make the method here aware of that case or? list($local, $domain) = explode('@', $user->email); $domains = $user->domains(); foreach ($domains as $_domain) { if ($domain == $_domain->namespace) { return true; } } \Log::info("Domain not for user"); - - return false; +*/ + return true; } } diff --git a/src/app/Handlers/Resource.php b/src/app/Handlers/Resource.php index 51da6b15..34f8e7e5 100644 --- a/src/app/Handlers/Resource.php +++ b/src/app/Handlers/Resource.php @@ -1,17 +1,23 @@ sku_id)->active) { + \Log::error("Sku not active"); return false; } return true; } } diff --git a/src/app/Handlers/SharedFolder.php b/src/app/Handlers/SharedFolder.php index a30905a4..7dda33ed 100644 --- a/src/app/Handlers/SharedFolder.php +++ b/src/app/Handlers/SharedFolder.php @@ -1,17 +1,23 @@ sku_id)->active) { + \Log::error("Sku not active"); return false; } return true; } } diff --git a/src/app/Handlers/Storage.php b/src/app/Handlers/Storage.php index 631e9ca6..94ac4642 100644 --- a/src/app/Handlers/Storage.php +++ b/src/app/Handlers/Storage.php @@ -1,17 +1,34 @@ user_id = $user->id; + $quota->save(); + + return $quota->id; + } + + public static function entitleableClass() + { + return Quota::class; + } + public static function preReq($entitlement, $user) { if (!Sku::find($entitlement->sku_id)->active) { + \Log::error("Sku not active"); return false; } return true; } } diff --git a/src/app/Http/Controllers/API/SignupController.php b/src/app/Http/Controllers/API/SignupController.php index c92290eb..7a6be7ef 100644 --- a/src/app/Http/Controllers/API/SignupController.php +++ b/src/app/Http/Controllers/API/SignupController.php @@ -1,255 +1,369 @@ all(), [ 'email' => 'required', - 'name' => 'required', + 'name' => 'required|max:512', + 'plan' => 'nullable|alpha_num|max:128', ] ); if ($v->fails()) { return response()->json(['status' => 'error', 'errors' => $v->errors()], 422); } // Validate user email (or phone) if ($error = $this->validatePhoneOrEmail($request->email, $is_phone)) { return response()->json(['status' => 'error', 'errors' => ['email' => __($error)]], 422); } // Generate the verification code $code = SignupCode::create([ 'data' => [ 'email' => $request->email, 'name' => $request->name, + 'plan' => $request->plan, ] ]); // Send email/sms message if ($is_phone) { SignupVerificationSMS::dispatch($code); } else { SignupVerificationEmail::dispatch($code); } return response()->json(['status' => 'success', 'code' => $code->code]); } /** * Validation of the verification code. * * @param Illuminate\Http\Request HTTP request * * @return \Illuminate\Http\JsonResponse JSON response */ public function verify(Request $request) { // Validate the request args $v = Validator::make( $request->all(), [ 'code' => 'required', 'short_code' => 'required', ] ); if ($v->fails()) { return response()->json(['status' => 'error', 'errors' => $v->errors()], 422); } // Validate the verification code $code = SignupCode::find($request->code); if ( empty($code) || $code->isExpired() || Str::upper($request->short_code) !== Str::upper($code->short_code) ) { $errors = ['short_code' => "The code is invalid or expired."]; return response()->json(['status' => 'error', 'errors' => $errors], 422); } // For signup last-step mode remember the code object, so we can delete it // with single SQL query (->delete()) instead of two (::destroy()) $this->code = $code; - // Return user name and email/phone from the codes database on success + $has_domain = $this->getPlan()->hasDomain(); + + // Return user name and email/phone from the codes database, + // domains list for selection and "plan type" flag return response()->json([ 'status' => 'success', 'email' => $code->data['email'], 'name' => $code->data['name'], + 'is_domain' => $has_domain, + 'domains' => $has_domain ? [] : Domain::getPublicDomains(), ]); } /** * Finishes the signup process by creating the user account. * * @param Illuminate\Http\Request HTTP request * * @return \Illuminate\Http\JsonResponse JSON response */ public function signup(Request $request) { // Validate input $v = Validator::make( $request->all(), [ 'login' => 'required|min:2', 'password' => 'required|min:4|confirmed', + 'domain' => 'required', ] ); if ($v->fails()) { return response()->json(['status' => 'error', 'errors' => $v->errors()], 422); } - $login = $request->login . '@' . \config('app.domain'); - - // Validate login (email) - if ($error = $this->validateEmail($login, true)) { - return response()->json(['status' => 'error', 'errors' => ['login' => $error]], 422); - } - // Validate verification codes (again) $v = $this->verify($request); if ($v->status() !== 200) { return $v; } + // Get the plan + $plan = $this->getPlan(); + $is_domain = $plan->hasDomain(); + + $login = $request->login; + $domain = $request->domain; + + // Validate login + if ($errors = $this->validateLogin($login, $domain, $is_domain)) { + $errors = $this->resolveErrors($errors); + return response()->json(['status' => 'error', 'errors' => $errors], 422); + } + // Get user name/email from the verification code database $code_data = $v->getData(); $user_name = $code_data->name; $user_email = $code_data->email; // We allow only ASCII, so we can safely lower-case the email address $login = Str::lower($login); + $domain = Str::lower($domain); - $user = User::create( - [ + DB::beginTransaction(); + + // Create user record + $user = User::create([ 'name' => $user_name, - 'email' => $login, + 'email' => $login . '@' . $domain, 'password' => $request->password, - ] - ); + ]); - // Save the external email in user settings - $user->setSettings(['external_email' => $user_email]); + // Create domain record + // FIXME: Should we do this in UserObserver::created()? + if ($is_domain) { + $domain = Domain::create([ + 'namespace' => $domain, + 'status' => Domain::STATUS_NEW, + 'type' => Domain::TYPE_EXTERNAL, + ]); + } + + // Create SKUs (after domain) + foreach ($plan->packages as $package) { + foreach ($package->skus as $sku) { + $sku->registerEntitlement($user, is_object($domain) ? [$domain] : []); + } + } + + // Save the external email and plan in user settings + $user->setSettings([ + 'external_email' => $user_email, + 'plan' => $plan->id, + ]); // Remove the verification code $this->code->delete(); + DB::commit(); + return UsersController::logonResponse($user); } /** * Checks if the input string is a valid email address or a phone number * * @param string $email Email address or phone number * @param bool &$is_phone Will be set to True if the string is valid phone number * * @return string Error message label on validation error */ protected function validatePhoneOrEmail($input, &$is_phone = false) { $is_phone = false; return $this->validateEmail($input); // TODO: Phone number support /* if (strpos($input, '@')) { return $this->validateEmail($input); } $input = str_replace(array('-', ' '), '', $input); if (!preg_match('/^\+?[0-9]{9,12}$/', $input)) { return 'validation.noemailorphone'; } $is_phone = true; */ } /** * Email address validation * - * @param string $email Email address - * @param bool $signup Enables additional checks for signup mode + * @param string $email Email address * * @return string Error message label on validation error */ - protected function validateEmail($email, $signup = false) + protected function validateEmail($email) { $v = Validator::make(['email' => $email], ['email' => 'required|email']); if ($v->fails()) { return 'validation.emailinvalid'; } list($local, $domain) = explode('@', $email); // don't allow @localhost and other no-fqdn if (strpos($domain, '.') === false) { return 'validation.emailinvalid'; } + } + + /** + * Login (kolab identity) validation + * + * @param string $email Login (local part of an email address) + * @param string $domain Domain name + * @param bool $external Enables additional checks for domain part + * + * @return array Error messages on validation error + */ + protected function validateLogin($login, $domain, $external = false) + { + // don't allow @localhost and other no-fqdn + if (empty($domain) || strpos($domain, '.') === false || stripos($domain, 'www.') === 0) { + return ['domain' => 'validation.domaininvalid']; + } - // Extended checks for an address that is supposed to become a login to Kolab - if ($signup) { - // Local part validation - if (!preg_match('/^[A-Za-z0-9_.-]+$/', $local)) { - return 'validation.emailinvalid'; + // Local part validation + if (!preg_match('/^[A-Za-z0-9_.-]+$/', $login)) { + return ['login' => 'validation.logininvalid']; + } + + $domain = Str::lower($domain); + + if (!$external) { + // Check if the local part is not one of exceptions + $exceptions = '/^(admin|administrator|sales|root)$/i'; + if (preg_match($exceptions, $login)) { + return ['login' => 'validation.loginexists']; } // Check if specified domain is allowed for signup - if ($domain != \config('app.domain')) { - return 'validation.emailinvalid'; + if (!in_array($domain, Domain::getPublicDomains())) { + return ['domain' => 'validation.domaininvalid']; + } + } else { + // Use email validator to validate the domain part + $v = Validator::make(['email' => 'user@' . $domain], ['email' => 'required|email']); + if ($v->fails()) { + return ['domain' => 'validation.domaininvalid']; } - // Check if the local part is not one of exceptions - $exceptions = '/^(admin|administrator|sales|root)$/i'; - if (preg_match($exceptions, $local)) { - return 'validation.emailexists'; + // TODO: DNS registration check - maybe after signup? + + // Check if domain is already registered with us + if (Domain::where('namespace', $domain)->first()) { + return ['domain' => 'validation.domainexists']; + } + } + + // Check if user with specified login already exists + // TODO: Aliases + $email = $login . '@' . $domain; + if (User::findByEmail($email)) { + return ['login' => 'validation.loginexists']; + } + } + + /** + * Returns plan for the signup process + * + * @returns \App\Plan Plan object selected for current signup process + */ + protected function getPlan() + { + if (!$this->plan) { + // Get the plan if specified and exists... + if ($this->code && $this->code->data['plan']) { + $plan = Plan::where('title', $this->code->data['plan'])->first(); } - // Check if user with specified login already exists - // TODO: Aliases - if (User::where('email', $email)->first()) { - return 'validation.emailexists'; + // ...otherwise use the default plan + if (empty($plan)) { + // TODO: Get default plan title from config + $plan = Plan::where('title', 'individual')->first(); } + + $this->plan = $plan; } + + return $this->plan; + } + + /** + * Convert error labels to actual (localized) text + */ + protected function resolveErrors(array $errors): array + { + $result = []; + + foreach ($errors as $idx => $label) { + $result[$idx] = __($label); + } + + return $result; } } diff --git a/src/app/Plan.php b/src/app/Plan.php index 746be3b6..d3b68321 100644 --- a/src/app/Plan.php +++ b/src/app/Plan.php @@ -1,65 +1,81 @@ 'datetime', 'promo_to' => 'datetime', 'discount_qty' => 'integer', 'discount_rate' => 'integer' ]; public function cost() { $costs = 0; foreach ($this->packages as $package) { $costs += $package->pivot->cost(); } return $costs; } public function packages() { return $this->belongsToMany( 'App\Package', 'plan_packages' )->using('App\PlanPackage')->withPivot( [ 'qty_min', 'qty_max', 'discount_qty', 'discount_rate' ] ); } + + /** + * Checks if the plan has domain SKU assigned + */ + public function hasDomain(): bool + { + foreach ($this->packages as $package) { + foreach ($package->skus as $sku) { + if ($sku->handler_class::entitleableClass() == \App\Domain::class) { + return true; + } + } + } + + return false; + } } diff --git a/src/app/Quota.php b/src/app/Quota.php index a389cbd0..55c1797e 100644 --- a/src/app/Quota.php +++ b/src/app/Quota.php @@ -1,13 +1,29 @@ 'int', + ]; + public function entitlement() { return $this->morphOne('App\Entitlement', 'entitleable'); } + + /** + * The owner of this quota entry + * + * @return \App\User + */ + public function user() + { + return $this->belongsTo('App\User', 'user_id', 'id'); + } } diff --git a/src/app/Sku.php b/src/app/Sku.php index 3dc4bab7..a99718c7 100644 --- a/src/app/Sku.php +++ b/src/app/Sku.php @@ -1,46 +1,85 @@ 'integer' ]; protected $fillable = [ 'title', 'description', 'cost', 'units_free', 'period', 'handler_class', 'active' ]; /** * List the entitlements that consume this SKU. * * @return Entitlement[] */ public function entitlements() { return $this->hasMany('App\Entitlement'); } public function packages() { return $this->belongsToMany( 'App\Package', 'package_skus' )->using('App\PackageSku')->withPivot(['qty']); } + + /** + * Register (default) SKU entitlement for specified user. + * This method should be used e.g. on user creation when we have + * a set of SKUs and want to create entitlements for them (using + * default values). + */ + public function registerEntitlement(\App\User $user, array $params = []) + { + $wallet = $user->wallets()->get()[0]; + + $entitlement = new \App\Entitlement(); + $entitlement->owner_id = $user->id; + $entitlement->wallet_id = $wallet->id; + $entitlement->sku_id = $this->id; + + $entitlement->entitleable_type = $this->handler_class::entitleableClass(); + + if ($user instanceof $entitlement->entitleable_type) { + $entitlement->entitleable_id = $user->id; + } else { + foreach ($params as $param) { + if ($param instanceof $entitlement->entitleable_type) { + $entitlement->entitleable_id = $param->id; + break; + } + } + } + + if (empty($entitlement->entitleable_id)) { + if (method_exists($this->handler_class, 'createDefaultEntitleable')) { + $entitlement->entitleable_id = $this->handler_class::createDefaultEntitleable($user); + } else { + // error + } + } + + $entitlement->save(); + } } diff --git a/src/app/User.php b/src/app/User.php index 4a2d8bcc..43cc8912 100644 --- a/src/app/User.php +++ b/src/app/User.php @@ -1,206 +1,206 @@ 'datetime', ]; /** * Any wallets on which this user is a controller. * * @return Wallet[] */ public function accounts() { return $this->belongsToMany( 'App\Wallet', // The foreign object definition 'user_accounts', // The table name 'user_id', // The local foreign key 'wallet_id' // The remote foreign key ); } /** * List the domains to which this user is entitled. * * @return Domain[] */ public function domains() { $domains = Domain::whereRaw( sprintf( '(type & %s) AND (status & %s)', Domain::TYPE_PUBLIC, Domain::STATUS_ACTIVE ) )->get(); foreach ($this->entitlements()->get() as $entitlement) { if ($entitlement->entitleable instanceof Domain) { $domain = Domain::find($entitlement->entitleable_id); \Log::info("Found domain {$domain->namespace}"); $domains[] = $domain; } } foreach ($this->accounts()->get() as $wallet) { foreach ($wallet->entitlements()->get() as $entitlement) { if ($entitlement->entitleable instanceof Domain) { $domain = Domain::find($entitlement->entitleable_id); \Log::info("Found domain {$domain->namespace}"); $domains[] = $domain; } } } return $domains; } public function entitlement() { return $this->morphOne('App\Entitlement', 'entitleable'); } /** * Entitlements for this user. * * @return Entitlement[] */ public function entitlements() { return $this->hasMany('App\Entitlement', 'owner_id', 'id'); } public function addEntitlement($entitlement) { if (!$this->entitlements()->get()->contains($entitlement)) { return $this->entitlements()->save($entitlement); } } /** * Helper to find user by email address, whether it is * main email address, alias or external email * * @param string $email Email address * * @return \App\User User model object */ public static function findByEmail(string $email) { if (strpos($email, '@') === false) { return; } - $user = self::where('email', $email); - - return $user->count() === 1 ? $user->first() : null; + $user = self::where('email', $email)->first(); // TODO: Aliases, External email + + return $user; } public function settings() { return $this->hasMany('App\UserSetting', 'user_id'); } /** * Verification codes for this user. * * @return VerificationCode[] */ public function verificationcodes() { return $this->hasMany('App\VerificationCode', 'user_id', 'id'); } /** * Wallets this user owns. * * @return Wallet[] */ public function wallets() { return $this->hasMany('App\Wallet'); } public function getJWTIdentifier() { return $this->getKey(); } public function getJWTCustomClaims() { return []; } public function setPasswordAttribute($password) { if (!empty($password)) { $this->attributes['password'] = bcrypt($password, [ "rounds" => 12 ]); $this->attributes['password_ldap'] = '{SSHA512}' . base64_encode( pack('H*', hash('sha512', $password)) ); } } public function setPasswordLdapAttribute($password) { if (!empty($password)) { $this->attributes['password'] = bcrypt($password, [ "rounds" => 12 ]); $this->attributes['password_ldap'] = '{SSHA512}' . base64_encode( pack('H*', hash('sha512', $password)) ); } } } diff --git a/src/database/migrations/2019_09_23_071701_create_domains_table.php b/src/database/migrations/2019_09_23_071701_create_domains_table.php index 61e7a16d..06399128 100644 --- a/src/database/migrations/2019_09_23_071701_create_domains_table.php +++ b/src/database/migrations/2019_09_23_071701_create_domains_table.php @@ -1,34 +1,34 @@ bigInteger('id'); - $table->string('namespace'); + $table->string('namespace')->unique(); $table->smallinteger('status'); $table->tinyinteger('type'); $table->timestamps(); }); } /** * Reverse the migrations. * * @return void */ public function down() { Schema::dropIfExists('domains'); } } diff --git a/src/package.json b/src/package.json index fafca483..926ddfa0 100644 --- a/src/package.json +++ b/src/package.json @@ -1,34 +1,34 @@ { "private": true, "scripts": { "dev": "npm run development", "development": "cross-env NODE_ENV=development node_modules/webpack/bin/webpack.js --progress --hide-modules --config=node_modules/laravel-mix/setup/webpack.config.js", "watch": "npm run development -- --watch", "watch-poll": "npm run watch -- --watch-poll", "hot": "cross-env NODE_ENV=development node_modules/webpack-dev-server/bin/webpack-dev-server.js --inline --hot --config=node_modules/laravel-mix/setup/webpack.config.js", "prod": "npm run production", "production": "cross-env NODE_ENV=production node_modules/webpack/bin/webpack.js --no-progress --hide-modules --config=node_modules/laravel-mix/setup/webpack.config.js", "lint": "eslint --ext .js,.vue resources && stylelint \"resources/sass/*.scss\" \"resources/vue/components/*.vue\"" }, "devDependencies": { "@deveodk/vue-toastr": "^1.1.0", "axios": "^0.19", - "bootstrap": "^4.3.1", + "bootstrap": "^4.4.1", "cross-env": "^5.1", "eslint": "^6.8.0", "eslint-plugin-vue": "^6.1.1", "jquery": "^3.4.1", "laravel-mix": "^4.0.7", "lodash": "^4.17.13", "popper.js": "^1.12", "resolve-url-loader": "^2.3.1", "sass": "^1.15.2", "sass-loader": "^7.1.0", "stylelint": "^12.0.1", "stylelint-config-standard": "^19.0.0", "vue": "^2.5.17", "vue-router": "^3.1.3", "vue-template-compiler": "^2.6.10", "vuex": "^3.1.1" } } diff --git a/src/resources/lang/en/validation.php b/src/resources/lang/en/validation.php index 23261b79..88de81ae 100644 --- a/src/resources/lang/en/validation.php +++ b/src/resources/lang/en/validation.php @@ -1,156 +1,159 @@ 'The :attribute must be accepted.', 'active_url' => 'The :attribute is not a valid URL.', 'after' => 'The :attribute must be a date after :date.', 'after_or_equal' => 'The :attribute must be a date after or equal to :date.', 'alpha' => 'The :attribute may only contain letters.', 'alpha_dash' => 'The :attribute may only contain letters, numbers, dashes and underscores.', 'alpha_num' => 'The :attribute may only contain letters and numbers.', 'array' => 'The :attribute must be an array.', 'before' => 'The :attribute must be a date before :date.', 'before_or_equal' => 'The :attribute must be a date before or equal to :date.', 'between' => [ 'numeric' => 'The :attribute must be between :min and :max.', 'file' => 'The :attribute must be between :min and :max kilobytes.', 'string' => 'The :attribute must be between :min and :max characters.', 'array' => 'The :attribute must have between :min and :max items.', ], 'boolean' => 'The :attribute field must be true or false.', 'confirmed' => 'The :attribute confirmation does not match.', 'date' => 'The :attribute is not a valid date.', 'date_equals' => 'The :attribute must be a date equal to :date.', 'date_format' => 'The :attribute does not match the format :format.', 'different' => 'The :attribute and :other must be different.', 'digits' => 'The :attribute must be :digits digits.', 'digits_between' => 'The :attribute must be between :min and :max digits.', 'dimensions' => 'The :attribute has invalid image dimensions.', 'distinct' => 'The :attribute field has a duplicate value.', 'email' => 'The :attribute must be a valid email address.', 'ends_with' => 'The :attribute must end with one of the following: :values', 'exists' => 'The selected :attribute is invalid.', 'file' => 'The :attribute must be a file.', 'filled' => 'The :attribute field must have a value.', 'gt' => [ 'numeric' => 'The :attribute must be greater than :value.', 'file' => 'The :attribute must be greater than :value kilobytes.', 'string' => 'The :attribute must be greater than :value characters.', 'array' => 'The :attribute must have more than :value items.', ], 'gte' => [ 'numeric' => 'The :attribute must be greater than or equal :value.', 'file' => 'The :attribute must be greater than or equal :value kilobytes.', 'string' => 'The :attribute must be greater than or equal :value characters.', 'array' => 'The :attribute must have :value items or more.', ], 'image' => 'The :attribute must be an image.', 'in' => 'The selected :attribute is invalid.', 'in_array' => 'The :attribute field does not exist in :other.', 'integer' => 'The :attribute must be an integer.', 'ip' => 'The :attribute must be a valid IP address.', 'ipv4' => 'The :attribute must be a valid IPv4 address.', 'ipv6' => 'The :attribute must be a valid IPv6 address.', 'json' => 'The :attribute must be a valid JSON string.', 'lt' => [ 'numeric' => 'The :attribute must be less than :value.', 'file' => 'The :attribute must be less than :value kilobytes.', 'string' => 'The :attribute must be less than :value characters.', 'array' => 'The :attribute must have less than :value items.', ], 'lte' => [ 'numeric' => 'The :attribute must be less than or equal :value.', 'file' => 'The :attribute must be less than or equal :value kilobytes.', 'string' => 'The :attribute must be less than or equal :value characters.', 'array' => 'The :attribute must not have more than :value items.', ], 'max' => [ 'numeric' => 'The :attribute may not be greater than :max.', 'file' => 'The :attribute may not be greater than :max kilobytes.', 'string' => 'The :attribute may not be greater than :max characters.', 'array' => 'The :attribute may not have more than :max items.', ], 'mimes' => 'The :attribute must be a file of type: :values.', 'mimetypes' => 'The :attribute must be a file of type: :values.', 'min' => [ 'numeric' => 'The :attribute must be at least :min.', 'file' => 'The :attribute must be at least :min kilobytes.', 'string' => 'The :attribute must be at least :min characters.', 'array' => 'The :attribute must have at least :min items.', ], 'not_in' => 'The selected :attribute is invalid.', 'not_regex' => 'The :attribute format is invalid.', 'numeric' => 'The :attribute must be a number.', 'present' => 'The :attribute field must be present.', 'regex' => 'The :attribute format is invalid.', 'required' => 'The :attribute field is required.', 'required_if' => 'The :attribute field is required when :other is :value.', 'required_unless' => 'The :attribute field is required unless :other is in :values.', 'required_with' => 'The :attribute field is required when :values is present.', 'required_with_all' => 'The :attribute field is required when :values are present.', 'required_without' => 'The :attribute field is required when :values is not present.', 'required_without_all' => 'The :attribute field is required when none of :values are present.', 'same' => 'The :attribute and :other must match.', 'size' => [ 'numeric' => 'The :attribute must be :size.', 'file' => 'The :attribute must be :size kilobytes.', 'string' => 'The :attribute must be :size characters.', 'array' => 'The :attribute must contain :size items.', ], 'starts_with' => 'The :attribute must start with one of the following: :values', 'string' => 'The :attribute must be a string.', 'timezone' => 'The :attribute must be a valid zone.', 'unique' => 'The :attribute has already been taken.', 'uploaded' => 'The :attribute failed to upload.', 'url' => 'The :attribute format is invalid.', 'uuid' => 'The :attribute must be a valid UUID.', - 'emailexists' => 'The specified email address already exists', 'emailinvalid' => 'The specified email address is invalid', + 'domaininvalid' => 'The specified domain is invalid', + 'logininvalid' => 'The specified login contains forbidden characters', + 'loginexists' => 'The specified login is not available for signup', + 'domainexists' => 'The specified domain is not available for signup', 'noemailorphone' => 'The specified text is neither a valid email address nor a phone number', 'usernotexists' => 'Unable to find user', 'noextemail' => 'This user has no external email address', /* |-------------------------------------------------------------------------- | Custom Validation Language Lines |-------------------------------------------------------------------------- | | Here you may specify custom validation messages for attributes using the | convention "attribute.rule" to name the lines. This makes it quick to | specify a specific custom language line for a given attribute rule. | */ 'custom' => [ 'attribute-name' => [ 'rule-name' => 'custom-message', ], ], /* |-------------------------------------------------------------------------- | Custom Validation Attributes |-------------------------------------------------------------------------- | | The following language lines are used to swap our attribute placeholder | with something more reader friendly such as "E-Mail Address" instead | of "email". This simply helps us make our message more expressive. | */ 'attributes' => [], ]; diff --git a/src/resources/vue/components/Signup.vue b/src/resources/vue/components/Signup.vue index 70ace083..4146265f 100644 --- a/src/resources/vue/components/Signup.vue +++ b/src/resources/vue/components/Signup.vue @@ -1,183 +1,204 @@ diff --git a/src/resources/vue/js/routes.js b/src/resources/vue/js/routes.js index edbd1805..0e81ff1f 100644 --- a/src/resources/vue/js/routes.js +++ b/src/resources/vue/js/routes.js @@ -1,76 +1,76 @@ import Vue from 'vue' import VueRouter from 'vue-router' Vue.use(VueRouter) import DashboardComponent from '../components/Dashboard' import Error404Component from '../components/404' import LoginComponent from '../components/Login' import LogoutComponent from '../components/Logout' import PasswordResetComponent from '../components/PasswordReset' import SignupComponent from '../components/Signup' import store from './store' const routes = [ { path: '/', redirect: { name: 'login' } }, { path: '/dashboard', name: 'dashboard', component: DashboardComponent, meta: { requiresAuth: true } }, { path: '/login', name: 'login', component: LoginComponent }, { path: '/logout', name: 'logout', component: LogoutComponent }, { path: '/password-reset/:code?', name: 'password-reset', component: PasswordResetComponent }, { - path: '/signup/:code?', + path: '/signup/:param?', name: 'signup', component: SignupComponent }, { name: '404', path: '*', component: Error404Component } ] const router = new VueRouter({ mode: 'history', routes }) router.beforeEach((to, from, next) => { // check if the route requires authentication and user is not logged in if (to.matched.some(route => route.meta.requiresAuth) && !store.state.isLoggedIn) { // redirect to login page next({ name: 'login' }) return } // if logged in redirect to dashboard if (to.path === '/login' && store.state.isLoggedIn) { next({ name: 'dashboard' }) return } next() }) export default router diff --git a/src/tests/Browser/PasswordResetTest.php b/src/tests/Browser/PasswordResetTest.php index 18cc8815..8baf8e48 100644 --- a/src/tests/Browser/PasswordResetTest.php +++ b/src/tests/Browser/PasswordResetTest.php @@ -1,277 +1,275 @@ 'passwordresettestdusk@' . \config('app.domain')]); $user->setSetting('external_email', 'external@domain.tld'); } /** * {@inheritDoc} * * @return void */ public function tearDown(): void { User::where('email', 'passwordresettestdusk@' . \config('app.domain'))->delete(); - - parent::tearDown(); } /** * Test the link from logon to password-reset page * * @return void */ public function testPasswordResetLinkOnLogon() { $this->browse(function (Browser $browser) { $browser->visit(new Home()); $browser->assertSeeLink('Forgot password?'); $browser->clickLink('Forgot password?'); $browser->on(new PasswordReset()); $browser->assertVisible('@step1'); }); } /** * Test 1st step of password-reset * * @return void */ public function testPasswordResetStep1() { $this->browse(function (Browser $browser) { $browser->visit(new PasswordReset()); $browser->assertVisible('@step1'); // Here we expect email input and submit button $browser->with('@step1', function ($step) { $step->assertVisible('#reset_email'); $step->assertFocused('#reset_email'); $step->assertVisible('[type=submit]'); }); // Submit empty form $browser->with('@step1', function ($step) { $step->click('[type=submit]'); $step->assertFocused('#reset_email'); }); // Submit invalid email // We expect email input to have is-invalid class added, with .invalid-feedback element $browser->with('@step1', function ($step) use ($browser) { $step->type('#reset_email', '@test'); $step->click('[type=submit]'); $step->waitFor('#reset_email.is-invalid'); $step->waitFor('#reset_email + .invalid-feedback'); $browser->waitFor('.toast-error'); $browser->click('.toast-error'); // remove the toast }); // Submit valid data $browser->with('@step1', function ($step) { $step->type('#reset_email', 'passwordresettestdusk@' . \config('app.domain')); $step->click('[type=submit]'); $step->assertMissing('#reset_email.is-invalid'); $step->assertMissing('#reset_email + .invalid-feedback'); }); $browser->waitUntilMissing('@step2 #reset_code[value=""]'); $browser->waitFor('@step2'); $browser->assertMissing('@step1'); }); } /** * Test 2nd Step of the password reset process * * @depends testPasswordResetStep1 * @return void */ public function testPasswordResetStep2() { $this->browse(function (Browser $browser) { $browser->assertVisible('@step2'); // Here we expect one text input, Back and Continue buttons $browser->with('@step2', function ($step) { $step->assertVisible('#reset_short_code'); $step->assertFocused('#reset_short_code'); $step->assertVisible('[type=button]'); $step->assertVisible('[type=submit]'); }); // Test Back button functionality $browser->click('@step2 [type=button]'); $browser->waitFor('@step1'); $browser->assertFocused('@step1 #reset_email'); $browser->assertMissing('@step2'); // Submit valid Step 1 data (again) $browser->with('@step1', function ($step) { $step->type('#reset_email', 'passwordresettestdusk@' . \config('app.domain')); $step->click('[type=submit]'); }); $browser->waitFor('@step2'); $browser->assertMissing('@step1'); // Submit invalid code // We expect code input to have is-invalid class added, with .invalid-feedback element $browser->with('@step2', function ($step) use ($browser) { $step->type('#reset_short_code', 'XXXXX'); $step->click('[type=submit]'); $browser->waitFor('.toast-error'); $step->assertVisible('#reset_short_code.is-invalid'); $step->assertVisible('#reset_short_code + .invalid-feedback'); $step->assertFocused('#reset_short_code'); $browser->click('.toast-error'); // remove the toast }); // Submit valid code // We expect error state on code input to be removed, and Step 3 form visible $browser->with('@step2', function ($step) { // Get the code and short_code from database // FIXME: Find a nice way to read javascript data without using hidden inputs $code = $step->value('#reset_code'); $this->assertNotEmpty($code); $code = VerificationCode::find($code); $step->type('#reset_short_code', $code->short_code); $step->click('[type=submit]'); $step->assertMissing('#reset_short_code.is-invalid'); $step->assertMissing('#reset_short_code + .invalid-feedback'); }); $browser->waitFor('@step3'); $browser->assertMissing('@step2'); }); } /** * Test 3rd Step of the password reset process * * @depends testPasswordResetStep2 * @return void */ public function testPasswordResetStep3() { $this->browse(function (Browser $browser) { $browser->assertVisible('@step3'); // Here we expect 2 text inputs, Back and Continue buttons $browser->with('@step3', function ($step) { $step->assertVisible('#reset_password'); $step->assertVisible('#reset_confirm'); $step->assertVisible('[type=button]'); $step->assertVisible('[type=submit]'); $step->assertFocused('#reset_password'); }); // Test Back button $browser->click('@step3 [type=button]'); $browser->waitFor('@step2'); $browser->assertFocused('@step2 #reset_short_code'); $browser->assertMissing('@step3'); $browser->assertMissing('@step1'); // TODO: Test form reset when going back // Because the verification code is removed in tearDown() // we'll start from the beginning (Step 1) $browser->click('@step2 [type=button]'); $browser->waitFor('@step1'); $browser->assertFocused('@step1 #reset_email'); $browser->assertMissing('@step3'); $browser->assertMissing('@step2'); // Submit valid data $browser->with('@step1', function ($step) { $step->type('#reset_email', 'passwordresettestdusk@' . \config('app.domain')); $step->click('[type=submit]'); }); $browser->waitFor('@step2'); $browser->waitUntilMissing('@step2 #reset_code[value=""]'); // Submit valid code again $browser->with('@step2', function ($step) { $code = $step->value('#reset_code'); $this->assertNotEmpty($code); $code = VerificationCode::find($code); $step->type('#reset_short_code', $code->short_code); $step->click('[type=submit]'); }); $browser->waitFor('@step3'); // Submit invalid data $browser->with('@step3', function ($step) use ($browser) { $step->assertFocused('#reset_password'); $step->type('#reset_password', '12345678'); $step->type('#reset_confirm', '123456789'); $step->click('[type=submit]'); $browser->waitFor('.toast-error'); $step->assertVisible('#reset_password.is-invalid'); $step->assertVisible('#reset_password + .invalid-feedback'); $step->assertFocused('#reset_password'); $browser->click('.toast-error'); // remove the toast }); // Submit valid data $browser->with('@step3', function ($step) { $step->type('#reset_confirm', '12345678'); $step->click('[type=submit]'); }); $browser->waitUntilMissing('@step3'); // At this point we should be auto-logged-in to dashboard $dashboard = new Dashboard(); $dashboard->assert($browser); // FIXME: Is it enough to be sure user is logged in? }); } } diff --git a/src/tests/Browser/SignupTest.php b/src/tests/Browser/SignupTest.php index 6cce96e2..025a1de2 100644 --- a/src/tests/Browser/SignupTest.php +++ b/src/tests/Browser/SignupTest.php @@ -1,319 +1,324 @@ delete(); - - parent::tearDown(); } /** * Test signup code verification with a link * * @return void */ public function testSignupCodeByLink() { // Test invalid code (invalid format) $this->browse(function (Browser $browser) { // Register Signup page element selectors we'll be using $browser->onWithoutAssert(new Signup()); // TODO: Test what happens if user is logged in $browser->visit('/signup/invalid-code'); // TODO: According to https://github.com/vuejs/vue-router/issues/977 // it is not yet easily possible to display error page component (route) // without changing the URL // TODO: Instead of css selector we should probably define page/component // and use it instead $browser->waitFor('#error-page'); }); // Test invalid code (valid format) $this->browse(function (Browser $browser) { $browser->visit('/signup/XXXXX-code'); // FIXME: User will not be able to continue anyway, so we should // either display 1st step or 404 error page $browser->waitFor('@step1'); $browser->waitFor('.toast-error'); $browser->click('.toast-error'); // remove the toast }); // Test valid code $this->browse(function (Browser $browser) { $code = SignupCode::create([ 'data' => [ 'email' => 'User@example.org', 'name' => 'User Name', + 'plan' => 'individual', ] ]); $browser->visit('/signup/' . $code->short_code . '-' . $code->code); $browser->waitFor('@step3'); $browser->assertMissing('@step1'); $browser->assertMissing('@step2'); // FIXME: Find a nice way to read javascript data without using hidden inputs $this->assertSame($code->code, $browser->value('@step2 #signup_code')); // TODO: Test if the signup process can be completed }); } /** * Test 1st step of the signup process * * @return void */ public function testSignupStep1() { $this->browse(function (Browser $browser) { $browser->visit(new Signup()); $browser->assertVisible('@step1'); $browser->within(new Menu(), function ($browser) { $browser->assertMenuItems(['signup', 'explore', 'blog', 'support', 'login']); $browser->assertActiveItem('signup'); }); // Here we expect two text inputs and Continue $browser->with('@step1', function ($step) { $step->assertVisible('#signup_name'); $step->assertFocused('#signup_name'); $step->assertVisible('#signup_email'); $step->assertVisible('[type=submit]'); }); // Submit empty form // Both Step 1 inputs are required, so after pressing Submit // we expect focus to be moved to the first input $browser->with('@step1', function ($step) { $step->click('[type=submit]'); $step->assertFocused('#signup_name'); }); // Submit invalid email // We expect email input to have is-invalid class added, with .invalid-feedback element $browser->with('@step1', function ($step) use ($browser) { $step->type('#signup_name', 'Test User'); $step->type('#signup_email', '@test'); $step->click('[type=submit]'); $step->waitFor('#signup_email.is-invalid'); $step->waitFor('#signup_email + .invalid-feedback'); $browser->waitFor('.toast-error'); $browser->click('.toast-error'); // remove the toast }); // Submit valid data // We expect error state on email input to be removed, and Step 2 form visible $browser->with('@step1', function ($step) { $step->type('#signup_name', 'Test User'); $step->type('#signup_email', 'BrowserSignupTestUser1@kolab.org'); $step->click('[type=submit]'); $step->assertMissing('#signup_email.is-invalid'); $step->assertMissing('#signup_email + .invalid-feedback'); }); $browser->waitUntilMissing('@step2 #signup_code[value=""]'); $browser->waitFor('@step2'); $browser->assertMissing('@step1'); }); } /** * Test 2nd Step of the signup process * * @depends testSignupStep1 * @return void */ public function testSignupStep2() { $this->browse(function (Browser $browser) { $browser->assertVisible('@step2'); // Here we expect one text input, Back and Continue buttons $browser->with('@step2', function ($step) { $step->assertVisible('#signup_short_code'); $step->assertFocused('#signup_short_code'); $step->assertVisible('[type=button]'); $step->assertVisible('[type=submit]'); }); // Test Back button functionality $browser->click('@step2 [type=button]'); $browser->waitFor('@step1'); $browser->assertFocused('@step1 #signup_name'); $browser->assertMissing('@step2'); // Submit valid Step 1 data (again) $browser->with('@step1', function ($step) { $step->type('#signup_name', 'Test User'); $step->type('#signup_email', 'BrowserSignupTestUser1@kolab.org'); $step->click('[type=submit]'); }); $browser->waitFor('@step2'); $browser->assertMissing('@step1'); // Submit invalid code // We expect code input to have is-invalid class added, with .invalid-feedback element $browser->with('@step2', function ($step) use ($browser) { $step->type('#signup_short_code', 'XXXXX'); $step->click('[type=submit]'); $browser->waitFor('.toast-error'); $step->assertVisible('#signup_short_code.is-invalid'); $step->assertVisible('#signup_short_code + .invalid-feedback'); $step->assertFocused('#signup_short_code'); $browser->click('.toast-error'); // remove the toast }); // Submit valid code // We expect error state on code input to be removed, and Step 3 form visible $browser->with('@step2', function ($step) { // Get the code and short_code from database // FIXME: Find a nice way to read javascript data without using hidden inputs $code = $step->value('#signup_code'); $this->assertNotEmpty($code); $code = SignupCode::find($code); $step->type('#signup_short_code', $code->short_code); $step->click('[type=submit]'); $step->assertMissing('#signup_short_code.is-invalid'); $step->assertMissing('#signup_short_code + .invalid-feedback'); }); $browser->waitFor('@step3'); $browser->assertMissing('@step2'); }); } /** * Test 3rd Step of the signup process * * @depends testSignupStep2 * @return void */ public function testSignupStep3() { $this->browse(function (Browser $browser) { $browser->assertVisible('@step3'); // Here we expect 3 text inputs, Back and Continue buttons $browser->with('@step3', function ($step) { $step->assertVisible('#signup_login'); $step->assertVisible('#signup_password'); $step->assertVisible('#signup_confirm'); + $step->assertVisible('select#signup_domain'); $step->assertVisible('[type=button]'); $step->assertVisible('[type=submit]'); $step->assertFocused('#signup_login'); - $step->assertSeeIn('#signup_login + span', '@' . \config('app.domain')); + $step->assertValue('select#signup_domain', \config('app.domain')); + $step->assertValue('#signup_login', ''); + $step->assertValue('#signup_password', ''); + $step->assertValue('#signup_confirm', ''); + + // TODO: Test domain selector }); // Test Back button $browser->click('@step3 [type=button]'); $browser->waitFor('@step2'); $browser->assertFocused('@step2 #signup_short_code'); $browser->assertMissing('@step3'); // TODO: Test form reset when going back // Submit valid code again $browser->with('@step2', function ($step) { $code = $step->value('#signup_code'); $this->assertNotEmpty($code); $code = SignupCode::find($code); $step->type('#signup_short_code', $code->short_code); $step->click('[type=submit]'); }); $browser->waitFor('@step3'); // Submit invalid data $browser->with('@step3', function ($step) use ($browser) { $step->assertFocused('#signup_login'); $step->type('#signup_login', '*'); $step->type('#signup_password', '12345678'); $step->type('#signup_confirm', '123456789'); $step->click('[type=submit]'); $browser->waitFor('.toast-error'); $step->assertVisible('#signup_login.is-invalid'); - $step->assertVisible('#signup_login + span + .invalid-feedback'); + $step->assertVisible('#signup_domain + .invalid-feedback'); $step->assertVisible('#signup_password.is-invalid'); $step->assertVisible('#signup_password + .invalid-feedback'); $step->assertFocused('#signup_login'); $browser->click('.toast-error'); // remove the toast }); // Submit invalid data (valid login, invalid password) $browser->with('@step3', function ($step) use ($browser) { $step->type('#signup_login', 'SignupTestDusk'); $step->click('[type=submit]'); $browser->waitFor('.toast-error'); $step->assertVisible('#signup_password.is-invalid'); $step->assertVisible('#signup_password + .invalid-feedback'); $step->assertMissing('#signup_login.is-invalid'); - $step->assertMissing('#signup_login + span + .invalid-feedback'); + $step->assertMissing('#signup_domain + .invalid-feedback'); $step->assertFocused('#signup_password'); $browser->click('.toast-error'); // remove the toast }); // Submit valid data $browser->with('@step3', function ($step) { $step->type('#signup_confirm', '12345678'); $step->click('[type=submit]'); }); $browser->waitUntilMissing('@step3'); // At this point we should be auto-logged-in to dashboard $dashboard = new Dashboard(); $dashboard->assert($browser); // FIXME: Is it enough to be sure user is logged in? }); } } diff --git a/src/tests/Feature/Controller/PasswordResetTest.php b/src/tests/Feature/Controller/PasswordResetTest.php index 69f2f5e9..a42d9068 100644 --- a/src/tests/Feature/Controller/PasswordResetTest.php +++ b/src/tests/Feature/Controller/PasswordResetTest.php @@ -1,328 +1,326 @@ 'passwordresettest@' . \config('app.domain')]); } /** * {@inheritDoc} * * @return void */ public function tearDown(): void { User::where('email', 'passwordresettest@' . \config('app.domain')) ->delete(); - - parent::tearDown(); } /** * Test password-reset/init with invalid input * * @return void */ public function testPasswordResetInitInvalidInput() { // Empty input data $data = []; $response = $this->post('/api/auth/password-reset/init', $data); $json = $response->json(); $response->assertStatus(422); $this->assertSame('error', $json['status']); $this->assertCount(1, $json['errors']); $this->assertArrayHasKey('email', $json['errors']); // Data with invalid email $data = [ 'email' => '@example.org', ]; $response = $this->post('/api/auth/password-reset/init', $data); $json = $response->json(); $response->assertStatus(422); $this->assertSame('error', $json['status']); $this->assertCount(1, $json['errors']); $this->assertArrayHasKey('email', $json['errors']); // Data with valid but non-existing email $data = [ 'email' => 'non-existing-password-reset@example.org', ]; $response = $this->post('/api/auth/password-reset/init', $data); $json = $response->json(); $response->assertStatus(422); $this->assertSame('error', $json['status']); $this->assertCount(1, $json['errors']); $this->assertArrayHasKey('email', $json['errors']); // Data with valid email af an existing user with no external email $data = [ 'email' => 'passwordresettest@' . \config('app.domain'), ]; $response = $this->post('/api/auth/password-reset/init', $data); $json = $response->json(); $response->assertStatus(422); $this->assertSame('error', $json['status']); $this->assertCount(1, $json['errors']); $this->assertArrayHasKey('email', $json['errors']); } /** * Test password-reset/init with valid input * * @return array */ public function testPasswordResetInitValidInput() { Queue::fake(); // Assert that no jobs were pushed... Queue::assertNothingPushed(); // Add required external email address to user settings $user = User::where('email', 'passwordresettest@' . \config('app.domain'))->first(); $user->setSetting('external_email', 'ext@email.com'); $data = [ 'email' => 'passwordresettest@' . \config('app.domain'), ]; $response = $this->post('/api/auth/password-reset/init', $data); $json = $response->json(); $response->assertStatus(200); $this->assertCount(2, $json); $this->assertSame('success', $json['status']); $this->assertNotEmpty($json['code']); // Assert the email sending job was pushed once Queue::assertPushed(\App\Jobs\PasswordResetEmail::class, 1); // Assert the job has proper data assigned Queue::assertPushed(\App\Jobs\PasswordResetEmail::class, function ($job) use ($user, &$code, $json) { // Access protected property $reflection = new \ReflectionClass($job); $code = $reflection->getProperty('code'); $code->setAccessible(true); $code = $code->getValue($job); return $code->user->id === $user->id && $code->code == $json['code']; }); return [ 'code' => $code ]; } /** * Test password-reset/verify with invalid input * * @return void */ public function testPasswordResetVerifyInvalidInput() { // Empty data $data = []; $response = $this->post('/api/auth/password-reset/verify', $data); $json = $response->json(); $response->assertStatus(422); $this->assertSame('error', $json['status']); $this->assertCount(2, $json['errors']); $this->assertArrayHasKey('short_code', $json['errors']); // Add verification code and required external email address to user settings $user = User::where('email', 'passwordresettest@' . \config('app.domain'))->first(); $code = new VerificationCode(['mode' => 'password-reset']); $user->verificationcodes()->save($code); // Data with existing code but missing short_code $data = [ 'code' => $code->code, ]; $response = $this->post('/api/auth/password-reset/verify', $data); $json = $response->json(); $response->assertStatus(422); $this->assertSame('error', $json['status']); $this->assertCount(1, $json['errors']); $this->assertArrayHasKey('short_code', $json['errors']); // Data with invalid code $data = [ 'short_code' => '123456789', 'code' => $code->code, ]; $response = $this->post('/api/auth/password-reset/verify', $data); $json = $response->json(); $response->assertStatus(422); $this->assertSame('error', $json['status']); $this->assertCount(1, $json['errors']); $this->assertArrayHasKey('short_code', $json['errors']); // TODO: Test expired code } /** * Test password-reset/verify with valid input * * @return void */ public function testPasswordResetVerifyValidInput() { // Add verification code and required external email address to user settings $user = User::where('email', 'passwordresettest@' . \config('app.domain'))->first(); $code = new VerificationCode(['mode' => 'password-reset']); $user->verificationcodes()->save($code); // Data with invalid code $data = [ 'short_code' => $code->short_code, 'code' => $code->code, ]; $response = $this->post('/api/auth/password-reset/verify', $data); $json = $response->json(); $response->assertStatus(200); $this->assertCount(1, $json); $this->assertSame('success', $json['status']); } /** * Test password-reset with invalid input * * @return void */ public function testPasswordResetInvalidInput() { // Empty data $data = []; $response = $this->post('/api/auth/password-reset', $data); $json = $response->json(); $response->assertStatus(422); $this->assertSame('error', $json['status']); $this->assertCount(1, $json['errors']); $this->assertArrayHasKey('password', $json['errors']); $user = User::where('email', 'passwordresettest@' . \config('app.domain'))->first(); $code = new VerificationCode(['mode' => 'password-reset']); $user->verificationcodes()->save($code); // Data with existing code but missing password $data = [ 'code' => $code->code, ]; $response = $this->post('/api/auth/password-reset', $data); $json = $response->json(); $response->assertStatus(422); $this->assertSame('error', $json['status']); $this->assertCount(1, $json['errors']); $this->assertArrayHasKey('password', $json['errors']); // Data with existing code but wrong password confirmation $data = [ 'code' => $code->code, 'short_code' => $code->short_code, 'password' => 'password', 'password_confirmation' => 'passwrong', ]; $response = $this->post('/api/auth/password-reset', $data); $json = $response->json(); $response->assertStatus(422); $this->assertSame('error', $json['status']); $this->assertCount(1, $json['errors']); $this->assertArrayHasKey('password', $json['errors']); // Data with invalid short code $data = [ 'code' => $code->code, 'short_code' => '123456789', 'password' => 'password', 'password_confirmation' => 'password', ]; $response = $this->post('/api/auth/password-reset', $data); $json = $response->json(); $response->assertStatus(422); $this->assertSame('error', $json['status']); $this->assertCount(1, $json['errors']); $this->assertArrayHasKey('short_code', $json['errors']); } /** * Test password reset with valid input * * @return void */ public function testPasswordResetValidInput() { $user = User::where('email', 'passwordresettest@' . \config('app.domain'))->first(); $code = new VerificationCode(['mode' => 'password-reset']); $user->verificationcodes()->save($code); $data = [ 'password' => 'test', 'password_confirmation' => 'test', 'code' => $code->code, 'short_code' => $code->short_code, ]; $response = $this->post('/api/auth/password-reset', $data); $json = $response->json(); $response->assertStatus(200); $this->assertCount(4, $json); $this->assertSame('success', $json['status']); $this->assertSame('bearer', $json['token_type']); $this->assertTrue(!empty($json['expires_in']) && is_int($json['expires_in']) && $json['expires_in'] > 0); $this->assertNotEmpty($json['access_token']); // Check if the code has been removed $this->assertNull(VerificationCode::find($code->code)); // TODO: Check password before and after (?) // TODO: Check if the access token works } } diff --git a/src/tests/Feature/Controller/SignupTest.php b/src/tests/Feature/Controller/SignupTest.php index 4249d9e2..5d58c182 100644 --- a/src/tests/Feature/Controller/SignupTest.php +++ b/src/tests/Feature/Controller/SignupTest.php @@ -1,404 +1,604 @@ 'SignupControllerTest1@' . \config('app.domain')]); + // TODO: Some tests depend on existence of individual and group plans, + // we should probably create plans here to not depend on that + $domain = self::getPublicDomain(); + + $user = User::firstOrCreate(['email' => "SignupControllerTest1@$domain"]); } /** * {@inheritDoc} * * @return void */ public function tearDown(): void { - User::where('email', 'signuplogin@' . \config('app.domain')) - ->orWhere('email', 'SignupControllerTest1@' . \config('app.domain')) + $domain = self::getPublicDomain(); + + User::where('email', "signuplogin@$domain") + ->orWhere('email', "SignupControllerTest1@$domain") + ->orWhere('email', 'admin@external.com') + ->delete(); + + Domain::where('namespace', 'signup-domain.com') + ->orWhere('namespace', 'external.com') ->delete(); + } - parent::tearDown(); + /** + * Return a public domain for signup tests + */ + public function getPublicDomain(): string + { + if (!self::$domain) { + $this->refreshApplication(); + self::$domain = Domain::getPublicDomains()[0]; + + if (empty(self::$domain)) { + self::$domain = 'signup-domain.com'; + Domain::create([ + 'namespace' => self::$domain, + 'status' => Domain::STATUS_Active, + 'type' => Domain::TYPE_PUBLIC, + ]); + } + } + + return self::$domain; } /** * Test signup initialization with invalid input * * @return void */ public function testSignupInitInvalidInput() { // Empty input data $data = []; $response = $this->post('/api/auth/signup/init', $data); $json = $response->json(); $response->assertStatus(422); $this->assertSame('error', $json['status']); $this->assertCount(2, $json['errors']); $this->assertArrayHasKey('email', $json['errors']); $this->assertArrayHasKey('name', $json['errors']); // Data with missing name $data = [ 'email' => 'UsersApiControllerTest1@UsersApiControllerTest.com', - 'password' => 'simple123', - 'password_confirmation' => 'simple123' ]; $response = $this->post('/api/auth/signup/init', $data); $json = $response->json(); $response->assertStatus(422); $this->assertSame('error', $json['status']); $this->assertCount(1, $json['errors']); $this->assertArrayHasKey('name', $json['errors']); // Data with invalid email (but not phone number) $data = [ 'email' => '@example.org', 'name' => 'Signup User', - 'password' => 'simple123', - 'password_confirmation' => 'simple123' ]; $response = $this->post('/api/auth/signup/init', $data); $json = $response->json(); $response->assertStatus(422); $this->assertSame('error', $json['status']); $this->assertCount(1, $json['errors']); $this->assertArrayHasKey('email', $json['errors']); // TODO: Test phone validation } /** * Test signup initialization with valid input * * @return array */ public function testSignupInitValidInput() { Queue::fake(); // Assert that no jobs were pushed... Queue::assertNothingPushed(); $data = [ 'email' => 'testuser@external.com', 'name' => 'Signup User', - 'password' => 'simple123', - 'password_confirmation' => 'simple123' + 'plan' => 'individual', ]; $response = $this->post('/api/auth/signup/init', $data); $json = $response->json(); $response->assertStatus(200); $this->assertCount(2, $json); $this->assertSame('success', $json['status']); $this->assertNotEmpty($json['code']); // Assert the email sending job was pushed once Queue::assertPushed(\App\Jobs\SignupVerificationEmail::class, 1); // Assert the job has proper data assigned Queue::assertPushed(\App\Jobs\SignupVerificationEmail::class, function ($job) use ($data, $json) { // Access protected property $reflection = new \ReflectionClass($job); $code = $reflection->getProperty('code'); $code->setAccessible(true); $code = $code->getValue($job); return $code->code === $json['code'] + && $code->data['plan'] === $data['plan'] && $code->data['email'] === $data['email'] && $code->data['name'] === $data['name']; }); return [ 'code' => $json['code'], 'email' => $data['email'], 'name' => $data['name'], + 'plan' => $data['plan'], ]; } /** * Test signup code verification with invalid input * * @depends testSignupInitValidInput * @return void */ public function testSignupVerifyInvalidInput(array $result) { // Empty data $data = []; $response = $this->post('/api/auth/signup/verify', $data); $json = $response->json(); $response->assertStatus(422); $this->assertSame('error', $json['status']); $this->assertCount(2, $json['errors']); $this->assertArrayHasKey('code', $json['errors']); $this->assertArrayHasKey('short_code', $json['errors']); // Data with existing code but missing short_code $data = [ 'code' => $result['code'], ]; $response = $this->post('/api/auth/signup/verify', $data); $json = $response->json(); $response->assertStatus(422); $this->assertSame('error', $json['status']); $this->assertCount(1, $json['errors']); $this->assertArrayHasKey('short_code', $json['errors']); // Data with invalid short_code $data = [ 'code' => $result['code'], 'short_code' => 'XXXX', ]; $response = $this->post('/api/auth/signup/verify', $data); $json = $response->json(); $response->assertStatus(422); $this->assertSame('error', $json['status']); $this->assertCount(1, $json['errors']); $this->assertArrayHasKey('short_code', $json['errors']); // TODO: Test expired code } /** * Test signup code verification with valid input * * @depends testSignupInitValidInput * * @return array */ public function testSignupVerifyValidInput(array $result) { $code = SignupCode::find($result['code']); $data = [ 'code' => $code->code, 'short_code' => $code->short_code, ]; $response = $this->post('/api/auth/signup/verify', $data); $json = $response->json(); $response->assertStatus(200); - $this->assertCount(3, $json); + $this->assertCount(5, $json); $this->assertSame('success', $json['status']); $this->assertSame($result['email'], $json['email']); $this->assertSame($result['name'], $json['name']); + $this->assertSame(false, $json['is_domain']); + $this->assertTrue(is_array($json['domains']) && !empty($json['domains'])); return $result; } /** * Test last signup step with invalid input * * @depends testSignupVerifyValidInput * @return void */ public function testSignupInvalidInput(array $result) { // Empty data $data = []; $response = $this->post('/api/auth/signup', $data); $json = $response->json(); $response->assertStatus(422); $this->assertSame('error', $json['status']); - $this->assertCount(2, $json['errors']); + $this->assertCount(3, $json['errors']); $this->assertArrayHasKey('login', $json['errors']); $this->assertArrayHasKey('password', $json['errors']); + $this->assertArrayHasKey('domain', $json['errors']); - // Passwords do not match + $domain = $this->getPublicDomain(); + + // Passwords do not match and missing domain $data = [ 'login' => 'test', 'password' => 'test', 'password_confirmation' => 'test2', ]; $response = $this->post('/api/auth/signup', $data); $json = $response->json(); $response->assertStatus(422); $this->assertSame('error', $json['status']); - $this->assertCount(1, $json['errors']); + $this->assertCount(2, $json['errors']); $this->assertArrayHasKey('password', $json['errors']); + $this->assertArrayHasKey('domain', $json['errors']); + + $domain = $this->getPublicDomain(); // Login too short $data = [ 'login' => '1', + 'domain' => $domain, 'password' => 'test', 'password_confirmation' => 'test', ]; $response = $this->post('/api/auth/signup', $data); $json = $response->json(); $response->assertStatus(422); $this->assertSame('error', $json['status']); $this->assertCount(1, $json['errors']); $this->assertArrayHasKey('login', $json['errors']); - // Login invalid + // Missing codes $data = [ - 'login' => 'żżżżż', + 'login' => 'login-valid', + 'domain' => $domain, 'password' => 'test', 'password_confirmation' => 'test', ]; $response = $this->post('/api/auth/signup', $data); $json = $response->json(); $response->assertStatus(422); $this->assertSame('error', $json['status']); - $this->assertCount(1, $json['errors']); - $this->assertArrayHasKey('login', $json['errors']); + $this->assertCount(2, $json['errors']); + $this->assertArrayHasKey('code', $json['errors']); + $this->assertArrayHasKey('short_code', $json['errors']); // Data with invalid short_code $data = [ 'login' => 'TestLogin', + 'domain' => $domain, 'password' => 'test', 'password_confirmation' => 'test', 'code' => $result['code'], 'short_code' => 'XXXX', ]; $response = $this->post('/api/auth/signup', $data); $json = $response->json(); $response->assertStatus(422); $this->assertSame('error', $json['status']); $this->assertCount(1, $json['errors']); $this->assertArrayHasKey('short_code', $json['errors']); + + // Valid code, invalid login + $code = SignupCode::find($result['code']); + $data = [ + 'login' => 'żżżżżż', + 'domain' => $domain, + 'password' => 'test', + 'password_confirmation' => 'test', + 'code' => $result['code'], + 'short_code' => $code->short_code, + ]; + + $response = $this->post('/api/auth/signup', $data); + $json = $response->json(); + + $response->assertStatus(422); + $this->assertSame('error', $json['status']); + $this->assertCount(1, $json['errors']); + $this->assertArrayHasKey('login', $json['errors']); } /** * Test last signup step with valid input (user creation) * * @depends testSignupVerifyValidInput * @return void */ public function testSignupValidInput(array $result) { - $identity = \strtolower('SignupLogin@') . \config('app.domain'); + $domain = $this->getPublicDomain(); + $identity = \strtolower('SignupLogin@') . $domain; $code = SignupCode::find($result['code']); $data = [ 'login' => 'SignupLogin', + 'domain' => $domain, 'password' => 'test', 'password_confirmation' => 'test', 'code' => $code->code, 'short_code' => $code->short_code, ]; $response = $this->post('/api/auth/signup', $data); $json = $response->json(); $response->assertStatus(200); $this->assertCount(4, $json); $this->assertSame('success', $json['status']); $this->assertSame('bearer', $json['token_type']); $this->assertTrue(!empty($json['expires_in']) && is_int($json['expires_in']) && $json['expires_in'] > 0); $this->assertNotEmpty($json['access_token']); // Check if the code has been removed $this->assertNull(SignupCode::where($result['code'])->first()); // Check if the user has been created $user = User::where('email', $identity)->first(); $this->assertNotEmpty($user); $this->assertSame($identity, $user->email); $this->assertSame($result['name'], $user->name); // Check external email in user settings $this->assertSame($result['email'], $user->getSetting('external_email')); + // TODO: Check SKUs/Plan + + // TODO: Check if the access token works + } + + /** + * Test signup for a group (custom domain) account + * + * @return void + */ + public function testSignupGroupAccount() + { + Queue::fake(); + + // Initial signup request + $user_data = $data = [ + 'email' => 'testuser@external.com', + 'name' => 'Signup User', + 'plan' => 'group', + ]; + + $response = $this->post('/api/auth/signup/init', $data); + $json = $response->json(); + + $response->assertStatus(200); + $this->assertCount(2, $json); + $this->assertSame('success', $json['status']); + $this->assertNotEmpty($json['code']); + + // Assert the email sending job was pushed once + Queue::assertPushed(\App\Jobs\SignupVerificationEmail::class, 1); + + // Assert the job has proper data assigned + Queue::assertPushed(\App\Jobs\SignupVerificationEmail::class, function ($job) use ($data, $json) { + // Access protected property + $reflection = new \ReflectionClass($job); + $code = $reflection->getProperty('code'); + $code->setAccessible(true); + $code = $code->getValue($job); + + return $code->code === $json['code'] + && $code->data['plan'] === $data['plan'] + && $code->data['email'] === $data['email'] + && $code->data['name'] === $data['name']; + }); + + // Verify the code + $code = SignupCode::find($json['code']); + $data = [ + 'code' => $code->code, + 'short_code' => $code->short_code, + ]; + + $response = $this->post('/api/auth/signup/verify', $data); + $result = $response->json(); + + $response->assertStatus(200); + $this->assertCount(5, $result); + $this->assertSame('success', $result['status']); + $this->assertSame($user_data['email'], $result['email']); + $this->assertSame($user_data['name'], $result['name']); + $this->assertSame(true, $result['is_domain']); + $this->assertSame([], $result['domains']); + + // Final signup request + $login = 'admin'; + $domain = 'external.com'; + $data = [ + 'login' => $login, + 'domain' => $domain, + 'password' => 'test', + 'password_confirmation' => 'test', + 'code' => $code->code, + 'short_code' => $code->short_code, + ]; + + $response = $this->post('/api/auth/signup', $data); + $result = $response->json(); + + $response->assertStatus(200); + $this->assertCount(4, $result); + $this->assertSame('success', $result['status']); + $this->assertSame('bearer', $result['token_type']); + $this->assertTrue(!empty($result['expires_in']) && is_int($result['expires_in']) && $result['expires_in'] > 0); + $this->assertNotEmpty($result['access_token']); + + // Check if the code has been removed + $this->assertNull(SignupCode::find($code->id)); + + // Check if the user has been created + $user = User::where('email', $login . '@' . $domain)->first(); + + $this->assertNotEmpty($user); + $this->assertSame($user_data['name'], $user->name); + + // Check domain record + + // Check external email in user settings + $this->assertSame($user_data['email'], $user->getSetting('external_email')); + + // TODO: Check SKUs/Plan + // TODO: Check if the access token works } + /** * List of email address validation cases for testValidateEmail() * * @return array Arguments for testValidateEmail() */ public function dataValidateEmail() { - // To access config from dataProvider method we have to refreshApplication() first - $this->refreshApplication(); - $domain = \config('app.domain'); - return [ - // general cases (invalid) - ['', false, 'validation.emailinvalid'], - ['example.org', false, 'validation.emailinvalid'], - ['@example.org', false, 'validation.emailinvalid'], - ['test@localhost', false, 'validation.emailinvalid'], - // general cases (valid) - ['test@domain.tld', false, null], - ['&@example.org', false, null], - // kolab identity cases - ['admin@' . $domain, true, 'validation.emailexists'], - ['administrator@' . $domain, true, 'validation.emailexists'], - ['sales@' . $domain, true, 'validation.emailexists'], - ['root@' . $domain, true, 'validation.emailexists'], - ['&@' . $domain, true, 'validation.emailinvalid'], - ['testnonsystemdomain@invalid.tld', true, 'validation.emailinvalid'], - // existing account - ['SignupControllerTest1@' . $domain, true, 'validation.emailexists'], - // valid for signup - ['test.test@' . $domain, true, null], - ['test_test@' . $domain, true, null], - ['test-test@' . $domain, true, null], + // invalid + ['', 'validation.emailinvalid'], + ['example.org', 'validation.emailinvalid'], + ['@example.org', 'validation.emailinvalid'], + ['test@localhost', 'validation.emailinvalid'], + // valid + ['test@domain.tld', null], + ['&@example.org', null], ]; } /** * Signup email validation. * - * Note: Technicly these are mostly unit tests, but let's keep it here for now. + * Note: Technicly these are unit tests, but let's keep it here for now. * FIXME: Shall we do a http request for each case? * * @dataProvider dataValidateEmail */ - public function testValidateEmail($email, $signup, $expected_result) + public function testValidateEmail($email, $expected_result) { $method = new \ReflectionMethod('App\Http\Controllers\API\SignupController', 'validateEmail'); $method->setAccessible(true); - $is_phone = false; - $result = $method->invoke(new SignupController(), $email, $signup); + $result = $method->invoke(new SignupController(), $email); + + $this->assertSame($expected_result, $result); + } + + /** + * List of login/domain validation cases for testValidateLogin() + * + * @return array Arguments for testValidateLogin() + */ + public function dataValidateLogin() + { + $domain = $this->getPublicDomain(); + + return [ + // Individual account + ['', $domain, false, ['login' => 'validation.logininvalid']], + ['test123456', 'localhost', false, ['domain' => 'validation.domaininvalid']], + ['test123456', 'unknown-domain.org', false, ['domain' => 'validation.domaininvalid']], + ['test.test', $domain, false, null], + ['test_test', $domain, false, null], + ['test-test', $domain, false, null], + ['admin', $domain, false, ['login' => 'validation.loginexists']], + ['administrator', $domain, false, ['login' => 'validation.loginexists']], + ['sales', $domain, false, ['login' => 'validation.loginexists']], + ['root', $domain, false, ['login' => 'validation.loginexists']], + // existing user + ['SignupControllerTest1', $domain, false, ['login' => 'validation.loginexists']], + + // Domain account + ['admin', 'kolabsys.com', true, null], + ['testnonsystemdomain', 'invalid', true, ['domain' => 'validation.domaininvalid']], + ['testnonsystemdomain', '.com', true, ['domain' => 'validation.domaininvalid']], + // existing user + ['SignupControllerTest1', $domain, true, ['domain' => 'validation.domainexists']], + ]; + } + + /** + * Signup login/domain validation. + * + * Note: Technicly these include unit tests, but let's keep it here for now. + * FIXME: Shall we do a http request for each case? + * + * @dataProvider dataValidateLogin + */ + public function testValidateLogin($login, $domain, $external, $expected_result) + { + $method = new \ReflectionMethod('App\Http\Controllers\API\SignupController', 'validateLogin'); + $method->setAccessible(true); + + $result = $method->invoke(new SignupController(), $login, $domain, $external); $this->assertSame($expected_result, $result); } } diff --git a/src/tests/Feature/Controller/UsersTest.php b/src/tests/Feature/Controller/UsersTest.php index 95e46ac9..0bfa5c55 100644 --- a/src/tests/Feature/Controller/UsersTest.php +++ b/src/tests/Feature/Controller/UsersTest.php @@ -1,79 +1,77 @@ 'UsersControllerTest1@UsersControllerTest.com' ] ); $user->delete(); } /** * {@inheritDoc} * * @return void */ public function tearDown(): void { $user = User::firstOrCreate( [ 'email' => 'UsersControllerTest1@UsersControllerTest.com' ] ); $user->delete(); - - parent::tearDown(); } public function testListUsers() { $user = User::firstOrCreate( [ 'email' => 'UsersControllerTest1@UsersControllerTest.com' ] ); $response = $this->actingAs($user)->get("api/v4/users"); $response->assertJsonCount(1); $response->assertStatus(200); } public function testUserEntitlements() { $userA = User::firstOrCreate( [ 'email' => 'UserEntitlement2A@UserEntitlement.com' ] ); $response = $this->actingAs($userA, 'api')->get("/api/v4/users/{$userA->id}"); $response->assertStatus(200); $response->assertJson(['id' => $userA->id]); $user = factory(User::class)->create(); $response = $this->actingAs($user)->get("/api/v4/users/{$userA->id}"); $response->assertStatus(404); } } diff --git a/src/tests/Feature/DomainTest.php b/src/tests/Feature/DomainTest.php index 553fea35..788941f9 100644 --- a/src/tests/Feature/DomainTest.php +++ b/src/tests/Feature/DomainTest.php @@ -1,75 +1,101 @@ first(); - if ($domain) { - $domain->delete(); - } + Domain::where('namespace', $namespace)->delete(); } + + Domain::where('namespace', 'public-active.com')->delete(); } - public function testDomainStatus() + public function testDomainStatus(): void { $statuses = [ "new", "active", "confirmed", "suspended", "deleted" ]; $domains = \App\Utils::powerSet($statuses); foreach ($domains as $namespace_elements) { $namespace = implode('-', $namespace_elements) . '.com'; $status = 1; if (in_array("new", $namespace_elements)) { $status += Domain::STATUS_NEW; } if (in_array("active", $namespace_elements)) { $status += Domain::STATUS_ACTIVE; } if (in_array("confirmed", $namespace_elements)) { $status += Domain::STATUS_CONFIRMED; } if (in_array("suspended", $namespace_elements)) { $status += Domain::STATUS_SUSPENDED; } if (in_array("deleted", $namespace_elements)) { $status += Domain::STATUS_DELETED; } $domain = Domain::firstOrCreate( [ 'namespace' => $namespace, 'status' => $status, 'type' => Domain::TYPE_EXTERNAL ] ); $this->assertTrue($domain->status > 1); } } + + /** + * Tests getPublicDomains() method + */ + public function testGetPublicDomains(): void + { + $public_domains = Domain::getPublicDomains(); + + $this->assertNotContains('public-active.com', $public_domains); + + Domain::create([ + 'namespace' => 'public-active.com', + 'status' => Domain::STATUS_NEW, + 'type' => Domain::TYPE_PUBLIC, + ]); + + // Public but non-active domain should not be returned + $public_domains = Domain::getPublicDomains(); + $this->assertNotContains('public-active.com', $public_domains); + + $domain = Domain::where('namespace', 'public-active.com')->first(); + $domain->status = Domain::STATUS_ACTIVE; + $domain->save(); + + // Public and active domain should be returned + $public_domains = Domain::getPublicDomains(); + $this->assertContains('public-active.com', $public_domains); + } } diff --git a/src/tests/Feature/Jobs/PasswordResetEmailTest.php b/src/tests/Feature/Jobs/PasswordResetEmailTest.php index 5012864d..22afe41d 100644 --- a/src/tests/Feature/Jobs/PasswordResetEmailTest.php +++ b/src/tests/Feature/Jobs/PasswordResetEmailTest.php @@ -1,71 +1,69 @@ 'PasswordReset@UserAccount.com' ]); $this->code = new VerificationCode([ 'mode' => 'password-reset', ]); $user->verificationcodes()->save($this->code); $user->setSettings(['external_email' => 'etx@email.com']); } /** * {@inheritDoc} * * @return void */ public function tearDown(): void { $this->code->user->delete(); - - parent::tearDown(); } /** * Test job handle * * @return void */ public function testPasswordResetEmailHandle() { Mail::fake(); // Assert that no jobs were pushed... Mail::assertNothingSent(); $job = new PasswordResetEmail($this->code); $job->handle(); // Assert the email sending job was pushed once Mail::assertSent(PasswordReset::class, 1); // Assert the mail was sent to the code's email Mail::assertSent(PasswordReset::class, function ($mail) { return $mail->hasTo($this->code->user->getSetting('external_email')); }); } } diff --git a/src/tests/Feature/Jobs/SignupVerificationEmailTest.php b/src/tests/Feature/Jobs/SignupVerificationEmailTest.php index 4dfbc3bf..18f93723 100644 --- a/src/tests/Feature/Jobs/SignupVerificationEmailTest.php +++ b/src/tests/Feature/Jobs/SignupVerificationEmailTest.php @@ -1,67 +1,65 @@ code = SignupCode::create([ 'data' => [ 'email' => 'SignupVerificationEmailTest1@' . \config('app.domain'), 'name' => "Test Job" ] ]); } /** * {@inheritDoc} * * @return void */ public function tearDown(): void { $this->code->delete(); - - parent::tearDown(); } /** * Test job handle * * @return void */ public function testSignupVerificationEmailHandle() { Mail::fake(); // Assert that no jobs were pushed... Mail::assertNothingSent(); $job = new SignupVerificationEmail($this->code); $job->handle(); // Assert the email sending job was pushed once Mail::assertSent(SignupVerification::class, 1); // Assert the mail was sent to the code's email Mail::assertSent(SignupVerification::class, function ($mail) { return $mail->hasTo($this->code->data['email']); }); } } diff --git a/src/tests/Feature/SkuTest.php b/src/tests/Feature/SkuTest.php new file mode 100644 index 00000000..ff28f243 --- /dev/null +++ b/src/tests/Feature/SkuTest.php @@ -0,0 +1,133 @@ + 'custom-domain.com', + 'status' => Domain::STATUS_NEW, + 'type' => Domain::TYPE_EXTERNAL, + ] + ); + User::firstOrCreate( + ['email' => 'sku-test-user@custom-domain.com'] + ); + } + + public function tearDown(): void + { + User::where('email', 'sku-test-user@custom-domain.com')->delete(); + Domain::where('namespace', 'custom-domain.com')->delete(); + } + + public function testRegisterEntitlement() + { + // TODO: This test depends on seeded SKUs, but probably should not + + $user = User::where('email', 'sku-test-user@custom-domain.com')->first(); + $domain = Domain::where('namespace', 'custom-domain.com')->first(); + $wallet = $user->wallets()->first(); + + // \App\Handlers\Mailbox SKU + // Note, we're testing mailbox SKU before domain SKU as it may potentially fail in that order + $sku = Sku::where('title', 'mailbox')->first(); + $sku->registerEntitlement($user); + + $entitlements = $sku->entitlements()->where('owner_id', $user->id)->get(); + $wallet->refresh(); + $balance = -$sku->cost; + + $this->assertCount(1, $entitlements); + $this->assertSame($user->id, $entitlements[0]->entitleable_id); + $this->assertSame(Handlers\Mailbox::entitleableClass(), $entitlements[0]->entitleable_type); + $this->assertEquals($balance, $wallet->balance); + + // \App\Handlers\Domain SKU + $sku = Sku::where('title', 'domain')->first(); + $sku->registerEntitlement($user, [$domain]); + + $entitlements = $sku->entitlements()->where('owner_id', $user->id)->get(); + $wallet->refresh(); + $balance -= $sku->cost; + + $this->assertCount(1, $entitlements); + $this->assertSame($domain->id, $entitlements[0]->entitleable_id); + $this->assertSame(Handlers\Domain::entitleableClass(), $entitlements[0]->entitleable_type); + $this->assertEquals($balance, $wallet->balance); + + // \App\Handlers\DomainRegistration SKU +/* + $sku = Sku::where('title', 'domain-registration')->first(); + $sku->registerEntitlement($user, [$domain]); + + $entitlements = $sku->entitlements()->where('owner_id', $user->id)->get(); + $wallet->refresh(); + $balance -= $sku->cost; + + $this->assertCount(1, $entitlements); + $this->assertSame($domain->id, $entitlements[0]->entitleable_id); + $this->assertSame(Handlers\DomainRegistration::entitleableClass(), $entitlements[0]->entitleable_type); + $this->assertEquals($balance, $wallet->balance); + + // \App\Handlers\DomainHosting SKU + $sku = Sku::where('title', 'domain-hosting')->first(); + $sku->registerEntitlement($user, [$domain]); + + $entitlements = $sku->entitlements()->where('owner_id', $user->id)->get(); + $wallet->refresh(); + $balance -= $sku->cost; + + $this->assertCount(1, $entitlements); + $this->assertSame($domain->id, $entitlements[0]->entitleable_id); + $this->assertSame(Handlers\DomainHosting::entitleableClass(), $entitlements[0]->entitleable_type); + $this->assertEquals($balance, $wallet->balance); +*/ + // \App\Handlers\Groupware SKU + $sku = Sku::where('title', 'groupware')->first(); + $sku->registerEntitlement($user, [$domain]); + + $entitlements = $sku->entitlements()->where('owner_id', $user->id)->get(); + $wallet->refresh(); + $balance -= $sku->cost; + + $this->assertCount(1, $entitlements); + $this->assertSame($user->id, $entitlements[0]->entitleable_id); + $this->assertSame(Handlers\Mailbox::entitleableClass(), $entitlements[0]->entitleable_type); + $this->assertEquals($balance, $wallet->balance); + + // \App\Handlers\Storage SKU + $sku = Sku::where('title', 'storage')->first(); + $sku->registerEntitlement($user, [$domain]); + + $entitlements = $sku->entitlements()->where('owner_id', $user->id)->get(); + $wallet->refresh(); + $balance -= $sku->cost; + + // For Storage entitlement we expect additional Quota record + $quota = Quota::where('user_id', $user->id)->first(); + $this->assertTrue(!empty($quota)); + // TODO: This should be a constant and/or config option, and probably + // quota should not be in bytes + $this->assertSame(2147483648, $quota->value); + + $this->assertCount(1, $entitlements); + $this->assertSame($quota->id, $entitlements[0]->entitleable_id); + $this->assertSame(Handlers\Storage::entitleableClass(), $entitlements[0]->entitleable_type); + $this->assertEquals($balance, $wallet->balance); + } +} diff --git a/src/tests/Feature/VerificationCodeTest.php b/src/tests/Feature/VerificationCodeTest.php index 3a179618..4c9bc8ce 100644 --- a/src/tests/Feature/VerificationCodeTest.php +++ b/src/tests/Feature/VerificationCodeTest.php @@ -1,46 +1,44 @@ 'UserAccountA@UserAccount.com']); $data = [ 'user_id' => $user->id, 'mode' => 'password-reset', ]; $now = new \DateTime('now'); $code = VerificationCode::create($data); $code_length = env('VERIFICATION_CODE_LENGTH', VerificationCode::SHORTCODE_LENGTH); $code_exp_hrs = env('VERIFICATION_CODE_EXPIRY', VerificationCode::CODE_EXP_HOURS); $this->assertFalse($code->isExpired()); $this->assertTrue(strlen($code->code) === VerificationCode::CODE_LENGTH); $this->assertTrue(strlen($code->short_code) === $code_length); $this->assertSame($data['mode'], $code->mode); $this->assertSame($user->id, $code->user->id); $this->assertInstanceOf(\DateTime::class, $code->expires_at); $this->assertSame($code_exp_hrs, $code->expires_at->diff($now)->h + 1); $inst = VerificationCode::find($code->code); $this->assertInstanceOf(VerificationCode::class, $inst); $this->assertSame($inst->code, $code->code); } } diff --git a/src/tests/Feature/WalletTest.php b/src/tests/Feature/WalletTest.php index 7f71a64f..0c82314b 100644 --- a/src/tests/Feature/WalletTest.php +++ b/src/tests/Feature/WalletTest.php @@ -1,242 +1,226 @@ users as $user) { $_user = User::firstOrCreate(['email' => $user]); $_user->delete(); } } public function tearDown(): void { foreach ($this->users as $user) { $_user = User::firstOrCreate(['email' => $user]); $_user->delete(); } - - parent::tearDown(); } /** - Verify a wallet is created, when a user is created. - - @return void + * Verify a wallet is created, when a user is created. */ - public function testCreateUserCreatesWallet() + public function testCreateUserCreatesWallet(): void { $user = User::firstOrCreate( [ 'email' => 'UserWallet1@UserWallet.com' ] ); $this->assertTrue($user->wallets()->count() == 1); } /** - Verify a user can haz more wallets. - - @return void + * Verify a user can haz more wallets. */ - public function testAddWallet() + public function testAddWallet(): void { $user = User::firstOrCreate( [ 'email' => 'UserWallet2@UserWallet.com' ] ); $user->wallets()->save( new Wallet(['currency' => 'USD']) ); $this->assertTrue($user->wallets()->count() >= 2); $user->wallets()->each( function ($wallet) { $this->assertTrue($wallet->balance === 0.00); } ); } /** - Verify we can not delete a user wallet that holds balance. - - @return void + * Verify we can not delete a user wallet that holds balance. */ - public function testDeleteWalletWithCredit() + public function testDeleteWalletWithCredit(): void { $user = User::firstOrCreate( [ 'email' => 'UserWallet3@UserWallet.com' ] ); $user->wallets()->each( function ($wallet) { $wallet->credit(1.00)->save(); } ); $user->wallets()->each( function ($wallet) { $this->assertFalse($wallet->delete()); } ); } /** - Verify we can not delete a wallet that is the last wallet. - - @return void + * Verify we can not delete a wallet that is the last wallet. */ - public function testDeleteLastWallet() + public function testDeleteLastWallet(): void { $user = User::firstOrCreate( [ 'email' => 'UserWallet4@UserWallet.com' ] ); $this->assertTrue($user->wallets()->count() == 1); $user->wallets()->each( function ($wallet) { $this->assertFalse($wallet->delete()); } ); } /** - Verify we can remove a wallet that is an additional wallet. - - @return void + * Verify we can remove a wallet that is an additional wallet. */ - public function testDeleteAddtWallet() + public function testDeleteAddtWallet(): void { $user = User::firstOrCreate( [ 'email' => 'UserWallet5@UserWallet.com' ] ); $user->wallets()->save( new Wallet(['currency' => 'USD']) ); $user->wallets()->each( function ($wallet) { if ($wallet->currency == 'USD') { $this->assertNotFalse($wallet->delete()); } } ); } /** - Verify a wallet can be assigned a controller. - - @return void + * Verify a wallet can be assigned a controller. */ - public function testAddWalletController() + public function testAddWalletController(): void { $userA = User::firstOrCreate( [ 'email' => 'WalletControllerA@WalletController.com' ] ); $userA->wallets()->each( function ($wallet) { $userB = User::firstOrCreate( [ 'email' => 'WalletControllerB@WalletController.com' ] ); $wallet->addController($userB); } ); $userB = User::firstOrCreate( [ 'email' => 'WalletControllerB@WalletController.com' ] ); $this->assertTrue($userB->accounts()->count() == 1); $aWallet = $userA->wallets()->get(); $bAccount = $userB->accounts()->get(); $this->assertTrue($bAccount[0]->id === $aWallet[0]->id); } /** - Verify controllers can also be removed from wallets. - - @return void + * Verify controllers can also be removed from wallets. */ - public function testRemoveWalletController() + public function testRemoveWalletController(): void { $userA = User::firstOrCreate( [ 'email' => 'WalletController2A@WalletController.com' ] ); $userA->wallets()->each( function ($wallet) { $userB = User::firstOrCreate( [ 'email' => 'WalletController2B@WalletController.com' ] ); $wallet->addController($userB); } ); $userB = User::firstOrCreate( [ 'email' => 'WalletController2B@WalletController.com' ] ); $userB->accounts()->each( function ($wallet) { $userB = User::firstOrCreate( [ 'email' => 'WalletController2B@WalletController.com' ] ); $wallet->removeController($userB); } ); $this->assertTrue($userB->accounts()->count() == 0); } }