diff --git a/src/app/Http/Controllers/API/SignupController.php b/src/app/Http/Controllers/API/SignupController.php index c18f1e9b..b6e1fb62 100644 --- a/src/app/Http/Controllers/API/SignupController.php +++ b/src/app/Http/Controllers/API/SignupController.php @@ -1,380 +1,380 @@ orderByDesc('title')->get()->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', 'first_name' => 'max:128', 'last_name' => 'max:128', 'plan' => 'nullable|alpha_num|max:128', 'voucher' => 'max:32', ] ); $is_phone = false; $errors = $v->fails() ? $v->errors()->toArray() : []; // Validate user email (or phone) if (empty($errors['email'])) { if ($error = $this->validatePhoneOrEmail($request->email, $is_phone)) { $errors['email'] = $error; } } if (!empty($errors)) { return response()->json(['status' => 'error', 'errors' => $errors], 422); } // Generate the verification code $code = SignupCode::create([ 'data' => [ 'email' => $request->email, 'first_name' => $request->first_name, 'last_name' => $request->last_name, 'plan' => $request->plan, 'voucher' => $request->voucher, ] ]); // 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/voucher from the codes database, // domains list for selection and "plan type" flag return response()->json([ 'status' => 'success', 'email' => $code->data['email'], 'first_name' => $code->data['first_name'], 'last_name' => $code->data['last_name'], 'voucher' => $code->data['voucher'], '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', 'voucher' => 'max:32', ] ); 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; } // Find the voucher discount if ($request->voucher) { $discount = Discount::where('code', \strtoupper($request->voucher)) ->where('active', true)->first(); if (!$discount) { $errors = ['voucher' => \trans('validation.voucherinvalid')]; return response()->json(['status' => 'error', 'errors' => $errors], 422); } } // Get the plan $plan = $this->getPlan(); $is_domain = $plan->hasDomain(); $login = $request->login; $domain = $request->domain; // Validate login 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_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([ '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, ]); } if (!empty($discount)) { $wallet = $user->wallets()->first(); $wallet->discount()->associate($discount); $wallet->save(); } $user->assignPlan($plan, $domain); // Save the external email and plan in user settings $user->setSettings([ 'external_email' => $user_email, 'first_name' => $code_data->first_name, 'last_name' => $code_data->last_name, ]); // Remove the verification code $this->code->delete(); DB::commit(); return AuthController::logonResponse($user); } /** * 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; } /** * 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 * * @return string Error message on validation error */ protected static function validatePhoneOrEmail($input, &$is_phone = false): ?string { $is_phone = false; $v = Validator::make( ['email' => $input], ['email' => ['required', 'string', new ExternalEmail()]] ); if ($v->fails()) { return $v->errors()->toArray()['email'][0]; } // TODO: Phone number support /* $input = str_replace(array('-', ' '), '', $input); 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 static function validateLogin($login, $domain, $external = false): ?array { // Validate login part alone $v = Validator::make( ['login' => $login], ['login' => ['required', 'string', new UserEmailLocal($external)]] ); if ($v->fails()) { return ['login' => $v->errors()->toArray()['login'][0]]; } $domains = $external ? null : Domain::getPublicDomains(); // Validate the domain $v = Validator::make( ['domain' => $domain], ['domain' => ['required', 'string', new UserEmailDomain($domains)]] ); if ($v->fails()) { return ['domain' => $v->errors()->toArray()['domain'][0]]; } $domain = Str::lower($domain); // Check if domain is already registered with us if ($external) { if (Domain::where('namespace', $domain)->first()) { return ['domain' => \trans('validation.domainexists')]; } } // Check if user with specified login already exists $email = $login . '@' . $domain; - if (User::findByEmail($email)) { + if (User::emailExists($email)) { return ['login' => \trans('validation.loginexists')]; } return null; } } diff --git a/src/app/Http/Controllers/API/V4/Admin/UsersController.php b/src/app/Http/Controllers/API/V4/Admin/UsersController.php index e55ebeda..f96c0a6d 100644 --- a/src/app/Http/Controllers/API/V4/Admin/UsersController.php +++ b/src/app/Http/Controllers/API/V4/Admin/UsersController.php @@ -1,162 +1,168 @@ input('search')); $owner = trim(request()->input('owner')); $result = collect([]); if ($owner) { if ($owner = User::find($owner)) { $result = $owner->users(false)->orderBy('email')->get(); } } elseif (strpos($search, '@')) { // Search by email - if ($user = User::findByEmail($search, false)) { + $user = User::where('email', $search)->first(); + if ($user) { $result->push($user); } else { - // Search by an external email - // TODO: This is not optimal (external email should be in users table) - $user_ids = UserSetting::where('key', 'external_email')->where('value', $search) - ->get()->pluck('user_id'); + // Search by an alias + $user_ids = UserAlias::where('alias', $search)->get()->pluck('user_id'); + if ($user_ids->isEmpty()) { + // Search by an external email + $user_ids = UserSetting::where('key', 'external_email') + ->where('value', $search)->get()->pluck('user_id'); + } - // TODO: Sort order - $result = User::find($user_ids); + if (!$user_ids->isEmpty()) { + $result = User::whereIn('id', $user_ids)->orderBy('email')->get(); + } } } elseif (is_numeric($search)) { // Search by user ID if ($user = User::find($search)) { $result->push($user); } } elseif (!empty($search)) { // Search by domain if ($domain = Domain::where('namespace', $search)->first()) { if ($wallet = $domain->wallet()) { $result->push($wallet->owner); } } } // Process the result $result = $result->map(function ($user) { $data = $user->toArray(); $data = array_merge($data, self::userStatuses($user)); return $data; }); $result = [ 'list' => $result, 'count' => count($result), 'message' => \trans('app.search-foundxusers', ['x' => count($result)]), ]; return response()->json($result); } /** * Suspend the user * * @param \Illuminate\Http\Request $request The API request. * @params string $id User identifier * * @return \Illuminate\Http\JsonResponse The response */ public function suspend(Request $request, $id) { $user = User::find($id); if (empty($user)) { return $this->errorResponse(404); } $user->suspend(); return response()->json([ 'status' => 'success', 'message' => __('app.user-suspend-success'), ]); } /** * Un-Suspend the user * * @param \Illuminate\Http\Request $request The API request. * @params string $id User identifier * * @return \Illuminate\Http\JsonResponse The response */ public function unsuspend(Request $request, $id) { $user = User::find($id); if (empty($user)) { return $this->errorResponse(404); } $user->unsuspend(); return response()->json([ 'status' => 'success', 'message' => __('app.user-unsuspend-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) { $user = User::find($id); if (empty($user)) { return $this->errorResponse(404); } // For now admins can change only user external email address $rules = []; if (array_key_exists('external_email', $request->input())) { $rules['external_email'] = 'email'; } // 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)); if (!empty($settings)) { $user->setSettings($settings); } return response()->json([ 'status' => 'success', 'message' => __('app.user-update-success'), ]); } } diff --git a/src/app/Http/Controllers/API/V4/UsersController.php b/src/app/Http/Controllers/API/V4/UsersController.php index 608a9c02..db065ca6 100644 --- a/src/app/Http/Controllers/API/V4/UsersController.php +++ b/src/app/Http/Controllers/API/V4/UsersController.php @@ -1,601 +1,671 @@ errorResponse(404); } // User can't remove himself until he's the controller if (!$this->guard()->user()->canDelete($user)) { return $this->errorResponse(403); } $user->delete(); return response()->json([ 'status' => 'success', 'message' => __('app.user-delete-success'), ]); } /** * Listing of users. * * The user-entitlements billed to the current user wallet(s) * * @return \Illuminate\Http\JsonResponse */ public function index() { $user = $this->guard()->user(); $result = $user->users()->orderBy('email')->get()->map(function ($user) { $data = $user->toArray(); $data = array_merge($data, self::userStatuses($user)); return $data; }); return response()->json($result); } /** * Display information on the user account specified by $id. * * @param int $id The account to show information for. * * @return \Illuminate\Http\JsonResponse */ public function show($id) { $user = User::find($id); if (empty($user)) { return $this->errorResponse(404); } if (!$this->guard()->user()->canRead($user)) { return $this->errorResponse(403); } $response = $this->userResponse($user); // Simplified Entitlement/SKU information, // TODO: I agree this format may need to be extended in future $response['skus'] = []; foreach ($user->entitlements as $ent) { $sku = $ent->sku; $response['skus'][$sku->id] = [ // 'cost' => $ent->cost, 'count' => isset($response['skus'][$sku->id]) ? $response['skus'][$sku->id]['count'] + 1 : 1, ]; } return response()->json($response); } /** * Fetch user status (and reload setup process) * * @param int $id User identifier * * @return \Illuminate\Http\JsonResponse */ public function status($id) { $user = User::find($id); if (empty($user)) { return $this->errorResponse(404); } if (!$this->guard()->user()->canRead($user)) { return $this->errorResponse(403); } $response = self::statusInfo($user); if (!empty(request()->input('refresh'))) { $updated = false; $last_step = 'none'; foreach ($response['process'] as $idx => $step) { $last_step = $step['label']; if (!$step['state']) { if (!$this->execProcessStep($user, $step['label'])) { break; } $updated = true; } } if ($updated) { $response = self::statusInfo($user); } $success = $response['isReady']; $suffix = $success ? 'success' : 'error-' . $last_step; $response['status'] = $success ? 'success' : 'error'; $response['message'] = \trans('app.process-' . $suffix); } $response = array_merge($response, self::userStatuses($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 { $process = []; $steps = [ 'user-new' => true, 'user-ldap-ready' => $user->isLdapReady(), 'user-imap-ready' => $user->isImapReady(), ]; // Create a process check list foreach ($steps as $step_name => $state) { $step = [ 'label' => $step_name, 'title' => \trans("app.process-{$step_name}"), 'state' => $state, ]; $process[] = $step; } 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 && !$domain->isPublic()) { $domain_status = DomainsController::statusInfo($domain); $process = array_merge($process, $domain_status['process']); } $all = count($process); $checked = count(array_filter($process, function ($v) { return $v['state']; })); $state = $all === $checked ? 'done' : 'running'; // After 180 seconds assume the process is in failed state, // this should unlock the Refresh button in the UI if ($all !== $checked && $user->created_at->diffInSeconds(Carbon::now()) > 180) { $state = 'failed'; } return [ 'process' => $process, 'processState' => $state, 'isReady' => $all === $checked, ]; } /** * Create a new user record. * * @param \Illuminate\Http\Request $request The API request. * * @return \Illuminate\Http\JsonResponse The response */ public function store(Request $request) { $current_user = $this->guard()->user(); $owner = $current_user->wallet()->owner; if ($owner->id != $current_user->id) { return $this->errorResponse(403); } if ($error_response = $this->validateUserRequest($request, null, $settings)) { return $error_response; } if (empty($request->package) || !($package = \App\Package::find($request->package))) { $errors = ['package' => \trans('validation.packagerequired')]; return response()->json(['status' => 'error', 'errors' => $errors], 422); } if ($package->isDomain()) { $errors = ['package' => \trans('validation.packageinvalid')]; return response()->json(['status' => 'error', 'errors' => $errors], 422); } DB::beginTransaction(); // Create user record $user = User::create([ 'email' => $request->email, 'password' => $request->password, ]); $owner->assignPackage($package, $user); if (!empty($settings)) { $user->setSettings($settings); } if (!empty($request->aliases)) { $user->setAliases($request->aliases); } DB::commit(); return response()->json([ 'status' => 'success', 'message' => __('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) { $user = User::find($id); if (empty($user)) { return $this->errorResponse(404); } $current_user = $this->guard()->user(); // TODO: Decide what attributes a user can change on his own profile if (!$current_user->canUpdate($user)) { return $this->errorResponse(403); } if ($error_response = $this->validateUserRequest($request, $user, $settings)) { return $error_response; } // Entitlements, only controller can do that if ($request->skus !== null && !$current_user->canDelete($user)) { return $this->errorResponse(422, "You have no permission to change entitlements"); } DB::beginTransaction(); $this->updateEntitlements($user, $request->skus); if (!empty($settings)) { $user->setSettings($settings); } if (!empty($request->password)) { $user->password = $request->password; $user->save(); } if (isset($request->aliases)) { $user->setAliases($request->aliases); } // TODO: Make sure that UserUpdate job is created in case of entitlements update // and no password change. So, for example quota change is applied to LDAP // TODO: Review use of $user->save() in the above context 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(); } /** * Update user entitlements. * * @param \App\User $user The user * @param array $rSkus List of SKU IDs requested for the user in the form [id=>qty] */ protected function updateEntitlements(User $user, $rSkus) { if (!is_array($rSkus)) { return; } // list of skus, [id=>obj] $skus = Sku::all()->mapWithKeys( function ($sku) { return [$sku->id => $sku]; } ); // existing entitlement's SKUs $eSkus = []; $user->entitlements()->groupBy('sku_id') ->selectRaw('count(*) as total, sku_id')->each( function ($e) use (&$eSkus) { $eSkus[$e->sku_id] = $e->total; } ); foreach ($skus as $skuID => $sku) { $e = array_key_exists($skuID, $eSkus) ? $eSkus[$skuID] : 0; $r = array_key_exists($skuID, $rSkus) ? $rSkus[$skuID] : 0; if ($sku->handler_class == \App\Handlers\Mailbox::class) { if ($r != 1) { throw new \Exception("Invalid quantity of mailboxes"); } } if ($e > $r) { // remove those entitled more than existing $user->removeSku($sku, ($e - $r)); } elseif ($e < $r) { // add those requested more than entitled $user->assignSku($sku, ($r - $e)); } } } /** * Create a response data array for specified user. * * @param \App\User $user User object * * @return array Response data */ public static function userResponse(User $user): array { $response = $user->toArray(); // Settings $response['settings'] = []; foreach ($user->settings()->whereIn('key', self::USER_SETTINGS)->get() 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); $response = array_merge($response, self::userStatuses($user)); // Add more info to the wallet object output $map_func = function ($wallet) use ($user) { $result = $wallet->toArray(); if ($wallet->discount) { $result['discount'] = $wallet->discount->discount; $result['discount_description'] = $wallet->discount->description; } if ($wallet->user_id != $user->id) { $result['user_email'] = $wallet->owner->email; } $provider = \App\Providers\PaymentProvider::factory($wallet); $result['provider'] = $provider->name(); return $result; }; // Information about wallets and accounts for access checks $response['wallets'] = $user->wallets->map($map_func)->toArray(); $response['accounts'] = $user->accounts->map($map_func)->toArray(); $response['wallet'] = $map_func($user->wallet()); return $response; } /** * Prepare user statuses for the UI * * @param \App\User $user User object * * @return array Statuses array */ protected static function userStatuses(User $user): array { return [ 'isImapReady' => $user->isImapReady(), 'isLdapReady' => $user->isLdapReady(), 'isSuspended' => $user->isSuspended(), 'isActive' => $user->isActive(), 'isDeleted' => $user->isDeleted() || $user->trashed(), ]; } /** * Validate user input * * @param \Illuminate\Http\Request $request The API request. * @param \App\User|null $user User identifier * @param array $settings User settings (from the request) * * @return \Illuminate\Http\JsonResponse|null The error response on error */ protected function validateUserRequest(Request $request, $user, &$settings = []) { $rules = [ 'external_email' => 'nullable|email', 'phone' => 'string|nullable|max:64|regex:/^[0-9+() -]+$/', 'first_name' => 'string|nullable|max:128', 'last_name' => 'string|nullable|max:128', 'organization' => 'string|nullable|max:512', 'billing_address' => 'string|nullable|max:1024', 'country' => 'string|nullable|alpha|size:2', 'currency' => 'string|nullable|alpha|size:3', 'aliases' => 'array|nullable', ]; 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->wallet()->owner : $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 = \App\Utils::validateEmail($email, $controller, false)) { + } 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 = \App\Utils::validateEmail($alias, $controller, true)) + && ($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']); return null; } /** * Execute (synchronously) specified step in a user setup process. * * @param \App\User $user User object * @param string $step Step identifier (as in self::statusInfo()) * * @return bool True if the execution succeeded, False otherwise */ public static function execProcessStep(User $user, string $step): bool { try { if (strpos($step, 'domain-') === 0) { list ($local, $domain) = explode('@', $user->email); $domain = Domain::where('namespace', $domain)->first(); return DomainsController::execProcessStep($domain, $step); } switch ($step) { case 'user-ldap-ready': // User not in LDAP, create it $job = new \App\Jobs\UserCreate($user); $job->handle(); return $user->isLdapReady(); case 'user-imap-ready': // User not in IMAP? Verify again $job = new \App\Jobs\UserVerify($user); $job->handle(); return $user->isImapReady(); } } catch (\Exception $e) { \Log::error($e); } return false; } + + /** + * 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 + */ + public static function validateEmail( + string $email, + \App\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 a user/alias with specified address already exists + // Allow assigning the same alias to a user in the same group account, + // but only for non-public domains + if ($exists = User::emailExists($email, true, $alias_exists)) { + if ( + !$is_alias + || !$alias_exists + || $domain->isPublic() + || $exists->wallet()->user_id != $user->id + ) { + return \trans('validation.entryexists', ['attribute' => $attribute]); + } + } + + return null; + } } diff --git a/src/app/Observers/UserAliasObserver.php b/src/app/Observers/UserAliasObserver.php index 0ec760e9..d246b1dc 100644 --- a/src/app/Observers/UserAliasObserver.php +++ b/src/app/Observers/UserAliasObserver.php @@ -1,66 +1,89 @@ alias = \strtolower($alias->alias); - if (User::where('email', $alias->alias)->first()) { - \Log::error("Failed creating alias {$alias->alias}. User exists."); - return false; + if ($exists = User::emailExists($alias->alias, true, $alias_exists)) { + if (!$alias_exists) { + \Log::error("Failed creating alias {$alias->alias}. Email address exists."); + return false; + } + + list($login, $domain) = explode('@', $alias->alias); + + $domain = Domain::where('namespace', $domain)->first(); + + if (!$domain || $domain->isPublic()) { + \Log::error("Failed creating alias {$alias->alias}. Alias exists in public domain."); + return false; + } + + if ($exists->wallet()->user_id != $alias->user->wallet()->user_id) { + \Log::error("Failed creating alias {$alias->alias}. Alias exists in another account."); + return false; + } } return true; } /** * 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); + if ($alias->user) { + \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); + if ($alias->user) { + \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); + if ($alias->user) { + \App\Jobs\UserUpdate::dispatch($alias->user); + } } } diff --git a/src/app/User.php b/src/app/User.php index de65365b..be68eb1b 100644 --- a/src/app/User.php +++ b/src/app/User.php @@ -1,659 +1,695 @@ 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()->first()->id; foreach ($package->skus as $sku) { for ($i = $sku->pivot->qty; $i > 0; $i--) { \App\Entitlement::create( [ 'wallet_id' => $wallet_id, 'sku_id' => $sku->id, 'cost' => $sku->pivot->cost(), 'entitleable_id' => $user->id, 'entitleable_type' => User::class ] ); } } return $user; } /** * Assign a package plan to a user. * * @param \App\Plan $plan The plan to assign * @param \App\Domain $domain Optional domain object * * @return \App\User Self */ public function assignPlan($plan, $domain = null): User { $this->setSetting('plan_id', $plan->id); foreach ($plan->packages as $package) { if ($package->isDomain()) { $domain->assignPackage($package, $this); } else { $this->assignPackage($package); } } return $this; } /** * Assign a Sku to a user. * * @param \App\Sku $sku The sku to assign. * @param int $count Count of entitlements to add * * @return \App\User Self * @throws \Exception */ public function assignSku(Sku $sku, int $count = 1): User { // TODO: I guess wallet could be parametrized in future $wallet = $this->wallet(); $exists = $this->entitlements()->where('sku_id', $sku->id)->count(); // TODO: Sanity check, this probably should be in preReq() on handlers // or in EntitlementObserver if ($sku->handler_class::entitleableClass() != User::class) { throw new \Exception("Cannot assign non-user SKU ({$sku->title}) to a user"); } while ($count > 0) { \App\Entitlement::create([ 'wallet_id' => $wallet->id, 'sku_id' => $sku->id, 'cost' => $exists >= $sku->units_free ? $sku->cost : 0, 'entitleable_id' => $this->id, 'entitleable_type' => User::class ]); $exists++; $count--; } return $this; } /** * Check if current user can delete another object. * * @param \App\User|\App\Domain $object A user|domain object * * @return bool True if he can, False otherwise */ public function canDelete($object): bool { if (!method_exists($object, 'wallet')) { return false; } $wallet = $object->wallet(); // TODO: For now controller can delete/update the account owner, // this may change in future, controllers are not 0-regression feature return $this->wallets->contains($wallet) || $this->accounts->contains($wallet); } /** * Check if current user can read data of another object. * * @param \App\User|\App\Domain|\App\Wallet $object A user|domain|wallet object * * @return bool True if he can, False otherwise */ public function canRead($object): bool { if ($this->role == "admin") { return true; } if ($object instanceof User && $this->id == $object->id) { return true; } if ($object instanceof Wallet) { return $object->user_id == $this->id || $object->controllers->contains($this); } if (!method_exists($object, 'wallet')) { return false; } $wallet = $object->wallet(); return $this->wallets->contains($wallet) || $this->accounts->contains($wallet); } /** * Check if current user can update data of another object. * * @param \App\User|\App\Domain $object A user|domain object * * @return bool True if he can, False otherwise */ public function canUpdate($object): bool { if (!method_exists($object, 'wallet')) { return false; } if ($object instanceof User && $this->id == $object->id) { return true; } return $this->canDelete($object); } /** * 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; } foreach ($this->wallets as $wallet) { $entitlements = $wallet->entitlements()->where('entitleable_type', Domain::class)->get(); foreach ($entitlements as $entitlement) { $domain = $entitlement->entitleable; \Log::info("Found domain for {$this->email}: {$domain->namespace} (owned)"); $domains[] = $domain; } } foreach ($this->accounts as $wallet) { $entitlements = $wallet->entitlements()->where('entitleable_type', Domain::class)->get(); foreach ($entitlements as $entitlement) { $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); } } + /** + * Find whether an email address exists (user or alias). + * Note: This will also find deleted users. + * + * @param string $email Email address + * @param bool $return_user Return User instance instead of boolean + * @param bool $is_alias Set to True if the existing email is an alias + * + * @return \App\User|bool True or User model object if found, False otherwise + */ + public static function emailExists(string $email, bool $return_user = false, &$is_alias = false) + { + if (strpos($email, '@') === false) { + return false; + } + + $email = \strtolower($email); + + $user = self::withTrashed()->where('email', $email)->first(); + + if ($user) { + return $return_user ? $user : true; + } + + $alias = UserAlias::where('alias', $email)->first(); + + if ($alias) { + $is_alias = true; + return $return_user ? User::withTrashed()->find($alias->user_id) : true; + } + + return false; + } + /** * Helper to find user by email address, whether it is - * main email address, alias or external email + * main email address, alias or an external email. + * + * If there's more than one alias NULL will be returned. * * @param string $email Email address - * @param bool $external Search also by an external email + * @param bool $external Search also for an external email * * @return \App\User User model object if found */ public static function findByEmail(string $email, bool $external = false): ?User { if (strpos($email, '@') === false) { return null; } $email = \strtolower($email); $user = self::where('email', $email)->first(); if ($user) { return $user; } - $alias = UserAlias::where('alias', $email)->first(); + $aliases = UserAlias::where('alias', $email)->get(); - if ($alias) { - return $alias->user; + if (count($aliases) == 1) { + return $aliases->first()->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; } /** * A shortcut to get the user name. * * @param bool $fallback Return " User" if there's no name * * @return string Full user name */ public function name(bool $fallback = false): string { $firstname = $this->getSetting('first_name'); $lastname = $this->getSetting('last_name'); $name = trim($firstname . ' ' . $lastname); if (empty($name) && $fallback) { return \config('app.name') . ' User'; } return $name; } /** * Remove a number of entitlements for the SKU. * * @param \App\Sku $sku The SKU * @param int $count The number of entitlements to remove * * @return User Self */ public function removeSku(Sku $sku, int $count = 1): User { $entitlements = $this->entitlements() ->where('sku_id', $sku->id) ->orderBy('cost', 'desc') ->orderBy('created_at') ->get(); $entitlements_count = count($entitlements); foreach ($entitlements as $entitlement) { if ($entitlements_count <= $sku->units_free) { continue; } if ($count > 0) { $entitlement->delete(); $entitlements_count--; $count--; } } return $this; } /** * Any (additional) properties of this user. * * @return \Illuminate\Database\Eloquent\Relations\HasMany */ public function settings() { return $this->hasMany('App\UserSetting', 'user_id'); } /** * Suspend this domain. * * @return void */ public function suspend(): void { if ($this->isSuspended()) { return; } $this->status |= User::STATUS_SUSPENDED; $this->save(); } /** * Unsuspend this domain. * * @return void */ public function unsuspend(): void { if (!$this->isSuspended()) { return; } $this->status ^= User::STATUS_SUSPENDED; $this->save(); } /** * Return users controlled by the current user. * * @param bool $with_accounts Include users assigned to wallets * the current user controls but not owns. * * @return \Illuminate\Database\Eloquent\Builder Query builder */ public function users($with_accounts = true) { $wallets = $this->wallets()->pluck('id')->all(); if ($with_accounts) { $wallets = array_merge($wallets, $this->accounts()->pluck('wallet_id')->all()); } return $this->select(['users.*', 'entitlements.wallet_id']) ->distinct() ->leftJoin('entitlements', 'entitlements.entitleable_id', '=', 'users.id') ->whereIn('entitlements.wallet_id', $wallets) ->where('entitlements.entitleable_type', 'App\User'); } /** * Verification codes for this user. * * @return \Illuminate\Database\Eloquent\Relations\HasMany */ public function verificationcodes() { return $this->hasMany('App\VerificationCode', 'user_id', 'id'); } /** * Returns the wallet by which the user is controlled * * @return \App\Wallet A wallet object */ public function wallet(): Wallet { $entitlement = $this->entitlement()->first(); // TODO: No entitlement should not happen, but in tests we have // such cases, so we fallback to the user's wallet in this case return $entitlement ? $entitlement->wallet : $this->wallets()->first(); } /** * 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/Utils.php b/src/app/Utils.php index d6818bcc..e0ef12dc 100644 --- a/src/app/Utils.php +++ b/src/app/Utils.php @@ -1,219 +1,155 @@ diffInDays($end) + 1; } /** * Provide all unique combinations of elements in $input, with order and duplicates irrelevant. * * @param array $input The input array of elements. * * @return array[] */ public static function powerSet(array $input): array { $output = []; for ($x = 0; $x < count($input); $x++) { self::combine($input, $x + 1, 0, [], 0, $output); } return $output; } /** * Returns the current user's email address or null. * * @return string */ public static function userEmailOrNull(): ?string { $user = Auth::user(); if (!$user) { return null; } return $user->email; } /** * Returns a UUID in the form of an integer. * * @return integer */ public static function uuidInt(): int { $hex = Uuid::uuid4(); $bin = pack('h*', str_replace('-', '', $hex)); $ids = unpack('L', $bin); $id = array_shift($ids); return $id; } /** * Returns a UUID in the form of a string. * * @return string */ public static function uuidStr(): string { return Uuid::uuid4()->toString(); } private static function combine($input, $r, $index, $data, $i, &$output): void { $n = count($input); // Current cobination is ready if ($index == $r) { $output[] = array_slice($data, 0, $r); return; } // When no more elements are there to put in data[] if ($i >= $n) { return; } // current is included, put next at next location $data[$index] = $input[$i]; self::combine($input, $r, $index + 1, $data, $i + 1, $output); // current is excluded, replace it with next (Note that i+1 // is passed, but index is not changed) self::combine($input, $r, $index, $data, $i + 1, $output); } /** * Create self URL * * @param string $route Route/Path * * @return string Full URL */ public static function serviceUrl(string $route): string { $url = \url($route); $app_url = trim(\config('app.url'), '/'); $pub_url = trim(\config('app.public_url'), '/'); if ($pub_url != $app_url) { $url = str_replace($app_url, $pub_url, $url); } return $url; } /** * Create a configuration/environment data to be passed to * the UI * * @todo For a lack of better place this is put here for now * * @return array Configuration data */ public static function uiEnv(): array { $opts = ['app.name', 'app.url', 'app.domain']; $env = \app('config')->getMany($opts); $countries = include resource_path('countries.php'); $env['countries'] = $countries ?: []; $isAdmin = strpos(request()->getHttpHost(), 'admin.') === 0; $env['jsapp'] = $isAdmin ? 'admin.js' : 'user.js'; $env['paymentProvider'] = \config('services.payment_provider'); $env['stripePK'] = \config('services.stripe.public_key'); return $env; } - - /** - * 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 - */ - public static function validateEmail( - string $email, - \App\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/database/migrations/2020_06_18_150000_update_user_aliases_unique_key.php b/src/database/migrations/2020_06_18_150000_update_user_aliases_unique_key.php new file mode 100644 index 00000000..8088fb71 --- /dev/null +++ b/src/database/migrations/2020_06_18_150000_update_user_aliases_unique_key.php @@ -0,0 +1,33 @@ +dropUnique(['alias']); + $table->unique(['alias', 'user_id']); + } + ); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + } +} diff --git a/src/database/seeds/local/UserSeeder.php b/src/database/seeds/local/UserSeeder.php index f36dde3c..f4a34258 100644 --- a/src/database/seeds/local/UserSeeder.php +++ b/src/database/seeds/local/UserSeeder.php @@ -1,141 +1,143 @@ 'kolab.org', 'status' => Domain::STATUS_NEW + Domain::STATUS_ACTIVE + Domain::STATUS_CONFIRMED + Domain::STATUS_VERIFIED, 'type' => Domain::TYPE_EXTERNAL ] ); $john = User::create( [ 'email' => 'john@kolab.org', 'password' => 'simple123', ] ); $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', 'organization' => 'Kolab Developers', 'phone' => '+1 509-248-1111', ] ); $john->setAliases(['john.doe@kolab.org']); $wallet = $john->wallets->first(); $package_domain = \App\Package::where('title', 'domain-hosting')->first(); $package_kolab = \App\Package::where('title', 'kolab')->first(); $package_lite = \App\Package::where('title', 'lite')->first(); $domain->assignPackage($package_domain, $john); $john->assignPackage($package_kolab); $jack = User::create( [ 'email' => 'jack@kolab.org', 'password' => 'simple123', ] ); $jack->setSettings( [ 'first_name' => 'Jack', 'last_name' => 'Daniels', 'currency' => 'USD', 'country' => 'US' ] ); $jack->setAliases(['jack.daniels@kolab.org']); $john->assignPackage($package_kolab, $jack); foreach ($john->entitlements as $entitlement) { $entitlement->created_at = Carbon::now()->subMonthsWithoutOverflow(1); $entitlement->updated_at = Carbon::now()->subMonthsWithoutOverflow(1); $entitlement->save(); } $ned = User::create( [ 'email' => 'ned@kolab.org', 'password' => 'simple123', ] ); $ned->setSettings( [ 'first_name' => 'Edward', 'last_name' => 'Flanders', 'currency' => 'USD', 'country' => 'US' ] ); $john->assignPackage($package_kolab, $ned); $ned->assignSku(\App\Sku::where('title', 'activesync')->first(), 1); // Ned is a controller on Jack's wallet $john->wallets()->first()->addController($ned); // Ned is also our 2FA test user $sku2fa = Sku::firstOrCreate(['title' => '2fa']); $ned->assignSku($sku2fa); SecondFactor::seed('ned@kolab.org'); $joe = User::create( [ 'email' => 'joe@kolab.org', 'password' => 'simple123', ] ); $john->assignPackage($package_lite, $joe); + $joe->setAliases(['joe.monster@kolab.org']); + factory(User::class, 10)->create(); $jeroen = User::create( [ 'email' => 'jeroen@jeroen.jeroen', 'password' => 'jeroen', ] ); $jeroen->role = "admin"; $jeroen->save(); } } diff --git a/src/tests/Feature/Controller/UsersTest.php b/src/tests/Feature/Controller/UsersTest.php index e9a2816b..81ffc966 100644 --- a/src/tests/Feature/Controller/UsersTest.php +++ b/src/tests/Feature/Controller/UsersTest.php @@ -1,1001 +1,1005 @@ deleteTestUser('jane@kolabnow.com'); $this->deleteTestUser('UsersControllerTest1@userscontroller.com'); $this->deleteTestUser('UsersControllerTest2@userscontroller.com'); $this->deleteTestUser('UsersControllerTest3@userscontroller.com'); $this->deleteTestUser('UserEntitlement2A@UserEntitlement.com'); $this->deleteTestUser('john2.doe2@kolab.org'); $this->deleteTestDomain('userscontroller.com'); $user = $this->getTestUser('john@kolab.org'); $wallet = $user->wallets()->first(); $wallet->discount()->dissociate(); $wallet->settings()->whereIn('key', ['mollie_id', 'stripe_id'])->delete(); $wallet->save(); $user->status |= User::STATUS_IMAP_READY; $user->save(); } /** * {@inheritDoc} */ public function tearDown(): void { $this->deleteTestUser('jane@kolabnow.com'); $this->deleteTestUser('UsersControllerTest1@userscontroller.com'); $this->deleteTestUser('UsersControllerTest2@userscontroller.com'); $this->deleteTestUser('UsersControllerTest3@userscontroller.com'); $this->deleteTestUser('UserEntitlement2A@UserEntitlement.com'); $this->deleteTestUser('john2.doe2@kolab.org'); $this->deleteTestDomain('userscontroller.com'); $user = $this->getTestUser('john@kolab.org'); $wallet = $user->wallets()->first(); $wallet->discount()->dissociate(); $wallet->settings()->whereIn('key', ['mollie_id', 'stripe_id'])->delete(); $wallet->save(); $user->status |= User::STATUS_IMAP_READY; $user->save(); parent::tearDown(); } /** * Test user deleting (DELETE /api/v4/users/) */ public function testDestroy(): void { // First create some users/accounts to delete $package_kolab = \App\Package::where('title', 'kolab')->first(); $package_domain = \App\Package::where('title', 'domain-hosting')->first(); $john = $this->getTestUser('john@kolab.org'); $user1 = $this->getTestUser('UsersControllerTest1@userscontroller.com'); $user2 = $this->getTestUser('UsersControllerTest2@userscontroller.com'); $user3 = $this->getTestUser('UsersControllerTest3@userscontroller.com'); $domain = $this->getTestDomain('userscontroller.com', [ 'status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_PUBLIC, ]); $user1->assignPackage($package_kolab); $domain->assignPackage($package_domain, $user1); $user1->assignPackage($package_kolab, $user2); $user1->assignPackage($package_kolab, $user3); // Test unauth access $response = $this->delete("api/v4/users/{$user2->id}"); $response->assertStatus(401); // Test access to other user/account $response = $this->actingAs($john)->delete("api/v4/users/{$user2->id}"); $response->assertStatus(403); $response = $this->actingAs($john)->delete("api/v4/users/{$user1->id}"); $response->assertStatus(403); $json = $response->json(); $this->assertSame('error', $json['status']); $this->assertSame("Access denied", $json['message']); $this->assertCount(2, $json); // Test that non-controller cannot remove himself $response = $this->actingAs($user3)->delete("api/v4/users/{$user3->id}"); $response->assertStatus(403); // Test removing a non-controller user $response = $this->actingAs($user1)->delete("api/v4/users/{$user3->id}"); $response->assertStatus(200); $json = $response->json(); $this->assertEquals('success', $json['status']); $this->assertEquals('User deleted successfully.', $json['message']); // Test removing self (an account with users) $response = $this->actingAs($user1)->delete("api/v4/users/{$user1->id}"); $response->assertStatus(200); $json = $response->json(); $this->assertEquals('success', $json['status']); $this->assertEquals('User deleted successfully.', $json['message']); } /** * Test user deleting (DELETE /api/v4/users/) */ public function testDestroyByController(): void { // Create an account with additional controller - $user2 $package_kolab = \App\Package::where('title', 'kolab')->first(); $package_domain = \App\Package::where('title', 'domain-hosting')->first(); $user1 = $this->getTestUser('UsersControllerTest1@userscontroller.com'); $user2 = $this->getTestUser('UsersControllerTest2@userscontroller.com'); $user3 = $this->getTestUser('UsersControllerTest3@userscontroller.com'); $domain = $this->getTestDomain('userscontroller.com', [ 'status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_PUBLIC, ]); $user1->assignPackage($package_kolab); $domain->assignPackage($package_domain, $user1); $user1->assignPackage($package_kolab, $user2); $user1->assignPackage($package_kolab, $user3); $user1->wallets()->first()->addController($user2); // TODO/FIXME: // For now controller can delete himself, as well as // the whole account he has control to, including the owner // Probably he should not be able to do none of those // However, this is not 0-regression scenario as we // do not fully support additional controllers. //$response = $this->actingAs($user2)->delete("api/v4/users/{$user2->id}"); //$response->assertStatus(403); $response = $this->actingAs($user2)->delete("api/v4/users/{$user3->id}"); $response->assertStatus(200); $response = $this->actingAs($user2)->delete("api/v4/users/{$user1->id}"); $response->assertStatus(200); // Note: More detailed assertions in testDestroy() above $this->assertTrue($user1->fresh()->trashed()); $this->assertTrue($user2->fresh()->trashed()); $this->assertTrue($user3->fresh()->trashed()); } /** * Test user listing (GET /api/v4/users) */ public function testIndex(): void { // Test unauth access $response = $this->get("api/v4/users"); $response->assertStatus(401); $jack = $this->getTestUser('jack@kolab.org'); $joe = $this->getTestUser('joe@kolab.org'); $john = $this->getTestUser('john@kolab.org'); $ned = $this->getTestUser('ned@kolab.org'); $response = $this->actingAs($jack)->get("/api/v4/users"); $response->assertStatus(200); $json = $response->json(); $this->assertCount(0, $json); $response = $this->actingAs($john)->get("/api/v4/users"); $response->assertStatus(200); $json = $response->json(); $this->assertCount(4, $json); $this->assertSame($jack->email, $json[0]['email']); $this->assertSame($joe->email, $json[1]['email']); $this->assertSame($john->email, $json[2]['email']); $this->assertSame($ned->email, $json[3]['email']); // Values below are tested by Unit tests $this->assertArrayHasKey('isDeleted', $json[0]); $this->assertArrayHasKey('isSuspended', $json[0]); $this->assertArrayHasKey('isActive', $json[0]); $this->assertArrayHasKey('isLdapReady', $json[0]); $this->assertArrayHasKey('isImapReady', $json[0]); $response = $this->actingAs($ned)->get("/api/v4/users"); $response->assertStatus(200); $json = $response->json(); $this->assertCount(4, $json); $this->assertSame($jack->email, $json[0]['email']); $this->assertSame($joe->email, $json[1]['email']); $this->assertSame($john->email, $json[2]['email']); $this->assertSame($ned->email, $json[3]['email']); } /** * 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)->get("/api/v4/users/{$userA->id}"); $json = $response->json(); $response->assertStatus(200); $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'])); $this->assertSame([], $json['skus']); // Values below are tested by Unit tests $this->assertArrayHasKey('isDeleted', $json); $this->assertArrayHasKey('isSuspended', $json); $this->assertArrayHasKey('isActive', $json); $this->assertArrayHasKey('isLdapReady', $json); $this->assertArrayHasKey('isImapReady', $json); $john = $this->getTestUser('john@kolab.org'); $jack = $this->getTestUser('jack@kolab.org'); $ned = $this->getTestUser('ned@kolab.org'); // Test unauthorized access to a profile of other user $response = $this->actingAs($jack)->get("/api/v4/users/{$userA->id}"); $response->assertStatus(403); // Test authorized access to a profile of other user // Ned: Additional account controller $response = $this->actingAs($ned)->get("/api/v4/users/{$john->id}"); $response->assertStatus(200); $response = $this->actingAs($ned)->get("/api/v4/users/{$jack->id}"); $response->assertStatus(200); // John: Account owner $response = $this->actingAs($john)->get("/api/v4/users/{$jack->id}"); $response->assertStatus(200); $response = $this->actingAs($john)->get("/api/v4/users/{$ned->id}"); $response->assertStatus(200); $json = $response->json(); $storage_sku = Sku::where('title', 'storage')->first(); $groupware_sku = Sku::where('title', 'groupware')->first(); $mailbox_sku = Sku::where('title', 'mailbox')->first(); $secondfactor_sku = Sku::where('title', '2fa')->first(); $this->assertCount(5, $json['skus']); $this->assertSame(2, $json['skus'][$storage_sku->id]['count']); $this->assertSame(1, $json['skus'][$groupware_sku->id]['count']); $this->assertSame(1, $json['skus'][$mailbox_sku->id]['count']); $this->assertSame(1, $json['skus'][$secondfactor_sku->id]['count']); } /** * Test fetching user status (GET /api/v4/users//status) * and forcing setup process update (?refresh=1) * * @group imap * @group dns */ public function testStatus(): void { $john = $this->getTestUser('john@kolab.org'); $jack = $this->getTestUser('jack@kolab.org'); // Test unauthorized access $response = $this->actingAs($jack)->get("/api/v4/users/{$john->id}/status"); $response->assertStatus(403); $john->status ^= User::STATUS_IMAP_READY; $john->save(); // Get user status $response = $this->actingAs($john)->get("/api/v4/users/{$john->id}/status"); $response->assertStatus(200); $json = $response->json(); $this->assertFalse($json['isImapReady']); $this->assertFalse($json['isReady']); $this->assertCount(7, $json['process']); $this->assertSame('user-imap-ready', $json['process'][2]['label']); $this->assertSame(false, $json['process'][2]['state']); $this->assertTrue(empty($json['status'])); $this->assertTrue(empty($json['message'])); // Make sure the domain is confirmed (other test might unset that status) $domain = $this->getTestDomain('kolab.org'); $domain->status |= Domain::STATUS_CONFIRMED; $domain->save(); // Now "reboot" the process and verify the user in imap synchronously $response = $this->actingAs($john)->get("/api/v4/users/{$john->id}/status?refresh=1"); $response->assertStatus(200); $json = $response->json(); $this->assertTrue($json['isImapReady']); $this->assertTrue($json['isReady']); $this->assertCount(7, $json['process']); $this->assertSame('user-imap-ready', $json['process'][2]['label']); $this->assertSame(true, $json['process'][2]['state']); $this->assertSame('success', $json['status']); $this->assertSame('Setup process finished successfully.', $json['message']); } /** * Test UsersController::statusInfo() */ public function testStatusInfo(): void { $user = $this->getTestUser('UsersControllerTest1@userscontroller.com'); $domain = $this->getTestDomain('userscontroller.com', [ 'status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_PUBLIC, ]); $user->created_at = Carbon::now(); $user->status = User::STATUS_NEW; $user->save(); $result = UsersController::statusInfo($user); $this->assertFalse($result['isReady']); $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']); $this->assertSame('running', $result['processState']); $user->created_at = Carbon::now()->subSeconds(181); $user->save(); $result = UsersController::statusInfo($user); $this->assertSame('failed', $result['processState']); $user->status |= User::STATUS_LDAP_READY | User::STATUS_IMAP_READY; $user->save(); $result = UsersController::statusInfo($user); $this->assertTrue($result['isReady']); $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']); $this->assertSame('done', $result['processState']); $domain->status |= Domain::STATUS_VERIFIED; $domain->type = Domain::TYPE_EXTERNAL; $domain->save(); $result = UsersController::statusInfo($user); $this->assertFalse($result['isReady']); $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']); } /** * Test user creation (POST /api/v4/users) */ public function testStore(): void { $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']); $package_kolab = \App\Package::where('title', 'kolab')->first(); $package_domain = \App\Package::where('title', 'domain-hosting')->first(); $post = [ 'password' => 'simple', 'password_confirmation' => 'simple', 'first_name' => 'John2', 'last_name' => 'Doe2', 'email' => 'john2.doe2@kolab.org', 'organization' => 'TestOrg', 'aliases' => ['useralias1@kolab.org', 'useralias2@kolab.org'], ]; // Missing package $response = $this->actingAs($john)->post("/api/v4/users", $post); $json = $response->json(); $response->assertStatus(422); $this->assertSame('error', $json['status']); $this->assertSame("Package is required.", $json['errors']['package']); $this->assertCount(2, $json); // Invalid package $post['package'] = $package_domain->id; $response = $this->actingAs($john)->post("/api/v4/users", $post); $json = $response->json(); $response->assertStatus(422); $this->assertSame('error', $json['status']); $this->assertSame("Invalid package selected.", $json['errors']['package']); $this->assertCount(2, $json); // Test full and valid data $post['package'] = $package_kolab->id; $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')); $this->assertSame('TestOrg', $user->getSetting('organization')); $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); // Assert the new user entitlements $this->assertUserEntitlements($user, ['groupware', 'mailbox', 'storage', 'storage']); // Assert the wallet to which the new user should be assigned to $wallet = $user->wallet(); $this->assertSame($john->wallets()->first()->id, $wallet->id); // Test acting as account controller (not owner) /* // FIXME: How do we know to which wallet the new user should be assigned to? $this->deleteTestUser('john2.doe2@kolab.org'); $response = $this->actingAs($ned)->post("/api/v4/users", $post); $json = $response->json(); $response->assertStatus(200); $this->assertSame('success', $json['status']); */ $this->markTestIncomplete(); } /** * Test user update (PUT /api/v4/users/) */ public function testUpdate(): void { $userA = $this->getTestUser('UsersControllerTest1@userscontroller.com'); $jack = $this->getTestUser('jack@kolab.org'); $john = $this->getTestUser('john@kolab.org'); $ned = $this->getTestUser('ned@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($jack)->get("/api/v4/users/{$userA->id}", []); $response->assertStatus(403); // Test authorized update of account owner by account controller $response = $this->actingAs($ned)->get("/api/v4/users/{$john->id}", []); $response->assertStatus(200); // 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', 'organization' => 'TestOrg', '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'], $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' => '', 'organization' => '', '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]); // Test authorized update of other user $response = $this->actingAs($ned)->get("/api/v4/users/{$jack->id}", []); $response->assertStatus(200); // TODO: Test error on aliases with invalid/non-existing/other-user's domain // Create entitlements and additional user for following tests $owner = $this->getTestUser('UsersControllerTest1@userscontroller.com'); $user = $this->getTestUser('UsersControllerTest2@userscontroller.com'); $package_domain = Package::where('title', 'domain-hosting')->first(); $package_kolab = Package::where('title', 'kolab')->first(); $package_lite = Package::where('title', 'lite')->first(); $sku_mailbox = Sku::where('title', 'mailbox')->first(); $sku_storage = Sku::where('title', 'storage')->first(); $sku_groupware = Sku::where('title', 'groupware')->first(); $domain = $this->getTestDomain( 'userscontroller.com', [ 'status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_EXTERNAL, ] ); $domain->assignPackage($package_domain, $owner); $owner->assignPackage($package_kolab); $owner->assignPackage($package_lite, $user); // Non-controller cannot update his own entitlements $post = ['skus' => []]; $response = $this->actingAs($user)->put("/api/v4/users/{$user->id}", $post); $response->assertStatus(422); // Test updating entitlements $post = [ 'skus' => [ $sku_mailbox->id => 1, $sku_storage->id => 3, $sku_groupware->id => 1, ], ]; $response = $this->actingAs($owner)->put("/api/v4/users/{$user->id}", $post); $response->assertStatus(200); $storage_cost = $user->entitlements() ->where('sku_id', $sku_storage->id) ->orderBy('cost') ->pluck('cost')->all(); $this->assertUserEntitlements( $user, ['groupware', 'mailbox', 'storage', 'storage', 'storage'] ); $this->assertSame([0, 0, 25], $storage_cost); } /** * Test UsersController::updateEntitlements() */ public function testUpdateEntitlements(): void { $jane = $this->getTestUser('jane@kolabnow.com'); $kolab = \App\Package::where('title', 'kolab')->first(); $storage = \App\Sku::where('title', 'storage')->first(); $activesync = \App\Sku::where('title', 'activesync')->first(); $groupware = \App\Sku::where('title', 'groupware')->first(); $mailbox = \App\Sku::where('title', 'mailbox')->first(); // standard package, 1 mailbox, 1 groupware, 2 storage $jane->assignPackage($kolab); // add 2 storage, 1 activesync $post = [ 'skus' => [ $mailbox->id => 1, $groupware->id => 1, $storage->id => 4, $activesync->id => 1 ] ]; $response = $this->actingAs($jane)->put("/api/v4/users/{$jane->id}", $post); $response->assertStatus(200); $this->assertUserEntitlements( $jane, [ 'activesync', 'groupware', 'mailbox', 'storage', 'storage', 'storage', 'storage' ] ); // add 2 storage, remove 1 activesync $post = [ 'skus' => [ $mailbox->id => 1, $groupware->id => 1, $storage->id => 6, $activesync->id => 0 ] ]; $response = $this->actingAs($jane)->put("/api/v4/users/{$jane->id}", $post); $response->assertStatus(200); $this->assertUserEntitlements( $jane, [ 'groupware', 'mailbox', 'storage', 'storage', 'storage', 'storage', 'storage', 'storage' ] ); // add mailbox $post = [ 'skus' => [ $mailbox->id => 2, $groupware->id => 1, $storage->id => 6, $activesync->id => 0 ] ]; $response = $this->actingAs($jane)->put("/api/v4/users/{$jane->id}", $post); $response->assertStatus(500); $this->assertUserEntitlements( $jane, [ 'groupware', 'mailbox', 'storage', 'storage', 'storage', 'storage', 'storage', 'storage' ] ); // remove mailbox $post = [ 'skus' => [ $mailbox->id => 0, $groupware->id => 1, $storage->id => 6, $activesync->id => 0 ] ]; $response = $this->actingAs($jane)->put("/api/v4/users/{$jane->id}", $post); $response->assertStatus(500); $this->assertUserEntitlements( $jane, [ 'groupware', 'mailbox', 'storage', 'storage', 'storage', 'storage', 'storage', 'storage' ] ); // less than free storage $post = [ 'skus' => [ $mailbox->id => 1, $groupware->id => 1, $storage->id => 1, $activesync->id => 0 ] ]; $response = $this->actingAs($jane)->put("/api/v4/users/{$jane->id}", $post); $response->assertStatus(200); $this->assertUserEntitlements( $jane, [ 'groupware', 'mailbox', 'storage', 'storage' ] ); } /** * Test user data response used in show and info actions */ public function testUserResponse(): void { $provider = \config('services.payment_provider') ?: 'mollie'; $user = $this->getTestUser('john@kolab.org'); $wallet = $user->wallets()->first(); $wallet->setSettings(['mollie_id' => null, 'stripe_id' => null]); $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']); $this->assertTrue(is_array($result['accounts'])); $this->assertTrue(is_array($result['wallets'])); $this->assertCount(0, $result['accounts']); $this->assertCount(1, $result['wallets']); $this->assertSame($wallet->id, $result['wallet']['id']); $this->assertArrayNotHasKey('discount', $result['wallet']); $ned = $this->getTestUser('ned@kolab.org'); $ned_wallet = $ned->wallets()->first(); $result = $this->invokeMethod(new UsersController(), 'userResponse', [$ned]); $this->assertEquals($ned->id, $result['id']); $this->assertEquals($ned->email, $result['email']); $this->assertTrue(is_array($result['accounts'])); $this->assertTrue(is_array($result['wallets'])); $this->assertCount(1, $result['accounts']); $this->assertCount(1, $result['wallets']); $this->assertSame($wallet->id, $result['wallet']['id']); $this->assertSame($wallet->id, $result['accounts'][0]['id']); $this->assertSame($ned_wallet->id, $result['wallets'][0]['id']); $this->assertSame($provider, $result['wallet']['provider']); $this->assertSame($provider, $result['wallets'][0]['provider']); // Test discount in a response $discount = Discount::where('code', 'TEST')->first(); $wallet->discount()->associate($discount); $wallet->save(); $mod_provider = $provider == 'mollie' ? 'stripe' : 'mollie'; $wallet->setSetting($mod_provider . '_id', 123); $user->refresh(); $result = $this->invokeMethod(new UsersController(), 'userResponse', [$user]); $this->assertEquals($user->id, $result['id']); $this->assertSame($discount->id, $result['wallet']['discount_id']); $this->assertSame($discount->discount, $result['wallet']['discount']); $this->assertSame($discount->description, $result['wallet']['discount_description']); $this->assertSame($mod_provider, $result['wallet']['provider']); $this->assertSame($discount->id, $result['wallets'][0]['discount_id']); $this->assertSame($discount->discount, $result['wallets'][0]['discount']); $this->assertSame($discount->description, $result['wallets'][0]['discount_description']); $this->assertSame($mod_provider, $result['wallets'][0]['provider']); } /** * 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 alias of other user, to be a user email + ["jack.daniels@kolab.org", $john, false, 'The specified email is not available.'], + + // existing alias of other user, to be an alias, user in the same group account + ["jack.daniels@kolab.org", $john, true, null], // 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 \App\Utils(), 'validateEmail', [$alias, $user, $is_alias]); + $args = [$alias, $user, $is_alias]; + $result = $this->invokeMethod(new UsersController(), 'validateEmail', $args); $this->assertSame($expected_result, $result); } } diff --git a/src/tests/Feature/UserTest.php b/src/tests/Feature/UserTest.php index 63df2b78..8cf3d562 100644 --- a/src/tests/Feature/UserTest.php +++ b/src/tests/Feature/UserTest.php @@ -1,479 +1,525 @@ deleteTestUser('user-test@' . \config('app.domain')); $this->deleteTestUser('UserAccountA@UserAccount.com'); $this->deleteTestUser('UserAccountB@UserAccount.com'); $this->deleteTestUser('UserAccountC@UserAccount.com'); $this->deleteTestDomain('UserAccount.com'); } public function tearDown(): void { $this->deleteTestUser('user-test@' . \config('app.domain')); $this->deleteTestUser('UserAccountA@UserAccount.com'); $this->deleteTestUser('UserAccountB@UserAccount.com'); $this->deleteTestUser('UserAccountC@UserAccount.com'); $this->deleteTestDomain('UserAccount.com'); parent::tearDown(); } /** * Tests for User::assignPackage() */ public function testAssignPackage(): void { $this->markTestIncomplete(); } /** * Tests for User::assignPlan() */ public function testAssignPlan(): void { $this->markTestIncomplete(); } /** * Tests for User::assignSku() */ public function testAssignSku(): 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-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 testAccounts(): void { $this->markTestIncomplete(); } public function testCanDelete(): void { $this->markTestIncomplete(); } public function testCanRead(): void { $this->markTestIncomplete(); } public function testCanUpdate(): void { $this->markTestIncomplete(); } /** * 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(\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 { // TODO: This test does not test much, probably could be removed // or moved to somewhere else, or extended with // other entitlements() related cases. $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 testDelete(): void { Queue::fake(); $user = $this->getTestUser('user-test@' . \config('app.domain')); $package = \App\Package::where('title', 'kolab')->first(); $user->assignPackage($package); $id = $user->id; $this->assertCount(4, $user->entitlements()->get()); $user->delete(); $this->assertCount(0, $user->entitlements()->get()); $this->assertTrue($user->fresh()->trashed()); $this->assertFalse($user->fresh()->isDeleted()); // Delete the user for real $job = new \App\Jobs\UserDelete($id); $job->handle(); $this->assertTrue(User::withTrashed()->where('id', $id)->first()->isDeleted()); $user->forceDelete(); $this->assertCount(0, User::withTrashed()->where('id', $id)->get()); // Test an account with users $userA = $this->getTestUser('UserAccountA@UserAccount.com'); $userB = $this->getTestUser('UserAccountB@UserAccount.com'); $userC = $this->getTestUser('UserAccountC@UserAccount.com'); $package_kolab = \App\Package::where('title', 'kolab')->first(); $package_domain = \App\Package::where('title', 'domain-hosting')->first(); $domain = $this->getTestDomain('UserAccount.com', [ 'status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_HOSTED, ]); $userA->assignPackage($package_kolab); $domain->assignPackage($package_domain, $userA); $userA->assignPackage($package_kolab, $userB); $userA->assignPackage($package_kolab, $userC); $entitlementsA = \App\Entitlement::where('entitleable_id', $userA->id); $entitlementsB = \App\Entitlement::where('entitleable_id', $userB->id); $entitlementsC = \App\Entitlement::where('entitleable_id', $userC->id); $entitlementsDomain = \App\Entitlement::where('entitleable_id', $domain->id); $this->assertSame(4, $entitlementsA->count()); $this->assertSame(4, $entitlementsB->count()); $this->assertSame(4, $entitlementsC->count()); $this->assertSame(1, $entitlementsDomain->count()); // Delete non-controller user $userC->delete(); $this->assertTrue($userC->fresh()->trashed()); $this->assertFalse($userC->fresh()->isDeleted()); $this->assertSame(0, $entitlementsC->count()); // Delete the controller (and expect "sub"-users to be deleted too) $userA->delete(); $this->assertSame(0, $entitlementsA->count()); $this->assertSame(0, $entitlementsB->count()); $this->assertSame(0, $entitlementsDomain->count()); $this->assertTrue($userA->fresh()->trashed()); $this->assertTrue($userB->fresh()->trashed()); $this->assertTrue($domain->fresh()->trashed()); $this->assertFalse($userA->isDeleted()); $this->assertFalse($userB->isDeleted()); $this->assertFalse($domain->isDeleted()); } + /** + * Tests for User::emailExists() + */ + public function testEmailExists(): void + { + $this->markTestIncomplete(); + } + /** * 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); // Use an alias $result = User::findByEmail('john.doe@kolab.org'); $this->assertInstanceOf(User::class, $result); $this->assertSame($user->id, $result->id); + // A case where two users have the same alias + $ned = $this->getTestUser('ned@kolab.org'); + $ned->setAliases(['joe.monster@kolab.org']); + $result = User::findByEmail('joe.monster@kolab.org'); + $this->assertNull($result); + $ned->setAliases([]); + // TODO: searching by external email (setting) $this->markTestIncomplete(); } /** * Test User::name() */ public function testName(): void { Queue::fake(); $user = $this->getTestUser('user-test@' . \config('app.domain')); $this->assertSame('', $user->name()); $this->assertSame(\config('app.name') . ' User', $user->name(true)); $user->setSetting('first_name', 'First'); $this->assertSame('First', $user->name()); $this->assertSame('First', $user->name(true)); $user->setSetting('last_name', 'Last'); $this->assertSame('First Last', $user->name()); $this->assertSame('First Last', $user->name(true)); } /** * Tests for UserAliasesTrait::setAliases() */ public function testSetAliases(): void { Queue::fake(); Queue::assertNothingPushed(); $user = $this->getTestUser('UserAccountA@UserAccount.com'); $this->assertCount(0, $user->aliases->all()); // Add an alias $user->setAliases(['UserAlias1@UserAccount.com']); Queue::assertPushed(\App\Jobs\UserUpdate::class, 1); $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']); Queue::assertPushed(\App\Jobs\UserUpdate::class, 2); $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']); Queue::assertPushed(\App\Jobs\UserUpdate::class, 3); $aliases = $user->aliases()->get(); $this->assertCount(1, $aliases); $this->assertSame('useralias1@useraccount.com', $aliases[0]['alias']); // Remove all aliases $user->setAliases([]); Queue::assertPushed(\App\Jobs\UserUpdate::class, 4); $this->assertCount(0, $user->aliases()->get()); + + // Test sanity checks in UserAliasObserver + Queue::fake(); + + // Existing user + $user->setAliases(['john@kolab.org']); + $this->assertCount(0, $user->aliases()->get()); + + // Existing alias (in another account) + $user->setAliases(['john.doe@kolab.org']); + $this->assertCount(0, $user->aliases()->get()); + + Queue::assertNothingPushed(); + + // Existing user (in the same group account) + $ned = $this->getTestUser('ned@kolab.org'); + $ned->setAliases(['john@kolab.org']); + $this->assertCount(0, $ned->aliases()->get()); + + // Existing alias (in the same group account) + $ned = $this->getTestUser('ned@kolab.org'); + $ned->setAliases(['john.doe@kolab.org']); + $this->assertSame('john.doe@kolab.org', $ned->aliases()->first()->alias); + + // Existing alias (in another account, public domain) + $user->setAliases(['alias@kolabnow.com']); + $ned->setAliases(['alias@kolabnow.com']); + $this->assertCount(0, $ned->aliases()->get()); + + // cleanup + $ned->setAliases([]); } /** * Tests for UserSettingsTrait::setSettings() and getSetting() */ public function testUserSettings(): void { Queue::fake(); Queue::assertNothingPushed(); $user = $this->getTestUser('UserAccountA@UserAccount.com'); Queue::assertPushed(\App\Jobs\UserUpdate::class, 0); // Test default settings // Note: Technicly this tests UserObserver::created() behavior $all_settings = $user->settings()->orderBy('key')->get(); $this->assertCount(2, $all_settings); $this->assertSame('country', $all_settings[0]->key); $this->assertSame('CH', $all_settings[0]->value); $this->assertSame('currency', $all_settings[1]->key); $this->assertSame('CHF', $all_settings[1]->value); // Add a setting $user->setSetting('first_name', 'Firstname'); Queue::assertPushed(\App\Jobs\UserUpdate::class, 1); // Note: We test both current user as well as fresh user object // to make sure cache works as expected $this->assertSame('Firstname', $user->getSetting('first_name')); $this->assertSame('Firstname', $user->fresh()->getSetting('first_name')); // Update a setting $user->setSetting('first_name', 'Firstname1'); Queue::assertPushed(\App\Jobs\UserUpdate::class, 2); // Note: We test both current user as well as fresh user object // to make sure cache works as expected $this->assertSame('Firstname1', $user->getSetting('first_name')); $this->assertSame('Firstname1', $user->fresh()->getSetting('first_name')); // Delete a setting (null) $user->setSetting('first_name', null); Queue::assertPushed(\App\Jobs\UserUpdate::class, 3); // Note: We test both current user as well as fresh user object // to make sure cache works as expected $this->assertSame(null, $user->getSetting('first_name')); $this->assertSame(null, $user->fresh()->getSetting('first_name')); // Delete a setting (empty string) $user->setSetting('first_name', 'Firstname1'); $user->setSetting('first_name', ''); Queue::assertPushed(\App\Jobs\UserUpdate::class, 5); // Note: We test both current user as well as fresh user object // to make sure cache works as expected $this->assertSame(null, $user->getSetting('first_name')); $this->assertSame(null, $user->fresh()->getSetting('first_name')); // Set multiple settings at once $user->setSettings([ 'first_name' => 'Firstname2', 'last_name' => 'Lastname2', 'country' => null, ]); // TODO: This really should create a single UserUpdate job, not 3 Queue::assertPushed(\App\Jobs\UserUpdate::class, 7); // Note: We test both current user as well as fresh user object // to make sure cache works as expected $this->assertSame('Firstname2', $user->getSetting('first_name')); $this->assertSame('Firstname2', $user->fresh()->getSetting('first_name')); $this->assertSame('Lastname2', $user->getSetting('last_name')); $this->assertSame('Lastname2', $user->fresh()->getSetting('last_name')); $this->assertSame(null, $user->getSetting('country')); $this->assertSame(null, $user->fresh()->getSetting('country')); $all_settings = $user->settings()->orderBy('key')->get(); $this->assertCount(3, $all_settings); } /** * Tests for User::users() */ public function testUsers(): void { $jack = $this->getTestUser('jack@kolab.org'); $joe = $this->getTestUser('joe@kolab.org'); $john = $this->getTestUser('john@kolab.org'); $ned = $this->getTestUser('ned@kolab.org'); $wallet = $john->wallets()->first(); $users = $john->users()->orderBy('email')->get(); $this->assertCount(4, $users); $this->assertEquals($jack->id, $users[0]->id); $this->assertEquals($joe->id, $users[1]->id); $this->assertEquals($john->id, $users[2]->id); $this->assertEquals($ned->id, $users[3]->id); $this->assertSame($wallet->id, $users[0]->wallet_id); $this->assertSame($wallet->id, $users[1]->wallet_id); $this->assertSame($wallet->id, $users[2]->wallet_id); $this->assertSame($wallet->id, $users[3]->wallet_id); $users = $jack->users()->orderBy('email')->get(); $this->assertCount(0, $users); $users = $ned->users()->orderBy('email')->get(); $this->assertCount(4, $users); } public function testWallets(): void { $this->markTestIncomplete(); } }