diff --git a/src/app/Backends/LDAP.php b/src/app/Backends/LDAP.php --- a/src/app/Backends/LDAP.php +++ b/src/app/Backends/LDAP.php @@ -716,16 +716,15 @@ list($local, $domainName) = explode('@', $member); $memberDN = "uid={$member},ou=People,{$domainBaseDN}"; + $memberEntry = $ldap->get_entry($memberDN); // if the member is in the local domain but doesn't exist, drop it - if ($domainName == $domain->namespace) { - if (!$ldap->get_entry($memberDN)) { - continue; - } + if ($domainName == $domain->namespace && !$memberEntry) { + continue; } // add the member if not in the local domain - if (!$ldap->get_entry($memberDN)) { + if (!$memberEntry) { $memberEntry = [ 'cn' => $member, 'mail' => $member, diff --git a/src/app/Console/Commands/Group/AddMemberCommand.php b/src/app/Console/Commands/Group/AddMemberCommand.php --- a/src/app/Console/Commands/Group/AddMemberCommand.php +++ b/src/app/Console/Commands/Group/AddMemberCommand.php @@ -3,6 +3,7 @@ namespace App\Console\Commands\Group; use App\Console\Command; +use App\Http\Controllers\API\V4\GroupsController; class AddMemberCommand extends Command { @@ -41,7 +42,9 @@ return 1; } - if ($error = CreateCommand::validateMemberEmail($member)) { + $owner = $group->wallet()->owner; + + if ($error = GroupsController::validateMemberEmail($member, $owner)) { $this->error("{$member}: $error"); return 1; } diff --git a/src/app/Console/Commands/Group/CreateCommand.php b/src/app/Console/Commands/Group/CreateCommand.php --- a/src/app/Console/Commands/Group/CreateCommand.php +++ b/src/app/Console/Commands/Group/CreateCommand.php @@ -3,11 +3,9 @@ namespace App\Console\Commands\Group; use App\Console\Command; -use App\Domain; use App\Group; -use App\User; +use App\Http\Controllers\API\V4\GroupsController; use Illuminate\Support\Facades\DB; -use Illuminate\Support\Facades\Validator; class CreateCommand extends Command { @@ -51,9 +49,9 @@ $owner = $domain->wallet()->owner; - // Validate group email address + // Validate members addresses foreach ($members as $i => $member) { - if ($error = $this->validateMemberEmail($member)) { + if ($error = GroupsController::validateMemberEmail($member, $owner)) { $this->error("{$member}: $error"); return 1; } @@ -63,8 +61,8 @@ } } - // Validate members addresses - if ($error = $this->validateGroupEmail($email, $owner)) { + // Validate group email address + if ($error = GroupsController::validateGroupEmail($email, $owner)) { $this->error("{$email}: {$error}"); return 1; } @@ -83,91 +81,4 @@ $this->info($group->id); } - - /** - * Validate an email address for use as a group member - * - * @param string $email Email address - * - * @return ?string Error message on validation error - */ - public static function validateMemberEmail(string $email): ?string - { - $v = Validator::make( - ['email' => $email], - ['email' => [new \App\Rules\ExternalEmail()]] - ); - - if ($v->fails()) { - return $v->errors()->toArray()['email'][0]; - } - - return null; - } - - /** - * Validate an email address for use as a group email - * - * @param string $email Email address - * @param \App\User $user The group owner - * - * @return ?string Error message on validation error - */ - public static function validateGroupEmail(string $email, \App\User $user): ?string - { - if (strpos($email, '@') === false) { - return \trans('validation.entryinvalid', ['attribute' => 'email']); - } - - list($login, $domain) = explode('@', \strtolower($email)); - - if (strlen($login) === 0 || strlen($domain) === 0) { - return \trans('validation.entryinvalid', ['attribute' => 'email']); - } - - // Check if domain exists - $domain = Domain::where('namespace', $domain)->first(); -/* - if (empty($domain)) { - return \trans('validation.domainnotavailable'); - } - - if ($domain->isPublic()) { - return \trans('validation.domainnotavailable'); - } -*/ - // Validate login part alone - $v = Validator::make( - ['email' => $login], - ['email' => [new \App\Rules\UserEmailLocal(!$domain->isPublic())]] - ); - - if ($v->fails()) { - return $v->errors()->toArray()['email'][0]; - } -/* - // Check if it is one of domains available to the user - $domains = \collect($user->domains())->pluck('namespace')->all(); - - if (!in_array($domain->namespace, $domains)) { - // return \trans('validation.entryexists', ['attribute' => 'domain']); - return \trans('validation.domainnotavailable'); - } -*/ - // Check if a user with specified address already exists - if (User::emailExists($email)) { - return \trans('validation.entryexists', ['attribute' => 'email']); - } - - // Check if an alias with specified address already exists. - if (User::aliasExists($email)) { - return \trans('validation.entryexists', ['attribute' => 'email']); - } - - if (Group::emailExists($email)) { - return \trans('validation.entryexists', ['attribute' => 'email']); - } - - return null; - } } diff --git a/src/app/Handlers/Distlist.php b/src/app/Handlers/Distlist.php new file mode 100644 --- /dev/null +++ b/src/app/Handlers/Distlist.php @@ -0,0 +1,49 @@ +wallet()->entitlements() + ->where('entitleable_type', \App\Domain::class)->count() > 0; + } + + return false; + } + + /** + * The priority that specifies the order of SKUs in UI. + * Higher number means higher on the list. + * + * @return int + */ + public static function priority(): int + { + return 10; + } +} diff --git a/src/app/Http/Controllers/API/V4/GroupsController.php b/src/app/Http/Controllers/API/V4/GroupsController.php new file mode 100644 --- /dev/null +++ b/src/app/Http/Controllers/API/V4/GroupsController.php @@ -0,0 +1,507 @@ +errorResponse(404); + } + + /** + * Delete a group. + * + * @param int $id Group identifier + * + * @return \Illuminate\Http\JsonResponse The response + */ + public function destroy($id) + { + $group = Group::find($id); + + if (empty($group)) { + return $this->errorResponse(404); + } + + if (!$this->guard()->user()->canDelete($group)) { + return $this->errorResponse(403); + } + + $group->delete(); + + return response()->json([ + 'status' => 'success', + 'message' => __('app.distlist-delete-success'), + ]); + } + + /** + * Show the form for editing the specified group. + * + * @param int $id Group identifier + * + * @return \Illuminate\Http\JsonResponse + */ + public function edit($id) + { + return $this->errorResponse(404); + } + + /** + * Listing of groups belonging to the authenticated user. + * + * The group-entitlements billed to the current user wallet(s) + * + * @return \Illuminate\Http\JsonResponse + */ + public function index() + { + $user = $this->guard()->user(); + + $result = $user->groups()->orderBy('email')->get() + ->map(function (Group $group) { + $data = [ + 'id' => $group->id, + 'email' => $group->email, + ]; + + $data = array_merge($data, self::groupStatuses($group)); + return $data; + }); + + return response()->json($result); + } + + /** + * Display information of a group specified by $id. + * + * @param int $id The group to show information for. + * + * @return \Illuminate\Http\JsonResponse + */ + public function show($id) + { + $group = Group::find($id); + + if (empty($group)) { + return $this->errorResponse(404); + } + + if (!$this->guard()->user()->canRead($group)) { + return $this->errorResponse(403); + } + + $response = $group->toArray(); + + $response = array_merge($response, self::groupStatuses($group)); + $response['statusInfo'] = self::statusInfo($group); + + return response()->json($response); + } + + /** + * Fetch group status (and reload setup process) + * + * @param int $id Group identifier + * + * @return \Illuminate\Http\JsonResponse + */ + public function status($id) + { + $group = Group::find($id); + + if (empty($group)) { + return $this->errorResponse(404); + } + + if (!$this->guard()->user()->canRead($group)) { + return $this->errorResponse(403); + } + + $response = self::statusInfo($group); + + if (!empty(request()->input('refresh'))) { + $updated = false; + $async = false; + $last_step = 'none'; + + foreach ($response['process'] as $idx => $step) { + $last_step = $step['label']; + + if (!$step['state']) { + $exec = $this->execProcessStep($group, $step['label']); + + if (!$exec) { + if ($exec === null) { + $async = true; + } + + break; + } + + $updated = true; + } + } + + if ($updated) { + $response = self::statusInfo($group); + } + + $success = $response['isReady']; + $suffix = $success ? 'success' : 'error-' . $last_step; + + $response['status'] = $success ? 'success' : 'error'; + $response['message'] = \trans('app.process-' . $suffix); + + if ($async && !$success) { + $response['processState'] = 'waiting'; + $response['status'] = 'success'; + $response['message'] = \trans('app.process-async'); + } + } + + $response = array_merge($response, self::groupStatuses($group)); + + return response()->json($response); + } + + /** + * Group status (extended) information + * + * @param \App\Group $group Group object + * + * @return array Status information + */ + public static function statusInfo(Group $group): array + { + $process = []; + $steps = [ + 'distlist-new' => true, + 'distlist-ldap-ready' => $group->isLdapReady(), + ]; + + // 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; + } + + $domain = $group->domain(); + + // 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 && $group->created_at->diffInSeconds(Carbon::now()) > 180) { + $state = 'failed'; + } + + return [ + 'process' => $process, + 'processState' => $state, + 'isReady' => $all === $checked, + ]; + } + + /** + * Create a new group 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); + } + + $email = request()->input('email'); + $members = request()->input('members'); + $errors = []; + + // Validate group address + if ($error = GroupsController::validateGroupEmail($email, $owner)) { + $errors['email'] = $error; + } + + // Validate members' email addresses + if (empty($members) || !is_array($members)) { + $errors['members'] = \trans('validation.listmembersrequired'); + } else { + foreach ($members as $i => $member) { + if (is_string($member) && !empty($member)) { + if ($error = GroupsController::validateMemberEmail($member, $owner)) { + $errors['members'][$i] = $error; + } elseif (\strtolower($member) === \strtolower($email)) { + $errors['members'][$i] = \trans('validation.memberislist'); + } + } else { + unset($members[$i]); + } + } + } + + if (!empty($errors)) { + return response()->json(['status' => 'error', 'errors' => $errors], 422); + } + + DB::beginTransaction(); + + // Create the group + $group = new Group(); + $group->email = $email; + $group->members = $members; + $group->save(); + + $group->assignToWallet($owner->wallets->first()); + + DB::commit(); + + return response()->json([ + 'status' => 'success', + 'message' => __('app.distlist-create-success'), + ]); + } + + /** + * Update a group. + * + * @param \Illuminate\Http\Request $request The API request. + * @param string $id Group identifier + * + * @return \Illuminate\Http\JsonResponse The response + */ + public function update(Request $request, $id) + { + $group = Group::find($id); + + if (empty($group)) { + return $this->errorResponse(404); + } + + $current_user = $this->guard()->user(); + + if (!$current_user->canUpdate($group)) { + return $this->errorResponse(403); + } + + $owner = $group->wallet()->owner; + + // It is possible to update members property only for now + $members = request()->input('members'); + $errors = []; + + // Validate members' email addresses + if (empty($members) || !is_array($members)) { + $errors['members'] = \trans('validation.listmembersrequired'); + } else { + foreach ((array) $members as $i => $member) { + if (is_string($member) && !empty($member)) { + if ($error = GroupsController::validateMemberEmail($member, $owner)) { + $errors['members'][$i] = $error; + } elseif (\strtolower($member) === $group->email) { + $errors['members'][$i] = \trans('validation.memberislist'); + } + } else { + unset($members[$i]); + } + } + } + + if (!empty($errors)) { + return response()->json(['status' => 'error', 'errors' => $errors], 422); + } + + $group->members = $members; + $group->save(); + + return response()->json([ + 'status' => 'success', + 'message' => __('app.distlist-update-success'), + ]); + } + + /** + * Execute (synchronously) specified step in a group setup process. + * + * @param \App\Group $group Group object + * @param string $step Step identifier (as in self::statusInfo()) + * + * @return bool|null True if the execution succeeded, False if not, Null when + * the job has been sent to the worker (result unknown) + */ + public static function execProcessStep(Group $group, string $step): ?bool + { + try { + if (strpos($step, 'domain-') === 0) { + return DomainsController::execProcessStep($group->domain(), $step); + } + + switch ($step) { + case 'distlist-ldap-ready': + // Group not in LDAP, create it + $job = new \App\Jobs\Group\CreateJob($group->id); + $job->handle(); + + $group->refresh(); + + return $group->isLdapReady(); + } + } catch (\Exception $e) { + \Log::error($e); + } + + return false; + } + + /** + * Prepare group statuses for the UI + * + * @param \App\Group $group Group object + * + * @return array Statuses array + */ + protected static function groupStatuses(Group $group): array + { + return [ + 'isLdapReady' => $group->isLdapReady(), + 'isSuspended' => $group->isSuspended(), + 'isActive' => $group->isActive(), + 'isDeleted' => $group->isDeleted() || $group->trashed(), + ]; + } + + /** + * Validate an email address for use as a group email + * + * @param string $email Email address + * @param \App\User $user The group owner + * + * @return ?string Error message on validation error + */ + public static function validateGroupEmail($email, \App\User $user): ?string + { + if (empty($email)) { + return \trans('validation.required', ['attribute' => 'email']); + } + + if (strpos($email, '@') === false) { + return \trans('validation.entryinvalid', ['attribute' => 'email']); + } + + list($login, $domain) = explode('@', \strtolower($email)); + + if (strlen($login) === 0 || strlen($domain) === 0) { + return \trans('validation.entryinvalid', ['attribute' => 'email']); + } + + // Check if domain exists + $domain = Domain::where('namespace', $domain)->first(); + + if (empty($domain)) { + return \trans('validation.domaininvalid'); + } + + $wallet = $domain->wallet(); + + // The domain must be owned by the user + if (!$wallet || !$user->wallets()->find($wallet->id)) { + return \trans('validation.domainnotavailable'); + } + + // Validate login part alone + $v = Validator::make( + ['email' => $login], + ['email' => [new \App\Rules\UserEmailLocal(true)]] + ); + + if ($v->fails()) { + return $v->errors()->toArray()['email'][0]; + } + + // Check if a user with specified address already exists + if (User::emailExists($email)) { + return \trans('validation.entryexists', ['attribute' => 'email']); + } + + // Check if an alias with specified address already exists. + if (User::aliasExists($email)) { + return \trans('validation.entryexists', ['attribute' => 'email']); + } + + if (Group::emailExists($email)) { + return \trans('validation.entryexists', ['attribute' => 'email']); + } + + return null; + } + + /** + * Validate an email address for use as a group member + * + * @param string $email Email address + * @param \App\User $user The group owner + * + * @return ?string Error message on validation error + */ + public static function validateMemberEmail($email, \App\User $user): ?string + { + $v = Validator::make( + ['email' => $email], + ['email' => [new \App\Rules\ExternalEmail()]] + ); + + if ($v->fails()) { + return $v->errors()->toArray()['email'][0]; + } + + // A local domain user must exist + if (!User::where('email', \strtolower($email))->first()) { + list($login, $domain) = explode('@', \strtolower($email)); + + $domain = Domain::where('namespace', $domain)->first(); + + // We return an error only if the domain belongs to the group owner + if ($domain && ($wallet = $domain->wallet()) && $user->wallets()->find($wallet->id)) { + return \trans('validation.notalocaluser'); + } + } + + return null; + } +} diff --git a/src/app/Http/Controllers/API/V4/OpenViduController.php b/src/app/Http/Controllers/API/V4/OpenViduController.php --- a/src/app/Http/Controllers/API/V4/OpenViduController.php +++ b/src/app/Http/Controllers/API/V4/OpenViduController.php @@ -217,7 +217,7 @@ return $this->errorResponse(404, \trans('meet.room-not-found')); } - // Check if there's still a valid beta entitlement for the room owner + // Check if there's still a valid meet entitlement for the room owner $sku = \App\Sku::where('title', 'meet')->first(); if ($sku && !$room->owner->entitlements()->where('sku_id', $sku->id)->first()) { return $this->errorResponse(404, \trans('meet.room-not-found')); diff --git a/src/app/Http/Controllers/API/V4/UsersController.php b/src/app/Http/Controllers/API/V4/UsersController.php --- a/src/app/Http/Controllers/API/V4/UsersController.php +++ b/src/app/Http/Controllers/API/V4/UsersController.php @@ -11,7 +11,6 @@ use App\User; use Carbon\Carbon; use Illuminate\Http\Request; -use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Validator; use Illuminate\Support\Str; @@ -257,6 +256,8 @@ 'skus' => $skus, // TODO: This will change when we enable all users to create domains 'enableDomains' => $isController && $hasCustomDomain, + // TODO: Make 'enableDistlists' working for wallet controllers that aren't account owners + 'enableDistlists' => $isController && $hasCustomDomain && in_array('distlist', $skus), 'enableUsers' => $isController, 'enableWallets' => $isController, 'process' => $process, @@ -396,16 +397,6 @@ return response()->json($response); } - /** - * Get the guard to be used during authentication. - * - * @return \Illuminate\Contracts\Auth\Guard - */ - public function guard() - { - return Auth::guard(); - } - /** * Update user entitlements. * diff --git a/src/app/Http/Controllers/Controller.php b/src/app/Http/Controllers/Controller.php --- a/src/app/Http/Controllers/Controller.php +++ b/src/app/Http/Controllers/Controller.php @@ -6,6 +6,7 @@ use Illuminate\Routing\Controller as BaseController; use Illuminate\Foundation\Validation\ValidatesRequests; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; +use Illuminate\Support\Facades\Auth; class Controller extends BaseController { @@ -47,4 +48,14 @@ return response()->json($response, $code); } + + /** + * Get the guard to be used during authentication. + * + * @return \Illuminate\Contracts\Auth\Guard + */ + protected function guard() + { + return Auth::guard(); + } } diff --git a/src/app/User.php b/src/app/User.php --- a/src/app/User.php +++ b/src/app/User.php @@ -281,35 +281,22 @@ */ 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; - } + $domains = Domain::whereRaw(sprintf('(type & %s)', Domain::TYPE_PUBLIC)) + ->whereRaw(sprintf('(status & %s)', Domain::STATUS_ACTIVE)) + ->get() + ->all(); 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; + $domains[] = $entitlement->entitleable; } } 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; + $domains[] = $entitlement->entitleable; } } @@ -414,18 +401,24 @@ /** * Return groups controlled by the current user. * + * @param bool $with_accounts Include groups assigned to wallets + * the current user controls but not owns. + * * @return \Illuminate\Database\Eloquent\Builder Query builder */ - public function groups() + public function groups($with_accounts = true) { $wallets = $this->wallets()->pluck('id')->all(); - $groupIds = \App\Entitlement::whereIn('entitlements.wallet_id', $wallets) - ->where('entitlements.entitleable_type', Group::class) - ->pluck('entitleable_id') - ->all(); + if ($with_accounts) { + $wallets = array_merge($wallets, $this->accounts()->pluck('wallet_id')->all()); + } - return Group::whereIn('id', $groupIds); + return Group::select(['groups.*', 'entitlements.wallet_id']) + ->distinct() + ->join('entitlements', 'entitlements.entitleable_id', '=', 'groups.id') + ->whereIn('entitlements.wallet_id', $wallets) + ->where('entitlements.entitleable_type', Group::class); } /** diff --git a/src/database/migrations/2021_04_22_120000_add_distlist_beta_sku.php b/src/database/migrations/2021_04_22_120000_add_distlist_beta_sku.php new file mode 100644 --- /dev/null +++ b/src/database/migrations/2021_04_22_120000_add_distlist_beta_sku.php @@ -0,0 +1,40 @@ +first()) { + \App\Sku::create([ + 'title' => 'distlist', + 'name' => 'Distribution lists', + 'description' => 'Access to mail distribution lists', + 'cost' => 0, + 'units_free' => 0, + 'period' => 'monthly', + 'handler_class' => 'App\Handlers\Distlist', + 'active' => true, + ]); + } + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + // there's no need to remove this SKU + } +} diff --git a/src/database/seeds/local/SkuSeeder.php b/src/database/seeds/local/SkuSeeder.php --- a/src/database/seeds/local/SkuSeeder.php +++ b/src/database/seeds/local/SkuSeeder.php @@ -199,5 +199,19 @@ ] ); } + + // Check existence because migration might have added this already + if (!\App\Sku::where('title', 'distlist')->first()) { + \App\Sku::create([ + 'title' => 'distlist', + 'name' => 'Distribution lists', + 'description' => 'Access to mail distribution lists', + 'cost' => 0, + 'units_free' => 0, + 'period' => 'monthly', + 'handler_class' => 'App\Handlers\Distlist', + 'active' => true, + ]); + } } } diff --git a/src/database/seeds/production/SkuSeeder.php b/src/database/seeds/production/SkuSeeder.php --- a/src/database/seeds/production/SkuSeeder.php +++ b/src/database/seeds/production/SkuSeeder.php @@ -199,5 +199,19 @@ ] ); } + + // Check existence because migration might have added this already + if (!\App\Sku::where('title', 'distlist')->first()) { + \App\Sku::create([ + 'title' => 'distlist', + 'name' => 'Distribution lists', + 'description' => 'Access to mail distribution lists', + 'cost' => 0, + 'units_free' => 0, + 'period' => 'monthly', + 'handler_class' => 'App\Handlers\Distlist', + 'active' => true, + ]); + } } } diff --git a/src/resources/js/app.js b/src/resources/js/app.js --- a/src/resources/js/app.js +++ b/src/resources/js/app.js @@ -38,7 +38,7 @@ // Note: You cannot use app inside of the function window.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) { + if (to.meta.requiresAuth && !store.state.isLoggedIn) { // remember the original request, to use after login store.state.afterLogin = to; @@ -90,6 +90,11 @@ $(form).find('.is-invalid').removeClass('is-invalid') $(form).find('.invalid-feedback').remove() }, + hasPermission(type) { + const authInfo = store.state.authInfo + const key = 'enable' + type.charAt(0).toUpperCase() + type.slice(1) + return !!(authInfo && authInfo.statusInfo[key]) + }, hasRoute(name) { return this.$router.resolve({ name: name }).resolved.matched.length > 0 }, @@ -300,6 +305,36 @@ return 'Active' }, + distlistStatusClass(list) { + if (list.isDeleted) { + return 'text-muted' + } + + if (list.isSuspended) { + return 'text-warning' + } + + if (!list.isLdapReady) { + return 'text-danger' + } + + return 'text-success' + }, + distlistStatusText(list) { + if (list.isDeleted) { + return 'Deleted' + } + + if (list.isSuspended) { + return 'Suspended' + } + + if (!list.isLdapReady) { + return 'Not Ready' + } + + return 'Active' + }, pageName(path) { let page = this.$route.path @@ -365,8 +400,7 @@ updateBodyClass(name) { // Add 'class' attribute to the body, different for each page // so, we can apply page-specific styles - let className = 'page-' + (name || this.pageName()).replace(/\/.*$/, '') - $(document.body).removeClass().addClass(className) + document.body.className = 'page-' + (name || this.pageName()).replace(/\/.*$/, '') } } }) @@ -441,11 +475,19 @@ 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') - } - }) + let controls = input.children(':not(:first-child)') + + if (!controls.length && typeof msg == 'string') { + // this is an empty list (the main input only) + // and the error message is not an array + input.find('.main-input').addClass('is-invalid') + } else { + controls.each((index, element) => { + if (msg[index]) { + $(element).find('input').addClass('is-invalid') + } + }) + } input.addClass('is-invalid').next('.invalid-feedback').remove() input.after(feedback) diff --git a/src/resources/js/fontawesome.js b/src/resources/js/fontawesome.js --- a/src/resources/js/fontawesome.js +++ b/src/resources/js/fontawesome.js @@ -27,6 +27,7 @@ faTrashAlt, faUser, faUserCog, + faUserFriends, faUsers, faWallet } from '@fortawesome/free-solid-svg-icons' @@ -59,6 +60,7 @@ faTrashAlt, faUser, faUserCog, + faUserFriends, faUsers, faWallet ) diff --git a/src/resources/js/routes-user.js b/src/resources/js/routes-user.js --- a/src/resources/js/routes-user.js +++ b/src/resources/js/routes-user.js @@ -1,4 +1,6 @@ import DashboardComponent from '../vue/Dashboard' +import DistlistInfoComponent from '../vue/Distlist/Info' +import DistlistListComponent from '../vue/Distlist/List' import DomainInfoComponent from '../vue/Domain/Info' import DomainListComponent from '../vue/Domain/List' import LoginComponent from '../vue/Login' @@ -25,6 +27,18 @@ component: DashboardComponent, meta: { requiresAuth: true } }, + { + path: '/distlist/:list', + name: 'distlist', + component: DistlistInfoComponent, + meta: { requiresAuth: true } + }, + { + path: '/distlists', + name: 'distlists', + component: DistlistListComponent, + meta: { requiresAuth: true } + }, { path: '/domain/:domain', name: 'domain', diff --git a/src/resources/lang/en/app.php b/src/resources/lang/en/app.php --- a/src/resources/lang/en/app.php +++ b/src/resources/lang/en/app.php @@ -19,6 +19,8 @@ 'process-user-new' => 'Registering a user...', 'process-user-ldap-ready' => 'Creating a user...', 'process-user-imap-ready' => 'Creating a mailbox...', + 'process-distlist-new' => 'Registering a distribution list...', + 'process-distlist-ldap-ready' => 'Creating a distribution list...', 'process-domain-new' => 'Registering a custom domain...', 'process-domain-ldap-ready' => 'Creating a custom domain...', 'process-domain-verified' => 'Verifying a custom domain...', @@ -29,6 +31,13 @@ 'process-error-domain-ldap-ready' => 'Failed to create a domain.', 'process-error-domain-verified' => 'Failed to verify a domain.', 'process-error-domain-confirmed' => 'Failed to verify an ownership of a domain.', + 'process-distlist-new' => 'Registering a distribution list...', + 'process-distlist-ldap-ready' => 'Creating a distribution list...', + 'process-error-distlist-ldap-ready' => 'Failed to create a distribution list.', + + 'distlist-update-success' => 'Distribution list updated successfully.', + 'distlist-create-success' => 'Distribution list created successfully.', + 'distlist-delete-success' => 'Distribution list deleted successfully.', 'domain-verify-success' => 'Domain verified successfully.', 'domain-verify-error' => 'Domain ownership verification failed.', diff --git a/src/resources/lang/en/validation.php b/src/resources/lang/en/validation.php --- a/src/resources/lang/en/validation.php +++ b/src/resources/lang/en/validation.php @@ -135,6 +135,9 @@ 'entryexists' => 'The specified :attribute is not available.', 'minamount' => 'Minimum amount for a single payment is :amount.', 'minamountdebt' => 'The specified amount does not cover the balance on the account.', + 'notalocaluser' => 'The specified email address does not exist.', + 'memberislist' => 'A recipient cannot be the same as the list address.', + 'listmembersrequired' => 'At least one recipient is required.', /* |-------------------------------------------------------------------------- diff --git a/src/resources/vue/Dashboard.vue b/src/resources/vue/Dashboard.vue --- a/src/resources/vue/Dashboard.vue +++ b/src/resources/vue/Dashboard.vue @@ -10,7 +10,10 @@ Domains - User accounts + User accounts + + + Distribution lists Wallet diff --git a/src/resources/vue/Distlist/Info.vue b/src/resources/vue/Distlist/Info.vue new file mode 100644 --- /dev/null +++ b/src/resources/vue/Distlist/Info.vue @@ -0,0 +1,110 @@ + + + diff --git a/src/resources/vue/Distlist/List.vue b/src/resources/vue/Distlist/List.vue new file mode 100644 --- /dev/null +++ b/src/resources/vue/Distlist/List.vue @@ -0,0 +1,63 @@ + + + diff --git a/src/resources/vue/User/Info.vue b/src/resources/vue/User/Info.vue --- a/src/resources/vue/User/Info.vue +++ b/src/resources/vue/User/Info.vue @@ -16,7 +16,7 @@
- +
{{ $root.userStatusText(user) }}
diff --git a/src/resources/vue/Widgets/ListInput.vue b/src/resources/vue/Widgets/ListInput.vue --- a/src/resources/vue/Widgets/ListInput.vue +++ b/src/resources/vue/Widgets/ListInput.vue @@ -46,16 +46,22 @@ if (value) { this.list.push(value) this.input.value = '' + this.input.classList.remove('is-invalid') + if (focus !== false) { this.input.focus() } + + if (this.list.length == 1) { + this.$el.classList.remove('is-invalid') + } } }, deleteItem(index) { this.$delete(this.list, index) - if (this.list.length == 1) { - $(this.$el).removeClass('is-invalid') + if (!this.list.length) { + this.$el.classList.remove('is-invalid') } }, keyDown(e) { diff --git a/src/resources/vue/Widgets/Status.vue b/src/resources/vue/Widgets/Status.vue --- a/src/resources/vue/Widgets/Status.vue +++ b/src/resources/vue/Widgets/Status.vue @@ -4,6 +4,7 @@

We are preparing your account. We are preparing the domain. + We are preparing the distribution list. We are preparing the user account.
Some features may be missing or readonly at the moment.
@@ -17,6 +18,7 @@

Your account is almost ready. The domain is almost ready. + The distribution list is almost ready. The user account is almost ready.
Verify your domain to finish the setup process. @@ -187,6 +189,9 @@ case 'domain': url = '/api/v4/domains/' + this.$route.params.domain + '/status' break + case 'distlist': + url = '/api/v4/groups/' + this.$route.params.list + '/status' + break default: url = '/api/v4/users/' + this.$route.params.user + '/status' } diff --git a/src/routes/api.php b/src/routes/api.php --- a/src/routes/api.php +++ b/src/routes/api.php @@ -63,9 +63,13 @@ Route::get('domains/{id}/confirm', 'API\V4\DomainsController@confirm'); Route::get('domains/{id}/status', 'API\V4\DomainsController@status'); + Route::apiResource('groups', API\V4\GroupsController::class); + Route::get('groups/{id}/status', 'API\V4\GroupsController@status'); + Route::apiResource('entitlements', API\V4\EntitlementsController::class); Route::apiResource('packages', API\V4\PackagesController::class); Route::apiResource('skus', API\V4\SkusController::class); + Route::apiResource('users', API\V4\UsersController::class); Route::get('users/{id}/skus', 'API\V4\SkusController@userSkus'); Route::get('users/{id}/status', 'API\V4\UsersController@status'); diff --git a/src/tests/Browser/DistlistTest.php b/src/tests/Browser/DistlistTest.php new file mode 100644 --- /dev/null +++ b/src/tests/Browser/DistlistTest.php @@ -0,0 +1,266 @@ +deleteTestGroup('group-test@kolab.org'); + $this->clearBetaEntitlements(); + } + + /** + * {@inheritDoc} + */ + public function tearDown(): void + { + $this->deleteTestGroup('group-test@kolab.org'); + $this->clearBetaEntitlements(); + + parent::tearDown(); + } + + /** + * Test distlist info page (unauthenticated) + */ + public function testInfoUnauth(): void + { + // Test that the page requires authentication + $this->browse(function (Browser $browser) { + $browser->visit('/distlist/abc')->on(new Home()); + }); + } + + /** + * Test distlist list page (unauthenticated) + */ + public function testListUnauth(): void + { + // Test that the page requires authentication + $this->browse(function (Browser $browser) { + $browser->visit('/distlists')->on(new Home()); + }); + } + + /** + * Test distlist list page + */ + public function testList(): void + { + // Log on the user + $this->browse(function (Browser $browser) { + $browser->visit(new Home()) + ->submitLogon('john@kolab.org', 'simple123', true) + ->on(new Dashboard()) + ->assertMissing('@links .link-distlists'); + }); + + // Test that Distribution lists page is not accessible without the 'distlist' entitlement + $this->browse(function (Browser $browser) { + $browser->visit('/distlists') + ->assertErrorPage(404); + }); + + // Create a single group, add beta+distlist entitlements + $john = $this->getTestUser('john@kolab.org'); + $this->addDistlistEntitlement($john); + $group = $this->getTestGroup('group-test@kolab.org'); + $group->assignToWallet($john->wallets->first()); + + // Test distribution lists page + $this->browse(function (Browser $browser) { + $browser->visit(new Dashboard()) + ->assertSeeIn('@links .link-distlists', 'Distribution lists') + ->click('@links .link-distlists') + ->on(new DistlistList()) + ->whenAvailable('@table', function (Browser $browser) { + $browser->waitFor('tbody tr') + ->assertElementsCount('tbody tr', 1) + ->assertSeeIn('tbody tr:nth-child(1) a', 'group-test@kolab.org') + ->assertText('tbody tr:nth-child(1) svg.text-danger title', 'Not Ready') + ->assertMissing('tfoot'); + }); + }); + } + + /** + * Test distlist creation/editing/deleting + * + * @depends testList + */ + public function testCreateUpdateDelete(): void + { + // Test that the page is not available accessible without the 'distlist' entitlement + $this->browse(function (Browser $browser) { + $browser->visit('/distlist/new') + ->assertErrorPage(404); + }); + + // Add beta+distlist entitlements + $john = $this->getTestUser('john@kolab.org'); + $this->addDistlistEntitlement($john); + + $this->browse(function (Browser $browser) { + // Create a group + $browser->visit(new DistlistList()) + ->assertSeeIn('button.create-list', 'Create list') + ->click('button.create-list') + ->on(new DistlistInfo()) + ->assertSeeIn('#distlist-info .card-title', 'New distribution list') + ->with('@form', function (Browser $browser) { + // Assert form content + $browser->assertMissing('#status') + ->assertSeeIn('div.row:nth-child(1) label', 'Email') + ->assertValue('div.row:nth-child(1) input[type=text]', '') + ->assertSeeIn('div.row:nth-child(2) label', 'Recipients') + ->assertVisible('div.row:nth-child(2) .list-input') + ->with(new ListInput('#members'), function (Browser $browser) { + $browser->assertListInputValue([]) + ->assertValue('@input', ''); + }) + ->assertSeeIn('button[type=submit]', 'Submit'); + }) + // Test error conditions + ->type('#email', 'group-test@kolabnow.com') + ->click('button[type=submit]') + ->waitFor('#email + .invalid-feedback') + ->assertSeeIn('#email + .invalid-feedback', 'The specified domain is not available.') + ->assertFocused('#email') + ->waitFor('#members + .invalid-feedback') + ->assertSeeIn('#members + .invalid-feedback', 'At least one recipient is required.') + ->assertToast(Toast::TYPE_ERROR, 'Form validation error') + // Test successful group creation + ->type('#email', 'group-test@kolab.org') + ->with(new ListInput('#members'), function (Browser $browser) { + $browser->addListEntry('test1@gmail.com') + ->addListEntry('test2@gmail.com'); + }) + ->click('button[type=submit]') + ->assertToast(Toast::TYPE_SUCCESS, 'Distribution list created successfully.') + ->on(new DistlistList()) + ->assertElementsCount('@table tbody tr', 1); + + // Test group update + $browser->click('@table tr:nth-child(1) a') + ->on(new DistlistInfo()) + ->assertSeeIn('#distlist-info .card-title', 'Distribution list') + ->with('@form', function (Browser $browser) { + // Assert form content + $browser->assertSeeIn('div.row:nth-child(1) label', 'Status') + ->assertSeeIn('div.row:nth-child(1) span.text-danger', 'Not Ready') + ->assertSeeIn('div.row:nth-child(2) label', 'Email') + ->assertValue('div.row:nth-child(2) input[type=text]:disabled', 'group-test@kolab.org') + ->assertSeeIn('div.row:nth-child(3) label', 'Recipients') + ->assertVisible('div.row:nth-child(3) .list-input') + ->with(new ListInput('#members'), function (Browser $browser) { + $browser->assertListInputValue(['test1@gmail.com', 'test2@gmail.com']) + ->assertValue('@input', ''); + }) + ->assertSeeIn('button[type=submit]', 'Submit'); + }) + // Test error handling + ->with(new ListInput('#members'), function (Browser $browser) { + $browser->addListEntry('invalid address'); + }) + ->click('button[type=submit]') + ->waitFor('#members + .invalid-feedback') + ->assertSeeIn('#members + .invalid-feedback', 'The specified email address is invalid.') + ->assertVisible('#members .input-group:nth-child(4) input.is-invalid') + ->assertToast(Toast::TYPE_ERROR, 'Form validation error') + // Test successful update + ->with(new ListInput('#members'), function (Browser $browser) { + $browser->removeListEntry(3)->removeListEntry(2); + }) + ->click('button[type=submit]') + ->assertToast(Toast::TYPE_SUCCESS, 'Distribution list updated successfully.') + ->assertMissing('.invalid-feedback') + ->on(new DistlistList()) + ->assertElementsCount('@table tbody tr', 1); + + $group = Group::where('email', 'group-test@kolab.org')->first(); + $this->assertSame(['test1@gmail.com'], $group->members); + + // Test group deletion + $browser->click('@table tr:nth-child(1) a') + ->on(new DistlistInfo()) + ->assertSeeIn('button.button-delete', 'Delete list') + ->click('button.button-delete') + ->assertToast(Toast::TYPE_SUCCESS, 'Distribution list deleted successfully.') + ->on(new DistlistList()) + ->assertElementsCount('@table tbody tr', 0) + ->assertVisible('@table tfoot'); + + $this->assertNull(Group::where('email', 'group-test@kolab.org')->first()); + }); + } + + /** + * Test distribution list status + * + * @depends testList + */ + public function testStatus(): void + { + $john = $this->getTestUser('john@kolab.org'); + $this->addDistlistEntitlement($john); + $group = $this->getTestGroup('group-test@kolab.org'); + $group->assignToWallet($john->wallets->first()); + $group->status = Group::STATUS_NEW | Group::STATUS_ACTIVE; + $group->save(); + + $this->assertFalse($group->isLdapReady()); + + $this->browse(function ($browser) use ($group) { + // Test auto-refresh + $browser->visit('/distlist/' . $group->id) + ->on(new DistlistInfo()) + ->with(new Status(), function ($browser) { + $browser->assertSeeIn('@body', 'We are preparing the distribution list') + ->assertProgress(83, 'Creating a distribution list...', 'pending') + ->assertMissing('@refresh-button') + ->assertMissing('@refresh-text') + ->assertMissing('#status-link') + ->assertMissing('#status-verify'); + }); + + $group->status |= Group::STATUS_LDAP_READY; + $group->save(); + + // Test Verify button + $browser->waitUntilMissing('@status', 10); + }); + + // TODO: Test all group statuses on the list + } + + + /** + * Register the beta + distlist entitlements for the user + */ + private function addDistlistEntitlement($user): void + { + // Add beta+distlist entitlements + $beta_sku = Sku::where('title', 'beta')->first(); + $distlist_sku = Sku::where('title', 'distlist')->first(); + $user->assignSku($beta_sku); + $user->assignSku($distlist_sku); + } +} diff --git a/src/tests/Browser/Pages/DistlistInfo.php b/src/tests/Browser/Pages/DistlistInfo.php new file mode 100644 --- /dev/null +++ b/src/tests/Browser/Pages/DistlistInfo.php @@ -0,0 +1,45 @@ +waitFor('@form') + ->waitUntilMissing('.app-loader'); + } + + /** + * Get the element shortcuts for the page. + * + * @return array + */ + public function elements(): array + { + return [ + '@app' => '#app', + '@form' => '#distlist-info form', + '@status' => '#status-box', + ]; + } +} diff --git a/src/tests/Browser/Pages/DistlistList.php b/src/tests/Browser/Pages/DistlistList.php new file mode 100644 --- /dev/null +++ b/src/tests/Browser/Pages/DistlistList.php @@ -0,0 +1,45 @@ +assertPathIs($this->url()) + ->waitUntilMissing('@app .app-loader') + ->assertSeeIn('#distlist-list .card-title', 'Distribution lists'); + } + + /** + * Get the element shortcuts for the page. + * + * @return array + */ + public function elements(): array + { + return [ + '@app' => '#app', + '@table' => '#distlist-list table', + ]; + } +} diff --git a/src/tests/Browser/UsersTest.php b/src/tests/Browser/UsersTest.php --- a/src/tests/Browser/UsersTest.php +++ b/src/tests/Browser/UsersTest.php @@ -605,8 +605,8 @@ $browser->visit('/user/' . $john->id) ->on(new UserInfo()) ->with('@skus', function ($browser) { - $browser->assertElementsCount('tbody tr', 7) - // Beta/Meet SKU + $browser->assertElementsCount('tbody tr', 8) + // Meet SKU ->assertSeeIn('tbody tr:nth-child(6) td.name', 'Voice & Video Conferencing (public beta)') ->assertSeeIn('tr:nth-child(6) td.price', '0,00 CHF/month') ->assertNotChecked('tbody tr:nth-child(6) td.selection input') @@ -624,35 +624,46 @@ 'tbody tr:nth-child(7) td.buttons button', 'Access to the private beta program subscriptions' ) -/* - // Check Meet, Uncheck Beta, expect Meet unchecked - ->click('#sku-input-meet') + // Distlist SKU + ->assertSeeIn('tbody tr:nth-child(8) td.name', 'Distribution lists') + ->assertSeeIn('tr:nth-child(8) td.price', '0,00 CHF/month') + ->assertNotChecked('tbody tr:nth-child(8) td.selection input') + ->assertEnabled('tbody tr:nth-child(8) td.selection input') + ->assertTip( + 'tbody tr:nth-child(8) td.buttons button', + 'Access to mail distribution lists' + ) + // Check Distlist, Uncheck Beta, expect Distlist unchecked + ->click('#sku-input-distlist') ->click('#sku-input-beta') ->assertNotChecked('#sku-input-beta') - ->assertNotChecked('#sku-input-meet') - // Click Meet expect an alert - ->click('#sku-input-meet') - ->assertDialogOpened('Video chat requires Beta program.') + ->assertNotChecked('#sku-input-distlist') + // Click Distlist expect an alert + ->click('#sku-input-distlist') + ->assertDialogOpened('Distribution lists requires Private Beta (invitation only).') ->acceptDialog() -*/ - // Enable Meet and submit - ->click('#sku-input-meet'); + // Enable Beta and Distlist and submit + ->click('#sku-input-beta') + ->click('#sku-input-distlist'); }) ->click('button[type=submit]') ->assertToast(Toast::TYPE_SUCCESS, 'User data updated successfully.'); - $expected = ['beta', 'groupware', 'mailbox', 'meet', 'storage', 'storage']; + $expected = ['beta', 'distlist', 'groupware', 'mailbox', 'storage', 'storage']; $this->assertUserEntitlements($john, $expected); $browser->visit('/user/' . $john->id) ->on(new UserInfo()) ->click('#sku-input-beta') - ->click('#sku-input-meet') ->click('button[type=submit]') ->assertToast(Toast::TYPE_SUCCESS, 'User data updated successfully.'); $expected = ['groupware', 'mailbox', 'storage', 'storage']; $this->assertUserEntitlements($john, $expected); }); + + // TODO: Test that the Distlist SKU is not available for users that aren't a group account owners + // TODO: Test that entitlements change has immediate effect on the available items in dashboard + // i.e. does not require a page reload nor re-login. } } diff --git a/src/tests/Feature/Controller/GroupsTest.php b/src/tests/Feature/Controller/GroupsTest.php new file mode 100644 --- /dev/null +++ b/src/tests/Feature/Controller/GroupsTest.php @@ -0,0 +1,492 @@ +deleteTestGroup('group-test@kolab.org'); + } + + /** + * {@inheritDoc} + */ + public function tearDown(): void + { + $this->deleteTestGroup('group-test@kolab.org'); + + parent::tearDown(); + } + + /** + * Test group deleting (DELETE /api/v4/groups/) + */ + public function testDestroy(): void + { + // First create some groups to delete + $john = $this->getTestUser('john@kolab.org'); + $jack = $this->getTestUser('jack@kolab.org'); + $group = $this->getTestGroup('group-test@kolab.org'); + $group->assignToWallet($john->wallets->first()); + + // Test unauth access + $response = $this->delete("api/v4/groups/{$group->id}"); + $response->assertStatus(401); + + // Test non-existing group + $response = $this->actingAs($john)->delete("api/v4/groups/abc"); + $response->assertStatus(404); + + // Test access to other user's group + $response = $this->actingAs($jack)->delete("api/v4/groups/{$group->id}"); + $response->assertStatus(403); + + $json = $response->json(); + + $this->assertSame('error', $json['status']); + $this->assertSame("Access denied", $json['message']); + $this->assertCount(2, $json); + + // Test removing a group + $response = $this->actingAs($john)->delete("api/v4/groups/{$group->id}"); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertEquals('success', $json['status']); + $this->assertEquals("Distribution list deleted successfully.", $json['message']); + } + + /** + * Test groups listing (GET /api/v4/groups) + */ + public function testIndex(): void + { + $jack = $this->getTestUser('jack@kolab.org'); + $john = $this->getTestUser('john@kolab.org'); + $ned = $this->getTestUser('ned@kolab.org'); + + $group = $this->getTestGroup('group-test@kolab.org'); + $group->assignToWallet($john->wallets->first()); + + // Test unauth access + $response = $this->get("api/v4/groups"); + $response->assertStatus(401); + + // Test a user with no groups + $response = $this->actingAs($jack)->get("/api/v4/groups"); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertCount(0, $json); + + // Test a user with a single group + $response = $this->actingAs($john)->get("/api/v4/groups"); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertCount(1, $json); + $this->assertSame($group->id, $json[0]['id']); + $this->assertSame($group->email, $json[0]['email']); + $this->assertArrayHasKey('isDeleted', $json[0]); + $this->assertArrayHasKey('isSuspended', $json[0]); + $this->assertArrayHasKey('isActive', $json[0]); + $this->assertArrayHasKey('isLdapReady', $json[0]); + + // Test that another wallet controller has access to groups + $response = $this->actingAs($ned)->get("/api/v4/groups"); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertCount(1, $json); + $this->assertSame($group->email, $json[0]['email']); + } + + /** + * Test fetching group data/profile (GET /api/v4/groups/) + */ + public function testShow(): void + { + $jack = $this->getTestUser('jack@kolab.org'); + $john = $this->getTestUser('john@kolab.org'); + $ned = $this->getTestUser('ned@kolab.org'); + + $group = $this->getTestGroup('group-test@kolab.org'); + $group->assignToWallet($john->wallets->first()); + + // Test unauthorized access to a profile of other user + $response = $this->get("/api/v4/groups/{$group->id}"); + $response->assertStatus(401); + + // Test unauthorized access to a group of another user + $response = $this->actingAs($jack)->get("/api/v4/groups/{$group->id}"); + $response->assertStatus(403); + + // John: Group owner - non-existing group + $response = $this->actingAs($john)->get("/api/v4/groups/abc"); + $response->assertStatus(404); + + // John: Group owner + $response = $this->actingAs($john)->get("/api/v4/groups/{$group->id}"); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertSame($group->id, $json['id']); + $this->assertSame($group->email, $json['email']); + $this->assertSame($group->members, $json['members']); + $this->assertTrue(!empty($json['statusInfo'])); + $this->assertArrayHasKey('isDeleted', $json); + $this->assertArrayHasKey('isSuspended', $json); + $this->assertArrayHasKey('isActive', $json); + $this->assertArrayHasKey('isLdapReady', $json); + } + + /** + * Test fetching group status (GET /api/v4/groups//status) + * and forcing setup process update (?refresh=1) + */ + public function testStatus(): void + { + Queue::fake(); + + $john = $this->getTestUser('john@kolab.org'); + $jack = $this->getTestUser('jack@kolab.org'); + + $group = $this->getTestGroup('group-test@kolab.org'); + $group->assignToWallet($john->wallets->first()); + + // Test unauthorized access + $response = $this->get("/api/v4/groups/abc/status"); + $response->assertStatus(401); + + // Test unauthorized access + $response = $this->actingAs($jack)->get("/api/v4/groups/{$group->id}/status"); + $response->assertStatus(403); + + $group->status = Group::STATUS_NEW | Group::STATUS_ACTIVE; + $group->save(); + + // Get group status + $response = $this->actingAs($john)->get("/api/v4/groups/{$group->id}/status"); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertFalse($json['isLdapReady']); + $this->assertFalse($json['isReady']); + $this->assertFalse($json['isSuspended']); + $this->assertTrue($json['isActive']); + $this->assertFalse($json['isDeleted']); + $this->assertCount(6, $json['process']); + $this->assertSame('distlist-new', $json['process'][0]['label']); + $this->assertSame(true, $json['process'][0]['state']); + $this->assertSame('distlist-ldap-ready', $json['process'][1]['label']); + $this->assertSame(false, $json['process'][1]['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 |= \App\Domain::STATUS_CONFIRMED; + $domain->save(); + + // Now "reboot" the process and the group + $response = $this->actingAs($john)->get("/api/v4/groups/{$group->id}/status?refresh=1"); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertTrue($json['isLdapReady']); + $this->assertTrue($json['isReady']); + $this->assertCount(6, $json['process']); + $this->assertSame('distlist-ldap-ready', $json['process'][1]['label']); + $this->assertSame(true, $json['process'][1]['state']); + $this->assertSame('success', $json['status']); + $this->assertSame('Setup process finished successfully.', $json['message']); + + // Test a case when a domain is not ready + $domain->status ^= \App\Domain::STATUS_CONFIRMED; + $domain->save(); + + $response = $this->actingAs($john)->get("/api/v4/groups/{$group->id}/status?refresh=1"); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertTrue($json['isLdapReady']); + $this->assertTrue($json['isReady']); + $this->assertCount(6, $json['process']); + $this->assertSame('distlist-ldap-ready', $json['process'][1]['label']); + $this->assertSame(true, $json['process'][1]['state']); + $this->assertSame('success', $json['status']); + $this->assertSame('Setup process finished successfully.', $json['message']); + } + + /** + * Test GroupsController::statusInfo() + */ + public function testStatusInfo(): void + { + $john = $this->getTestUser('john@kolab.org'); + $group = $this->getTestGroup('group-test@kolab.org'); + $group->assignToWallet($john->wallets->first()); + $group->status = Group::STATUS_NEW | Group::STATUS_ACTIVE; + $group->save(); + + $result = GroupsController::statusInfo($group); + + $this->assertFalse($result['isReady']); + $this->assertCount(6, $result['process']); + $this->assertSame('distlist-new', $result['process'][0]['label']); + $this->assertSame(true, $result['process'][0]['state']); + $this->assertSame('distlist-ldap-ready', $result['process'][1]['label']); + $this->assertSame(false, $result['process'][1]['state']); + $this->assertSame('running', $result['processState']); + + $group->created_at = Carbon::now()->subSeconds(181); + $group->save(); + + $result = GroupsController::statusInfo($group); + + $this->assertSame('failed', $result['processState']); + + $group->status |= Group::STATUS_LDAP_READY; + $group->save(); + + $result = GroupsController::statusInfo($group); + + $this->assertTrue($result['isReady']); + $this->assertCount(6, $result['process']); + $this->assertSame('distlist-new', $result['process'][0]['label']); + $this->assertSame(true, $result['process'][0]['state']); + $this->assertSame('distlist-ldap-ready', $result['process'][1]['label']); + $this->assertSame(true, $result['process'][2]['state']); + $this->assertSame('done', $result['processState']); + } + + /** + * Test group creation (POST /api/v4/groups) + */ + public function testStore(): void + { + Queue::fake(); + + $jack = $this->getTestUser('jack@kolab.org'); + $john = $this->getTestUser('john@kolab.org'); + + // Test unauth request + $response = $this->post("/api/v4/groups", []); + $response->assertStatus(401); + + // Test non-controller user + $response = $this->actingAs($jack)->post("/api/v4/groups", []); + $response->assertStatus(403); + + // Test empty request + $response = $this->actingAs($john)->post("/api/v4/groups", []); + $response->assertStatus(422); + + $json = $response->json(); + + $this->assertSame('error', $json['status']); + $this->assertSame("The email field is required.", $json['errors']['email']); + $this->assertCount(2, $json); + + // Test missing members + $post = ['email' => 'group-test@kolab.org']; + $response = $this->actingAs($john)->post("/api/v4/groups", $post); + $json = $response->json(); + + $response->assertStatus(422); + + $this->assertSame('error', $json['status']); + $this->assertSame("At least one recipient is required.", $json['errors']['members']); + $this->assertCount(2, $json); + + // Test invalid email + $post = ['email' => 'invalid']; + $response = $this->actingAs($john)->post("/api/v4/groups", $post); + $response->assertStatus(422); + + $json = $response->json(); + + $this->assertSame('error', $json['status']); + $this->assertCount(2, $json); + $this->assertSame('The specified email is invalid.', $json['errors']['email']); + + // Test successful group creation + $post = [ + 'email' => 'group-test@kolab.org', + 'members' => ['test1@domain.tld', 'test2@domain.tld'] + ]; + + $response = $this->actingAs($john)->post("/api/v4/groups", $post); + $json = $response->json(); + + $response->assertStatus(200); + + $this->assertSame('success', $json['status']); + $this->assertSame("Distribution list created successfully.", $json['message']); + $this->assertCount(2, $json); + + $group = Group::where('email', 'group-test@kolab.org')->first(); + $this->assertInstanceOf(Group::class, $group); + $this->assertSame($post['email'], $group->email); + $this->assertSame($post['members'], $group->members); + $this->assertTrue($john->groups()->get()->contains($group)); + } + + /** + * Test group update (PUT /api/v4/groups/) + */ + public function testUpdate(): void + { + Queue::fake(); + + $jack = $this->getTestUser('jack@kolab.org'); + $john = $this->getTestUser('john@kolab.org'); + $ned = $this->getTestUser('ned@kolab.org'); + + $group = $this->getTestGroup('group-test@kolab.org'); + $group->assignToWallet($john->wallets->first()); + + // Test unauthorized update + $response = $this->get("/api/v4/groups/{$group->id}", []); + $response->assertStatus(401); + + // Test unauthorized update + $response = $this->actingAs($jack)->get("/api/v4/groups/{$group->id}", []); + $response->assertStatus(403); + + // Test updating - missing members + $response = $this->actingAs($john)->put("/api/v4/groups/{$group->id}", []); + $response->assertStatus(422); + + $json = $response->json(); + + $this->assertSame('error', $json['status']); + $this->assertSame("At least one recipient is required.", $json['errors']['members']); + $this->assertCount(2, $json); + + // Test some invalid data + $post = ['members' => ['test@domain.tld', 'invalid']]; + $response = $this->actingAs($john)->put("/api/v4/groups/{$group->id}", $post); + $response->assertStatus(422); + + $json = $response->json(); + + $this->assertSame('error', $json['status']); + $this->assertCount(2, $json); + $this->assertSame('The specified email address is invalid.', $json['errors']['members'][1]); + + // Valid data - members changed + $post = [ + 'members' => ['member1@test.domain', 'member2@test.domain'] + ]; + + $response = $this->actingAs($john)->put("/api/v4/groups/{$group->id}", $post); + $json = $response->json(); + + $response->assertStatus(200); + + $this->assertSame('success', $json['status']); + $this->assertSame("Distribution list updated successfully.", $json['message']); + $this->assertCount(2, $json); + $this->assertSame($group->fresh()->members, $post['members']); + } + + /** + * Group email address validation. + */ + public function testValidateGroupEmail(): void + { + $john = $this->getTestUser('john@kolab.org'); + $group = $this->getTestGroup('group-test@kolab.org'); + + // Invalid email + $result = GroupsController::validateGroupEmail('', $john); + $this->assertSame("The email field is required.", $result); + + $result = GroupsController::validateGroupEmail('kolab.org', $john); + $this->assertSame("The specified email is invalid.", $result); + + $result = GroupsController::validateGroupEmail('.@kolab.org', $john); + $this->assertSame("The specified email is invalid.", $result); + + $result = GroupsController::validateGroupEmail('test123456@localhost', $john); + $this->assertSame("The specified domain is invalid.", $result); + + $result = GroupsController::validateGroupEmail('test123456@unknown-domain.org', $john); + $this->assertSame("The specified domain is invalid.", $result); + + // forbidden public domain + $result = GroupsController::validateGroupEmail('testtest@kolabnow.com', $john); + $this->assertSame("The specified domain is not available.", $result); + + // existing alias + $result = GroupsController::validateGroupEmail('jack.daniels@kolab.org', $john); + $this->assertSame("The specified email is not available.", $result); + + // existing user + $result = GroupsController::validateGroupEmail('ned@kolab.org', $john); + $this->assertSame("The specified email is not available.", $result); + + // existing group + $result = GroupsController::validateGroupEmail('group-test@kolab.org', $john); + $this->assertSame("The specified email is not available.", $result); + + // valid + $result = GroupsController::validateGroupEmail('admin@kolab.org', $john); + $this->assertSame(null, $result); + } + + /** + * Group member email address validation. + */ + public function testValidateMemberEmail(): void + { + $john = $this->getTestUser('john@kolab.org'); + + // Invalid format + $result = GroupsController::validateMemberEmail('kolab.org', $john); + $this->assertSame("The specified email address is invalid.", $result); + + $result = GroupsController::validateMemberEmail('.@kolab.org', $john); + $this->assertSame("The specified email address is invalid.", $result); + + $result = GroupsController::validateMemberEmail('test123456@localhost', $john); + $this->assertSame("The specified email address is invalid.", $result); + + // Test local non-existing user + $result = GroupsController::validateMemberEmail('unknown@kolab.org', $john); + $this->assertSame("The specified email address does not exist.", $result); + + // Test local existing user + $result = GroupsController::validateMemberEmail('ned@kolab.org', $john); + $this->assertSame(null, $result); + + // Test existing user, but not in the same account + $result = GroupsController::validateMemberEmail('jeroen@jeroen.jeroen', $john); + $this->assertSame(null, $result); + + // Valid address + $result = GroupsController::validateMemberEmail('test@google.com', $john); + $this->assertSame(null, $result); + } +} diff --git a/src/tests/Feature/Controller/UsersTest.php b/src/tests/Feature/Controller/UsersTest.php --- a/src/tests/Feature/Controller/UsersTest.php +++ b/src/tests/Feature/Controller/UsersTest.php @@ -1177,7 +1177,6 @@ $domain = reset($public_domains); $john = $this->getTestUser('john@kolab.org'); - $jack = $this->getTestUser('jack@kolab.org'); $user = $this->getTestUser('UsersControllerTest1@userscontroller.com'); return [ diff --git a/src/tests/TestCaseTrait.php b/src/tests/TestCaseTrait.php --- a/src/tests/TestCaseTrait.php +++ b/src/tests/TestCaseTrait.php @@ -35,9 +35,12 @@ */ protected function clearBetaEntitlements(): void { - $betas = \App\Sku::where('handler_class', 'like', 'App\\Handlers\\Beta\\%') - ->orWhere('handler_class', 'App\Handlers\Beta') - ->pluck('id')->all(); + $beta_handlers = [ + 'App\Handlers\Beta', + 'App\Handlers\Distlist', + ]; + + $betas = \App\Sku::whereIn('handler_class', $beta_handlers)->pluck('id')->all(); \App\Entitlement::whereIn('sku_id', $betas)->delete(); }