Page MenuHomePhorge

D2500.1775273105.diff
No OneTemporary

Authored By
Unknown
Size
86 KB
Referenced Files
None
Subscribers
None

D2500.1775273105.diff

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 @@
+<?php
+
+namespace App\Handlers;
+
+class Distlist extends Beta\Base
+{
+ /**
+ * The entitleable class for this handler.
+ *
+ * @return string
+ */
+ public static function entitleableClass(): string
+ {
+ return \App\User::class;
+ }
+
+ /**
+ * Check if the SKU is available to the user.
+ *
+ * @param \App\Sku $sku The SKU object
+ * @param \App\User $user The user object
+ *
+ * @return bool
+ */
+ public static function isAvailable(\App\Sku $sku, \App\User $user): bool
+ {
+ // This SKU must be:
+ // - already assigned, or active and a 'beta' entitlement must exist
+ // - and this is a group account owner (custom domain)
+
+ if (parent::isAvailable($sku, $user)) {
+ return $user->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 @@
+<?php
+
+namespace App\Http\Controllers\API\V4;
+
+use App\Http\Controllers\Controller;
+use App\Domain;
+use App\Group;
+use App\User;
+use Carbon\Carbon;
+use Illuminate\Http\Request;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Validator;
+
+class GroupsController extends Controller
+{
+ /**
+ * Show the form for creating a new group.
+ *
+ * @return \Illuminate\Http\JsonResponse
+ */
+ public function create()
+ {
+ return $this->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;
@@ -258,6 +257,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,
@@ -398,16 +399,6 @@
}
/**
- * 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
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 @@
+<?php
+
+use Illuminate\Support\Facades\Schema;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Database\Migrations\Migration;
+
+// phpcs:ignore
+class AddDistlistBetaSku extends Migration
+{
+ /**
+ * Run the migrations.
+ *
+ * @return void
+ */
+ public function up()
+ {
+ 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,
+ ]);
+ }
+ }
+
+ /**
+ * 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
},
@@ -296,6 +301,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
@@ -361,8 +396,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(/\/.*$/, '')
}
}
})
@@ -437,11 +471,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'
@@ -26,6 +28,18 @@
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',
component: DomainInfoComponent,
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 @@
<svg-icon icon="globe"></svg-icon><span class="name">Domains</span>
</router-link>
<router-link v-if="status.enableUsers" class="card link-users" :to="{ name: 'users' }">
- <svg-icon icon="users"></svg-icon><span class="name">User accounts</span>
+ <svg-icon icon="user-friends"></svg-icon><span class="name">User accounts</span>
+ </router-link>
+ <router-link v-if="status.enableDistlists" class="card link-distlists" :to="{ name: 'distlists' }">
+ <svg-icon icon="users"></svg-icon><span class="name">Distribution lists</span>
</router-link>
<router-link v-if="status.enableWallets" class="card link-wallet" :to="{ name: 'wallet' }">
<svg-icon icon="wallet"></svg-icon><span class="name">Wallet</span>
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 @@
+<template>
+ <div class="container">
+ <status-component v-if="list_id !== 'new'" :status="status" @status-update="statusUpdate"></status-component>
+
+ <div class="card" id="distlist-info">
+ <div class="card-body">
+ <div class="card-title" v-if="list_id !== 'new'">
+ Distribution list
+ <button class="btn btn-outline-danger button-delete float-right" @click="deleteList()" tag="button">
+ <svg-icon icon="trash-alt"></svg-icon> Delete list
+ </button>
+ </div>
+ <div class="card-title" v-if="list_id === 'new'">New distribution list</div>
+ <div class="card-text">
+ <form @submit.prevent="submit">
+ <div v-if="list_id !== 'new'" class="form-group row plaintext">
+ <label for="status" class="col-sm-4 col-form-label">Status</label>
+ <div class="col-sm-8">
+ <span :class="$root.distlistStatusClass(list) + ' form-control-plaintext'" id="status">{{ $root.distlistStatusText(list) }}</span>
+ </div>
+ </div>
+ <div class="form-group row">
+ <label for="email" class="col-sm-4 col-form-label">Email</label>
+ <div class="col-sm-8">
+ <input type="text" class="form-control" id="email" :disabled="list_id !== 'new'" required v-model="list.email">
+ </div>
+ </div>
+ <div class="form-group row">
+ <label for="members-input" class="col-sm-4 col-form-label">Recipients</label>
+ <div class="col-sm-8">
+ <list-input id="members" :list="list.members"></list-input>
+ </div>
+ </div>
+ <button class="btn btn-primary" type="submit"><svg-icon icon="check"></svg-icon> Submit</button>
+ </form>
+ </div>
+ </div>
+ </div>
+ </div>
+</template>
+
+<script>
+ import ListInput from '../Widgets/ListInput'
+ import StatusComponent from '../Widgets/Status'
+
+ export default {
+ components: {
+ ListInput,
+ StatusComponent
+ },
+ data() {
+ return {
+ list_id: null,
+ list: { members: [] },
+ status: {}
+ }
+ },
+ created() {
+ if (!this.$root.hasPermission('distlists')) {
+ this.$root.errorPage(404)
+ return
+ }
+
+ this.list_id = this.$route.params.list
+
+ if (this.list_id != 'new') {
+ this.$root.startLoading()
+
+ axios.get('/api/v4/groups/' + this.list_id)
+ .then(response => {
+ this.$root.stopLoading()
+ this.list = response.data
+ this.status = response.data.statusInfo
+ })
+ .catch(this.$root.errorHandler)
+ }
+ },
+ methods: {
+ deleteList() {
+ axios.delete('/api/v4/groups/' + this.list_id)
+ .then(response => {
+ if (response.data.status == 'success') {
+ this.$toast.success(response.data.message)
+ this.$router.push({ name: 'distlists' })
+ }
+ })
+ },
+ statusUpdate(list) {
+ this.list = Object.assign({}, this.list, list)
+ },
+ submit() {
+ this.$root.clearFormValidation($('#list-info form'))
+
+ let method = 'post'
+ let location = '/api/v4/groups'
+
+ if (this.list_id !== 'new') {
+ method = 'put'
+ location += '/' + this.list_id
+ }
+
+ axios[method](location, this.list)
+ .then(response => {
+ this.$toast.success(response.data.message)
+ this.$router.push({ name: 'distlists' })
+ })
+ }
+ }
+ }
+</script>
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 @@
+<template>
+ <div class="container">
+ <div class="card" id="distlist-list">
+ <div class="card-body">
+ <div class="card-title">
+ Distribution lists
+ <router-link class="btn btn-success float-right create-list" :to="{ path: 'distlist/new' }" tag="button">
+ <svg-icon icon="users"></svg-icon> Create list
+ </router-link>
+ </div>
+ <div class="card-text">
+ <table class="table table-sm table-hover">
+ <thead class="thead-light">
+ <tr>
+ <th scope="col">Email</th>
+ </tr>
+ </thead>
+ <tbody>
+ <tr v-for="list in lists" :key="list.id" @click="$root.clickRecord">
+ <td>
+ <svg-icon icon="users" :class="$root.distlistStatusClass(list)" :title="$root.distlistStatusText(list)"></svg-icon>
+ <router-link :to="{ path: 'distlist/' + list.id }">{{ list.email }}</router-link>
+ </td>
+ </tr>
+ </tbody>
+ <tfoot class="table-fake-body">
+ <tr>
+ <td>There are no distribution lists in this account.</td>
+ </tr>
+ </tfoot>
+ </table>
+ </div>
+ </div>
+ </div>
+ </div>
+</template>
+
+<script>
+ export default {
+ data() {
+ return {
+ lists: []
+ }
+ },
+ created() {
+ // TODO: Find a way to do this in some more global way. Note that it cannot
+ // be done in the vue-router, but maybe the app component?
+ if (!this.$root.hasPermission('distlists')) {
+ this.$root.errorPage(404)
+ return
+ }
+
+ this.$root.startLoading()
+
+ axios.get('/api/v4/groups')
+ .then(response => {
+ this.$root.stopLoading()
+ this.lists = response.data
+ })
+ .catch(this.$root.errorHandler)
+ }
+ }
+</script>
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 @@
<div class="card-text">
<form @submit.prevent="submit">
<div v-if="user_id !== 'new'" class="form-group row plaintext">
- <label for="first_name" class="col-sm-4 col-form-label">Status</label>
+ <label for="status" class="col-sm-4 col-form-label">Status</label>
<div class="col-sm-8">
<span :class="$root.userStatusClass(user) + ' form-control-plaintext'" id="status">{{ $root.userStatusText(user) }}</span>
</div>
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 @@
<p id="status-body" class="flex-grow-1">
<span v-if="scope == 'dashboard'">We are preparing your account.</span>
<span v-else-if="scope == 'domain'">We are preparing the domain.</span>
+ <span v-else-if="scope == 'distlist'">We are preparing the distribution list.</span>
<span v-else>We are preparing the user account.</span>
<br>
Some features may be missing or readonly at the moment.<br>
@@ -17,6 +18,7 @@
<p id="status-body" class="flex-grow-1">
<span v-if="scope == 'dashboard'">Your account is almost ready.</span>
<span v-else-if="scope == 'domain'">The domain is almost ready.</span>
+ <span v-else-if="scope == 'distlist'">The distribution list is almost ready.</span>
<span v-else>The user account is almost ready.</span>
<br>
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 @@
+<?php
+
+namespace Tests\Browser;
+
+use App\Group;
+use App\Sku;
+use Tests\Browser;
+use Tests\Browser\Components\ListInput;
+use Tests\Browser\Components\Status;
+use Tests\Browser\Components\Toast;
+use Tests\Browser\Pages\Dashboard;
+use Tests\Browser\Pages\Home;
+use Tests\Browser\Pages\DistlistInfo;
+use Tests\Browser\Pages\DistlistList;
+use Tests\TestCaseDusk;
+
+class DistlistTest extends TestCaseDusk
+{
+ /**
+ * {@inheritDoc}
+ */
+ public function setUp(): void
+ {
+ parent::setUp();
+
+ $this->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 @@
+<?php
+
+namespace Tests\Browser\Pages;
+
+use Laravel\Dusk\Page;
+
+class DistlistInfo extends Page
+{
+ /**
+ * Get the URL for the page.
+ *
+ * @return string
+ */
+ public function url(): string
+ {
+ return '';
+ }
+
+ /**
+ * Assert that the browser is on the page.
+ *
+ * @param \Laravel\Dusk\Browser $browser The browser object
+ *
+ * @return void
+ */
+ public function assert($browser)
+ {
+ $browser->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 @@
+<?php
+
+namespace Tests\Browser\Pages;
+
+use Laravel\Dusk\Page;
+
+class DistlistList extends Page
+{
+ /**
+ * Get the URL for the page.
+ *
+ * @return string
+ */
+ public function url(): string
+ {
+ return '/distlists';
+ }
+
+ /**
+ * Assert that the browser is on the page.
+ *
+ * @param \Laravel\Dusk\Browser $browser The browser object
+ *
+ * @return void
+ */
+ public function assert($browser)
+ {
+ $browser->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
@@ -652,8 +652,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')
@@ -671,35 +671,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 @@
+<?php
+
+namespace Tests\Feature\Controller;
+
+use App\Group;
+use App\Http\Controllers\API\V4\GroupsController;
+use Carbon\Carbon;
+use Illuminate\Support\Facades\Queue;
+use Tests\TestCase;
+
+class GroupsTest extends TestCase
+{
+ /**
+ * {@inheritDoc}
+ */
+ public function setUp(): void
+ {
+ parent::setUp();
+
+ $this->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/<id>)
+ */
+ 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/<group-id>)
+ */
+ 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/<group-id>/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/<group-id>)
+ */
+ 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
@@ -1181,7 +1181,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();
}

File Metadata

Mime Type
text/plain
Expires
Sat, Apr 4, 3:25 AM (3 d, 8 h ago)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
18827898
Default Alt Text
D2500.1775273105.diff (86 KB)

Event Timeline