diff --git a/src/app/Http/Controllers/API/V4/Admin/DomainsController.php b/src/app/Http/Controllers/API/V4/Admin/DomainsController.php index 01069e1c..3946ab6f 100644 --- a/src/app/Http/Controllers/API/V4/Admin/DomainsController.php +++ b/src/app/Http/Controllers/API/V4/Admin/DomainsController.php @@ -1,7 +1,55 @@ input('search')); + $owner = trim(request()->input('owner')); + $result = collect([]); + + if ($owner) { + if ($owner = User::find($owner)) { + foreach ($owner->wallets as $wallet) { + $entitlements = $wallet->entitlements()->where('entitleable_type', Domain::class)->get(); + + foreach ($entitlements as $entitlement) { + $domain = $entitlement->entitleable; + $result->push($domain); + } + } + + $result = $result->sortBy('namespace'); + } + } elseif (!empty($search)) { + if ($domain = Domain::where('namespace', $search)->first()) { + $result->push($domain); + } + } + + // Process the result + $result = $result->map(function ($domain) { + $data = $domain->toArray(); + $data = array_merge($data, self::domainStatuses($domain)); + return $data; + }); + + $result = [ + 'list' => $result, + 'count' => count($result), + 'message' => \trans('app.search-foundxdomains', ['x' => count($result)]), + ]; + + return response()->json($result); + } } diff --git a/src/app/Http/Controllers/API/V4/Admin/UsersController.php b/src/app/Http/Controllers/API/V4/Admin/UsersController.php index 2fcc072c..8e09fa4b 100644 --- a/src/app/Http/Controllers/API/V4/Admin/UsersController.php +++ b/src/app/Http/Controllers/API/V4/Admin/UsersController.php @@ -1,63 +1,68 @@ input('search')); + $owner = trim(request()->input('owner')); $result = collect([]); - if (strpos($search, '@')) { + 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)) { $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'); // TODO: Sort order $result = User::find($user_ids); } } 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); } } diff --git a/src/app/Http/Controllers/API/V4/SkusController.php b/src/app/Http/Controllers/API/V4/SkusController.php index 869b19e2..6de81c59 100644 --- a/src/app/Http/Controllers/API/V4/SkusController.php +++ b/src/app/Http/Controllers/API/V4/SkusController.php @@ -1,181 +1,183 @@ errorResponse(404); } /** * Remove the specified sku from storage. * * @param int $id SKU identifier * * @return \Illuminate\Http\JsonResponse */ public function destroy($id) { // TODO return $this->errorResponse(404); } /** * Show the form for editing the specified sku. * * @param int $id SKU identifier * * @return \Illuminate\Http\JsonResponse */ public function edit($id) { // TODO return $this->errorResponse(404); } /** * Display a listing of the sku. * * @return \Illuminate\Http\JsonResponse */ public function index() { - $response = []; - $skus = Sku::select()->get(); + // Note: Order by title for consistent ordering in tests + $skus = Sku::select()->orderBy('title')->get(); // Note: we do not limit the result to active SKUs only. // It's because we might need users assigned to old SKUs, // we need to display these old SKUs on the entitlements list + $response = []; + foreach ($skus as $sku) { if ($data = $this->skuElement($sku)) { $response[] = $data; } } usort($response, function ($a, $b) { return ($b['prio'] <=> $a['prio']); }); return response()->json($response); } /** * Store a newly created sku in storage. * * @param \Illuminate\Http\Request $request * * @return \Illuminate\Http\JsonResponse */ public function store(Request $request) { // TODO return $this->errorResponse(404); } /** * Display the specified sku. * * @param int $id SKU identifier * * @return \Illuminate\Http\JsonResponse */ public function show($id) { // TODO return $this->errorResponse(404); } /** * Update the specified sku in storage. * * @param \Illuminate\Http\Request $request Request object * @param int $id SKU identifier * * @return \Illuminate\Http\JsonResponse */ public function update(Request $request, $id) { // TODO return $this->errorResponse(404); } /** * Convert SKU information to metadata used by UI to * display the form control * * @param \App\Sku $sku SKU object * * @return array|null Metadata */ protected function skuElement($sku): ?array { $type = $sku->handler_class::entitleableClass(); // ignore incomplete handlers if (!$type) { return null; } $type = explode('\\', $type); $type = strtolower(end($type)); $handler = explode('\\', $sku->handler_class); $handler = strtolower(end($handler)); $data = $sku->toArray(); $data['type'] = $type; $data['handler'] = $handler; $data['readonly'] = false; $data['enabled'] = false; $data['prio'] = $sku->handler_class::priority(); // Use localized value, toArray() does not get them right $data['name'] = $sku->name; $data['description'] = $sku->description; unset($data['handler_class']); switch ($handler) { case 'activesync': $data['required'] = ['groupware']; break; case 'auth2f': $data['forbidden'] = ['activesync']; break; case 'storage': // Quota range input $data['readonly'] = true; // only the checkbox will be disabled, not range $data['enabled'] = true; $data['range'] = [ 'min' => $data['units_free'], 'max' => $sku->handler_class::MAX_ITEMS, 'unit' => $sku->handler_class::ITEM_UNIT, ]; break; case 'mailbox': // Mailbox is always enabled and cannot be unset $data['readonly'] = true; $data['enabled'] = true; break; } return $data; } } diff --git a/src/app/Http/Controllers/API/V4/UsersController.php b/src/app/Http/Controllers/API/V4/UsersController.php index 05bc2866..76cbe4df 100644 --- a/src/app/Http/Controllers/API/V4/UsersController.php +++ b/src/app/Http/Controllers/API/V4/UsersController.php @@ -1,488 +1,492 @@ 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() { \Log::debug("Regular API"); $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); } /** * 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']; })); return [ 'process' => $process, '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); } $user_name = !empty($settings['first_name']) ? $settings['first_name'] : ''; if (!empty($settings['last_name'])) { $user_name .= ' ' . $settings['last_name']; } DB::beginTransaction(); // Create user record $user = User::create([ 'name' => $user_name, 'email' => $request->email, 'password' => $request->password, ]); $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|null $skus Set of SKUs for the user */ protected function updateEntitlements(User $user, $skus) { if (!is_array($skus)) { return; } // Existing SKUs // FIXME: Is there really no query builder method to get result indexed // by some column or primary key? $all_skus = Sku::all()->mapWithKeys(function ($sku) { return [$sku->id => $sku]; }); // Existing user entitlements // Note: We sort them by cost, so e.g. for storage we get these free first $entitlements = $user->entitlements()->orderBy('cost')->get(); // Go through existing entitlements and remove those no longer needed foreach ($entitlements as $ent) { $sku_id = $ent->sku_id; if (array_key_exists($sku_id, $skus)) { // An existing entitlement exists on the requested list $skus[$sku_id] -= 1; if ($skus[$sku_id] < 0) { $ent->delete(); } } elseif ($all_skus[$sku_id]->handler_class != \App\Handlers\Mailbox::class) { // An existing entitlement does not exists on the requested list // Never delete 'mailbox' SKU $ent->delete(); } } // Add missing entitlements foreach ($skus as $sku_id => $count) { if ($count > 0 && $all_skus->has($sku_id)) { $user->assignSku($all_skus[$sku_id], $count); } } } /** * 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 // TODO: It might be reasonable to limit the list of settings here to these // that are safe and are used in the UI $response['settings'] = []; foreach ($user->settings as $item) { $response['settings'][$item->key] = $item->value; } // Aliases $response['aliases'] = []; foreach ($user->aliases as $item) { $response['aliases'][] = $item->alias; } // Status info $response['statusInfo'] = self::statusInfo($user); $response = array_merge($response, self::userStatuses($user)); // Add discount info to wallet object output - $map_func = function ($wallet) { + $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; + } + 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 The response on error */ protected function validateUserRequest(Request $request, $user, &$settings = []) { $rules = [ 'external_email' => 'nullable|email', 'phone' => 'string|nullable|max:64|regex:/^[0-9+() -]+$/', 'first_name' => 'string|nullable|max:512', 'last_name' => 'string|nullable|max:512', 'billing_address' => 'string|nullable|max:1024', 'country' => 'string|nullable|alpha|size:2', 'currency' => 'string|nullable|alpha|size:3', 'aliases' => 'array|nullable', ]; if (empty($user) || !empty($request->password) || !empty($request->password_confirmation)) { $rules['password'] = 'required|min:4|max:2048|confirmed'; } $errors = []; // Validate input $v = Validator::make($request->all(), $rules); if ($v->fails()) { $errors = $v->errors()->toArray(); } $controller = $user ? $user->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)) { $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)) ) { 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']); } } diff --git a/src/app/User.php b/src/app/User.php index ed986278..d3f2cf70 100644 --- a/src/app/User.php +++ b/src/app/User.php @@ -1,619 +1,621 @@ 'datetime', ]; /** * Any wallets on which this user is a controller. * * This does not include wallets owned by the user. * * @return \Illuminate\Database\Eloquent\Relations\BelongsToMany */ public function accounts() { return $this->belongsToMany( 'App\Wallet', // The foreign object definition 'user_accounts', // The table name 'user_id', // The local foreign key 'wallet_id' // The remote foreign key ); } /** * Email aliases of this user. * * @return \Illuminate\Database\Eloquent\Relations\HasMany */ public function aliases() { return $this->hasMany('App\UserAlias', 'user_id'); } /** * Assign a package to a user. The user should not have any existing entitlements. * * @param \App\Package $package The package to assign. * @param \App\User|null $user Assign the package to another user. * * @return \App\User */ public function assignPackage($package, $user = null) { if (!$user) { $user = $this; } $wallet_id = $this->wallets()->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, 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' => $sku->units_free >= $exists ? $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; } if ($this->role == "admin") { return true; } $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 $object A user|domain object * * @return bool True if he can, False otherwise */ public function canRead($object): bool { if (!method_exists($object, 'wallet')) { return false; } if ($this->role == "admin") { return true; } if ($object instanceof User && $this->id == $object->id) { return true; } $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 ($this->role == "admin") { return true; } 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); } } /** * Helper to find user by email address, whether it is * main email address, alias or external email * * @param string $email Email address * @param bool $external Search also by 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(); if ($alias) { return $alias->user; } // TODO: External email return null; } public function getJWTIdentifier() { return $this->getKey(); } public function getJWTCustomClaims() { return []; } /** * Returns whether this domain is active. * * @return bool */ public function isActive(): bool { return ($this->status & self::STATUS_ACTIVE) > 0; } /** * Returns whether this domain is deleted. * * @return bool */ public function isDeleted(): bool { return ($this->status & self::STATUS_DELETED) > 0; } /** * Returns whether this (external) domain has been verified * to exist in DNS. * * @return bool */ public function isImapReady(): bool { return ($this->status & self::STATUS_IMAP_READY) > 0; } /** * Returns whether this user is registered in LDAP. * * @return bool */ public function isLdapReady(): bool { return ($this->status & self::STATUS_LDAP_READY) > 0; } /** * Returns whether this user is new. * * @return bool */ public function isNew(): bool { return ($this->status & self::STATUS_NEW) > 0; } /** * Returns whether this domain is suspended. * * @return bool */ public function isSuspended(): bool { return ($this->status & self::STATUS_SUSPENDED) > 0; } /** * Any (additional) properties of this user. * * @return \Illuminate\Database\Eloquent\Relations\HasMany */ public function settings() { return $this->hasMany('App\UserSetting', 'user_id'); } /** * 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. * - * Users assigned to wallets the current user controls or owns. + * @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() + public function users($with_accounts = true) { - $wallets = array_merge( - $this->wallets()->pluck('id')->all(), - $this->accounts()->pluck('wallet_id')->all() - ); + $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 a789f146..50fa6341 100644 --- a/src/app/Utils.php +++ b/src/app/Utils.php @@ -1,163 +1,164 @@ 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 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 ?: []; - $env['jsapp'] = strpos(request()->getHttpHost(), 'admin.') === 0 ? 'admin.js' : 'user.js'; + $env['isAdmin'] = strpos(request()->getHttpHost(), 'admin.') === 0; + $env['jsapp'] = $env['isAdmin'] ? 'admin.js' : 'user.js'; 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/app/Wallet.php b/src/app/Wallet.php index a185840e..43ebdcbd 100644 --- a/src/app/Wallet.php +++ b/src/app/Wallet.php @@ -1,212 +1,211 @@ 0, 'currency' => 'CHF' ]; protected $fillable = [ 'currency' ]; protected $nullable = [ 'description', ]; protected $casts = [ 'balance' => 'integer', ]; - protected $guarded = ['balance']; /** * Add a controller to this wallet. * * @param \App\User $user The user to add as a controller to this wallet. * * @return void */ public function addController(User $user) { if (!$this->controllers->contains($user)) { $this->controllers()->save($user); } } public function chargeEntitlements($apply = true) { $charges = 0; $discount = $this->discount ? $this->discount->discount : 0; $discount = (100 - $discount) / 100; foreach ($this->entitlements()->get()->fresh() as $entitlement) { // This entitlement has been created less than or equal to 14 days ago (this is at // maximum the fourteenth 24-hour period). if ($entitlement->created_at > Carbon::now()->subDays(14)) { continue; } // This entitlement was created, or billed last, less than a month ago. if ($entitlement->updated_at > Carbon::now()->subMonthsWithoutOverflow(1)) { continue; } // created more than a month ago -- was it billed? if ($entitlement->updated_at <= Carbon::now()->subMonthsWithoutOverflow(1)) { $diff = $entitlement->updated_at->diffInMonths(Carbon::now()); $cost = (int) ($entitlement->cost * $discount * $diff); $charges += $cost; // if we're in dry-run, you know... if (!$apply) { continue; } $entitlement->updated_at = $entitlement->updated_at->copy()->addMonthsWithoutOverflow($diff); $entitlement->save(); // TODO: This would be better done out of the loop (debit() will call save()), // but then, maybe we should use a db transaction $this->debit($cost); } } return $charges; } /** * The discount assigned to the wallet. * * @return \Illuminate\Database\Eloquent\Relations\BelongsTo */ public function discount() { return $this->belongsTo('App\Discount', 'discount_id', 'id'); } /** * Calculate the expected charges to this wallet. * * @return int */ public function expectedCharges() { return $this->chargeEntitlements(false); } /** * Remove a controller from this wallet. * * @param \App\User $user The user to remove as a controller from this wallet. * * @return void */ public function removeController(User $user) { if ($this->controllers->contains($user)) { $this->controllers()->detach($user); } } /** * Add an amount of pecunia to this wallet's balance. * * @param int $amount The amount of pecunia to add (in cents). * * @return Wallet Self */ public function credit(int $amount): Wallet { $this->balance += $amount; $this->save(); return $this; } /** * Deduct an amount of pecunia from this wallet's balance. * * @param int $amount The amount of pecunia to deduct (in cents). * * @return Wallet Self */ public function debit(int $amount): Wallet { $this->balance -= $amount; $this->save(); return $this; } /** * Controllers of this wallet. * * @return \Illuminate\Database\Eloquent\Relations\BelongsToMany */ public function controllers() { return $this->belongsToMany( 'App\User', // The foreign object definition 'user_accounts', // The table name 'wallet_id', // The local foreign key 'user_id' // The remote foreign key ); } /** * Entitlements billed to this wallet. * * @return \Illuminate\Database\Eloquent\Relations\HasMany */ public function entitlements() { return $this->hasMany('App\Entitlement'); } /** * The owner of the wallet -- the wallet is in his/her back pocket. * * @return \Illuminate\Database\Eloquent\Relations\BelongsTo */ public function owner() { return $this->belongsTo('App\User', 'user_id', 'id'); } /** * Payments on this wallet. * * @return \Illuminate\Database\Eloquent\Relations\HasMany */ public function payments() { return $this->hasMany('App\Payment'); } } diff --git a/src/resources/js/app.js b/src/resources/js/app.js index f2a0a106..951cc640 100644 --- a/src/resources/js/app.js +++ b/src/resources/js/app.js @@ -1,286 +1,289 @@ /** * First we will load all of this project's JavaScript dependencies which * includes Vue and other libraries. It is a great starting point when * building robust, powerful web applications using Vue and Laravel. */ require('./bootstrap') import AppComponent from '../vue/App' import MenuComponent from '../vue/Menu' import store from './store' import FontAwesomeIcon from './fontawesome' import VueToastr from '@deveodk/vue-toastr' window.Vue = require('vue') Vue.component('svg-icon', FontAwesomeIcon) Vue.use(VueToastr, { defaultPosition: 'toast-bottom-right', defaultTimeout: 5000 }) const vTooltip = (el, binding) => { const t = [] if (binding.modifiers.focus) t.push('focus') if (binding.modifiers.hover) t.push('hover') if (binding.modifiers.click) t.push('click') if (!t.length) t.push('hover') $(el).tooltip({ title: binding.value, placement: binding.arg || 'top', trigger: t.join(' '), html: !!binding.modifiers.html, }); } Vue.directive('tooltip', { bind: vTooltip, update: vTooltip, unbind (el) { $(el).tooltip('dispose') } }) // Add a response interceptor for general/validation error handler // This have to be before Vue and Router setup. Otherwise we would // not be able to handle axios responses initiated from inside // components created/mounted handlers (e.g. signup code verification link) window.axios.interceptors.response.use( response => { // Do nothing return response }, error => { let error_msg let status = error.response ? error.response.status : 200 if (error.response && status == 422) { error_msg = "Form validation error" $.each(error.response.data.errors || {}, (idx, msg) => { $('form').each((i, form) => { const input_name = ($(form).data('validation-prefix') || '') + idx const input = $('#' + input_name) if (input.length) { // Create an error message\ // API responses can use a string, array or object let msg_text = '' if ($.type(msg) !== 'string') { $.each(msg, (index, str) => { msg_text += str + ' ' }) } else { msg_text = msg } let feedback = $('
').text(msg_text) if (input.is('.list-input')) { // List input widget input.children(':not(:first-child)').each((index, element) => { if (msg[index]) { $(element).find('input').addClass('is-invalid') } }) input.addClass('is-invalid').next('.invalid-feedback').remove() input.after(feedback) } else { // Standard form element input.addClass('is-invalid') input.parent().find('.invalid-feedback').remove() input.parent().append(feedback) } return false } }); }) $('form .is-invalid:not(.listinput-widget)').first().focus() } else if (error.response && error.response.data) { error_msg = error.response.data.message } else { error_msg = error.request ? error.request.statusText : error.message } app.$toastr('error', error_msg || "Server Error", 'Error') // Pass the error as-is return Promise.reject(error) } ) const app = new Vue({ el: '#app', components: { 'app-component': AppComponent, 'menu-component': MenuComponent }, store, router: window.router, data() { return { isLoading: true } }, methods: { // Clear (bootstrap) form validation state clearFormValidation(form) { $(form).find('.is-invalid').removeClass('is-invalid') $(form).find('.invalid-feedback').remove() }, isController(wallet_id) { if (wallet_id && store.state.authInfo) { let i for (i = 0; i < store.state.authInfo.wallets.length; i++) { if (wallet_id == store.state.authInfo.wallets[i].id) { return true } } for (i = 0; i < store.state.authInfo.accounts.length; i++) { if (wallet_id == store.state.authInfo.accounts[i].id) { return true } } } return false }, // Set user state to "logged in" loginUser(token, dashboard) { store.commit('logoutUser') // destroy old state data store.commit('loginUser') localStorage.setItem('token', token) axios.defaults.headers.common.Authorization = 'Bearer ' + token if (dashboard !== false) { this.$router.push(store.state.afterLogin || { name: 'dashboard' }) } store.state.afterLogin = null }, // Set user state to "not logged in" logoutUser() { store.commit('logoutUser') localStorage.setItem('token', '') delete axios.defaults.headers.common.Authorization this.$router.push({ name: 'login' }) }, // Display "loading" overlay (to be used by route components) startLoading() { this.isLoading = true // Lock the UI with the 'loading...' element - $('#app').append($('
Loading
')) + let loading = $('#app > .app-loader').show() + if (!loading.length) { + $('#app').append($('
Loading
')) + } }, // Hide "loading" overlay stopLoading() { $('#app > .app-loader').fadeOut() this.isLoading = false }, errorPage(code, msg) { // Until https://github.com/vuejs/vue-router/issues/977 is implemented // we can't really use router to display error page as it has two side // effects: it changes the URL and adds the error page to browser history. // For now we'll be replacing current view with error page "manually". const map = { 400: "Bad request", 401: "Unauthorized", 403: "Access denied", 404: "Not found", 405: "Method not allowed", 500: "Internal server error" } if (!msg) msg = map[code] || "Unknown Error" const error_page = `
${code}
${msg}
` $('#app').children(':not(nav)').remove() $('#app').append(error_page) }, errorHandler(error) { this.stopLoading() if (!error.response) { // TODO: probably network connection error } else if (error.response.status === 401) { this.logoutUser() } else { this.errorPage(error.response.status, error.response.statusText) } }, price(price) { return (price/100).toLocaleString('de-DE', { style: 'currency', currency: 'CHF' }) }, domainStatusClass(domain) { if (domain.isDeleted) { return 'text-muted' } if (domain.isSuspended) { return 'text-warning' } if (!domain.isVerified || !domain.isLdapReady || !domain.isConfirmed) { return 'text-danger' } return 'text-success' }, domainStatusText(domain) { if (domain.isDeleted) { return 'Deleted' } if (domain.isSuspended) { return 'Suspended' } if (!domain.isVerified || !domain.isLdapReady || !domain.isConfirmed) { return 'Not Ready' } return 'Active' }, userStatusClass(user) { if (user.isDeleted) { return 'text-muted' } if (user.isSuspended) { return 'text-warning' } if (!user.isImapReady || !user.isLdapReady) { return 'text-danger' } return 'text-success' }, userStatusText(user) { if (user.isDeleted) { return 'Deleted' } if (user.isSuspended) { return 'Suspended' } if (!user.isImapReady || !user.isLdapReady) { return 'Not Ready' } return 'Active' } } }) diff --git a/src/resources/js/routes-admin.js b/src/resources/js/routes-admin.js index 52856ade..6ee3ce4b 100644 --- a/src/resources/js/routes-admin.js +++ b/src/resources/js/routes-admin.js @@ -1,74 +1,75 @@ import Vue from 'vue' import VueRouter from 'vue-router' Vue.use(VueRouter) import DashboardComponent from '../vue/Admin/Dashboard' +import DomainComponent from '../vue/Admin/Domain' import Error404Component from '../vue/404' import LoginComponent from '../vue/Login' import LogoutComponent from '../vue/Logout' -import PasswordResetComponent from '../vue/PasswordReset' import UserComponent from '../vue/Admin/User' import store from './store' const routes = [ { path: '/', redirect: { name: 'dashboard' } }, { path: '/dashboard', name: 'dashboard', component: DashboardComponent, meta: { requiresAuth: true } }, + { + path: '/domain/:domain', + name: 'domain', + component: DomainComponent, + meta: { requiresAuth: true } + }, { path: '/login', name: 'login', component: LoginComponent }, { path: '/logout', name: 'logout', component: LogoutComponent }, - { - path: '/password-reset/:code?', - name: 'password-reset', - component: PasswordResetComponent - }, { path: '/user/:user', name: 'user', component: UserComponent, meta: { requiresAuth: true } }, { name: '404', path: '*', component: Error404Component } ] const router = new VueRouter({ mode: 'history', routes }) router.beforeEach((to, from, next) => { // check if the route requires authentication and user is not logged in if (to.matched.some(route => route.meta.requiresAuth) && !store.state.isLoggedIn) { // remember the original request, to use after login store.state.afterLogin = to; // redirect to login page next({ name: 'login' }) return } next() }) export default router diff --git a/src/resources/lang/en/app.php b/src/resources/lang/en/app.php index 6a64c4e8..87f433d0 100644 --- a/src/resources/lang/en/app.php +++ b/src/resources/lang/en/app.php @@ -1,29 +1,30 @@ 'Choose :plan', 'process-user-new' => 'User registered', 'process-user-ldap-ready' => 'User created', 'process-user-imap-ready' => 'User mailbox created', 'process-domain-new' => 'Custom domain registered', 'process-domain-ldap-ready' => 'Custom domain created', 'process-domain-verified' => 'Custom domain verified', 'process-domain-confirmed' => 'Custom domain ownership verified', 'domain-verify-success' => 'Domain verified successfully.', 'user-update-success' => 'User data updated successfully.', 'user-create-success' => 'User created successfully.', 'user-delete-success' => 'User deleted successfully.', + 'search-foundxdomains' => ':x domains have been found.', 'search-foundxusers' => ':x user accounts have been found.', ]; diff --git a/src/resources/sass/app.scss b/src/resources/sass/app.scss index 432d2d42..7a8f5ad6 100644 --- a/src/resources/sass/app.scss +++ b/src/resources/sass/app.scss @@ -1,187 +1,202 @@ // Fonts // Variables @import 'variables'; // Bootstrap @import '~bootstrap/scss/bootstrap'; // Toastr @import '~@deveodk/vue-toastr/dist/@deveodk/vue-toastr.css'; // Fixes Toastr incompatibility with Bootstrap .toast-container > .toast { opacity: 1; } @import 'menu'; nav + .container { margin-top: 120px; } #app { margin-bottom: 2rem; } #error-page { position: absolute; top: 0; height: 100%; width: 100%; align-items: center; display: flex; justify-content: center; color: #636b6f; z-index: 10; background: white; .code { text-align: right; border-right: 2px solid; font-size: 26px; padding: 0 15px; } .message { font-size: 18px; padding: 0 15px; } } .app-loader { background-color: $body-bg; height: 100%; width: 100%; position: absolute; top: 0; left: 0; display: flex; align-items: center; justify-content: center; .spinner-border { width: 120px; height: 120px; border-width: 15px; color: #b2aa99; } } pre { margin: 1rem 0; padding: 1rem; background-color: $menu-bg-color; } .card-title { font-size: 1.2rem; font-weight: bold; } .list-input { & > div { &:not(:last-child) { margin-bottom: -1px; input, a.btn { border-bottom-right-radius: 0; border-bottom-left-radius: 0; } } &:not(:first-child) { input, a.btn { border-top-right-radius: 0; border-top-left-radius: 0; } } } input.is-invalid { z-index: 2; } } .range-input { display: flex; label { margin-right: 0.5em; } } -table.form-list { - td { - border: 0; +tfoot.table-fake-body { + background-color: #f8f8f8; + color: grey; + text-align: center; + height: 8em; - &:first-child { - padding-left: 0; - } + td { + vertical-align: middle; + } - &:last-child { - padding-right: 0; - } + tbody:not(:empty) + & { + display: none; } } table { td.buttons, td.price, td.selection { width: 1%; } td.price { text-align: right; } + + &.form-list { + td { + border: 0; + + &:first-child { + padding-left: 0; + } + + &:last-child { + padding-right: 0; + } + } + } } ul.status-list { list-style: none; padding: 0; margin: 0; svg { width: 1.25rem !important; height: 1.25rem; } span { vertical-align: top; } } #dashboard-nav { display: flex; flex-wrap: wrap; justify-content: center; margin-top: 1rem; & > a { padding: 1rem; text-align: center; white-space: nowrap; margin: 0 0.5rem 0.5rem 0; text-decoration: none; min-width: 8rem; &.disabled { pointer-events: none; opacity: 0.6; } .badge { position: absolute; top: 0.5rem; right: 0.5rem; } } svg { width: 6rem; height: 6rem; } } diff --git a/src/resources/vue/Admin/Domain.vue b/src/resources/vue/Admin/Domain.vue new file mode 100644 index 00000000..cfa0159c --- /dev/null +++ b/src/resources/vue/Admin/Domain.vue @@ -0,0 +1,69 @@ + + + diff --git a/src/resources/vue/Admin/User.vue b/src/resources/vue/Admin/User.vue index fea6e12a..94337d2c 100644 --- a/src/resources/vue/Admin/User.vue +++ b/src/resources/vue/Admin/User.vue @@ -1,107 +1,348 @@ diff --git a/src/resources/vue/App.vue b/src/resources/vue/App.vue index ff0507ce..d2c87675 100644 --- a/src/resources/vue/App.vue +++ b/src/resources/vue/App.vue @@ -1,38 +1,50 @@ diff --git a/src/resources/vue/Login.vue b/src/resources/vue/Login.vue index 30b02fb9..8e1cc965 100644 --- a/src/resources/vue/Login.vue +++ b/src/resources/vue/Login.vue @@ -1,80 +1,81 @@ diff --git a/src/resources/vue/Signup.vue b/src/resources/vue/Signup.vue index 3229d6a8..7e383e07 100644 --- a/src/resources/vue/Signup.vue +++ b/src/resources/vue/Signup.vue @@ -1,246 +1,246 @@ diff --git a/src/resources/vue/User/Info.vue b/src/resources/vue/User/Info.vue index dfdeaff7..f45b4331 100644 --- a/src/resources/vue/User/Info.vue +++ b/src/resources/vue/User/Info.vue @@ -1,360 +1,360 @@ diff --git a/src/tests/Browser.php b/src/tests/Browser.php index 50c2531e..64a24fc9 100644 --- a/src/tests/Browser.php +++ b/src/tests/Browser.php @@ -1,206 +1,222 @@ resolver->findOrFail($selector); + + Assert::assertEquals( + $element->getAttribute($name), + $value, + "Failed asserting value of [$selector][$name] attribute" + ); + + return $this; + } + /** * Assert number of (visible) elements */ public function assertElementsCount($selector, $expected_count, $visible = true) { $elements = $this->elements($selector); $count = count($elements); if ($visible) { foreach ($elements as $element) { if (!$element->isDisplayed()) { $count--; } } } Assert::assertEquals($expected_count, $count, "Count of [$selector] elements is not $count"); return $this; } /** * Assert Tip element content */ public function assertTip($selector, $content) { return $this->click($selector) ->withinBody(function ($browser) use ($content) { $browser->assertSeeIn('div.tooltip .tooltip-inner', $content); }) ->click($selector); } /** * Assert Toast element content (and close it) */ public function assertToast($type, $title, $message) { return $this->withinBody(function ($browser) use ($type, $title, $message) { $browser->with(new Toast($type), function (Browser $browser) use ($title, $message) { $browser->assertToastTitle($title) ->assertToastMessage($message) ->closeToast(); }); }); } /** * Assert specified error page is displayed. */ public function assertErrorPage(int $error_code) { $this->with(new Error($error_code), function ($browser) { // empty, assertions will be made by the Error component itself }); return $this; } /** * Assert that the given element has specified class assigned. */ public function assertHasClass($selector, $class_name) { $element = $this->resolver->findOrFail($selector); $classes = explode(' ', (string) $element->getAttribute('class')); Assert::assertContains($class_name, $classes, "[$selector] has no class '{$class_name}'"); return $this; } /** * Assert that the given element is readonly */ public function assertReadonly($selector) { $element = $this->resolver->findOrFail($selector); $value = $element->getAttribute('readonly'); Assert::assertTrue($value == 'true', "Element [$selector] is not readonly"); return $this; } /** * Assert that the given element is not readonly */ public function assertNotReadonly($selector) { $element = $this->resolver->findOrFail($selector); $value = $element->getAttribute('readonly'); Assert::assertTrue($value != 'true', "Element [$selector] is not readonly"); return $this; } /** * Assert that the given element contains specified text, * no matter it's displayed or not. */ public function assertText($selector, $text) { $element = $this->resolver->findOrFail($selector); Assert::assertTrue(strpos($element->getText(), $text) !== false, "No expected text in [$selector]"); return $this; } /** * Remove all toast messages */ public function clearToasts() { $this->script("jQuery('.toast-container > *').remove()"); return $this; } /** * Check if in Phone mode */ public static function isPhone() { return getenv('TESTS_MODE') == 'phone'; } /** * Check if in Tablet mode */ public static function isTablet() { return getenv('TESTS_MODE') == 'tablet'; } /** * Check if in Desktop mode */ public static function isDesktop() { return !self::isPhone() && !self::isTablet(); } /** * Returns content of a downloaded file */ public function readDownloadedFile($filename) { $filename = __DIR__ . "/Browser/downloads/$filename"; // Give the browser a chance to finish download if (!file_exists($filename)) { sleep(2); } Assert::assertFileExists($filename); return file_get_contents($filename); } /** * Removes downloaded file */ public function removeDownloadedFile($filename) { @unlink(__DIR__ . "/Browser/downloads/$filename"); return $this; } /** * Execute code within body context. * Useful to execute code that selects elements outside of a component context */ public function withinBody($callback) { if ($this->resolver->prefix != 'body') { $orig_prefix = $this->resolver->prefix; $this->resolver->prefix = 'body'; } call_user_func($callback, $this); if (isset($orig_prefix)) { $this->resolver->prefix = $orig_prefix; } return $this; } } diff --git a/src/tests/Browser/Admin/DomainTest.php b/src/tests/Browser/Admin/DomainTest.php new file mode 100644 index 00000000..a407d089 --- /dev/null +++ b/src/tests/Browser/Admin/DomainTest.php @@ -0,0 +1,89 @@ +browse(function (Browser $browser) { + $domain = $this->getTestDomain('kolab.org'); + $browser->visit('/domain/' . $domain->id)->on(new Home()); + }); + } + + /** + * Test domain info page + */ + public function testDomainInfo(): void + { + $this->browse(function (Browser $browser) { + $domain = $this->getTestDomain('kolab.org'); + $domain_page = new DomainPage($domain->id); + $john = $this->getTestUser('john@kolab.org'); + $user_page = new UserPage($john->id); + + // Goto the domain page + $browser->visit(new Home()) + ->submitLogon('jeroen@jeroen.jeroen', 'jeroen', true) + ->on(new Dashboard()) + ->visit($user_page) + ->on($user_page) + ->pause(500) + ->click('@nav #tab-domains') + ->click('@user-domains table tbody tr:first-child td a'); + + $browser->on($domain_page) + ->assertSeeIn('@domain-info .card-title', 'kolab.org') + ->with('@domain-info form', function (Browser $browser) use ($domain) { + $browser->assertElementsCount('.row', 2) + ->assertSeeIn('.row:nth-child(1) label', 'ID (Created at)') + ->assertSeeIn('.row:nth-child(1) #domainid', "{$domain->id} ({$domain->created_at})") + ->assertSeeIn('.row:nth-child(2) label', 'Status') + ->assertSeeIn('.row:nth-child(2) #status span.text-success', 'Active'); + }); + + // Some tabs are loaded in background, wait a second + $browser->pause(500) + ->assertElementsCount('@nav a', 1); + + // Assert Configuration tab + $browser->assertSeeIn('@nav #tab-config', 'Configuration') + ->with('@domain-config', function (Browser $browser) { + $browser->assertSeeIn('pre#dns-verify', 'kolab-verify.kolab.org.') + ->assertSeeIn('pre#dns-config', 'kolab.org.'); + }); + }); + } +} diff --git a/src/tests/Browser/Admin/LogonTest.php b/src/tests/Browser/Admin/LogonTest.php index 1b39f40b..09cc7b22 100644 --- a/src/tests/Browser/Admin/LogonTest.php +++ b/src/tests/Browser/Admin/LogonTest.php @@ -1,157 +1,159 @@ browse(function (Browser $browser) { - $browser->visit(new Home()); - $browser->within(new Menu(), function ($browser) { - $browser->assertMenuItems(['signup', 'explore', 'blog', 'support', 'webmail']); - }); + $browser->visit(new Home()) + ->with(new Menu(), function ($browser) { + $browser->assertMenuItems(['signup', 'explore', 'blog', 'support', 'webmail']); + }) + ->assertMissing('@second-factor-input') + ->assertMissing('@forgot-password'); }); } /** * Test redirect to /login if user is unauthenticated */ public function testLogonRedirect(): void { $this->browse(function (Browser $browser) { $browser->visit('/dashboard'); // Checks if we're really on the login page $browser->waitForLocation('/login') ->on(new Home()); }); } /** * Logon with wrong password/user test */ public function testLogonWrongCredentials(): void { $this->browse(function (Browser $browser) { $browser->visit(new Home()) ->submitLogon('jeroen@jeroen.jeroen', 'wrong'); // Error message $browser->with(new Toast(Toast::TYPE_ERROR), function (Browser $browser) { $browser->assertToastTitle('Error') ->assertToastMessage('Invalid username or password.') ->closeToast(); }); // Checks if we're still on the logon page $browser->on(new Home()); }); } /** * Successful logon test */ public function testLogonSuccessful(): void { $this->browse(function (Browser $browser) { $browser->visit(new Home()) ->submitLogon('jeroen@jeroen.jeroen', 'jeroen', true); // Checks if we're really on Dashboard page $browser->on(new Dashboard()) ->within(new Menu(), function ($browser) { $browser->assertMenuItems(['support', 'contact', 'webmail', 'logout']); }) ->assertUser('jeroen@jeroen.jeroen'); // Test that visiting '/' with logged in user does not open logon form // but "redirects" to the dashboard $browser->visit('/')->on(new Dashboard()); }); } /** * Logout test * * @depends testLogonSuccessful */ public function testLogout(): void { $this->browse(function (Browser $browser) { $browser->on(new Dashboard()); // Click the Logout button $browser->within(new Menu(), function ($browser) { $browser->click('.link-logout'); }); // We expect the logon page $browser->waitForLocation('/login') ->on(new Home()); // with default menu $browser->within(new Menu(), function ($browser) { $browser->assertMenuItems(['signup', 'explore', 'blog', 'support', 'webmail']); }); // Success toast message $browser->with(new Toast(Toast::TYPE_SUCCESS), function (Browser $browser) { $browser->assertToastTitle('') ->assertToastMessage('Successfully logged out') ->closeToast(); }); }); } /** * Logout by URL test */ public function testLogoutByURL(): void { $this->browse(function (Browser $browser) { $browser->visit(new Home()) ->submitLogon('jeroen@jeroen.jeroen', 'jeroen', true); // Checks if we're really on Dashboard page $browser->on(new Dashboard()); // Use /logout url, and expect the logon page $browser->visit('/logout') ->waitForLocation('/login') ->on(new Home()); // with default menu $browser->within(new Menu(), function ($browser) { $browser->assertMenuItems(['signup', 'explore', 'blog', 'support', 'webmail']); }); // Success toast message $browser->with(new Toast(Toast::TYPE_SUCCESS), function (Browser $browser) { $browser->assertToastTitle('') ->assertToastMessage('Successfully logged out') ->closeToast(); }); }); } } diff --git a/src/tests/Browser/Admin/UserTest.php b/src/tests/Browser/Admin/UserTest.php new file mode 100644 index 00000000..3b630d00 --- /dev/null +++ b/src/tests/Browser/Admin/UserTest.php @@ -0,0 +1,342 @@ +getTestUser('john@kolab.org'); + $john->setSettings([ + 'phone' => '+48123123123', + ]); + + $wallet = $john->wallets()->first(); + $wallet->discount()->dissociate(); + $wallet->balance = 0; + $wallet->save(); + } + + /** + * {@inheritDoc} + */ + public function tearDown(): void + { + $john = $this->getTestUser('john@kolab.org'); + $john->setSettings([ + 'phone' => null, + ]); + + $wallet = $john->wallets()->first(); + $wallet->discount()->dissociate(); + $wallet->balance = 0; + $wallet->save(); + + parent::tearDown(); + } + + /** + * Test user info page (unauthenticated) + */ + public function testUserUnauth(): void + { + // Test that the page requires authentication + $this->browse(function (Browser $browser) { + $jack = $this->getTestUser('jack@kolab.org'); + $browser->visit('/user/' . $jack->id)->on(new Home()); + }); + } + + /** + * Test user info page + */ + public function testUserInfo(): void + { + $this->browse(function (Browser $browser) { + $jack = $this->getTestUser('jack@kolab.org'); + $page = new UserPage($jack->id); + + $browser->visit(new Home()) + ->submitLogon('jeroen@jeroen.jeroen', 'jeroen', true) + ->on(new Dashboard()) + ->visit($page) + ->on($page); + + // Assert main info box content + $browser->assertSeeIn('@user-info .card-title', $jack->email) + ->with('@user-info form', function (Browser $browser) use ($jack) { + $browser->assertElementsCount('.row', 7) + ->assertSeeIn('.row:nth-child(1) label', 'Managed by') + ->assertSeeIn('.row:nth-child(1) #manager a', 'john@kolab.org') + ->assertSeeIn('.row:nth-child(2) label', 'ID (Created at)') + ->assertSeeIn('.row:nth-child(2) #userid', "{$jack->id} ({$jack->created_at})") + ->assertSeeIn('.row:nth-child(3) label', 'Status') + ->assertSeeIn('.row:nth-child(3) #status span.text-success', 'Active') + ->assertSeeIn('.row:nth-child(4) label', 'First name') + ->assertSeeIn('.row:nth-child(4) #first_name', 'Jack') + ->assertSeeIn('.row:nth-child(5) label', 'Last name') + ->assertSeeIn('.row:nth-child(5) #last_name', 'Daniels') + ->assertSeeIn('.row:nth-child(6) label', 'External email') + ->assertMissing('.row:nth-child(6) #external_email a') + ->assertSeeIn('.row:nth-child(7) label', 'Country') + ->assertSeeIn('.row:nth-child(7) #country', 'United States of America'); + }); + + // Some tabs are loaded in background, wait a second + $browser->pause(500) + ->assertElementsCount('@nav a', 5); + + // Assert Finances tab + $browser->assertSeeIn('@nav #tab-finances', 'Finances') + ->with('@user-finances', function (Browser $browser) { + $browser->assertSeeIn('.card-title', 'Account balance') + ->assertSeeIn('.card-title .text-success', '0,00 CHF') + ->with('form', function (Browser $browser) { + $browser->assertElementsCount('.row', 1) + ->assertSeeIn('.row:nth-child(1) label', 'Discount') + ->assertSeeIn('.row:nth-child(1) #discount span', 'none'); + }); + }); + + // Assert Aliases tab + $browser->assertSeeIn('@nav #tab-aliases', 'Aliases (1)') + ->click('@nav #tab-aliases') + ->whenAvailable('@user-aliases', function (Browser $browser) { + $browser->assertElementsCount('table tbody tr', 1) + ->assertSeeIn('table tbody tr:first-child td:first-child', 'jack.daniels@kolab.org') + ->assertMissing('table tfoot'); + }); + + // Assert Subscriptions tab + $browser->assertSeeIn('@nav #tab-subscriptions', 'Subscriptions (3)') + ->click('@nav #tab-subscriptions') + ->with('@user-subscriptions', function (Browser $browser) { + $browser->assertElementsCount('table tbody tr', 3) + ->assertSeeIn('table tbody tr:nth-child(1) td:first-child', 'User Mailbox') + ->assertSeeIn('table tbody tr:nth-child(1) td:last-child', '4,44 CHF') + ->assertSeeIn('table tbody tr:nth-child(2) td:first-child', 'Storage Quota 2 GB') + ->assertSeeIn('table tbody tr:nth-child(2) td:last-child', '0,00 CHF') + ->assertSeeIn('table tbody tr:nth-child(3) td:first-child', 'Groupware Features') + ->assertSeeIn('table tbody tr:nth-child(3) td:last-child', '5,55 CHF') + ->assertMissing('table tfoot'); + }); + + // Assert Domains tab + $browser->assertSeeIn('@nav #tab-domains', 'Domains (0)') + ->click('@nav #tab-domains') + ->with('@user-domains', function (Browser $browser) { + $browser->assertElementsCount('table tbody tr', 0) + ->assertSeeIn('table tfoot tr td', 'There are no domains in this account.'); + }); + + // Assert Users tab + $browser->assertSeeIn('@nav #tab-users', 'Users (0)') + ->click('@nav #tab-users') + ->with('@user-users', function (Browser $browser) { + $browser->assertElementsCount('table tbody tr', 0) + ->assertSeeIn('table tfoot tr td', 'There are no users in this account.'); + }); + }); + } + + /** + * Test user info page (continue) + * + * @depends testUserInfo + */ + public function testUserInfo2(): void + { + $this->browse(function (Browser $browser) { + $john = $this->getTestUser('john@kolab.org'); + $page = new UserPage($john->id); + $discount = Discount::where('code', 'TEST')->first(); + $wallet = $john->wallet(); + $wallet->discount()->associate($discount); + $wallet->debit(2010); + $wallet->save(); + + // Click the managed-by link on Jack's page + $browser->click('@user-info #manager a') + ->on($page); + + // Assert main info box content + $browser->assertSeeIn('@user-info .card-title', $john->email) + ->with('@user-info form', function (Browser $browser) use ($john) { + $ext_email = $john->getSetting('external_email'); + + $browser->assertElementsCount('.row', 8) + ->assertSeeIn('.row:nth-child(1) label', 'ID (Created at)') + ->assertSeeIn('.row:nth-child(1) #userid', "{$john->id} ({$john->created_at})") + ->assertSeeIn('.row:nth-child(2) label', 'Status') + ->assertSeeIn('.row:nth-child(2) #status span.text-success', 'Active') + ->assertSeeIn('.row:nth-child(3) label', 'First name') + ->assertSeeIn('.row:nth-child(3) #first_name', 'John') + ->assertSeeIn('.row:nth-child(4) label', 'Last name') + ->assertSeeIn('.row:nth-child(4) #last_name', 'Doe') + ->assertSeeIn('.row:nth-child(5) label', 'Phone') + ->assertSeeIn('.row:nth-child(5) #phone', $john->getSetting('phone')) + ->assertSeeIn('.row:nth-child(6) label', 'External email') + ->assertSeeIn('.row:nth-child(6) #external_email a', $ext_email) + ->assertAttribute('.row:nth-child(6) #external_email a', 'href', "mailto:$ext_email") + ->assertSeeIn('.row:nth-child(7) label', 'Address') + ->assertSeeIn('.row:nth-child(7) #billing_address', $john->getSetting('billing_address')) + ->assertSeeIn('.row:nth-child(8) label', 'Country') + ->assertSeeIn('.row:nth-child(8) #country', 'United States of America'); + }); + + // Some tabs are loaded in background, wait a second + $browser->pause(500) + ->assertElementsCount('@nav a', 5); + + // Assert Finances tab + $browser->assertSeeIn('@nav #tab-finances', 'Finances') + ->with('@user-finances', function (Browser $browser) { + $browser->assertSeeIn('.card-title', 'Account balance') + ->assertSeeIn('.card-title .text-danger', '-20,10 CHF') + ->with('form', function (Browser $browser) { + $browser->assertElementsCount('.row', 1) + ->assertSeeIn('.row:nth-child(1) label', 'Discount') + ->assertSeeIn('.row:nth-child(1) #discount span', '10% - Test voucher'); + }); + }); + + // Assert Aliases tab + $browser->assertSeeIn('@nav #tab-aliases', 'Aliases (1)') + ->click('@nav #tab-aliases') + ->whenAvailable('@user-aliases', function (Browser $browser) { + $browser->assertElementsCount('table tbody tr', 1) + ->assertSeeIn('table tbody tr:first-child td:first-child', 'john.doe@kolab.org') + ->assertMissing('table tfoot'); + }); + + // Assert Subscriptions tab + $browser->assertSeeIn('@nav #tab-subscriptions', 'Subscriptions (3)') + ->click('@nav #tab-subscriptions') + ->with('@user-subscriptions', function (Browser $browser) { + $browser->assertElementsCount('table tbody tr', 3) + ->assertSeeIn('table tbody tr:nth-child(1) td:first-child', 'User Mailbox') + ->assertSeeIn('table tbody tr:nth-child(1) td:last-child', '3,99 CHF/month¹') + ->assertSeeIn('table tbody tr:nth-child(2) td:first-child', 'Storage Quota 2 GB') + ->assertSeeIn('table tbody tr:nth-child(2) td:last-child', '0,00 CHF/month¹') + ->assertSeeIn('table tbody tr:nth-child(3) td:first-child', 'Groupware Features') + ->assertSeeIn('table tbody tr:nth-child(3) td:last-child', '4,99 CHF/month¹') + ->assertMissing('table tfoot') + ->assertSeeIn('table + .hint', '¹ applied discount: 10% - Test voucher'); + }); + + // Assert Domains tab + $browser->assertSeeIn('@nav #tab-domains', 'Domains (1)') + ->click('@nav #tab-domains') + ->with('@user-domains table', function (Browser $browser) { + $browser->assertElementsCount('tbody tr', 1) + ->assertSeeIn('tbody tr:nth-child(1) td:first-child a', 'kolab.org') + ->assertVisible('tbody tr:nth-child(1) td:first-child svg.text-success') + ->assertMissing('tfoot'); + }); + + // Assert Users tab + $browser->assertSeeIn('@nav #tab-users', 'Users (3)') + ->click('@nav #tab-users') + ->with('@user-users table', function (Browser $browser) { + $browser->assertElementsCount('tbody tr', 3) + ->assertSeeIn('tbody tr:nth-child(1) td:first-child a', 'jack@kolab.org') + ->assertVisible('tbody tr:nth-child(1) td:first-child svg.text-success') + ->assertSeeIn('tbody tr:nth-child(2) td:first-child a', 'joe@kolab.org') + ->assertVisible('tbody tr:nth-child(2) td:first-child svg.text-success') + ->assertSeeIn('tbody tr:nth-child(3) td:first-child a', 'ned@kolab.org') + ->assertVisible('tbody tr:nth-child(3) td:first-child svg.text-success') + ->assertMissing('tfoot'); + }); + }); + + // Now we go to Ned's info page, he's a controller on John's wallet + $this->browse(function (Browser $browser) { + $ned = $this->getTestUser('ned@kolab.org'); + $page = new UserPage($ned->id); + + $browser->click('@user-users tbody tr:nth-child(3) td:first-child a') + ->on($page); + + // Assert main info box content + $browser->assertSeeIn('@user-info .card-title', $ned->email) + ->with('@user-info form', function (Browser $browser) use ($ned) { + $browser->assertSeeIn('.row:nth-child(2) label', 'ID (Created at)') + ->assertSeeIn('.row:nth-child(2) #userid', "{$ned->id} ({$ned->created_at})"); + }); + + // Some tabs are loaded in background, wait a second + $browser->pause(500) + ->assertElementsCount('@nav a', 5); + + // Assert Finances tab + $browser->assertSeeIn('@nav #tab-finances', 'Finances') + ->with('@user-finances', function (Browser $browser) { + $browser->assertSeeIn('.card-title', 'Account balance') + ->assertSeeIn('.card-title .text-success', '0,00 CHF') + ->with('form', function (Browser $browser) { + $browser->assertElementsCount('.row', 1) + ->assertSeeIn('.row:nth-child(1) label', 'Discount') + ->assertSeeIn('.row:nth-child(1) #discount span', 'none'); + }); + }); + + // Assert Aliases tab + $browser->assertSeeIn('@nav #tab-aliases', 'Aliases (0)') + ->click('@nav #tab-aliases') + ->whenAvailable('@user-aliases', function (Browser $browser) { + $browser->assertElementsCount('table tbody tr', 0) + ->assertSeeIn('table tfoot tr td', 'This user has no email aliases.'); + }); + + // Assert Subscriptions tab, we expect John's discount here + $browser->assertSeeIn('@nav #tab-subscriptions', 'Subscriptions (5)') + ->click('@nav #tab-subscriptions') + ->with('@user-subscriptions', function (Browser $browser) { + $browser->assertElementsCount('table tbody tr', 5) + ->assertSeeIn('table tbody tr:nth-child(1) td:first-child', 'User Mailbox') + ->assertSeeIn('table tbody tr:nth-child(1) td:last-child', '3,99 CHF/month¹') + ->assertSeeIn('table tbody tr:nth-child(2) td:first-child', 'Storage Quota 2 GB') + ->assertSeeIn('table tbody tr:nth-child(2) td:last-child', '0,00 CHF/month¹') + ->assertSeeIn('table tbody tr:nth-child(3) td:first-child', 'Groupware Features') + ->assertSeeIn('table tbody tr:nth-child(3) td:last-child', '4,99 CHF/month¹') + ->assertSeeIn('table tbody tr:nth-child(4) td:first-child', 'Activesync') + ->assertSeeIn('table tbody tr:nth-child(4) td:last-child', '0,90 CHF/month¹') + ->assertSeeIn('table tbody tr:nth-child(5) td:first-child', '2-Factor Authentication') + ->assertSeeIn('table tbody tr:nth-child(5) td:last-child', '0,00 CHF/month¹') + ->assertMissing('table tfoot') + ->assertSeeIn('table + .hint', '¹ applied discount: 10% - Test voucher'); + }); + + // We don't expect John's domains here + $browser->assertSeeIn('@nav #tab-domains', 'Domains (0)') + ->click('@nav #tab-domains') + ->with('@user-domains', function (Browser $browser) { + $browser->assertElementsCount('table tbody tr', 0) + ->assertSeeIn('table tfoot tr td', 'There are no domains in this account.'); + }); + + // We don't expect John's users here + $browser->assertSeeIn('@nav #tab-users', 'Users (0)') + ->click('@nav #tab-users') + ->with('@user-users', function (Browser $browser) { + $browser->assertElementsCount('table tbody tr', 0) + ->assertSeeIn('table tfoot tr td', 'There are no users in this account.'); + }); + }); + } +} diff --git a/src/tests/Browser/Pages/Admin/Domain.php b/src/tests/Browser/Pages/Admin/Domain.php new file mode 100644 index 00000000..5c8d3d63 --- /dev/null +++ b/src/tests/Browser/Pages/Admin/Domain.php @@ -0,0 +1,58 @@ +domainid = $domainid; + } + + /** + * Get the URL for the page. + * + * @return string + */ + public function url(): string + { + return '/domain/' . $this->domainid; + } + + /** + * Assert that the browser is on the page. + * + * @param \Laravel\Dusk\Browser $browser The browser object + * + * @return void + */ + public function assert($browser): void + { + $browser->waitForLocation($this->url()) + ->waitFor('@domain-info'); + } + + /** + * Get the element shortcuts for the page. + * + * @return array + */ + public function elements(): array + { + return [ + '@app' => '#app', + '@domain-info' => '#domain-info', + '@nav' => 'ul.nav-tabs', + '@domain-config' => '#domain-config', + ]; + } +} diff --git a/src/tests/Browser/Pages/Admin/User.php b/src/tests/Browser/Pages/Admin/User.php new file mode 100644 index 00000000..99f8fdbe --- /dev/null +++ b/src/tests/Browser/Pages/Admin/User.php @@ -0,0 +1,62 @@ +userid = $userid; + } + + /** + * Get the URL for the page. + * + * @return string + */ + public function url(): string + { + return '/user/' . $this->userid; + } + + /** + * Assert that the browser is on the page. + * + * @param \Laravel\Dusk\Browser $browser The browser object + * + * @return void + */ + public function assert($browser): void + { + $browser->waitForLocation($this->url()) + ->waitFor('@user-info'); + } + + /** + * Get the element shortcuts for the page. + * + * @return array + */ + public function elements(): array + { + return [ + '@app' => '#app', + '@user-info' => '#user-info', + '@nav' => 'ul.nav-tabs', + '@user-finances' => '#user-finances', + '@user-aliases' => '#user-aliases', + '@user-subscriptions' => '#user-subscriptions', + '@user-domains' => '#user-domains', + '@user-users' => '#user-users', + ]; + } +} diff --git a/src/tests/Feature/Controller/Admin/DomainsTest.php b/src/tests/Feature/Controller/Admin/DomainsTest.php new file mode 100644 index 00000000..32210530 --- /dev/null +++ b/src/tests/Feature/Controller/Admin/DomainsTest.php @@ -0,0 +1,87 @@ +getTestUser('john@kolab.org'); + $admin = $this->getTestUser('jeroen@jeroen.jeroen'); + + // Non-admin user + $response = $this->actingAs($john)->get("api/v4/domains"); + $response->assertStatus(403); + + // Search with no search criteria + $response = $this->actingAs($admin)->get("api/v4/domains"); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertSame(0, $json['count']); + $this->assertSame([], $json['list']); + + // Search with no matches expected + $response = $this->actingAs($admin)->get("api/v4/domains?search=abcd12.org"); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertSame(0, $json['count']); + $this->assertSame([], $json['list']); + + // Search by a domain name + $response = $this->actingAs($admin)->get("api/v4/domains?search=kolab.org"); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertSame(1, $json['count']); + $this->assertCount(1, $json['list']); + $this->assertSame('kolab.org', $json['list'][0]['namespace']); + + // Search by owner + $response = $this->actingAs($admin)->get("api/v4/domains?owner={$john->id}"); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertSame(1, $json['count']); + $this->assertCount(1, $json['list']); + $this->assertSame('kolab.org', $json['list'][0]['namespace']); + + // Search by owner (Ned is a controller on John's wallets, + // here we expect only domains assigned to Ned's wallet(s)) + $ned = $this->getTestUser('ned@kolab.org'); + $response = $this->actingAs($admin)->get("api/v4/domains?owner={$ned->id}"); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertSame(0, $json['count']); + $this->assertCount(0, $json['list']); + } +} diff --git a/src/tests/Feature/Controller/Admin/UsersTest.php b/src/tests/Feature/Controller/Admin/UsersTest.php index 328eb9f9..4b0c4526 100644 --- a/src/tests/Feature/Controller/Admin/UsersTest.php +++ b/src/tests/Feature/Controller/Admin/UsersTest.php @@ -1,123 +1,143 @@ getTestUser('jack@kolab.org'); $jack->setSetting('external_email', null); } /** * {@inheritDoc} */ public function tearDown(): void { $jack = $this->getTestUser('jack@kolab.org'); $jack->setSetting('external_email', null); parent::tearDown(); } /** * Test users searching (/api/v4/users) */ public function testIndex(): void { $user = $this->getTestUser('john@kolab.org'); $admin = $this->getTestUser('jeroen@jeroen.jeroen'); // Non-admin user $response = $this->actingAs($user)->get("api/v4/users"); $response->assertStatus(403); // Search with no search criteria $response = $this->actingAs($admin)->get("api/v4/users"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(0, $json['count']); $this->assertSame([], $json['list']); // Search with no matches expected $response = $this->actingAs($admin)->get("api/v4/users?search=abcd1234efgh5678"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(0, $json['count']); $this->assertSame([], $json['list']); // Search by domain $response = $this->actingAs($admin)->get("api/v4/users?search=kolab.org"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(1, $json['count']); $this->assertCount(1, $json['list']); $this->assertSame($user->id, $json['list'][0]['id']); $this->assertSame($user->email, $json['list'][0]['email']); // Search by user ID $response = $this->actingAs($admin)->get("api/v4/users?search={$user->id}"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(1, $json['count']); $this->assertCount(1, $json['list']); $this->assertSame($user->id, $json['list'][0]['id']); $this->assertSame($user->email, $json['list'][0]['email']); // Search by email (primary) $response = $this->actingAs($admin)->get("api/v4/users?search=john@kolab.org"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(1, $json['count']); $this->assertCount(1, $json['list']); $this->assertSame($user->id, $json['list'][0]['id']); $this->assertSame($user->email, $json['list'][0]['email']); // Search by email (alias) $response = $this->actingAs($admin)->get("api/v4/users?search=john.doe@kolab.org"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(1, $json['count']); $this->assertCount(1, $json['list']); $this->assertSame($user->id, $json['list'][0]['id']); $this->assertSame($user->email, $json['list'][0]['email']); // Search by email (external), expect two users in a result $jack = $this->getTestUser('jack@kolab.org'); $jack->setSetting('external_email', 'john.doe.external@gmail.com'); $response = $this->actingAs($admin)->get("api/v4/users?search=john.doe.external@gmail.com"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(2, $json['count']); $this->assertCount(2, $json['list']); $emails = array_column($json['list'], 'email'); $this->assertContains($user->email, $emails); $this->assertContains($jack->email, $emails); + + // Search by owner + $response = $this->actingAs($admin)->get("api/v4/users?owner={$user->id}"); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertSame(4, $json['count']); + $this->assertCount(4, $json['list']); + + // Search by owner (Ned is a controller on John's wallets, + // here we expect only users assigned to Ned's wallet(s)) + $ned = $this->getTestUser('ned@kolab.org'); + $response = $this->actingAs($admin)->get("api/v4/users?owner={$ned->id}"); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertSame(0, $json['count']); + $this->assertCount(0, $json['list']); } }