diff --git a/src/app/Http/Controllers/API/SignupController.php b/src/app/Http/Controllers/API/SignupController.php index 9ae8cf69..89dae2b0 100644 --- a/src/app/Http/Controllers/API/SignupController.php +++ b/src/app/Http/Controllers/API/SignupController.php @@ -1,384 +1,347 @@ map(function ($plan) use (&$plans) { $plans[] = [ 'title' => $plan->title, 'name' => $plan->name, 'button' => __('app.planbutton', ['plan' => $plan->name]), 'description' => $plan->description, ]; }); return response()->json(['status' => 'success', 'plans' => $plans]); } /** * Starts signup process. * * Verifies user name and email/phone, sends verification email/sms message. * Returns the verification code. * * @param \Illuminate\Http\Request $request HTTP request * * @return \Illuminate\Http\JsonResponse JSON response */ public function init(Request $request) { // Check required fields $v = Validator::make( $request->all(), [ 'email' => '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); + 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 $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; $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 $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); } // 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); + if ($errors = self::validateLogin($login, $domain, $is_domain)) { 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); DB::beginTransaction(); // Create user record $user = User::create([ 'name' => $user_name, 'email' => $login . '@' . $domain, 'password' => $request->password, ]); // 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, ]); } $user->assignPlan($plan, $domain); // Save the external email and plan in user settings $user->setSetting('external_email', $user_email); // 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 $input Email address or phone number - * @param bool $is_phone Will have been set to True if the string is valid phone number + * Returns plan for the signup process * - * @return string Error message label on validation error + * @returns \App\Plan Plan object selected for current signup process */ - protected function validatePhoneOrEmail($input, &$is_phone = false) + protected function getPlan() { - $is_phone = false; - - return $this->validateEmail($input); - - // TODO: Phone number support -/* - if (strpos($input, '@')) { - return $this->validateEmail($input); - } + 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(); + } - $input = str_replace(array('-', ' '), '', $input); + // ...otherwise use the default plan + if (empty($plan)) { + // TODO: Get default plan title from config + $plan = Plan::where('title', 'individual')->first(); + } - if (!preg_match('/^\+?[0-9]{9,12}$/', $input)) { - return 'validation.noemailorphone'; + $this->plan = $plan; } - $is_phone = true; -*/ + return $this->plan; } /** - * Email address validation + * Checks if the input string is a valid email address or a phone number * - * @param string $email Email address + * @param string $input Email address or phone number + * @param bool $is_phone Will have been set to True if the string is valid phone number * - * @return string Error message label on validation error + * @return string Error message on validation error */ - protected function validateEmail($email) + protected static function validatePhoneOrEmail($input, &$is_phone = false): ?string { - $v = Validator::make(['email' => $email], ['email' => 'required|email']); + $is_phone = false; + + $v = Validator::make( + ['email' => $input], + ['email' => ['required', 'string', new ExternalEmail()]] + ); if ($v->fails()) { - return 'validation.emailinvalid'; + return $v->errors()->toArray()['email'][0]; } - list($local, $domain) = explode('@', $email); + // TODO: Phone number support +/* + $input = str_replace(array('-', ' '), '', $input); - // don't allow @localhost and other no-fqdn - if (strpos($domain, '.') === false) { - return 'validation.emailinvalid'; + if (!preg_match('/^\+?[0-9]{9,12}$/', $input)) { + return \trans('validation.noemailorphone'); } + + $is_phone = true; +*/ + return null; } /** * Login (kolab identity) validation * * @param string $login 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) + protected static function validateLogin($login, $domain, $external = false): ?array { - // don't allow @localhost and other no-fqdn - if (empty($domain) || strpos($domain, '.') === false || stripos($domain, 'www.') === 0) { - return ['domain' => 'validation.domaininvalid']; - } + // Validate login part alone + $v = Validator::make( + ['login' => $login], + ['login' => ['required', 'string', new UserEmailLocal($external)]] + ); - // Local part validation - if (!preg_match('/^[A-Za-z0-9_.-]+$/', $login)) { - return ['login' => 'validation.logininvalid']; + if ($v->fails()) { + return ['login' => $v->errors()->toArray()['login'][0]]; } - $domain = Str::lower($domain); + $domains = $external ? null : Domain::getPublicDomains(); - 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']; - } + // Validate the domain + $v = Validator::make( + ['domain' => $domain], + ['domain' => ['required', 'string', new UserEmailDomain($domains)]] + ); - // Check if specified domain is allowed for signup - 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']; - } + if ($v->fails()) { + return ['domain' => $v->errors()->toArray()['domain'][0]]; + } - // TODO: DNS registration check - maybe after signup? + $domain = Str::lower($domain); - // Check if domain is already registered with us + // Check if domain is already registered with us + if ($external) { if (Domain::where('namespace', $domain)->first()) { - return ['domain' => 'validation.domainexists']; + return ['domain' => \trans('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(); - } - - // ...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 ['login' => \trans('validation.loginexists')]; } - return $result; + return null; } } diff --git a/src/app/Http/Controllers/API/UsersController.php b/src/app/Http/Controllers/API/UsersController.php index ffb3382a..5fe3293d 100644 --- a/src/app/Http/Controllers/API/UsersController.php +++ b/src/app/Http/Controllers/API/UsersController.php @@ -1,348 +1,545 @@ middleware('auth:api', ['except' => ['login']]); } /** * Helper method for other controllers with user auto-logon * functionality * * @param \App\User $user User model object */ public static function logonResponse(User $user) { $token = auth()->login($user); return response()->json([ 'status' => 'success', 'access_token' => $token, 'token_type' => 'bearer', 'expires_in' => Auth::guard()->factory()->getTTL() * 60, ]); } /** * Display a listing of the resources. * * The user themself, and other user entitlements. * * @return \Illuminate\Http\JsonResponse */ public function index() { $user = Auth::user(); if (!$user) { return response()->json(['error' => 'unauthorized'], 401); } $result = [$user]; $user->entitlements()->each( function ($entitlement) { $result[] = User::find($entitlement->user_id); } ); return response()->json($result); } /** * Get the authenticated User * * @return \Illuminate\Http\JsonResponse */ public function info() { $user = $this->guard()->user(); - $response = $user->toArray(); - - // Settings - // TODO: It might be reasonable to limit the list of settings here to these - // that are safe and are used in the UI - $response['settings'] = []; - foreach ($user->settings as $item) { - $response['settings'][$item->key] = $item->value; - } - - // Status info - $response['statusInfo'] = self::statusInfo($user); + $response = $this->userResponse($user); return response()->json($response); } /** * Get a JWT token via given credentials. * * @param \Illuminate\Http\Request $request The API request. * * @return \Illuminate\Http\JsonResponse */ public function login(Request $request) { $v = Validator::make( $request->all(), [ 'email' => 'required|min:2', 'password' => 'required|min:4', ] ); if ($v->fails()) { return response()->json(['status' => 'error', 'errors' => $v->errors()], 422); } - $credentials = $request->only('email', 'password'); if ($token = $this->guard()->attempt($credentials)) { return $this->respondWithToken($token); } return response()->json(['status' => 'error', 'message' => __('auth.failed')], 401); } /** * Log the user out (Invalidate the token) * * @return \Illuminate\Http\JsonResponse */ public function logout() { $this->guard()->logout(); return response()->json([ 'status' => 'success', 'message' => __('auth.logoutsuccess') ]); } /** * Refresh a token. * * @return \Illuminate\Http\JsonResponse */ public function refresh() { return $this->respondWithToken($this->guard()->refresh()); } /** * Get the token array structure. * * @param string $token Respond with this token. * * @return \Illuminate\Http\JsonResponse */ protected function respondWithToken($token) { return response()->json( [ 'access_token' => $token, 'token_type' => 'bearer', 'expires_in' => $this->guard()->factory()->getTTL() * 60 ] ); } /** * Display information on the user account specified by $id. * * @param int $id The account to show information for. * * @return \Illuminate\Http\JsonResponse|void */ public function show($id) { if (!$this->hasAccess($id)) { return $this->errorResponse(403); } $user = User::find($id); if (empty($user)) { - return $this->errorResponse(404); + return $this->errorResponse(404); } - return response()->json($user); + $response = $this->userResponse($user); + + return response()->json($response); } /** * User status (extended) information * * @param \App\User $user User object * * @return array Status information */ public static function statusInfo(User $user): array { $status = 'new'; $process = []; $steps = [ 'user-new' => true, 'user-ldap-ready' => 'isLdapReady', 'user-imap-ready' => 'isImapReady', ]; if ($user->isDeleted()) { $status = 'deleted'; } elseif ($user->isSuspended()) { $status = 'suspended'; } elseif ($user->isActive()) { $status = 'active'; } list ($local, $domain) = explode('@', $user->email); $domain = Domain::where('namespace', $domain)->first(); // If that is not a public domain, add domain specific steps - if (!$domain->isPublic()) { + if ($domain && !$domain->isPublic()) { $steps['domain-new'] = true; $steps['domain-ldap-ready'] = 'isLdapReady'; $steps['domain-verified'] = 'isVerified'; $steps['domain-confirmed'] = 'isConfirmed'; } // Create a process check list foreach ($steps as $step_name => $func) { $object = strpos($step_name, 'user-') === 0 ? $user : $domain; $step = [ 'label' => $step_name, 'title' => __("app.process-{$step_name}"), 'state' => is_bool($func) ? $func : $object->{$func}(), ]; if ($step_name == 'domain-confirmed' && !$step['state']) { $step['link'] = "/domain/{$domain->id}"; } $process[] = $step; } return [ 'process' => $process, 'status' => $status, ]; } /** * Create a new user record. * * @param \Illuminate\Http\Request $request The API request. * * @return \Illuminate\Http\JsonResponse The response */ public function store(Request $request) { - // TODO + if ($this->guard()->user()->controller()->id !== $this->guard()->user()->id) { + return $this->errorResponse(403); + } + + if ($error_response = $this->validateUserRequest($request, null, $settings)) { + return $error_response; + } + + $user_name = !empty($settings['first_name']) ? $settings['first_name'] : ''; + if (!empty($settings['last_name'])) { + $user_name .= ' ' . $settings['last_name']; + } + + DB::beginTransaction(); + + // Create user record + $user = User::create([ + 'name' => $user_name, + 'email' => $request->email, + 'password' => $request->password, + ]); + + if (!empty($settings)) { + $user->setSettings($settings); + } + + // TODO: Assign package + + // Add aliases + if (!empty($request->aliases)) { + $user->setAliases($request->aliases); + } + + DB::commit(); + + return response()->json([ + 'status' => 'success', + 'message' => __('app.user-create-success'), + ]); } /** * Update user data. * * @param \Illuminate\Http\Request $request The API request. * @params string $id User identifier * * @return \Illuminate\Http\JsonResponse The response */ public function update(Request $request, $id) { if (!$this->hasAccess($id)) { return $this->errorResponse(403); } $user = User::find($id); if (empty($user)) { return $this->errorResponse(404); } - $rules = [ - 'external_email' => 'nullable|email', - 'phone' => 'string|nullable|max:64|regex:/^[0-9+() -]+$/', - 'first_name' => 'string|nullable|max:512', - 'last_name' => 'string|nullable|max:512', - 'billing_address' => 'string|nullable|max:1024', - 'country' => 'string|nullable|alpha|size:2', - 'currency' => 'string|nullable|alpha|size:3', - ]; - - if (!empty($request->password) || !empty($request->password_confirmation)) { - $rules['password'] = 'required|min:4|max:2048|confirmed'; + if ($error_response = $this->validateUserRequest($request, $user, $settings)) { + return $error_response; } - // Validate input - $v = Validator::make($request->all(), $rules); - - if ($v->fails()) { - return response()->json(['status' => 'error', 'errors' => $v->errors()], 422); - } - - // Update user settings - $settings = $request->only(array_keys($rules)); - unset($settings['password']); + DB::beginTransaction(); if (!empty($settings)) { $user->setSettings($settings); } // Update user password - if (!empty($rules['password'])) { + if (!empty($request->password)) { $user->password = $request->password; $user->save(); } + // Update aliases + if (isset($request->aliases)) { + $user->setAliases($request->aliases); + } + + DB::commit(); + return response()->json([ 'status' => 'success', 'message' => __('app.user-update-success'), ]); } /** * Get the guard to be used during authentication. * * @return \Illuminate\Contracts\Auth\Guard */ public function guard() { return Auth::guard(); } /** * Check if the current user has access to the specified user * * @param int $user_id User identifier * * @return bool True if current user has access, False otherwise */ protected function hasAccess($user_id): bool { $current_user = $this->guard()->user(); // TODO: Admins, other users // FIXME: This probably should be some kind of middleware/guard return $current_user->id == $user_id; } + + /** + * Create a response data array for specified user. + * + * @param \App\User $user User object + * + * @return array Response data + */ + protected function userResponse(User $user): array + { + $response = $user->toArray(); + + // Settings + // TODO: It might be reasonable to limit the list of settings here to these + // that are safe and are used in the UI + $response['settings'] = []; + foreach ($user->settings as $item) { + $response['settings'][$item->key] = $item->value; + } + + // Aliases + $response['aliases'] = []; + foreach ($user->aliases as $item) { + $response['aliases'][] = $item->alias; + } + + // Status info + $response['statusInfo'] = self::statusInfo($user); + + return $response; + } + + /** + * 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 The 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:512', + 'last_name' => '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', + ]; + + if (empty($user) || !empty($request->password) || !empty($request->password_confirmation)) { + $rules['password'] = 'required|min:4|max:2048|confirmed'; + } + + $errors = []; + + // Validate input + $v = Validator::make($request->all(), $rules); + + if ($v->fails()) { + $errors = $v->errors()->toArray(); + } + + $controller = $user ? $user->controller() : $this->guard()->user(); + + // 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, false)) { + $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::validateEmail($alias, $controller, true)) + ) { + 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']); + } + + /** + * Email address (login or alias) validation + * + * @param string $email Email address + * @param \App\User $user The account owner + * @param bool $is_alias The email is an alias + * + * @return string Error message on validation error + */ + protected static function validateEmail(string $email, User $user, bool $is_alias = false): ?string + { + $attribute = $is_alias ? 'alias' : 'email'; + + if (strpos($email, '@') === false) { + return \trans('validation.entryinvalid', ['attribute' => $attribute]); + } + + list($login, $domain) = explode('@', $email); + + // Check if domain exists + $domain = Domain::where('namespace', Str::lower($domain))->first(); + + if (empty($domain)) { + return \trans('validation.domaininvalid'); + } + + // Validate login part alone + $v = Validator::make( + [$attribute => $login], + [$attribute => ['required', new UserEmailLocal(!$domain->isPublic())]] + ); + + if ($v->fails()) { + return $v->errors()->toArray()[$attribute][0]; + } + + // Check if it is one of domains available to the user + // TODO: We should have a helper that returns "flat" array with domain names + // I guess we could use pluck() somehow + $domains = array_map( + function ($domain) { + return $domain->namespace; + }, + $user->domains() + ); + + if (!in_array($domain->namespace, $domains)) { + return \trans('validation.entryexists', ['attribute' => 'domain']); + } + + // Check if user with specified address already exists + if (User::findByEmail($email)) { + return \trans('validation.entryexists', ['attribute' => $attribute]); + } + + return null; + } } diff --git a/src/app/Observers/UserAliasObserver.php b/src/app/Observers/UserAliasObserver.php new file mode 100644 index 00000000..1e372317 --- /dev/null +++ b/src/app/Observers/UserAliasObserver.php @@ -0,0 +1,88 @@ +alias = \strtolower($alias->alias); + + if (User::where('email', $alias->alias)->first()) { + \Log::error("Failed creating alias {$alias->alias}. User exists."); + return false; + } + } + + /** + * Handle the user alias "created" event. + * + * @param \App\UserAlias $alias User email alias + * + * @return void + */ + public function created(UserAlias $alias) + { + \App\Jobs\UserUpdate::dispatch($alias->user); + } + + /** + * Handle the user setting "updated" event. + * + * @param \App\UserAlias $alias User email alias + * + * @return void + */ + public function updated(UserAlias $alias) + { + \App\Jobs\UserUpdate::dispatch($alias->user); + } + + /** + * Handle the user setting "deleted" event. + * + * @param \App\UserAlias $alias User email alias + * + * @return void + */ + public function deleted(UserAlias $alias) + { + \App\Jobs\UserUpdate::dispatch($alias->user); + } + + /** + * Handle the user alias "restored" event. + * + * @param \App\UserAlias $alias User email alias + * + * @return void + */ + public function restored(UserAlias $alias) + { + // not used + } + + /** + * Handle the user alias "force deleted" event. + * + * @param \App\UserAlias $alias User email alias + * + * @return void + */ + public function forceDeleted(UserAlias $alias) + { + // not used + } +} diff --git a/src/app/Providers/AppServiceProvider.php b/src/app/Providers/AppServiceProvider.php index dc66c7b8..2a15f1bd 100644 --- a/src/app/Providers/AppServiceProvider.php +++ b/src/app/Providers/AppServiceProvider.php @@ -1,46 +1,47 @@ sql, implode(', ', $query->bindings))); }); } } } diff --git a/src/app/Rules/ExternalEmail.php b/src/app/Rules/ExternalEmail.php new file mode 100644 index 00000000..de112d2a --- /dev/null +++ b/src/app/Rules/ExternalEmail.php @@ -0,0 +1,52 @@ + $email], ['email' => 'required|email']); + + if ($v->fails()) { + $this->message = \trans('validation.emailinvalid'); + return false; + } + + list($local, $domain) = explode('@', $email); + + // don't allow @localhost and other no-fqdn + if (strpos($domain, '.') === false) { + $this->message = \trans('validation.emailinvalid'); + return false; + } + + return true; + } + + /** + * Get the validation error message. + * + * @return string + */ + public function message(): ?string + { + return $this->message; + } +} diff --git a/src/app/Rules/UserEmailDomain.php b/src/app/Rules/UserEmailDomain.php new file mode 100644 index 00000000..7dbecd3a --- /dev/null +++ b/src/app/Rules/UserEmailDomain.php @@ -0,0 +1,70 @@ +domains = $domains; + } + + /** + * Determine if the validation rule passes. + * + * Validation of local part of an email address that's + * going to be user's login. + * + * @param string $attribute Attribute name + * @param mixed $domain Domain part of email address + * + * @return bool + */ + public function passes($attribute, $domain): bool + { + // don't allow @localhost and other no-fqdn + if (empty($domain) || strpos($domain, '.') === false || stripos($domain, 'www.') === 0) { + $this->message = \trans('validation.domaininvalid'); + return false; + } + + $domain = Str::lower($domain); + + // Use email validator to validate the domain part + $v = Validator::make(['email' => 'user@' . $domain], ['email' => 'required|email']); + if ($v->fails()) { + $this->message = \trans('validation.domaininvalid'); + return false; + } + + // Check if specified domain is allowed for signup + if (is_array($this->domains) && !in_array($domain, $this->domains)) { + $this->message = \trans('validation.domaininvalid'); + return false; + } + + return true; + } + + /** + * Get the validation error message. + * + * @return string + */ + public function message(): ?string + { + return $this->message; + } +} diff --git a/src/app/Rules/UserEmailLocal.php b/src/app/Rules/UserEmailLocal.php new file mode 100644 index 00000000..894c04a6 --- /dev/null +++ b/src/app/Rules/UserEmailLocal.php @@ -0,0 +1,72 @@ +external = $external; + } + + /** + * Determine if the validation rule passes. + * + * Validation of local part of an email address that's + * going to be user's login. + * + * @param string $attribute Attribute name + * @param mixed $login Local part of email address + * + * @return bool + */ + public function passes($attribute, $login): bool + { + // Strict validation + if (!preg_match('/^[A-Za-z0-9_.-]+$/', $login)) { + $this->message = \trans('validation.entryinvalid', ['attribute' => $attribute]); + return false; + } + + // Standard email address validation + $v = Validator::make([$attribute => $login . '@test.com'], [$attribute => 'required|email']); + if ($v->fails()) { + $this->message = \trans('validation.entryinvalid', ['attribute' => $attribute]); + return false; + } + + // Check if the local part is not one of exceptions + // (when signing up for an account in public domain + if (!$this->external) { + $exceptions = '/^(admin|administrator|sales|root)$/i'; + + if (preg_match($exceptions, $login)) { + $this->message = \trans('validation.entryexists', ['attribute' => $attribute]); + return false; + } + } + + return true; + } + + /** + * Get the validation error message. + * + * @return string + */ + public function message(): ?string + { + return $this->message; + } +} diff --git a/src/app/Traits/UserAliasesTrait.php b/src/app/Traits/UserAliasesTrait.php new file mode 100644 index 00000000..d0323dff --- /dev/null +++ b/src/app/Traits/UserAliasesTrait.php @@ -0,0 +1,41 @@ + 'some@other.erg']); + * $user->setAliases(['alias1@other.org', 'alias2@other.org']); + * ``` + * + * @param array $aliases An array of email addresses + * + * @return void + */ + public function setAliases(array $aliases): void + { + $existing_aliases = $this->aliases()->get()->map(function ($alias) { + return $alias->alias; + })->toArray(); + + $aliases = array_map('strtolower', $aliases); + $aliases = array_unique($aliases); + + foreach (array_diff($aliases, $existing_aliases) as $alias) { + $this->aliases()->create(['alias' => $alias]); + } + + foreach (array_diff($existing_aliases, $aliases) as $alias) { + $this->aliases()->where('alias', $alias)->delete(); + } + } +} diff --git a/src/app/User.php b/src/app/User.php index f3bebc1c..822eb9a4 100644 --- a/src/app/User.php +++ b/src/app/User.php @@ -1,400 +1,448 @@ 'datetime', ]; /** * Any wallets on which this user is a controller. * * @return \Illuminate\Database\Eloquent\Relations\BelongsToMany */ 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 ); } + /** + * Email aliases of this user. + * + * @return \Illuminate\Database\Eloquent\Relations\HasMany + */ + public function aliases() + { + return $this->hasMany('App\UserAlias', 'user_id'); + } + /** * Assign a package to a user. The user should not have any existing entitlements. * * @param \App\Package $package The package to assign. * @param \App\User|null $user Assign the package to another user. * * @return \App\User */ public function assignPackage($package, $user = null) { if (!$user) { $user = $this; } $wallet_id = $this->wallets()->get()[0]->id; foreach ($package->skus as $sku) { for ($i = $sku->pivot->qty; $i > 0; $i--) { \App\Entitlement::create( [ 'owner_id' => $this->id, 'wallet_id' => $wallet_id, 'sku_id' => $sku->id, 'entitleable_id' => $user->id, 'entitleable_type' => User::class ] ); } } return $user; } + /** + * Returns user controlling the current user (or self when it's the account owner) + * + * @return \App\User A user object + */ + public function controller(): User + { + // FIXME: This is most likely not the best way to do this + $entitlement = \App\Entitlement::where([ + 'entitleable_id' => $this->id, + 'entitleable_type' => User::class + ])->first(); + + if ($entitlement && $entitlement->owner_id != $this->id) { + return $entitlement->owner; + } + + return $this; + } + public function assignPlan($plan, $domain = null) { $this->setSetting('plan_id', $plan->id); foreach ($plan->packages as $package) { if ($package->isDomain()) { $domain->assignPackage($package, $this); } else { $this->assignPackage($package); } } } /** * List the domains to which this user is entitled. * * @return Domain[] */ public function domains() { $dbdomains = Domain::whereRaw( sprintf( '(type & %s) AND (status & %s)', Domain::TYPE_PUBLIC, Domain::STATUS_ACTIVE ) )->get(); $domains = []; foreach ($dbdomains as $dbdomain) { $domains[] = $dbdomain; } $entitlements = Entitlement::where('owner_id', $this->id)->get(); foreach ($entitlements as $entitlement) { if ($entitlement->entitleable instanceof Domain) { $domain = $entitlement->entitleable; \Log::info("Found domain for {$this->email}: {$domain->namespace} (owned)"); $domains[] = $domain; } } foreach ($this->accounts as $wallet) { foreach ($wallet->entitlements as $entitlement) { if ($entitlement->entitleable instanceof Domain) { $domain = $entitlement->entitleable; \Log::info("Found domain {$this->email}: {$domain->namespace} (charged)"); $domains[] = $domain; } } } return $domains; } public function entitlement() { return $this->morphOne('App\Entitlement', 'entitleable'); } /** * Entitlements for this user. * * Note that these are entitlements that apply to the user account, and not entitlements that * this user owns. * * @return \Illuminate\Database\Eloquent\Relations\HasMany */ public function entitlements() { return $this->hasMany('App\Entitlement', 'entitleable_id', 'id'); } public function addEntitlement($entitlement) { if (!$this->entitlements->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 + * @return \App\User User model object if found */ public static function findByEmail(string $email): ?User { if (strpos($email, '@') === false) { return null; } + $email = \strtolower($email); + $user = self::where('email', $email)->first(); - // TODO: Aliases, External email + if ($user) { + return $user; + } - return $user; + $alias = UserAlias::where('alias', $email)->first(); + + if ($alias) { + return $alias->user; + } + + // TODO: External email + + return null; } public function getJWTIdentifier() { return $this->getKey(); } public function getJWTCustomClaims() { return []; } /** * Returns whether this domain is active. * * @return bool */ public function isActive(): bool { return ($this->status & self::STATUS_ACTIVE) > 0; } /** * Returns whether this domain is deleted. * * @return bool */ public function isDeleted(): bool { return ($this->status & self::STATUS_DELETED) > 0; } /** * Returns whether this (external) domain has been verified * to exist in DNS. * * @return bool */ public function isImapReady(): bool { return ($this->status & self::STATUS_IMAP_READY) > 0; } /** * Returns whether this user is registered in LDAP. * * @return bool */ public function isLdapReady(): bool { return ($this->status & self::STATUS_LDAP_READY) > 0; } /** * Returns whether this user is new. * * @return bool */ public function isNew(): bool { return ($this->status & self::STATUS_NEW) > 0; } /** * Returns whether this domain is suspended. * * @return bool */ public function isSuspended(): bool { return ($this->status & self::STATUS_SUSPENDED) > 0; } /** * Any (additional) properties of this user. * * @return \Illuminate\Database\Eloquent\Relations\HasMany */ public function settings() { return $this->hasMany('App\UserSetting', 'user_id'); } /** * Verification codes for this user. * * @return \Illuminate\Database\Eloquent\Relations\HasMany */ public function verificationcodes() { return $this->hasMany('App\VerificationCode', 'user_id', 'id'); } /** * Wallets this user owns. * * @return \Illuminate\Database\Eloquent\Relations\HasMany */ public function wallets() { return $this->hasMany('App\Wallet'); } /** * User password mutator * * @param string $password The password in plain text. * * @return void */ 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)) ); } } /** * User LDAP password mutator * * @param string $password The password in plain text. * * @return void */ 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)) ); } } /** * User status mutator * * @throws \Exception */ public function setStatusAttribute($status) { $new_status = 0; $allowed_values = [ self::STATUS_NEW, self::STATUS_ACTIVE, self::STATUS_SUSPENDED, self::STATUS_DELETED, self::STATUS_LDAP_READY, self::STATUS_IMAP_READY, ]; foreach ($allowed_values as $value) { if ($status & $value) { $new_status |= $value; $status ^= $value; } } if ($status > 0) { throw new \Exception("Invalid user status: {$status}"); } $this->attributes['status'] = $new_status; } } diff --git a/src/app/UserAlias.php b/src/app/UserAlias.php new file mode 100644 index 00000000..caae7739 --- /dev/null +++ b/src/app/UserAlias.php @@ -0,0 +1,33 @@ +belongsTo( + '\App\User', + 'user_id', /* local */ + 'id' /* remote */ + ); + } +} diff --git a/src/database/migrations/2020_02_26_000000_create_user_aliases_table.php b/src/database/migrations/2020_02_26_000000_create_user_aliases_table.php new file mode 100644 index 00000000..bfa07e6b --- /dev/null +++ b/src/database/migrations/2020_02_26_000000_create_user_aliases_table.php @@ -0,0 +1,39 @@ +bigIncrements('id'); + $table->bigInteger('user_id'); + $table->string('alias')->unique(); + $table->timestamps(); + + $table->foreign('user_id')->references('id')->on('users') + ->onDelete('cascade')->onUpdate('cascade'); + } + ); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::dropIfExists('user_aliases'); + } +} diff --git a/src/database/seeds/UserSeeder.php b/src/database/seeds/UserSeeder.php index 32828f74..75f4d901 100644 --- a/src/database/seeds/UserSeeder.php +++ b/src/database/seeds/UserSeeder.php @@ -1,78 +1,82 @@ 'kolab.org', 'status' => Domain::STATUS_NEW + Domain::STATUS_ACTIVE + Domain::STATUS_CONFIRMED + Domain::STATUS_VERIFIED, 'type' => Domain::TYPE_EXTERNAL ] ); $john = User::create( [ 'name' => 'John Doe', 'email' => 'john@kolab.org', 'password' => 'simple123', 'email_verified_at' => now() ] ); $john->setSettings( [ 'first_name' => 'John', 'last_name' => 'Doe', 'currency' => 'USD', 'country' => 'US', 'billing_address' => "601 13th Street NW\nSuite 900 South\nWashington, DC 20005", 'external_email' => 'john.doe.external@gmail.com', 'phone' => '+1 509-248-1111', ] ); + $john->setAliases(['john.doe@kolab.org']); + $user_wallets = $john->wallets()->get(); $package_domain = \App\Package::where('title', 'domain-hosting')->first(); $package_kolab = \App\Package::where('title', 'kolab')->first(); $domain->assignPackage($package_domain, $john); $john->assignPackage($package_kolab); $jack = User::create( [ 'name' => 'Jack Daniels', 'email' => 'jack@kolab.org', 'password' => 'simple123', 'email_verified_at' => now() ] ); $jack->setSettings( [ 'first_name' => 'Jack', 'last_name' => 'Daniels', 'currency' => 'USD', 'country' => 'US' ] ); + $jack->setAliases(['jack.daniels@kolab.org']); + $john->assignPackage($package_kolab, $jack); factory(User::class, 10)->create(); } } diff --git a/src/resources/js/app.js b/src/resources/js/app.js index 148a7ba1..c4607432 100644 --- a/src/resources/js/app.js +++ b/src/resources/js/app.js @@ -1,150 +1,179 @@ /** * First we will load all of this project's JavaScript dependencies which * includes Vue and other libraries. It is a great starting point when * building robust, powerful web applications using Vue and Laravel. */ require('./bootstrap') window.Vue = require('vue') import AppComponent from '../vue/components/App' import MenuComponent from '../vue/components/Menu' import router from '../vue/js/routes.js' import store from '../vue/js/store' import VueToastr from '@deveodk/vue-toastr' // Add a response interceptor for general/validation error handler // This have to be before Vue and Router setup. Otherwise we would // not be able to handle axios responses initiated from inside // components created/mounted handlers (e.g. signup code verification link) window.axios.interceptors.response.use( response => { // Do nothing return response }, error => { var error_msg if (error.response && error.response.status == 422) { error_msg = "Form validation error" $.each(error.response.data.errors || {}, (idx, msg) => { $('form').each((i, form) => { const input_name = ($(form).data('validation-prefix') || '') + idx const input = $('#' + input_name) if (input.length) { - input.addClass('is-invalid') - input.parent().find('.invalid-feedback').remove() - input.parent().append($('
') - .text($.type(msg) === 'string' ? msg : msg.join(' '))) + // Create an error message\ + // API responses can use a string, array or object + let msg_text = '' + if ($.type(msg) !== 'string') { + $.each(msg, (index, str) => { + msg_text += str + ' ' + }) + } + else { + msg_text = msg + } + + let feedback = $('
').text(msg_text) + + if (input.is('.listinput')) { + // List input widget + let list = input.next('.listinput-widget') + + list.children(':not(:first-child)').each((index, element) => { + if (msg[index]) { + $(element).find('input').addClass('is-invalid') + } + }) + + list.addClass('is-invalid').next('.invalid-feedback').remove() + list.after(feedback) + } + else { + // Standard form element + input.addClass('is-invalid') + input.parent().find('.invalid-feedback').remove() + input.parent().append(feedback) + } return false } }); }) - $('form .is-invalid').first().focus() + $('form .is-invalid:not(.listinput-widget)').first().focus() } else if (error.response && error.response.data) { error_msg = error.response.data.message } else { error_msg = error.request ? error.request.statusText : error.message } app.$toastr('error', error_msg || "Server Error", 'Error') // Pass the error as-is return Promise.reject(error) } ) const app = new Vue({ el: '#app', components: { 'app-component': AppComponent, 'menu-component': MenuComponent }, store, router, data() { return { isLoading: true } }, methods: { // Clear (bootstrap) form validation state clearFormValidation(form) { $(form).find('.is-invalid').removeClass('is-invalid') $(form).find('.invalid-feedback').remove() }, // Set user state to "logged in" loginUser(token, dashboard) { store.commit('logoutUser') // destroy old state data store.commit('loginUser') localStorage.setItem('token', token) axios.defaults.headers.common.Authorization = 'Bearer ' + token if (dashboard !== false) { router.push(store.state.afterLogin || { name: 'dashboard' }) } store.state.afterLogin = null }, // Set user state to "not logged in" logoutUser() { store.commit('logoutUser') localStorage.setItem('token', '') delete axios.defaults.headers.common.Authorization router.push({ name: 'login' }) }, // Display "loading" overlay (to be used by route components) startLoading() { this.isLoading = true // Lock the UI with the 'loading...' element $('#app').append($('
Loading
')) }, // Hide "loading" overlay stopLoading() { $('#app > .app-loader').fadeOut() this.isLoading = false }, errorPage(code, msg) { // Until https://github.com/vuejs/vue-router/issues/977 is implemented // we can't really use router to display error page as it has two side // effects: it changes the URL and adds the error page to browser history. // For now we'll be replacing current view with error page "manually". const map = { 400: "Bad request", 401: "Unauthorized", 403: "Access denied", 404: "Not found", 405: "Method not allowed", 500: "Internal server error" } if (!msg) msg = map[code] || "Unknown Error" const error_page = `
${code}
${msg}
` $('#app').children(':not(nav)').remove() $('#app').append(error_page) }, errorHandler(error) { this.stopLoading() if (error.response.status === 401) { this.logoutUser() } else { this.errorPage(error.response.status, error.response.statusText) } } } }) Vue.use(VueToastr, { defaultPosition: 'toast-bottom-right', defaultTimeout: 5000 }) diff --git a/src/resources/lang/en/app.php b/src/resources/lang/en/app.php index bce1536f..441cad6b 100644 --- a/src/resources/lang/en/app.php +++ b/src/resources/lang/en/app.php @@ -1,25 +1,26 @@ 'Choose :plan', 'process-user-new' => 'User registered', 'process-user-ldap-ready' => 'User created', 'process-user-imap-ready' => 'User mailbox created', 'process-domain-new' => 'Custom domain registered', 'process-domain-ldap-ready' => 'Custom domain created', 'process-domain-verified' => 'Custom domain verified', 'process-domain-confirmed' => 'Custom domain ownership verified', 'domain-verify-success' => 'Domain verified successfully', 'user-update-success' => 'User data updated successfully', + 'user-create-success' => 'User created successfully', ]; diff --git a/src/resources/lang/en/validation.php b/src/resources/lang/en/validation.php index 88de81ae..468172fd 100644 --- a/src/resources/lang/en/validation.php +++ b/src/resources/lang/en/validation.php @@ -1,159 +1,160 @@ '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.', - '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', + 'emailinvalid' => 'The specified email address is invalid.', + 'domaininvalid' => 'The specified domain is invalid.', + 'logininvalid' => 'The specified login is invalid.', + 'loginexists' => 'The specified login is not available.', + 'domainexists' => 'The specified domain is not available.', + '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.', + 'entryinvalid' => 'The specified :attribute is invalid.', + 'entryexists' => 'The specified :attribute is not available.', /* |-------------------------------------------------------------------------- | 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/sass/app.scss b/src/resources/sass/app.scss index 08c768c8..f40ddca2 100644 --- a/src/resources/sass/app.scss +++ b/src/resources/sass/app.scss @@ -1,80 +1,104 @@ // Fonts // Variables @import 'variables'; // Bootstrap @import '~bootstrap/scss/bootstrap'; // Toastr @import '~@deveodk/vue-toastr/dist/@deveodk/vue-toastr.css'; // Fixes Toastr incompatibility with Bootstrap .toast-container > .toast { opacity: 1; } @import 'menu'; nav + .container { margin-top: 120px; } #app { margin-bottom: 2rem; } #error-page { position: absolute; top: 0; height: 100%; width: 100%; align-items: center; display: flex; justify-content: center; color: #636b6f; z-index: 10; background: white; .code { text-align: right; border-right: 2px solid; font-size: 26px; padding: 0 15px; } .message { font-size: 18px; padding: 0 15px; } } .app-loader { background-color: $body-bg; height: 100%; width: 100%; position: absolute; top: 0; left: 0; display: flex; align-items: center; justify-content: center; .spinner-border { width: 120px; height: 120px; border-width: 15px; color: #b2aa99; } } pre { margin: 1rem 0; padding: 1rem; background-color: $menu-bg-color; } .card-title { font-size: 1.2rem; font-weight: bold; } + +.listinput { + display: none; +} + +.listinput-widget { + & > div { + &:not(:last-child) { + margin-bottom: -1px; + + input, a.btn { + border-bottom-right-radius: 0; + border-bottom-left-radius: 0; + } + } + + &:not(:first-child) { + input, a.btn { + border-top-right-radius: 0; + border-top-left-radius: 0; + } + } + } +} diff --git a/src/resources/vue/components/User/Info.vue b/src/resources/vue/components/User/Info.vue index 73f059d8..c767d6af 100644 --- a/src/resources/vue/components/User/Info.vue +++ b/src/resources/vue/components/User/Info.vue @@ -1,33 +1,189 @@ diff --git a/src/resources/vue/components/User/List.vue b/src/resources/vue/components/User/List.vue index 8f3d2ef0..23916b9c 100644 --- a/src/resources/vue/components/User/List.vue +++ b/src/resources/vue/components/User/List.vue @@ -1,42 +1,43 @@ diff --git a/src/tests/Browser/Components/ListInput.php b/src/tests/Browser/Components/ListInput.php new file mode 100644 index 00000000..67134809 --- /dev/null +++ b/src/tests/Browser/Components/ListInput.php @@ -0,0 +1,109 @@ +selector = $selector; + } + + /** + * Get the root selector for the component. + * + * @return string + */ + public function selector() + { + return $this->selector . ' + .listinput-widget'; + } + + /** + * Assert that the browser page contains the component. + * + * @param Browser $browser + * + * @return void + */ + public function assert(Browser $browser) + { +// $list = explode("\n", $browser->value($this->selector)); + + $browser->waitFor($this->selector()) + ->assertMissing($this->selector) + ->assertVisible('@input') + ->assertVisible('@add-btn'); +// ->assertListInputValue($list); + } + + /** + * Get the element shortcuts for the component. + * + * @return array + */ + public function elements() + { + return [ + '@input' => '.input-group:first-child input', + '@add-btn' => '.input-group:first-child a.btn', + ]; + } + + /** + * Assert list input content + */ + public function assertListInputValue(Browser $browser, array $list) + { + if (empty($list)) { + $browser->assertMissing('.input-group:not(:first-child)'); + return; + } + + foreach ($list as $idx => $value) { + $selector = '.input-group:nth-child(' . ($idx + 2) . ') input'; + $browser->assertVisible($selector)->assertValue($selector, $value); + } + } + + /** + * Add list entry + */ + public function addListEntry(Browser $browser, string $value) + { + $browser->type('@input', $value) + ->click('@add-btn') + ->assertValue('.input-group:last-child input', $value); + } + + /** + * Remove list entry + */ + public function removeListEntry(Browser $browser, int $num) + { + $selector = '.input-group:nth-child(' . ($num + 1) . ') a.btn'; + $browser->click($selector)->assertMissing($selector); + } + + /** + * Assert an error message on the widget + */ + public function assertFormError(Browser $browser, int $num, string $msg, bool $focused = false) + { + $selector = '.input-group:nth-child(' . ($num + 1) . ') input.is-invalid'; + + $browser->assertVisible($selector) + ->assertSeeIn(' + .invalid-feedback', $msg); + + if ($focused) { + $browser->assertFocused($selector); + } + } +} diff --git a/src/tests/Browser/Components/Toast.php b/src/tests/Browser/Components/Toast.php index e6e1f164..b1c99543 100644 --- a/src/tests/Browser/Components/Toast.php +++ b/src/tests/Browser/Components/Toast.php @@ -1,86 +1,88 @@ type = $type; } /** * Get the root selector for the component. * * @return string */ public function selector() { return '.toast-container > .toast.toast-' . $this->type; } /** * Assert that the browser page contains the component. * * @param Browser $browser * * @return void */ public function assert(Browser $browser) { $browser->waitFor($this->selector()); + $this->element = $browser->element($this->selector()); } /** * Get the element shortcuts for the component. * * @return array */ public function elements() { return [ '@title' => ".toast-title", '@message' => ".toast-message", ]; } /** * Assert title of the toast element */ public function assertToastTitle(Browser $browser, string $title) { if (empty($title)) { $browser->assertMissing('@title'); } else { $browser->assertSeeIn('@title', $title); } } /** * Assert message of the toast element */ public function assertToastMessage(Browser $browser, string $message) { $browser->assertSeeIn('@message', $message); } /** * Close the toast with a click */ public function closeToast(Browser $browser) { - $browser->click(); + $this->element->click(); } } diff --git a/src/tests/Browser/Pages/UserInfo.php b/src/tests/Browser/Pages/UserInfo.php new file mode 100644 index 00000000..c7d1216d --- /dev/null +++ b/src/tests/Browser/Pages/UserInfo.php @@ -0,0 +1,44 @@ +waitFor('@form'); + } + + /** + * Get the element shortcuts for the page. + * + * @return array + */ + public function elements(): array + { + return [ + '@app' => '#app', + '@form' => '#user-info form', + ]; + } +} diff --git a/src/tests/Browser/Pages/UserList.php b/src/tests/Browser/Pages/UserList.php new file mode 100644 index 00000000..8f122b34 --- /dev/null +++ b/src/tests/Browser/Pages/UserList.php @@ -0,0 +1,46 @@ +assertPathIs($this->url()) + ->waitUntilMissing('@app .app-loader') + ->assertSeeIn('#user-list .card-title', 'User Accounts'); + } + + /** + * Get the element shortcuts for the page. + * + * @return array + */ + public function elements(): array + { + return [ + '@app' => '#app', + '@table' => '#user-list table', + ]; + } +} diff --git a/src/tests/Browser/UsersTest.php b/src/tests/Browser/UsersTest.php new file mode 100644 index 00000000..c38c0459 --- /dev/null +++ b/src/tests/Browser/UsersTest.php @@ -0,0 +1,305 @@ + 'John', + 'last_name' => 'Doe', + ]; + + /** + * {@inheritDoc} + */ + public function setUp(): void + { + parent::setUp(); + + // TODO: Use TestCase::deleteTestUser() + User::withTrashed()->where('email', 'john.rambo@kolab.org')->forceDelete(); + + $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(); + } + + /** + * {@inheritDoc} + */ + public function tearDown(): void + { + // TODO: Use TestCase::deleteTestUser() + User::withTrashed()->where('email', 'john.rambo@kolab.org')->forceDelete(); + + $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(); + + parent::tearDown(); + } + + /** + * Test user info page (unauthenticated) + */ + public function testInfoUnauth(): void + { + // Test that the page requires authentication + $this->browse(function (Browser $browser) { + $user = User::where('email', 'john@kolab.org')->first(); + + $browser->visit('/user/' . $user->id)->on(new Home()); + }); + } + + /** + * Test users list page (unauthenticated) + */ + public function testListUnauth(): void + { + // Test that the page requires authentication + $this->browse(function (Browser $browser) { + $browser->visit('/users')->on(new Home()); + }); + + // TODO: Test that jack@kolab.org can't access this page + } + + /** + * Test users list page + */ + public function testList(): void + { + // Test that the page requires authentication + $this->browse(function (Browser $browser) { + $browser->visit(new Home()) + ->submitLogon('john@kolab.org', 'simple123', true) + ->on(new Dashboard()) + ->assertSeeIn('@links .link-users', 'User accounts') + ->click('@links .link-users') + ->on(new UserList()) + ->whenAvailable('@table', function ($browser) { + $this->assertCount(1, $browser->elements('tbody tr')); + $browser->assertSeeIn('tbody tr td a', 'john@kolab.org'); + }); + }); + } + + /** + * Test user account editing page (not profile page) + * + * @depends testList + */ + public function testInfo(): void + { + $this->browse(function (Browser $browser) { + $browser->on(new UserList()) + ->click('@table tr:first-child a') + ->on(new UserInfo()) + ->assertSeeIn('#user-info .card-title', 'User account') + ->with('@form', function (Browser $browser) { + // Assert form content + $browser->assertFocused('div.row:nth-child(1) input') + ->assertSeeIn('div.row:nth-child(1) label', 'First name') + ->assertValue('div.row:nth-child(1) input[type=text]', $this->profile['first_name']) + ->assertSeeIn('div.row:nth-child(2) label', 'Last name') + ->assertValue('div.row:nth-child(2) input[type=text]', $this->profile['last_name']) + ->assertSeeIn('div.row:nth-child(3) label', 'Email') + ->assertValue('div.row:nth-child(3) input[type=text]', 'john@kolab.org') +//TODO ->assertDisabled('div.row:nth-child(3) input') + ->assertSeeIn('div.row:nth-child(4) label', 'Email aliases') + ->assertVisible('div.row:nth-child(4) .listinput-widget') + ->with(new ListInput('#aliases'), function (Browser $browser) { + $browser->assertListInputValue(['john.doe@kolab.org']) + ->assertValue('@input', ''); + }) + ->assertSeeIn('div.row:nth-child(5) label', 'Password') + ->assertValue('div.row:nth-child(5) input[type=password]', '') + ->assertSeeIn('div.row:nth-child(6) label', 'Confirm password') + ->assertValue('div.row:nth-child(6) input[type=password]', '') + ->assertSeeIn('button[type=submit]', 'Submit'); + + // Clear some fields and submit + $browser->type('#first_name', '') + ->type('#last_name', '') + ->click('button[type=submit]'); + }) + ->with(new Toast(Toast::TYPE_SUCCESS), function (Browser $browser) { + $browser->assertToastTitle('') + ->assertToastMessage('User data updated successfully') + ->closeToast(); + }); + + // Test error handling (password) + $browser->with('@form', function (Browser $browser) { + $browser->type('#password', 'aaaaaa') + ->type('#password_confirmation', '') + ->click('button[type=submit]') + ->waitFor('#password + .invalid-feedback') + ->assertSeeIn('#password + .invalid-feedback', 'The password confirmation does not match.') + ->assertFocused('#password'); + }) + ->with(new Toast(Toast::TYPE_ERROR), function (Browser $browser) { + $browser->assertToastTitle('Error') + ->assertToastMessage('Form validation error') + ->closeToast(); + }); + + // TODO: Test password change + + // Test form error handling (aliases) + $browser->with('@form', function (Browser $browser) { + // TODO: For some reason, clearing the input value + // with ->type('#password', '') does not work, maybe some dusk/vue intricacy + // For now we just use the default password + $browser->type('#password', 'simple123') + ->type('#password_confirmation', 'simple123') + ->with(new ListInput('#aliases'), function (Browser $browser) { + $browser->addListEntry('invalid address'); + }) + ->click('button[type=submit]'); + }) + ->with(new Toast(Toast::TYPE_ERROR), function (Browser $browser) { + $browser->assertToastTitle('Error') + ->assertToastMessage('Form validation error') + ->closeToast(); + }) + ->with('@form', function (Browser $browser) { + $browser->with(new ListInput('#aliases'), function (Browser $browser) { + $browser->assertFormError(2, 'The specified alias is invalid.', false); + }); + }); + + // Test adding aliases + $browser->with('@form', function (Browser $browser) { + $browser->with(new ListInput('#aliases'), function (Browser $browser) { + $browser->removeListEntry(2) + ->addListEntry('john.test@kolab.org'); + }) + ->click('button[type=submit]'); + }) + ->with(new Toast(Toast::TYPE_SUCCESS), function (Browser $browser) { + $browser->assertToastTitle('') + ->assertToastMessage('User data updated successfully') + ->closeToast(); + }); + + $john = User::where('email', 'john@kolab.org')->first(); + $alias = UserAlias::where('user_id', $john->id)->where('alias', 'john.test@kolab.org')->first(); + $this->assertTrue(!empty($alias)); + }); + } + + /** + * Test user adding page + * + * @depends testList + */ + public function testNewUser(): void + { + $this->browse(function (Browser $browser) { + $browser->visit(new UserList()) + ->assertSeeIn('button.create-user', 'Create user') + ->click('button.create-user') + ->on(new UserInfo()) + ->assertSeeIn('#user-info .card-title', 'New user account') + ->with('@form', function (Browser $browser) { + // Assert form content + $browser->assertFocused('div.row:nth-child(1) input') + ->assertSeeIn('div.row:nth-child(1) label', 'First name') + ->assertValue('div.row:nth-child(1) input[type=text]', '') + ->assertSeeIn('div.row:nth-child(2) label', 'Last name') + ->assertValue('div.row:nth-child(2) input[type=text]', '') + ->assertSeeIn('div.row:nth-child(3) label', 'Email') + ->assertValue('div.row:nth-child(3) input[type=text]', '') + ->assertEnabled('div.row:nth-child(3) input') + ->assertSeeIn('div.row:nth-child(4) label', 'Email aliases') + ->assertVisible('div.row:nth-child(4) .listinput-widget') + ->with(new ListInput('#aliases'), function (Browser $browser) { + $browser->assertListInputValue([]) + ->assertValue('@input', ''); + }) + ->assertSeeIn('div.row:nth-child(5) label', 'Password') + ->assertValue('div.row:nth-child(5) input[type=password]', '') + ->assertSeeIn('div.row:nth-child(6) label', 'Confirm password') + ->assertValue('div.row:nth-child(6) input[type=password]', '') + ->assertSeeIn('button[type=submit]', 'Submit'); + + // Test browser-side required fields and error handling + $browser->click('button[type=submit]') + ->assertFocused('#email') + ->type('#email', 'invalid email') + ->click('button[type=submit]') + ->assertFocused('#password') + ->type('#password', 'simple123') + ->click('button[type=submit]') + ->assertFocused('#password_confirmation') + ->type('#password_confirmation', 'simple') + ->click('button[type=submit]'); + }) + ->with(new Toast(Toast::TYPE_ERROR), function (Browser $browser) { + $browser->assertToastTitle('Error') + ->assertToastMessage('Form validation error') + ->closeToast(); + }) + ->with('@form', function (Browser $browser) { + $browser->assertSeeIn('#email + .invalid-feedback', 'The specified email is invalid.') + ->assertSeeIn('#password + .invalid-feedback', 'The password confirmation does not match.'); + }); + + // Test form error handling (aliases) + $browser->with('@form', function (Browser $browser) { + $browser->type('#email', 'john.rambo@kolab.org') + ->type('#password_confirmation', 'simple123') + ->with(new ListInput('#aliases'), function (Browser $browser) { + $browser->addListEntry('invalid address'); + }) + ->click('button[type=submit]'); + }) + ->with(new Toast(Toast::TYPE_ERROR), function (Browser $browser) { + $browser->assertToastTitle('Error') + ->assertToastMessage('Form validation error') + ->closeToast(); + }) + ->with('@form', function (Browser $browser) { + $browser->with(new ListInput('#aliases'), function (Browser $browser) { + $browser->assertFormError(1, 'The specified alias is invalid.', false); + }); + }); + + // Successful account creation + $browser->with('@form', function (Browser $browser) { + $browser->with(new ListInput('#aliases'), function (Browser $browser) { + $browser->removeListEntry(1) + ->addListEntry('john.rambo2@kolab.org'); + }) + ->click('button[type=submit]'); + }) + ->with(new Toast(Toast::TYPE_SUCCESS), function (Browser $browser) { + $browser->assertToastTitle('') + ->assertToastMessage('User created successfully') + ->closeToast(); + }); + + // TODO: assert redirect to users list + + $john = User::where('email', 'john.rambo@kolab.org')->first(); + $alias = UserAlias::where('user_id', $john->id)->where('alias', 'john.rambo2@kolab.org')->first(); + $this->assertTrue(!empty($alias)); + }); + } +} diff --git a/src/tests/Feature/Controller/SignupTest.php b/src/tests/Feature/Controller/SignupTest.php index 8825140e..30cb7384 100644 --- a/src/tests/Feature/Controller/SignupTest.php +++ b/src/tests/Feature/Controller/SignupTest.php @@ -1,641 +1,601 @@ domain = $this->getPublicDomain(); $this->deleteTestUser("SignupControllerTest1@$this->domain"); $this->deleteTestUser("signuplogin@$this->domain"); $this->deleteTestUser("admin@external.com"); $this->deleteTestDomain('external.com'); $this->deleteTestDomain('signup-domain.com'); } /** * {@inheritDoc} */ public function tearDown(): void { $this->deleteTestUser("SignupControllerTest1@$this->domain"); $this->deleteTestUser("signuplogin@$this->domain"); $this->deleteTestUser("admin@external.com"); $this->deleteTestDomain('external.com'); $this->deleteTestDomain('signup-domain.com'); parent::tearDown(); } /** * Return a public domain for signup tests */ private function getPublicDomain(): string { if (!$this->domain) { $this->refreshApplication(); $public_domains = Domain::getPublicDomains(); $this->domain = reset($public_domains); if (empty($this->domain)) { $this->domain = 'signup-domain.com'; Domain::create([ 'namespace' => $this->domain, 'status' => Domain::STATUS_ACTIVE, 'type' => Domain::TYPE_PUBLIC, ]); } } return $this->domain; } /** * Test fetching plans for signup * * @return void */ public function testSignupPlans() { // Note: this uses plans that already have been seeded into the DB $response = $this->get('/api/auth/signup/plans'); $json = $response->json(); $response->assertStatus(200); $this->assertSame('success', $json['status']); $this->assertCount(2, $json['plans']); $this->assertArrayHasKey('title', $json['plans'][0]); $this->assertArrayHasKey('name', $json['plans'][0]); $this->assertArrayHasKey('description', $json['plans'][0]); $this->assertArrayHasKey('button', $json['plans'][0]); } /** * 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', ]; $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', ]; $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', '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) { $code = TestCase::getObjectProperty($job, 'code'); 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(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(3, $json['errors']); $this->assertArrayHasKey('login', $json['errors']); $this->assertArrayHasKey('password', $json['errors']); $this->assertArrayHasKey('domain', $json['errors']); $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(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']); // Missing codes $data = [ '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(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) { Queue::fake(); $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']); Queue::assertPushed(\App\Jobs\UserCreate::class, 1); Queue::assertPushed(\App\Jobs\UserCreate::class, function ($job) use ($data) { $job_user = TestCase::getObjectProperty($job, 'user'); return $job_user->email === \strtolower($data['login'] . '@' . $data['domain']); }); // Check if the code has been removed $this->assertNull(SignupCode::where('code', $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->withoutMiddleware()->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) { $code = TestCase::getObjectProperty($job, 'code'); 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']); Queue::assertPushed(\App\Jobs\DomainCreate::class, 1); Queue::assertPushed(\App\Jobs\DomainCreate::class, function ($job) use ($domain) { $job_domain = TestCase::getObjectProperty($job, 'domain'); return $job_domain->namespace === $domain; }); Queue::assertPushed(\App\Jobs\UserCreate::class, 1); Queue::assertPushed(\App\Jobs\UserCreate::class, function ($job) use ($data) { $job_user = TestCase::getObjectProperty($job, 'user'); return $job_user->email === $data['login'] . '@' . $data['domain']; }); // 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() - { - return [ - // 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 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, $expected_result) - { - $method = new \ReflectionMethod('App\Http\Controllers\API\SignupController', 'validateEmail'); - $method->setAccessible(true); - - $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() + public function dataValidateLogin(): array { $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']], + ['', $domain, false, ['login' => 'The login field is required.']], + ['test123456', 'localhost', false, ['domain' => 'The specified domain is invalid.']], + ['test123456', 'unknown-domain.org', false, ['domain' => 'The specified domain is invalid.']], ['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']], + ['admin', $domain, false, ['login' => 'The specified login is not available.']], + ['administrator', $domain, false, ['login' => 'The specified login is not available.']], + ['sales', $domain, false, ['login' => 'The specified login is not available.']], + ['root', $domain, false, ['login' => 'The specified login is not available.']], - // existing user - ['jack', 'kolab.org', true, ['domain' => 'validation.domainexists']], + // TODO existing (public domain) user + // ['signuplogin', $domain, false, ['login' => 'The specified login is not available.']], // Domain account ['admin', 'kolabsys.com', true, null], - ['testnonsystemdomain', 'invalid', true, ['domain' => 'validation.domaininvalid']], - ['testnonsystemdomain', '.com', true, ['domain' => 'validation.domaininvalid']], + ['testnonsystemdomain', 'invalid', true, ['domain' => 'The specified domain is invalid.']], + ['testnonsystemdomain', '.com', true, ['domain' => 'The specified domain is invalid.']], - // existing user - ['john', 'kolab.org', true, ['domain' => 'validation.domainexists']], + // existing custom domain + ['jack', 'kolab.org', true, ['domain' => 'The specified domain is not available.']], ]; } /** * Signup login/domain validation. * - * Note: Technicly these include unit tests, but let's keep it here for now. + * Note: Technically 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) + public function testValidateLogin($login, $domain, $external, $expected_result): void { - $method = new \ReflectionMethod('App\Http\Controllers\API\SignupController', 'validateLogin'); - $method->setAccessible(true); + $result = $this->invokeMethod(new SignupController(), 'validateLogin', [$login, $domain, $external]); - $result = $method->invoke(new SignupController(), $login, $domain, $external); - - $this->assertSame($expected_result, $result, var_export(func_get_args(), true)); + $this->assertSame($expected_result, $result); } } diff --git a/src/tests/Feature/Controller/UsersTest.php b/src/tests/Feature/Controller/UsersTest.php index 42beb37a..69b36872 100644 --- a/src/tests/Feature/Controller/UsersTest.php +++ b/src/tests/Feature/Controller/UsersTest.php @@ -1,328 +1,537 @@ deleteTestUser('UsersControllerTest1@userscontroller.com'); $this->deleteTestUser('UserEntitlement2A@UserEntitlement.com'); + $this->deleteTestUser('john2.doe2@kolab.org'); $this->deleteTestDomain('userscontroller.com'); } /** * {@inheritDoc} */ public function tearDown(): void { $this->deleteTestUser('UsersControllerTest1@userscontroller.com'); $this->deleteTestUser('UserEntitlement2A@UserEntitlement.com'); + $this->deleteTestUser('john2.doe2@kolab.org'); $this->deleteTestDomain('userscontroller.com'); parent::tearDown(); } /** * Test fetching current user info (/api/auth/info) */ public function testInfo(): void { $user = $this->getTestUser('UsersControllerTest1@userscontroller.com'); $domain = $this->getTestDomain('userscontroller.com', [ 'status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_PUBLIC, ]); $response = $this->actingAs($user)->get("api/auth/info"); $response->assertStatus(200); $json = $response->json(); $this->assertEquals($user->id, $json['id']); $this->assertEquals($user->email, $json['email']); $this->assertEquals(User::STATUS_NEW, $json['status']); $this->assertTrue(is_array($json['statusInfo'])); - $this->assertEquals($user->getSetting('country'), $json['settings']['country']); - $this->assertEquals($user->getSetting('currency'), $json['settings']['currency']); + $this->assertTrue(is_array($json['settings'])); + $this->assertTrue(is_array($json['aliases'])); + + // Note: Details of the content are tested in testUserResponse() } public function testIndex(): void { // TODO $this->markTestIncomplete(); } /** * Test /api/auth/login */ public function testLogin(): string { // Request with no data $response = $this->post("api/auth/login", []); $response->assertStatus(422); $json = $response->json(); $this->assertSame('error', $json['status']); $this->assertCount(2, $json['errors']); $this->assertArrayHasKey('email', $json['errors']); $this->assertArrayHasKey('password', $json['errors']); // Request with invalid password $post = ['email' => 'john@kolab.org', 'password' => 'wrong']; $response = $this->post("api/auth/login", $post); $response->assertStatus(401); $json = $response->json(); $this->assertSame('error', $json['status']); $this->assertSame('Invalid username or password.', $json['message']); // Valid user+password $post = ['email' => 'john@kolab.org', 'password' => 'simple123']; $response = $this->post("api/auth/login", $post); $json = $response->json(); $response->assertStatus(200); $this->assertTrue(!empty($json['access_token'])); $this->assertEquals(\config('jwt.ttl') * 60, $json['expires_in']); $this->assertEquals('bearer', $json['token_type']); return $json['access_token']; } /** * Test /api/auth/logout * * @depends testLogin */ public function testLogout($token): void { // Request with no token, testing that it requires auth $response = $this->post("api/auth/logout"); $response->assertStatus(401); // Test the same using JSON mode $response = $this->json('POST', "api/auth/logout", []); $response->assertStatus(401); // Request with valid token $response = $this->withHeaders(['Authorization' => 'Bearer ' . $token])->post("api/auth/logout"); $response->assertStatus(200); $json = $response->json(); $this->assertEquals('success', $json['status']); $this->assertEquals('Successfully logged out', $json['message']); // Check if it really destroyed the token? $response = $this->withHeaders(['Authorization' => 'Bearer ' . $token])->get("api/auth/info"); $response->assertStatus(401); } public function testRefresh(): void { // TODO $this->markTestIncomplete(); } public function testStatusInfo(): void { $user = $this->getTestUser('UsersControllerTest1@userscontroller.com'); $domain = $this->getTestDomain('userscontroller.com', [ 'status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_PUBLIC, ]); $user->status = User::STATUS_NEW; $user->save(); $result = UsersController::statusInfo($user); $this->assertSame('new', $result['status']); $this->assertCount(3, $result['process']); $this->assertSame('user-new', $result['process'][0]['label']); $this->assertSame(true, $result['process'][0]['state']); $this->assertSame('user-ldap-ready', $result['process'][1]['label']); $this->assertSame(false, $result['process'][1]['state']); $this->assertSame('user-imap-ready', $result['process'][2]['label']); $this->assertSame(false, $result['process'][2]['state']); $user->status |= User::STATUS_LDAP_READY | User::STATUS_IMAP_READY; $user->save(); $result = UsersController::statusInfo($user); $this->assertSame('new', $result['status']); $this->assertCount(3, $result['process']); $this->assertSame('user-new', $result['process'][0]['label']); $this->assertSame(true, $result['process'][0]['state']); $this->assertSame('user-ldap-ready', $result['process'][1]['label']); $this->assertSame(true, $result['process'][1]['state']); $this->assertSame('user-imap-ready', $result['process'][2]['label']); $this->assertSame(true, $result['process'][2]['state']); $user->status |= User::STATUS_ACTIVE; $user->save(); $domain->status |= Domain::STATUS_VERIFIED; $domain->type = Domain::TYPE_EXTERNAL; $domain->save(); $result = UsersController::statusInfo($user); $this->assertSame('active', $result['status']); $this->assertCount(7, $result['process']); $this->assertSame('user-new', $result['process'][0]['label']); $this->assertSame(true, $result['process'][0]['state']); $this->assertSame('user-ldap-ready', $result['process'][1]['label']); $this->assertSame(true, $result['process'][1]['state']); $this->assertSame('user-imap-ready', $result['process'][2]['label']); $this->assertSame(true, $result['process'][2]['state']); $this->assertSame('domain-new', $result['process'][3]['label']); $this->assertSame(true, $result['process'][3]['state']); $this->assertSame('domain-ldap-ready', $result['process'][4]['label']); $this->assertSame(false, $result['process'][4]['state']); $this->assertSame('domain-verified', $result['process'][5]['label']); $this->assertSame(true, $result['process'][5]['state']); $this->assertSame('domain-confirmed', $result['process'][6]['label']); $this->assertSame(false, $result['process'][6]['state']); $user->status |= User::STATUS_DELETED; $user->save(); $result = UsersController::statusInfo($user); $this->assertSame('deleted', $result['status']); } + /** + * Test user data response used in show and info actions + */ + public function testUserResponse(): void + { + $user = $this->getTestUser('john@kolab.org'); + + $result = $this->invokeMethod(new UsersController(), 'userResponse', [$user]); + + $this->assertEquals($user->id, $result['id']); + $this->assertEquals($user->email, $result['email']); + $this->assertEquals($user->status, $result['status']); + $this->assertTrue(is_array($result['statusInfo'])); + + $this->assertTrue(is_array($result['aliases'])); + $this->assertCount(1, $result['aliases']); + $this->assertSame('john.doe@kolab.org', $result['aliases'][0]); + + $this->assertTrue(is_array($result['settings'])); + $this->assertSame('US', $result['settings']['country']); + $this->assertSame('USD', $result['settings']['currency']); + } + /** * Test fetching user data/profile (GET /api/v4/users/) */ public function testShow(): void { $userA = $this->getTestUser('UserEntitlement2A@UserEntitlement.com'); // Test getting profile of self $response = $this->actingAs($userA, 'api')->get("/api/v4/users/{$userA->id}"); + $json = $response->json(); + $response->assertStatus(200); - $response->assertJson(['id' => $userA->id]); + $this->assertEquals($userA->id, $json['id']); + $this->assertEquals($userA->email, $json['email']); + $this->assertTrue(is_array($json['statusInfo'])); + $this->assertTrue(is_array($json['settings'])); + $this->assertTrue(is_array($json['aliases'])); // Test unauthorized access to a profile of other user $user = $this->getTestUser('jack@kolab.org'); $response = $this->actingAs($user)->get("/api/v4/users/{$userA->id}"); $response->assertStatus(403); // TODO: Test authorized access to a profile of other user $this->markTestIncomplete(); } /** * Test user creation (POST /api/v4/users) */ public function testStore(): void { - // TODO - $this->markTestIncomplete(); + $jack = $this->getTestUser('jack@kolab.org'); + $john = $this->getTestUser('john@kolab.org'); + + // Test empty request + $response = $this->actingAs($john)->post("/api/v4/users", []); + $response->assertStatus(422); + + $json = $response->json(); + + $this->assertSame('error', $json['status']); + $this->assertSame("The email field is required.", $json['errors']['email']); + $this->assertSame("The password field is required.", $json['errors']['password'][0]); + $this->assertCount(2, $json); + + // Test access by user not being a wallet controller + $post = ['first_name' => 'Test']; + $response = $this->actingAs($jack)->post("/api/v4/users", $post); + $json = $response->json(); + + $response->assertStatus(403); + + $this->assertSame('error', $json['status']); + $this->assertSame("Access denied", $json['message']); + $this->assertCount(2, $json); + + // Test some invalid data + $post = ['password' => '12345678', 'email' => 'invalid']; + $response = $this->actingAs($john)->post("/api/v4/users", $post); + $response->assertStatus(422); + + $json = $response->json(); + + $this->assertSame('error', $json['status']); + $this->assertCount(2, $json); + $this->assertSame('The password confirmation does not match.', $json['errors']['password'][0]); + $this->assertSame('The specified email is invalid.', $json['errors']['email']); + + // Test existing user email + $post = [ + 'password' => 'simple', + 'password_confirmation' => 'simple', + 'first_name' => 'John2', + 'last_name' => 'Doe2', + 'email' => 'jack.daniels@kolab.org', + ]; + + $response = $this->actingAs($john)->post("/api/v4/users", $post); + $response->assertStatus(422); + + $json = $response->json(); + + $this->assertSame('error', $json['status']); + $this->assertCount(2, $json); + $this->assertSame('The specified email is not available.', $json['errors']['email']); + + // Test full user data + $post = [ + 'password' => 'simple', + 'password_confirmation' => 'simple', + 'first_name' => 'John2', + 'last_name' => 'Doe2', + 'email' => 'john2.doe2@kolab.org', + 'aliases' => ['useralias1@kolab.org', 'useralias2@kolab.org'] + ]; + + $response = $this->actingAs($john)->post("/api/v4/users", $post); + $json = $response->json(); + + $response->assertStatus(200); + + $this->assertSame('success', $json['status']); + $this->assertSame("User created successfully", $json['message']); + $this->assertCount(2, $json); + + $user = User::where('email', 'john2.doe2@kolab.org')->first(); + $this->assertInstanceOf(User::class, $user); + $this->assertSame('John2', $user->getSetting('first_name')); + $this->assertSame('Doe2', $user->getSetting('last_name')); + $aliases = $user->aliases()->orderBy('alias')->get(); + $this->assertCount(2, $aliases); + $this->assertSame('useralias1@kolab.org', $aliases[0]->alias); + $this->assertSame('useralias2@kolab.org', $aliases[1]->alias); + + // TODO: Test assigning a package to new user } /** * Test user update (PUT /api/v4/users/) */ public function testUpdate(): void { $userA = $this->getTestUser('UsersControllerTest1@userscontroller.com'); - $userB = $this->getTestUser('jack@kolab.org'); + $jack = $this->getTestUser('jack@kolab.org'); + $domain = $this->getTestDomain( + 'userscontroller.com', + ['status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_EXTERNAL] + ); // Test unauthorized update of other user profile - $response = $this->actingAs($userB)->get("/api/v4/users/{$userA->id}", []); + $response = $this->actingAs($jack)->get("/api/v4/users/{$userA->id}", []); $response->assertStatus(403); - // Test updating of self + // Test updating of self (empty request) $response = $this->actingAs($userA)->put("/api/v4/users/{$userA->id}", []); $response->assertStatus(200); $json = $response->json(); $this->assertSame('success', $json['status']); $this->assertSame("User data updated successfully", $json['message']); $this->assertCount(2, $json); // Test some invalid data $post = ['password' => '12345678', 'currency' => 'invalid']; $response = $this->actingAs($userA)->put("/api/v4/users/{$userA->id}", $post); $response->assertStatus(422); $json = $response->json(); $this->assertSame('error', $json['status']); $this->assertCount(2, $json); $this->assertSame('The password confirmation does not match.', $json['errors']['password'][0]); $this->assertSame('The currency must be 3 characters.', $json['errors']['currency'][0]); // Test full profile update including password $post = [ 'password' => 'simple', 'password_confirmation' => 'simple', 'first_name' => 'John2', 'last_name' => 'Doe2', 'phone' => '+123 123 123', 'external_email' => 'external@gmail.com', 'billing_address' => 'billing', 'country' => 'CH', 'currency' => 'CHF', + 'aliases' => ['useralias1@' . \config('app.domain'), 'useralias2@' . \config('app.domain')] ]; $response = $this->actingAs($userA)->put("/api/v4/users/{$userA->id}", $post); $json = $response->json(); $response->assertStatus(200); $this->assertSame('success', $json['status']); $this->assertSame("User data updated successfully", $json['message']); $this->assertCount(2, $json); $this->assertTrue($userA->password != $userA->fresh()->password); - unset($post['password'], $post['password_confirmation']); + unset($post['password'], $post['password_confirmation'], $post['aliases']); foreach ($post as $key => $value) { $this->assertSame($value, $userA->getSetting($key)); } + $aliases = $userA->aliases()->orderBy('alias')->get(); + $this->assertCount(2, $aliases); + $this->assertSame('useralias1@' . \config('app.domain'), $aliases[0]->alias); + $this->assertSame('useralias2@' . \config('app.domain'), $aliases[1]->alias); // Test unsetting values $post = [ 'first_name' => '', 'last_name' => '', 'phone' => '', 'external_email' => '', 'billing_address' => '', 'country' => '', 'currency' => '', + 'aliases' => ['useralias2@' . \config('app.domain')] ]; $response = $this->actingAs($userA)->put("/api/v4/users/{$userA->id}", $post); $json = $response->json(); $response->assertStatus(200); $this->assertSame('success', $json['status']); $this->assertSame("User data updated successfully", $json['message']); $this->assertCount(2, $json); + unset($post['aliases']); foreach ($post as $key => $value) { $this->assertNull($userA->getSetting($key)); } + $aliases = $userA->aliases()->get(); + $this->assertCount(1, $aliases); + $this->assertSame('useralias2@' . \config('app.domain'), $aliases[0]->alias); + + // Test error on setting an alias to other user's domain + // and missing password confirmation + $post = [ + 'password' => 'simple123', + 'aliases' => ['useralias2@' . \config('app.domain'), 'useralias1@kolab.org'] + ]; + + $response = $this->actingAs($userA)->put("/api/v4/users/{$userA->id}", $post); + $json = $response->json(); + + $response->assertStatus(422); + + $this->assertSame('error', $json['status']); + $this->assertCount(2, $json['errors']); + $this->assertCount(1, $json['errors']['aliases']); + $this->assertSame("The specified domain is not available.", $json['errors']['aliases'][1]); + $this->assertSame("The password confirmation does not match.", $json['errors']['password'][0]); + // TODO: Test error on aliases with invalid/non-existing/other-user's domain // TODO: Test authorized update of other user $this->markTestIncomplete(); } + + /** + * List of alias validation cases for testValidateEmail() + * + * @return array Arguments for testValidateEmail() + */ + public function dataValidateEmail(): array + { + $this->refreshApplication(); + $public_domains = Domain::getPublicDomains(); + $domain = reset($public_domains); + + $john = $this->getTestUser('john@kolab.org'); + $jack = $this->getTestUser('jack@kolab.org'); + $user = $this->getTestUser('UsersControllerTest1@userscontroller.com'); + + return [ + // Invalid format + ["$domain", $john, true, 'The specified alias is invalid.'], + [".@$domain", $john, true, 'The specified alias is invalid.'], + ["test123456@localhost", $john, true, 'The specified domain is invalid.'], + ["test123456@unknown-domain.org", $john, true, 'The specified domain is invalid.'], + + ["$domain", $john, false, 'The specified email is invalid.'], + [".@$domain", $john, false, 'The specified email is invalid.'], + + // forbidden local part on public domains + ["admin@$domain", $john, true, 'The specified alias is not available.'], + ["administrator@$domain", $john, true, 'The specified alias is not available.'], + + // forbidden (other user's domain) + ["testtest@kolab.org", $user, true, 'The specified domain is not available.'], + + // existing alias of other user + ["jack.daniels@kolab.org", $john, true, 'The specified alias is not available.'], + + // existing user + ["jack@kolab.org", $john, true, 'The specified alias is not available.'], + + // valid (user domain) + ["admin@kolab.org", $john, true, null], + + // valid (public domain) + ["test.test@$domain", $john, true, null], + ]; + } + + /** + * User email/alias validation. + * + * Note: Technically these include 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($alias, $user, $is_alias, $expected_result): void + { + $result = $this->invokeMethod(new UsersController(), 'validateEmail', [$alias, $user, $is_alias]); + + $this->assertSame($expected_result, $result); + } } diff --git a/src/tests/Feature/UserTest.php b/src/tests/Feature/UserTest.php index d2ae74ae..70d16970 100644 --- a/src/tests/Feature/UserTest.php +++ b/src/tests/Feature/UserTest.php @@ -1,164 +1,263 @@ deleteTestUser('user-create-test@' . \config('app.domain')); $this->deleteTestUser('UserAccountA@UserAccount.com'); $this->deleteTestUser('UserAccountB@UserAccount.com'); $this->deleteTestUser('userdeletejob@kolabnow.com'); } public function tearDown(): void { $this->deleteTestUser('user-create-test@' . \config('app.domain')); $this->deleteTestUser('UserAccountA@UserAccount.com'); $this->deleteTestUser('UserAccountB@UserAccount.com'); $this->deleteTestUser('userdeletejob@kolabnow.com'); parent::tearDown(); } + /** + * Tests for User::assignPackage() + */ + public function testAssignPackage(): void + { + $this->markTestIncomplete(); + } + + /** + * Tests for User::assignPlan() + */ + public function testAssignPlan(): void + { + $this->markTestIncomplete(); + } + /** * Verify user creation process */ public function testUserCreateJob(): void { // Fake the queue, assert that no jobs were pushed... Queue::fake(); Queue::assertNothingPushed(); $user = User::create([ 'email' => 'user-create-test@' . \config('app.domain') ]); Queue::assertPushed(\App\Jobs\UserCreate::class, 1); Queue::assertPushed(\App\Jobs\UserCreate::class, function ($job) use ($user) { $job_user = TestCase::getObjectProperty($job, 'user'); return $job_user->id === $user->id && $job_user->email === $user->email; }); Queue::assertPushedWithChain(\App\Jobs\UserCreate::class, [ \App\Jobs\UserVerify::class, ]); /* FIXME: Looks like we can't really do detailed assertions on chained jobs Another thing to consider is if we maybe should run these jobs independently (not chained) and make sure there's no race-condition in status update Queue::assertPushed(\App\Jobs\UserVerify::class, 1); Queue::assertPushed(\App\Jobs\UserVerify::class, function ($job) use ($user) { $job_user = TestCase::getObjectProperty($job, 'user'); return $job_user->id === $user->id && $job_user->email === $user->email; }); */ } /** * Verify a wallet assigned a controller is among the accounts of the assignee. */ public function testListUserAccounts(): void { $userA = $this->getTestUser('UserAccountA@UserAccount.com'); $userB = $this->getTestUser('UserAccountB@UserAccount.com'); $this->assertTrue($userA->wallets()->count() == 1); $userA->wallets()->each( function ($wallet) use ($userB) { $wallet->addController($userB); } ); $this->assertTrue($userB->accounts()->get()[0]->id === $userA->wallets()->get()[0]->id); } - public function testUserDomains(): void + /** + * Tests for User::controller() + */ + public function testController(): void + { + $john = $this->getTestUser('john@kolab.org'); + + $this->assertSame($john->id, $john->controller()->id); + + $jack = $this->getTestUser('jack@kolab.org'); + + $this->assertSame($john->id, $jack->controller()->id); + } + + /** + * Tests for User::domains() + */ + public function testDomains(): void { $user = $this->getTestUser('john@kolab.org'); $domains = []; foreach ($user->domains() as $domain) { $domains[] = $domain->namespace; } - $this->assertContains('kolabnow.com', $domains); + $this->assertContains(\config('app.domain'), $domains); $this->assertContains('kolab.org', $domains); + + // Jack is not the wallet controller, so for him the list should not + // include John's domains, kolab.org specifically + $user = $this->getTestUser('jack@kolab.org'); + $domains = []; + + foreach ($user->domains() as $domain) { + $domains[] = $domain->namespace; + } + + $this->assertContains(\config('app.domain'), $domains); + $this->assertNotContains('kolab.org', $domains); } public function testUserQuota(): void { $user = $this->getTestUser('john@kolab.org'); $storage_sku = \App\Sku::where('title', 'storage')->first(); $count = 0; foreach ($user->entitlements()->get() as $entitlement) { if ($entitlement->sku_id == $storage_sku->id) { $count += 1; } } $this->assertTrue($count == 2); } /** * Test user deletion */ public function testUserDelete(): void { $user = $this->getTestUser('userdeletejob@kolabnow.com'); $package = \App\Package::where('title', 'kolab')->first(); $user->assignPackage($package); $id = $user->id; $user->delete(); $job = new \App\Jobs\UserDelete($id); $job->handle(); $user->forceDelete(); $entitlements = \App\Entitlement::where('owner_id', 'id')->get(); $this->assertCount(0, $entitlements); } /** * Tests for User::findByEmail() */ public function testFindByEmail(): void { $user = $this->getTestUser('john@kolab.org'); $result = User::findByEmail('john'); $this->assertNull($result); $result = User::findByEmail('non-existing@email.com'); $this->assertNull($result); $result = User::findByEmail('john@kolab.org'); $this->assertInstanceOf(User::class, $result); $this->assertSame($user->id, $result->id); - // TODO: Make sure searching is case-insensitive - // TODO: Alias, eternal email + // Use an alias + $result = User::findByEmail('john.doe@kolab.org'); + $this->assertInstanceOf(User::class, $result); + $this->assertSame($user->id, $result->id); + + // TODO: searching by external email (setting) + $this->markTestIncomplete(); + } + + /** + * Tests for UserAliasesTrait::setAliases() + */ + public function testSetAliases(): void + { + Queue::fake(); + + $user = $this->getTestUser('UserAccountA@UserAccount.com'); + + $this->assertCount(0, $user->aliases->all()); + + // Add an alias + $user->setAliases(['UserAlias1@UserAccount.com']); + + $aliases = $user->aliases()->get(); + $this->assertCount(1, $aliases); + $this->assertSame('useralias1@useraccount.com', $aliases[0]['alias']); + + // Add another alias + $user->setAliases(['UserAlias1@UserAccount.com', 'UserAlias2@UserAccount.com']); + + $aliases = $user->aliases()->orderBy('alias')->get(); + $this->assertCount(2, $aliases); + $this->assertSame('useralias1@useraccount.com', $aliases[0]->alias); + $this->assertSame('useralias2@useraccount.com', $aliases[1]->alias); + + // Remove an alias + $user->setAliases(['UserAlias1@UserAccount.com']); + + $aliases = $user->aliases()->get(); + $this->assertCount(1, $aliases); + $this->assertSame('useralias1@useraccount.com', $aliases[0]['alias']); + + // Remove all aliases + $user->setAliases([]); + + $this->assertCount(0, $user->aliases()->get()); + + // TODO: Test that the changes are propagated to ldap + } + + /** + * Tests for UserSettingsTrait::setSettings() + */ + public function testSetSettings(): void + { + $this->markTestIncomplete(); } } diff --git a/src/tests/TestCase.php b/src/tests/TestCase.php index e815449b..499d8cb2 100644 --- a/src/tests/TestCase.php +++ b/src/tests/TestCase.php @@ -1,76 +1,94 @@ where('namespace', $name)->first(); if (!$domain) { return; } $job = new \App\Jobs\DomainDelete($domain); $job->handle(); $domain->forceDelete(); } protected function deleteTestUser($email) { Queue::fake(); $user = User::withTrashed()->where('email', $email)->first(); if (!$user) { return; } $job = new \App\Jobs\UserDelete($user->id); $job->handle(); $user->forceDelete(); } /** * Get Domain object by namespace, create it if needed. * Skip LDAP jobs. */ protected function getTestDomain($name, $attrib = []) { // Disable jobs (i.e. skip LDAP oprations) Queue::fake(); return Domain::firstOrCreate(['namespace' => $name], $attrib); } /** * Get User object by email, create it if needed. * Skip LDAP jobs. */ protected function getTestUser($email, $attrib = []) { // Disable jobs (i.e. skip LDAP oprations) Queue::fake(); return User::firstOrCreate(['email' => $email], $attrib); } /** * 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); } + + /** + * 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. + */ + public function invokeMethod($object, $methodName, array $parameters = array()) + { + $reflection = new \ReflectionClass(get_class($object)); + $method = $reflection->getMethod($methodName); + $method->setAccessible(true); + + return $method->invokeArgs($object, $parameters); + } } diff --git a/src/tests/Unit/Rules/ExternalEmailTest.php b/src/tests/Unit/Rules/ExternalEmailTest.php new file mode 100644 index 00000000..ddd33c10 --- /dev/null +++ b/src/tests/Unit/Rules/ExternalEmailTest.php @@ -0,0 +1,52 @@ + $email], + ['email' => [new ExternalEmail()]] + ); + + $result = null; + if ($v->fails()) { + $result = $v->errors()->toArray()['email'][0]; + } + + $this->assertSame($expected_result, $result); + } +} diff --git a/src/tests/Unit/Rules/UserEmailDomainTest.php b/src/tests/Unit/Rules/UserEmailDomainTest.php new file mode 100644 index 00000000..559d337c --- /dev/null +++ b/src/tests/Unit/Rules/UserEmailDomainTest.php @@ -0,0 +1,18 @@ +markTestIncomplete(); + } +} diff --git a/src/tests/Unit/Rules/UserEmailLocalTest.php b/src/tests/Unit/Rules/UserEmailLocalTest.php new file mode 100644 index 00000000..8476a8fb --- /dev/null +++ b/src/tests/Unit/Rules/UserEmailLocalTest.php @@ -0,0 +1,18 @@ +markTestIncomplete(); + } +}