Page Menu
Home
Phorge
Search
Configure Global Search
Log In
Files
F117805653
D2500.1775273105.diff
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Flag For Later
Award Token
Authored By
Unknown
Size
86 KB
Referenced Files
None
Subscribers
None
D2500.1775273105.diff
View Options
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
Details
Attached
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)
Attached To
Mode
D2500: [User] Distribution lists UI
Attached
Detach File
Event Timeline