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 @@ -3,6 +3,7 @@ namespace App\Backends; use App\Domain; +use App\Group; use App\User; class LDAP @@ -214,6 +215,64 @@ } /** + * Create a group in LDAP. + * + * @param \App\Group $group The group to create. + * + * @throws \Exception + */ + public static function createGroup(Group $group): void + { + $config = self::getConfig('admin'); + $ldap = self::initLDAP($config); + + list($cn, $domainName) = explode('@', $group->email); + + $domain = $group->domain(); + + if (empty($domain)) { + self::throwException( + $ldap, + "Failed to create group {$group->email} in LDAP (" . __LINE__ . ")" + ); + } + + $hostedRootDN = \config('ldap.hosted.root_dn'); + + $domainBaseDN = "ou={$domain->namespace},{$hostedRootDN}"; + + $groupBaseDN = "ou=Groups,{$domainBaseDN}"; + + $dn = "cn={$cn},{$groupBaseDN}"; + + $entry = [ + 'cn' => $cn, + 'mail' => $group->email, + 'objectclass' => [ + 'top', + 'groupofuniquenames', + 'kolabgroupofuniquenames' + ], + 'uniqueMember' => [] + ]; + + self::setGroupAttributes($group, $entry); + + $result = $ldap->add_entry($dn, $entry); + + if (!$result) { + self::throwException( + $ldap, + "Failed to create group {$group->email} in LDAP (" . __LINE__ . ")" + ); + } + + if (empty(self::$ldap)) { + $ldap->close(); + } + } + + /** * Create a user in LDAP. * * Only need to add user if in any of the local domains? Figure that out here for now. Should @@ -279,7 +338,7 @@ /** * Delete a domain from LDAP. * - * @param \App\Domain $domain The domain to update. + * @param \App\Domain $domain The domain to delete * * @throws \Exception */ @@ -323,9 +382,37 @@ } /** + * Delete a group from LDAP. + * + * @param \App\Group $group The group to delete. + * + * @throws \Exception + */ + public static function deleteGroup(Group $group): void + { + $config = self::getConfig('admin'); + $ldap = self::initLDAP($config); + + if (self::getGroupEntry($ldap, $group->email, $dn)) { + $result = $ldap->delete_entry($dn); + + if (!$result) { + self::throwException( + $ldap, + "Failed to delete group {$group->email} from LDAP (" . __LINE__ . ")" + ); + } + } + + if (empty(self::$ldap)) { + $ldap->close(); + } + } + + /** * Delete a user from LDAP. * - * @param \App\User $user The user account to update. + * @param \App\User $user The user account to delete. * * @throws \Exception */ @@ -377,6 +464,28 @@ } /** + * Get a group data from LDAP. + * + * @param string $email The group email. + * + * @return array|false|null + * @throws \Exception + */ + public static function getGroup(string $email) + { + $config = self::getConfig('admin'); + $ldap = self::initLDAP($config); + + $group = self::getGroupEntry($ldap, $email, $dn); + + if (empty(self::$ldap)) { + $ldap->close(); + } + + return $group; + } + + /** * Get a user data from LDAP. * * @param string $email The user email. @@ -443,6 +552,66 @@ } /** + * Update a group in LDAP. + * + * @param \App\Group $group The group to update + * + * @throws \Exception + */ + public static function updateGroup(Group $group): void + { + $config = self::getConfig('admin'); + $ldap = self::initLDAP($config); + + list($cn, $domainName) = explode('@', $group->email); + + $domain = $group->domain(); + + if (empty($domain)) { + self::throwException( + $ldap, + "Failed to update group {$group->email} in LDAP (" . __LINE__ . ")" + ); + } + + $hostedRootDN = \config('ldap.hosted.root_dn'); + + $domainBaseDN = "ou={$domain->namespace},{$hostedRootDN}"; + + $groupBaseDN = "ou=Groups,{$domainBaseDN}"; + + $dn = "cn={$cn},{$groupBaseDN}"; + + $entry = [ + 'cn' => $cn, + 'mail' => $group->email, + 'objectclass' => [ + 'top', + 'groupofuniquenames', + 'kolabgroupofuniquenames' + ], + 'uniqueMember' => [] + ]; + + $oldEntry = $ldap->get_entry($dn); + + self::setGroupAttributes($group, $entry); + + $result = $ldap->modify_entry($dn, $oldEntry, $entry); + + if (!is_array($result)) { + self::throwException( + $ldap, + "Failed to update group {$group->email} in LDAP (" . __LINE__ . ")" + ); + } + + if (empty(self::$ldap)) { + $ldap->close(); + } + } + + /** * Update a user in LDAP. * * @param \App\User $user The user account to update. @@ -531,6 +700,63 @@ } /** + * Convert group member addresses in to valid entries. + */ + private static function setGroupAttributes(Group $group, &$entry) + { + $config = self::getConfig('admin'); + $ldap = self::initLDAP($config); + + $validMembers = []; + + $domain = $group->domain(); + + $hostedRootDN = \config('ldap.hosted.root_dn'); + + $domainBaseDN = "ou={$domain->namespace},{$hostedRootDN}"; + + foreach ($group->members as $member) { + list($local, $domainName) = explode('@', $member); + + $memberDN = "uid={$member},ou=People,{$domainBaseDN}"; + + // if the member is in the local domain but doesn't exist, drop it + if ($domainName == $domain->namespace) { + if (!$ldap->get_entry($memberDN)) { + continue; + } + } + + // add the member if not in the local domain + if (!$ldap->get_entry($memberDN)) { + $memberEntry = [ + 'cn' => $member, + 'mail' => $member, + 'objectclass' => [ + 'top', + 'inetorgperson', + 'organizationalperson', + 'person' + ], + 'sn' => 'unknown' + ]; + + $ldap->add_entry($memberDN, $memberEntry); + } + + $entry['uniquemember'][] = $memberDN; + $validMembers[] = $member; + } + + // Update members in sql (some might have been removed), + // skip model events to not invoke another update job + $group->members = $validMembers; + Group::withoutEvents(function () use ($group) { + $group->save(); + }); + } + + /** * Set common user attributes */ private static function setUserAttributes(User $user, array &$entry) @@ -625,6 +851,33 @@ } /** + * Get group entry from LDAP. + * + * @param \Net_LDAP3 $ldap Ldap connection + * @param string $email Group email (mail) + * @param string $dn Reference to group DN + * + * @return false|null|array Group entry, False on error, NULL if not found + */ + private static function getGroupEntry($ldap, $email, &$dn = null) + { + list($_local, $_domain) = explode('@', $email, 2); + + $domain = $ldap->find_domain($_domain); + + if (!$domain) { + return $domain; + } + + $base_dn = $ldap->domain_root_dn($_domain); + $dn = "cn={$_local},ou=Groups,{$base_dn}"; + + $entry = $ldap->get_entry($dn); + + return $entry ?: null; + } + + /** * Get user entry from LDAP. * * @param \Net_LDAP3 $ldap Ldap connection diff --git a/src/app/Console/Commands/Group/AddMemberCommand.php b/src/app/Console/Commands/Group/AddMemberCommand.php new file mode 100644 --- /dev/null +++ b/src/app/Console/Commands/Group/AddMemberCommand.php @@ -0,0 +1,53 @@ +argument('group'); + $member = \strtolower($this->argument('member')); + $group = $this->getObject(\App\Group::class, $input, 'email'); + + if (empty($group)) { + $this->error("Group {$input} does not exist."); + return 1; + } + + if (in_array($member, $group->members)) { + $this->error("{$member}: Already exists in the group."); + return 1; + } + + if ($error = CreateCommand::validateMemberEmail($member)) { + $this->error("{$member}: $error"); + return 1; + } + + // We can't modify the property indirectly, therefor array_merge() + $group->members = array_merge($group->members, [$member]); + $group->save(); + } +} diff --git a/src/app/Console/Commands/Group/CreateCommand.php b/src/app/Console/Commands/Group/CreateCommand.php new file mode 100644 --- /dev/null +++ b/src/app/Console/Commands/Group/CreateCommand.php @@ -0,0 +1,173 @@ +argument('email'); + $members = $this->option('member'); + + list($local, $domainName) = explode('@', $email, 2); + + $domain = $this->getDomain($domainName); + + if (!$domain) { + $this->error("No such domain {$domainName}."); + return 1; + } + + if ($domain->isPublic()) { + $this->error("Domain {$domainName} is public."); + return 1; + } + + $owner = $domain->wallet()->owner; + + // Validate group email address + foreach ($members as $i => $member) { + if ($error = $this->validateMemberEmail($member)) { + $this->error("{$member}: $error"); + return 1; + } + if (\strtolower($member) === \strtolower($email)) { + $this->error("{$member}: Cannot be the same as the group address."); + return 1; + } + } + + // Validate members addresses + if ($error = $this->validateGroupEmail($email, $owner)) { + $this->error("{$email}: {$error}"); + return 1; + } + + DB::beginTransaction(); + + // Create the group + $group = new Group(); + $group->email = $email; + $group->members = $members; + $group->save(); + + $group->assignToWallet($owner->wallets->first()); + + DB::commit(); + + $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/Console/Commands/Group/DeleteCommand.php b/src/app/Console/Commands/Group/DeleteCommand.php new file mode 100644 --- /dev/null +++ b/src/app/Console/Commands/Group/DeleteCommand.php @@ -0,0 +1,40 @@ +argument('group'); + $group = $this->getObject(\App\Group::class, $input, 'email'); + + if (empty($group)) { + $this->error("Group {$input} does not exist."); + return 1; + } + + $group->delete(); + } +} diff --git a/src/app/Console/Commands/Group/InfoCommand.php b/src/app/Console/Commands/Group/InfoCommand.php new file mode 100644 --- /dev/null +++ b/src/app/Console/Commands/Group/InfoCommand.php @@ -0,0 +1,48 @@ +argument('group'); + $group = $this->getObject(\App\Group::class, $input, 'email'); + + if (empty($group)) { + $this->error("Group {$input} does not exist."); + return 1; + } + + $this->info('Id: ' . $group->id); + $this->info('Email: ' . $group->email); + $this->info('Status: ' . $group->status); + + // TODO: Print owner/wallet + + foreach ($group->members as $member) { + $this->info('Member: ' . $member); + } + } +} diff --git a/src/app/Console/Commands/Group/RemoveMemberCommand.php b/src/app/Console/Commands/Group/RemoveMemberCommand.php new file mode 100644 --- /dev/null +++ b/src/app/Console/Commands/Group/RemoveMemberCommand.php @@ -0,0 +1,56 @@ +argument('group'); + $member = \strtolower($this->argument('member')); + + $group = $this->getObject(\App\Group::class, $input, 'email'); + + if (empty($group)) { + $this->error("Group {$input} does not exist."); + return 1; + } + + $members = []; + + foreach ($group->members as $m) { + if ($m !== $member) { + $members[] = $m; + } + } + + if (count($members) == count($group->members)) { + $this->error("Member {$member} not found in the group."); + return 1; + } + + $group->members = $members; + $group->save(); + } +} diff --git a/src/app/Group.php b/src/app/Group.php new file mode 100644 --- /dev/null +++ b/src/app/Group.php @@ -0,0 +1,277 @@ +id)) { + throw new \Exception("Group not yet exists"); + } + + if ($this->entitlement()->count()) { + throw new \Exception("Group already assigned to a wallet"); + } + + $sku = \App\Sku::where('title', 'group')->first(); + $exists = $wallet->entitlements()->where('sku_id', $sku->id)->count(); + + \App\Entitlement::create([ + 'wallet_id' => $wallet->id, + 'sku_id' => $sku->id, + 'cost' => $exists >= $sku->units_free ? $sku->cost : 0, + 'entitleable_id' => $this->id, + 'entitleable_type' => Group::class + ]); + + return $this; + } + + /** + * Returns group domain. + * + * @return ?\App\Domain The domain group belongs to, NULL if it does not exist + */ + public function domain(): ?Domain + { + list($local, $domainName) = explode('@', $this->email); + + return Domain::where('namespace', $domainName)->first(); + } + + /** + * Find whether an email address exists as a group (including deleted groups). + * + * @param string $email Email address + * @param bool $return_group Return Group instance instead of boolean + * + * @return \App\Group|bool True or Group model object if found, False otherwise + */ + public static function emailExists(string $email, bool $return_group = false) + { + if (strpos($email, '@') === false) { + return false; + } + + $email = \strtolower($email); + + $group = self::withTrashed()->where('email', $email)->first(); + + if ($group) { + return $return_group ? $group : true; + } + + return false; + } + + /** + * The group entitlement. + * + * @return \Illuminate\Database\Eloquent\Relations\MorphOne + */ + public function entitlement() + { + return $this->morphOne('App\Entitlement', 'entitleable'); + } + + /** + * Group members propert accessor. Converts internal comma-separated list into an array + * + * @param string $members Comma-separated list of email addresses + * + * @return array Email addresses of the group members, as an array + */ + public function getMembersAttribute($members): array + { + return $members ? explode(',', $members) : []; + } + + /** + * Returns whether this domain is active. + * + * @return bool + */ + public function isActive(): bool + { + return ($this->status & self::STATUS_ACTIVE) > 0; + } + + /** + * Returns whether this domain is deleted. + * + * @return bool + */ + public function isDeleted(): bool + { + return ($this->status & self::STATUS_DELETED) > 0; + } + + /** + * Returns whether this domain is new. + * + * @return bool + */ + public function isNew(): bool + { + return ($this->status & self::STATUS_NEW) > 0; + } + + /** + * Returns whether this domain is registered in LDAP. + * + * @return bool + */ + public function isLdapReady(): bool + { + return ($this->status & self::STATUS_LDAP_READY) > 0; + } + + /** + * Returns whether this domain is suspended. + * + * @return bool + */ + public function isSuspended(): bool + { + return ($this->status & self::STATUS_SUSPENDED) > 0; + } + + /** + * Ensure the email is appropriately cased. + * + * @param string $email Group email address + */ + public function setEmailAttribute(string $email) + { + $this->attributes['email'] = strtolower($email); + } + + /** + * Ensure the members are appropriately formatted. + * + * @param array $members Email addresses of the group members + */ + public function setMembersAttribute(array $members): void + { + $members = array_filter(array_map('strtolower', $members)); + $this->attributes['members'] = implode(',', $members); + } + + /** + * Group status mutator + * + * @throws \Exception + */ + public function setStatusAttribute($status) + { + $new_status = 0; + + $allowed_values = [ + self::STATUS_NEW, + self::STATUS_ACTIVE, + self::STATUS_SUSPENDED, + self::STATUS_DELETED, + self::STATUS_LDAP_READY, + ]; + + foreach ($allowed_values as $value) { + if ($status & $value) { + $new_status |= $value; + $status ^= $value; + } + } + + if ($status > 0) { + throw new \Exception("Invalid group status: {$status}"); + } + + $this->attributes['status'] = $new_status; + } + + /** + * Suspend this group. + * + * @return void + */ + public function suspend(): void + { + if ($this->isSuspended()) { + return; + } + + $this->status |= Group::STATUS_SUSPENDED; + $this->save(); + } + + /** + * Unsuspend this group. + * + * @return void + */ + public function unsuspend(): void + { + if (!$this->isSuspended()) { + return; + } + + $this->status ^= Group::STATUS_SUSPENDED; + $this->save(); + } + + /** + * Returns the wallet by which the group is controlled + * + * @return \App\Wallet A wallet object + */ + public function wallet(): ?Wallet + { + // Note: Not all domains have a entitlement/wallet + $entitlement = $this->entitlement()->withTrashed()->first(); + + return $entitlement ? $entitlement->wallet : null; + } +} diff --git a/src/app/Handlers/Group.php b/src/app/Handlers/Group.php new file mode 100644 --- /dev/null +++ b/src/app/Handlers/Group.php @@ -0,0 +1,16 @@ + \trans('validation.loginexists')]; } 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 @@ -4,6 +4,7 @@ use App\Http\Controllers\Controller; use App\Domain; +use App\Group; use App\Rules\UserEmailDomain; use App\Rules\UserEmailLocal; use App\Sku; @@ -30,10 +31,10 @@ ]; /** - * On user create it is filled with a user object to force-delete + * On user create it is filled with a user or group object to force-delete * before the creation of a new user record is possible. * - * @var \App\User|null + * @var \App\User|\App\Group|null */ protected $deleteBeforeCreate; @@ -673,10 +674,10 @@ /** * Email address validation for use as a user mailbox (login). * - * @param string $email Email address - * @param \App\User $user The account owner - * @param ?\App\User $deleted Filled with an instance of a deleted user with - * the specified email address, if exists + * @param string $email Email address + * @param \App\User $user The account owner + * @param null|\App\User|\App\Group $deleted Filled with an instance of a deleted user or group + * with the specified email address, if exists * * @return ?string Error message on validation error */ @@ -734,6 +735,17 @@ return \trans('validation.entryexists', ['attribute' => 'email']); } + // Check if a group with specified address already exists + if ($existing_group = Group::emailExists($email, true)) { + // If this is a deleted group in the same custom domain + // we'll force delete it before + if (!$domain->isPublic() && $existing_group->trashed()) { + $deleted = $existing_group; + } else { + return \trans('validation.entryexists', ['attribute' => 'email']); + } + } + return null; } @@ -798,6 +810,11 @@ } } + // Check if a group with specified address already exists + if (Group::emailExists($email)) { + return \trans('validation.entryexists', ['attribute' => 'alias']); + } + return null; } } diff --git a/src/app/Jobs/DomainJob.php b/src/app/Jobs/DomainJob.php --- a/src/app/Jobs/DomainJob.php +++ b/src/app/Jobs/DomainJob.php @@ -31,7 +31,7 @@ /** * Create a new job instance. * - * @param int $domainId The ID for the user to create. + * @param int $domainId The ID for the domain to create. * * @return void */ diff --git a/src/app/Jobs/Group/CreateJob.php b/src/app/Jobs/Group/CreateJob.php new file mode 100644 --- /dev/null +++ b/src/app/Jobs/Group/CreateJob.php @@ -0,0 +1,25 @@ +getGroup(); + + if (!$group->isLdapReady()) { + \App\Backends\LDAP::createGroup($group); + + $group->status |= \App\Group::STATUS_LDAP_READY; + $group->save(); + } + } +} diff --git a/src/app/Jobs/Group/DeleteJob.php b/src/app/Jobs/Group/DeleteJob.php new file mode 100644 --- /dev/null +++ b/src/app/Jobs/Group/DeleteJob.php @@ -0,0 +1,34 @@ +getGroup(); + + // sanity checks + if ($group->isDeleted()) { + $this->fail(new \Exception("Group {$this->groupId} is already marked as deleted.")); + return; + } + + \App\Backends\LDAP::deleteGroup($group); + + $group->status |= \App\Group::STATUS_DELETED; + + if ($group->isLdapReady()) { + $group->status ^= \App\Group::STATUS_LDAP_READY; + } + + $group->save(); + } +} diff --git a/src/app/Jobs/Group/UpdateJob.php b/src/app/Jobs/Group/UpdateJob.php new file mode 100644 --- /dev/null +++ b/src/app/Jobs/Group/UpdateJob.php @@ -0,0 +1,25 @@ +getGroup(); + + if (!$group->isLdapReady()) { + $this->delete(); + return; + } + + \App\Backends\LDAP::updateGroup($group); + } +} diff --git a/src/app/Jobs/GroupJob.php b/src/app/Jobs/GroupJob.php new file mode 100644 --- /dev/null +++ b/src/app/Jobs/GroupJob.php @@ -0,0 +1,66 @@ +handle(); + * ``` + */ +abstract class GroupJob extends CommonJob +{ + /** + * The ID for the \App\Group. This is the shortest globally unique identifier and saves Redis space + * compared to a serialized version of the complete \App\Group object. + * + * @var int + */ + protected $groupId; + + /** + * The \App\Group email property, for legibility in the queue management. + * + * @var string + */ + protected $groupEmail; + + /** + * Create a new job instance. + * + * @param int $groupId The ID for the group to create. + * + * @return void + */ + public function __construct(int $groupId) + { + $this->groupId = $groupId; + + $group = $this->getGroup(); + + if ($group) { + $this->groupEmail = $group->email; + } + } + + /** + * Get the \App\Group entry associated with this job. + * + * @return \App\Group|null + * + * @throws \Exception + */ + protected function getGroup() + { + $group = \App\Group::withTrashed()->find($this->groupId); + + if (!$group) { + $this->fail(new \Exception("Group {$this->groupId} could not be found in the database.")); + } + + return $group; + } +} diff --git a/src/app/Observers/DomainObserver.php b/src/app/Observers/DomainObserver.php --- a/src/app/Observers/DomainObserver.php +++ b/src/app/Observers/DomainObserver.php @@ -68,6 +68,10 @@ */ public function deleted(Domain $domain) { + if ($domain->isForceDeleting()) { + return; + } + \App\Jobs\Domain\DeleteJob::dispatch($domain->id); } diff --git a/src/app/Observers/GroupObserver.php b/src/app/Observers/GroupObserver.php new file mode 100644 --- /dev/null +++ b/src/app/Observers/GroupObserver.php @@ -0,0 +1,113 @@ +find($allegedly_unique)) { + $group->{$group->getKeyName()} = $allegedly_unique; + break; + } + } + + $group->status |= Group::STATUS_NEW | Group::STATUS_ACTIVE; + } + + /** + * Handle the group "created" event. + * + * @param \App\Group $group The group + * + * @return void + */ + public function created(Group $group) + { + \App\Jobs\Group\CreateJob::dispatch($group->id); + } + + /** + * Handle the group "deleting" event. + * + * @param \App\Group $group The group + * + * @return void + */ + public function deleting(Group $group) + { + // Entitlements do not have referential integrity on the entitled object, so this is our + // way of doing an onDelete('cascade') without the foreign key. + \App\Entitlement::where('entitleable_id', $group->id) + ->where('entitleable_type', Group::class) + ->delete(); + } + + /** + * Handle the group "deleted" event. + * + * @param \App\Group $group The group + * + * @return void + */ + public function deleted(Group $group) + { + if ($group->isForceDeleting()) { + return; + } + + \App\Jobs\Group\DeleteJob::dispatch($group->id); + } + + /** + * Handle the group "updated" event. + * + * @param \App\Group $group The group + * + * @return void + */ + public function updated(Group $group) + { + \App\Jobs\Group\UpdateJob::dispatch($group->id); + } + + /** + * Handle the group "restored" event. + * + * @param \App\Group $group The group + * + * @return void + */ + public function restored(Group $group) + { + // + } + + /** + * Handle the group "force deleting" event. + * + * @param \App\Group $group The group + * + * @return void + */ + public function forceDeleted(Group $group) + { + // A group can be force-deleted separately from the owner + // we have to force-delete entitlements + \App\Entitlement::where('entitleable_id', $group->id) + ->where('entitleable_type', Group::class) + ->forceDelete(); + } +} diff --git a/src/app/Observers/UserObserver.php b/src/app/Observers/UserObserver.php --- a/src/app/Observers/UserObserver.php +++ b/src/app/Observers/UserObserver.php @@ -4,6 +4,7 @@ use App\Entitlement; use App\Domain; +use App\Group; use App\Transaction; use App\User; use App\Wallet; @@ -97,7 +98,16 @@ */ public function deleted(User $user) { - // + // Remove the user from existing groups + $wallet = $user->wallet(); + if ($wallet && $wallet->owner) { + $wallet->owner->groups()->each(function ($group) use ($user) { + if (in_array($user->email, $group->members)) { + $group->members = array_diff($group->members, [$user->email]); + $group->save(); + } + }); + } } /** @@ -132,6 +142,7 @@ $assignments = Entitlement::whereIn('wallet_id', $wallets)->get(); $users = []; $domains = []; + $groups = []; $entitlements = []; foreach ($assignments as $entitlement) { @@ -139,30 +150,35 @@ $domains[] = $entitlement->entitleable_id; } elseif ($entitlement->entitleable_type == User::class && $entitlement->entitleable_id != $user->id) { $users[] = $entitlement->entitleable_id; + } elseif ($entitlement->entitleable_type == Group::class) { + $groups[] = $entitlement->entitleable_id; } else { - $entitlements[] = $entitlement->id; + $entitlements[] = $entitlement; } } - $users = array_unique($users); - $domains = array_unique($domains); - // Domains/users/entitlements need to be deleted one by one to make sure // events are fired and observers can do the proper cleanup. if (!empty($users)) { - foreach (User::whereIn('id', $users)->get() as $_user) { + foreach (User::whereIn('id', array_unique($users))->get() as $_user) { $_user->delete(); } } if (!empty($domains)) { - foreach (Domain::whereIn('id', $domains)->get() as $_domain) { + foreach (Domain::whereIn('id', array_unique($domains))->get() as $_domain) { $_domain->delete(); } } - if (!empty($entitlements)) { - Entitlement::whereIn('id', $entitlements)->delete(); + if (!empty($groups)) { + foreach (Group::whereIn('id', array_unique($groups))->get() as $_group) { + $_group->delete(); + } + } + + foreach ($entitlements as $entitlement) { + $entitlement->delete(); } // FIXME: What do we do with user wallets? @@ -186,6 +202,7 @@ $assignments = Entitlement::withTrashed()->whereIn('wallet_id', $wallets)->get(); $entitlements = []; $domains = []; + $groups = []; $users = []; foreach ($assignments as $entitlement) { @@ -198,12 +215,11 @@ && $entitlement->entitleable_id != $user->id ) { $users[] = $entitlement->entitleable_id; + } elseif ($entitlement->entitleable_type == Group::class) { + $groups[] = $entitlement->entitleable_id; } } - $users = array_unique($users); - $domains = array_unique($domains); - // Remove the user "direct" entitlements explicitely, if they belong to another // user's wallet they will not be removed by the wallets foreign key cascade Entitlement::withTrashed() @@ -213,14 +229,19 @@ // Users need to be deleted one by one to make sure observers can do the proper cleanup. if (!empty($users)) { - foreach (User::withTrashed()->whereIn('id', $users)->get() as $_user) { + foreach (User::withTrashed()->whereIn('id', array_unique($users))->get() as $_user) { $_user->forceDelete(); } } // Domains can be just removed if (!empty($domains)) { - Domain::withTrashed()->whereIn('id', $domains)->forceDelete(); + Domain::withTrashed()->whereIn('id', array_unique($domains))->forceDelete(); + } + + // Groups can be just removed + if (!empty($groups)) { + Group::withTrashed()->whereIn('id', array_unique($groups))->forceDelete(); } // Remove transactions, they also have no foreign key constraint diff --git a/src/app/Providers/AppServiceProvider.php b/src/app/Providers/AppServiceProvider.php --- a/src/app/Providers/AppServiceProvider.php +++ b/src/app/Providers/AppServiceProvider.php @@ -29,6 +29,7 @@ \App\Discount::observe(\App\Observers\DiscountObserver::class); \App\Domain::observe(\App\Observers\DomainObserver::class); \App\Entitlement::observe(\App\Observers\EntitlementObserver::class); + \App\Group::observe(\App\Observers\GroupObserver::class); \App\Package::observe(\App\Observers\PackageObserver::class); \App\PackageSku::observe(\App\Observers\PackageSkuObserver::class); \App\Plan::observe(\App\Observers\PlanObserver::class); diff --git a/src/app/User.php b/src/app/User.php --- a/src/app/User.php +++ b/src/app/User.php @@ -414,6 +414,23 @@ } /** + * Return groups controlled by the current user. + * + * @return \Illuminate\Database\Eloquent\Builder Query builder + */ + public function groups() + { + $wallets = $this->wallets()->pluck('id')->all(); + + $groupIds = \App\Entitlement::whereIn('entitlements.wallet_id', $wallets) + ->where('entitlements.entitleable_type', Group::class) + ->pluck('entitleable_id') + ->all(); + + return Group::whereIn('id', $groupIds); + } + + /** * Check if user has an entitlement for the specified SKU. * * @param string $title The SKU title @@ -606,7 +623,7 @@ ->distinct() ->leftJoin('entitlements', 'entitlements.entitleable_id', '=', 'users.id') ->whereIn('entitlements.wallet_id', $wallets) - ->where('entitlements.entitleable_type', 'App\User'); + ->where('entitlements.entitleable_type', User::class); } /** @@ -622,11 +639,11 @@ /** * Returns the wallet by which the user is controlled * - * @return \App\Wallet A wallet object + * @return ?\App\Wallet A wallet object */ - public function wallet(): Wallet + public function wallet(): ?Wallet { - $entitlement = $this->entitlement()->first(); + $entitlement = $this->entitlement()->withTrashed()->first(); // TODO: No entitlement should not happen, but in tests we have // such cases, so we fallback to the user's wallet in this case diff --git a/src/database/migrations/2020_12_28_140000_create_groups_table.php b/src/database/migrations/2020_12_28_140000_create_groups_table.php new file mode 100644 --- /dev/null +++ b/src/database/migrations/2020_12_28_140000_create_groups_table.php @@ -0,0 +1,55 @@ +bigInteger('id'); + $table->string('email')->unique(); + $table->text('members')->nullable(); + $table->smallInteger('status'); + + $table->timestamps(); + $table->softDeletes(); + + $table->primary('id'); + } + ); + + if (!\App\Sku::where('title', 'group')->first()) { + \App\Sku::create([ + 'title' => 'group', + 'name' => 'Group', + 'description' => 'Distribution list', + 'cost' => 0, + 'units_free' => 0, + 'period' => 'monthly', + 'handler_class' => 'App\Handlers\Group', + 'active' => true, + ]); + } + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::dropIfExists('groups'); + } +} 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 @@ -183,5 +183,21 @@ ] ); } + + // Check existence because migration might have added this already + if (!\App\Sku::where('title', 'group')->first()) { + Sku::create( + [ + 'title' => 'group', + 'name' => 'Group', + 'description' => 'Distribution list', + 'cost' => 0, + 'units_free' => 0, + 'period' => 'monthly', + 'handler_class' => 'App\Handlers\Group', + '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 @@ -183,5 +183,21 @@ ] ); } + + // Check existence because migration might have added this already + if (!\App\Sku::where('title', 'group')->first()) { + Sku::create( + [ + 'title' => 'group', + 'name' => 'Group', + 'description' => 'Distribution list', + 'cost' => 0, + 'units_free' => 0, + 'period' => 'monthly', + 'handler_class' => 'App\Handlers\Group', + 'active' => true, + ] + ); + } } } 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 @@ -121,6 +121,7 @@ '2fainvalid' => 'Second factor code is invalid.', 'emailinvalid' => 'The specified email address is invalid.', 'domaininvalid' => 'The specified domain is invalid.', + 'domainnotavailable' => 'The specified domain is not available.', 'logininvalid' => 'The specified login is invalid.', 'loginexists' => 'The specified login is not available.', 'domainexists' => 'The specified domain is not available.', diff --git a/src/tests/Feature/Backends/LDAPTest.php b/src/tests/Feature/Backends/LDAPTest.php --- a/src/tests/Feature/Backends/LDAPTest.php +++ b/src/tests/Feature/Backends/LDAPTest.php @@ -4,6 +4,7 @@ use App\Backends\LDAP; use App\Domain; +use App\Group; use App\Entitlement; use App\User; use Illuminate\Support\Facades\Queue; @@ -26,6 +27,8 @@ $this->deleteTestUser('user-ldap-test@' . \config('app.domain')); $this->deleteTestDomain('testldap.com'); + $this->deleteTestGroup('group@kolab.org'); + // TODO: Remove group members } /** @@ -37,6 +40,8 @@ $this->deleteTestUser('user-ldap-test@' . \config('app.domain')); $this->deleteTestDomain('testldap.com'); + $this->deleteTestGroup('group@kolab.org'); + // TODO: Remove group members parent::tearDown(); } @@ -110,6 +115,88 @@ } /** + * Test creating/updating/deleting a group record + * + * @group ldap + */ + public function testGroup(): void + { + Queue::fake(); + + $root_dn = \config('ldap.hosted.root_dn'); + $group = $this->getTestGroup('group@kolab.org', [ + 'members' => ['member1@testldap.com', 'member2@testldap.com'] + ]); + + // Create the group + LDAP::createGroup($group); + + $ldap_group = LDAP::getGroup($group->email); + + $expected = [ + 'cn' => 'group', + 'dn' => 'cn=group,ou=Groups,ou=kolab.org,' . $root_dn, + 'mail' => $group->email, + 'objectclass' => [ + 'top', + 'groupofuniquenames', + 'kolabgroupofuniquenames' + ], + 'uniquemember' => [ + 'uid=member1@testldap.com,ou=People,ou=kolab.org,' . $root_dn, + 'uid=member2@testldap.com,ou=People,ou=kolab.org,' . $root_dn, + ], + ]; + + foreach ($expected as $attr => $value) { + $this->assertEquals($value, isset($ldap_group[$attr]) ? $ldap_group[$attr] : null, "Group $attr attribute"); + } + + // Update members + $group->members = ['member3@testldap.com']; + $group->save(); + + LDAP::updateGroup($group); + + // TODO: Should we force this to be always an array? + $expected['uniquemember'] = 'uid=member3@testldap.com,ou=People,ou=kolab.org,' . $root_dn; + + $ldap_group = LDAP::getGroup($group->email); + + foreach ($expected as $attr => $value) { + $this->assertEquals($value, isset($ldap_group[$attr]) ? $ldap_group[$attr] : null, "Group $attr attribute"); + } + + $this->assertSame(['member3@testldap.com'], $group->fresh()->members); + + // Update members (add non-existing local member, expect it to be aot-removed from the group) + $group->members = ['member3@testldap.com', 'member-local@kolab.org']; + $group->save(); + + LDAP::updateGroup($group); + + // TODO: Should we force this to be always an array? + $expected['uniquemember'] = 'uid=member3@testldap.com,ou=People,ou=kolab.org,' . $root_dn; + + $ldap_group = LDAP::getGroup($group->email); + + foreach ($expected as $attr => $value) { + $this->assertEquals($value, isset($ldap_group[$attr]) ? $ldap_group[$attr] : null, "Group $attr attribute"); + } + + $this->assertSame(['member3@testldap.com'], $group->fresh()->members); + + // We called save() twice, so we expect two update obs, this is making sure + // that there's no job executed by the LDAP backend + Queue::assertPushed(\App\Jobs\Group\UpdateJob::class, 2); + + // Delete the domain + LDAP::deleteGroup($group); + + $this->assertSame(null, LDAP::getGroup($group->email)); + } + + /** * Test creating/editing/deleting a user record * * @group ldap diff --git a/src/tests/Feature/Console/Group/AddMemberTest.php b/src/tests/Feature/Console/Group/AddMemberTest.php new file mode 100644 --- /dev/null +++ b/src/tests/Feature/Console/Group/AddMemberTest.php @@ -0,0 +1,81 @@ +deleteTestGroup('group-test@kolabnow.com'); + } + + /** + * {@inheritDoc} + */ + public function tearDown(): void + { + $this->deleteTestGroup('group-test@kolabnow.com'); + + parent::tearDown(); + } + + /** + * Test command runs + */ + public function testHandle(): void + { + Queue::fake(); + + // Warning: We're not using artisan() here, as this will not + // allow us to test "empty output" cases + + // Non-existing group + $code = \Artisan::call("group:add-member test@group.com member@group.com"); + $output = trim(\Artisan::output()); + + $this->assertSame(1, $code); + $this->assertSame("Group test@group.com does not exist.", $output); + + $group = Group::create(['email' => 'group-test@kolabnow.com']); + + // Existing group, invalid member + $code = \Artisan::call("group:add-member {$group->email} member"); + $output = trim(\Artisan::output()); + + $this->assertSame(1, $code); + $this->assertSame("member: The specified email address is invalid.", $output); + + // Existing group + $code = \Artisan::call("group:add-member {$group->email} member@gmail.com"); + $output = trim(\Artisan::output()); + + $this->assertSame(0, $code); + $this->assertSame('', $output); + $this->assertSame(['member@gmail.com'], $group->refresh()->members); + + // Existing group + $code = \Artisan::call("group:add-member {$group->email} member2@gmail.com"); + $output = trim(\Artisan::output()); + + $this->assertSame(0, $code); + $this->assertSame('', $output); + $this->assertSame(['member@gmail.com', 'member2@gmail.com'], $group->refresh()->members); + + // Add a member that already exists + $code = \Artisan::call("group:add-member {$group->email} member@gmail.com"); + $output = trim(\Artisan::output()); + + $this->assertSame(1, $code); + $this->assertSame("member@gmail.com: Already exists in the group.", $output); + $this->assertSame(['member@gmail.com', 'member2@gmail.com'], $group->refresh()->members); + } +} diff --git a/src/tests/Feature/Console/Group/CreateTest.php b/src/tests/Feature/Console/Group/CreateTest.php new file mode 100644 --- /dev/null +++ b/src/tests/Feature/Console/Group/CreateTest.php @@ -0,0 +1,102 @@ +deleteTestGroup('group-test@kolab.org'); + $this->deleteTestGroup('group-testm@kolab.org'); + } + + /** + * {@inheritDoc} + */ + public function tearDown(): void + { + $this->deleteTestGroup('group-test@kolab.org'); + $this->deleteTestGroup('group-testm@kolab.org'); + + parent::tearDown(); + } + + /** + * Test command runs + */ + public function testHandle(): void + { + Queue::fake(); + + // Warning: We're not using artisan() here, as this will not + // allow us to test "empty output" cases + + $user = $this->getTestUser('john@kolab.org'); + + // Domain not existing + $code = \Artisan::call("group:create testgroup@unknown.org"); + $output = trim(\Artisan::output()); + $this->assertSame(1, $code); + $this->assertSame("No such domain unknown.org.", $output); + + // Existing email + $code = \Artisan::call("group:create jack@kolab.org"); + $output = trim(\Artisan::output()); + $this->assertSame(1, $code); + $this->assertSame("jack@kolab.org: The specified email is not available.", $output); + + // Existing email (of a user alias) + $code = \Artisan::call("group:create jack.daniels@kolab.org"); + $output = trim(\Artisan::output()); + $this->assertSame(1, $code); + $this->assertSame("jack.daniels@kolab.org: The specified email is not available.", $output); + + // Public domain not allowed in the group email address + $code = \Artisan::call("group:create group-test@kolabnow.com"); + $output = trim(\Artisan::output()); + $this->assertSame(1, $code); + $this->assertSame("Domain kolabnow.com is public.", $output); + + // Create a group without members + $code = \Artisan::call("group:create group-test@kolab.org"); + $output = trim(\Artisan::output()); + $group = Group::where('email', 'group-test@kolab.org')->first(); + + $this->assertSame(0, $code); + $this->assertEquals($group->id, $output); + $this->assertSame([], $group->members); + $this->assertSame($user->wallets->first()->id, $group->wallet()->id); + + // Existing email (of a group) + $code = \Artisan::call("group:create group-test@kolab.org"); + $output = trim(\Artisan::output()); + $this->assertSame(1, $code); + $this->assertSame("group-test@kolab.org: The specified email is not available.", $output); + + // Invalid member + $code = \Artisan::call("group:create group-testm@kolab.org --member=invalid"); + $output = trim(\Artisan::output()); + $this->assertSame(1, $code); + $this->assertSame("invalid: The specified email address is invalid.", $output); + + // Valid members + $code = \Artisan::call( + "group:create group-testm@kolab.org --member=member1@kolabnow.com --member=member2@gmail.com" + ); + $output = trim(\Artisan::output()); + $group = Group::where('email', 'group-testm@kolab.org')->first(); + $this->assertSame(0, $code); + $this->assertEquals($group->id, $output); + $this->assertSame(['member1@kolabnow.com', 'member2@gmail.com'], $group->members); + $this->assertSame($user->wallets->first()->id, $group->wallet()->id); + } +} diff --git a/src/tests/Feature/Console/Group/DeleteTest.php b/src/tests/Feature/Console/Group/DeleteTest.php new file mode 100644 --- /dev/null +++ b/src/tests/Feature/Console/Group/DeleteTest.php @@ -0,0 +1,61 @@ +deleteTestGroup('group-test@kolabnow.com'); + $this->deleteTestUser('group-owner@kolabnow.com'); + } + + /** + * {@inheritDoc} + */ + public function tearDown(): void + { + $this->deleteTestGroup('group-test@kolabnow.com'); + $this->deleteTestUser('group-owner@kolabnow.com'); + + parent::tearDown(); + } + + /** + * Test command runs + */ + public function testHandle(): void + { + Queue::fake(); + + // Warning: We're not using artisan() here, as this will not + // allow us to test "empty output" cases + + // Non-existing group + $code = \Artisan::call("group:delete test@group.com"); + $output = trim(\Artisan::output()); + + $this->assertSame(1, $code); + $this->assertSame("Group test@group.com does not exist.", $output); + + $user = $this->getTestUser('group-owner@kolabnow.com'); + $group = $this->getTestGroup('group-test@kolabnow.com'); + + // Existing group + $code = \Artisan::call("group:delete {$group->email}"); + $output = trim(\Artisan::output()); + + $this->assertSame(0, $code); + $this->assertSame('', $output); + $this->assertTrue($group->refresh()->trashed()); + } +} diff --git a/src/tests/Feature/Console/Group/InfoTest.php b/src/tests/Feature/Console/Group/InfoTest.php new file mode 100644 --- /dev/null +++ b/src/tests/Feature/Console/Group/InfoTest.php @@ -0,0 +1,64 @@ +deleteTestGroup('group-test@kolabnow.com'); + } + + /** + * {@inheritDoc} + */ + public function tearDown(): void + { + $this->deleteTestGroup('group-test@kolabnow.com'); + + parent::tearDown(); + } + + /** + * Test command runs + */ + public function testHandle(): void + { + Queue::fake(); + + $code = \Artisan::call("group:info unknown@unknown.org"); + $output = trim(\Artisan::output()); + $this->assertSame(1, $code); + $this->assertSame("Group unknown@unknown.org does not exist.", $output); + + // A group without members + $group = $this->getTestGroup('group-test@kolabnow.com'); + + $expected = "Id: {$group->id}\nEmail: {$group->email}\nStatus: {$group->status}"; + + $code = \Artisan::call("group:info {$group->email}"); + $output = trim(\Artisan::output()); + $this->assertSame(0, $code); + $this->assertSame($expected, $output); + + // Group with members + $group->members = ['test@member.com']; + $group->save(); + + $expected .= "\nMember: test@member.com"; + + $code = \Artisan::call("group:info {$group->email}"); + $output = trim(\Artisan::output()); + $this->assertSame(0, $code); + $this->assertSame($expected, $output); + } +} diff --git a/src/tests/Feature/Console/Group/RemoveMemberTest.php b/src/tests/Feature/Console/Group/RemoveMemberTest.php new file mode 100644 --- /dev/null +++ b/src/tests/Feature/Console/Group/RemoveMemberTest.php @@ -0,0 +1,76 @@ +deleteTestGroup('group-test@kolabnow.com'); + } + + /** + * {@inheritDoc} + */ + public function tearDown(): void + { + $this->deleteTestGroup('group-test@kolabnow.com'); + + parent::tearDown(); + } + + /** + * Test command runs + */ + public function testHandle(): void + { + Queue::fake(); + + // Warning: We're not using artisan() here, as this will not + // allow us to test "empty output" cases + + // Non-existing group + $code = \Artisan::call("group:remove-member test@group.com member@group.com"); + $output = trim(\Artisan::output()); + + $this->assertSame(1, $code); + $this->assertSame("Group test@group.com does not exist.", $output); + + $group = Group::create([ + 'email' => 'group-test@kolabnow.com', + 'members' => ['member1@gmail.com', 'member2@gmail.com'], + ]); + + // Existing group, non-existing member + $code = \Artisan::call("group:remove-member {$group->email} nonexisting@gmail.com"); + $output = trim(\Artisan::output()); + + $this->assertSame(1, $code); + $this->assertSame("Member nonexisting@gmail.com not found in the group.", $output); + + // Existing group, existing member + $code = \Artisan::call("group:remove-member {$group->email} member1@gmail.com"); + $output = trim(\Artisan::output()); + + $this->assertSame(0, $code); + $this->assertSame('', $output); + $this->assertSame(['member2@gmail.com'], $group->refresh()->members); + + // Existing group, the last existing member + $code = \Artisan::call("group:remove-member {$group->email} member2@gmail.com"); + $output = trim(\Artisan::output()); + + $this->assertSame(0, $code); + $this->assertSame('', $output); + $this->assertSame([], $group->refresh()->members); + } +} diff --git a/src/tests/Feature/Controller/SignupTest.php b/src/tests/Feature/Controller/SignupTest.php --- a/src/tests/Feature/Controller/SignupTest.php +++ b/src/tests/Feature/Controller/SignupTest.php @@ -31,6 +31,8 @@ $this->deleteTestDomain('external.com'); $this->deleteTestDomain('signup-domain.com'); + + $this->deleteTestGroup('group-test@kolabnow.com'); } /** @@ -45,6 +47,8 @@ $this->deleteTestDomain('external.com'); $this->deleteTestDomain('signup-domain.com'); + $this->deleteTestGroup('group-test@kolabnow.com'); + parent::tearDown(); } @@ -686,4 +690,21 @@ $this->assertSame($expected_result, $result); } + + /** + * Signup login/domain validation, more cases + * + * Note: Technically these include unit tests, but let's keep it here for now. + */ + public function testValidateLoginMore(): void + { + $group = $this->getTestGroup('group-test@kolabnow.com'); + $login = 'group-test'; + $domain = 'kolabnow.com'; + $external = false; + + $result = $this->invokeMethod(new SignupController(), 'validateLogin', [$login, $domain, $external]); + + $this->assertSame(['login' => 'The specified login is not available.'], $result); + } } diff --git a/src/tests/Feature/Controller/SkusTest.php b/src/tests/Feature/Controller/SkusTest.php --- a/src/tests/Feature/Controller/SkusTest.php +++ b/src/tests/Feature/Controller/SkusTest.php @@ -49,7 +49,7 @@ $json = $response->json(); - $this->assertCount(7, $json); + $this->assertCount(8, $json); $this->assertSame(100, $json[0]['prio']); $this->assertSame($sku->id, $json[0]['id']); @@ -80,7 +80,7 @@ $json = $response->json(); - $this->assertCount(6, $json); + $this->assertCount(7, $json); $this->assertSkuElement('mailbox', $json[0], [ 'prio' => 100, @@ -137,6 +137,14 @@ 'readonly' => false, ]); + $this->assertSkuElement('group', $json[6], [ + 'prio' => 0, + 'type' => 'group', + 'handler' => 'group', + 'enabled' => false, + 'readonly' => false, + ]); + // Test filter by type $response = $this->actingAs($user)->get("api/v4/users/{$user->id}/skus?type=domain"); $response->assertStatus(200); 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 @@ -32,6 +32,8 @@ $this->deleteTestUser('deleted@kolab.org'); $this->deleteTestUser('deleted@kolabnow.com'); $this->deleteTestDomain('userscontroller.com'); + $this->deleteTestGroup('group-test@kolabnow.com'); + $this->deleteTestGroup('group-test@kolab.org'); $user = $this->getTestUser('john@kolab.org'); $wallet = $user->wallets()->first(); @@ -56,6 +58,8 @@ $this->deleteTestUser('deleted@kolab.org'); $this->deleteTestUser('deleted@kolabnow.com'); $this->deleteTestDomain('userscontroller.com'); + $this->deleteTestGroup('group-test@kolabnow.com'); + $this->deleteTestGroup('group-test@kolab.org'); $user = $this->getTestUser('john@kolab.org'); $wallet = $user->wallets()->first(); @@ -1126,6 +1130,45 @@ } /** + * User email validation - tests for an address being a group email address + * + * Note: Technically these include unit tests, but let's keep it here for now. + * FIXME: Shall we do a http request for each case? + */ + public function testValidateEmailGroup(): void + { + Queue::fake(); + + $john = $this->getTestUser('john@kolab.org'); + $pub_group = $this->getTestGroup('group-test@kolabnow.com'); + $priv_group = $this->getTestGroup('group-test@kolab.org'); + + // A group in a public domain, existing + $result = UsersController::validateEmail($pub_group->email, $john, $deleted); + $this->assertSame('The specified email is not available.', $result); + $this->assertNull($deleted); + + $pub_group->delete(); + + // A group in a public domain, deleted + $result = UsersController::validateEmail($pub_group->email, $john, $deleted); + $this->assertSame('The specified email is not available.', $result); + $this->assertNull($deleted); + + // A group in a private domain, existing + $result = UsersController::validateEmail($priv_group->email, $john, $deleted); + $this->assertSame('The specified email is not available.', $result); + $this->assertNull($deleted); + + $priv_group->delete(); + + // A group in a private domain, deleted + $result = UsersController::validateEmail($priv_group->email, $john, $deleted); + $this->assertSame(null, $result); + $this->assertSame($priv_group->id, $deleted->id); + } + + /** * List of alias validation cases for testValidateAlias() * * @return array Arguments for testValidateAlias() @@ -1204,6 +1247,7 @@ $deleted_pub = $this->getTestUser('deleted@kolabnow.com'); $deleted_pub->setAliases(['deleted-alias@kolabnow.com']); $deleted_pub->delete(); + $group = $this->getTestGroup('group-test@kolabnow.com'); // An alias that was a user email before is allowed, but only for custom domains $result = UsersController::validateAlias('deleted@kolab.org', $john); @@ -1217,5 +1261,9 @@ $result = UsersController::validateAlias('deleted-alias@kolabnow.com', $john); $this->assertSame('The specified alias is not available.', $result); + + // A grpoup with the same email address exists + $result = UsersController::validateAlias($group->email, $john); + $this->assertSame('The specified alias is not available.', $result); } } diff --git a/src/tests/Feature/GroupTest.php b/src/tests/Feature/GroupTest.php new file mode 100644 --- /dev/null +++ b/src/tests/Feature/GroupTest.php @@ -0,0 +1,266 @@ +deleteTestUser('user-test@kolabnow.com'); + $this->deleteTestGroup('group-test@kolabnow.com'); + } + + public function tearDown(): void + { + $this->deleteTestUser('user-test@kolabnow.com'); + $this->deleteTestGroup('group-test@kolabnow.com'); + + parent::tearDown(); + } + + /** + * Tests for Group::assignToWallet() + */ + public function testAssignToWallet(): void + { + $user = $this->getTestUser('user-test@kolabnow.com'); + $group = $this->getTestGroup('group-test@kolabnow.com'); + + $result = $group->assignToWallet($user->wallets->first()); + + $this->assertSame($group, $result); + $this->assertSame(1, $group->entitlement()->count()); + + // Can't be done twice on the same group + $this->expectException(\Exception::class); + $result->assignToWallet($user->wallets->first()); + } + + /** + * Test group status assignment and is*() methods + */ + public function testStatus(): void + { + $group = new Group(); + + $this->assertSame(false, $group->isNew()); + $this->assertSame(false, $group->isActive()); + $this->assertSame(false, $group->isDeleted()); + $this->assertSame(false, $group->isLdapReady()); + $this->assertSame(false, $group->isSuspended()); + + $group->status = Group::STATUS_NEW; + + $this->assertSame(true, $group->isNew()); + $this->assertSame(false, $group->isActive()); + $this->assertSame(false, $group->isDeleted()); + $this->assertSame(false, $group->isLdapReady()); + $this->assertSame(false, $group->isSuspended()); + + $group->status |= Group::STATUS_ACTIVE; + + $this->assertSame(true, $group->isNew()); + $this->assertSame(true, $group->isActive()); + $this->assertSame(false, $group->isDeleted()); + $this->assertSame(false, $group->isLdapReady()); + $this->assertSame(false, $group->isSuspended()); + + $group->status |= Group::STATUS_LDAP_READY; + + $this->assertSame(true, $group->isNew()); + $this->assertSame(true, $group->isActive()); + $this->assertSame(false, $group->isDeleted()); + $this->assertSame(true, $group->isLdapReady()); + $this->assertSame(false, $group->isSuspended()); + + $group->status |= Group::STATUS_DELETED; + + $this->assertSame(true, $group->isNew()); + $this->assertSame(true, $group->isActive()); + $this->assertSame(true, $group->isDeleted()); + $this->assertSame(true, $group->isLdapReady()); + $this->assertSame(false, $group->isSuspended()); + + $group->status |= Group::STATUS_SUSPENDED; + + $this->assertSame(true, $group->isNew()); + $this->assertSame(true, $group->isActive()); + $this->assertSame(true, $group->isDeleted()); + $this->assertSame(true, $group->isLdapReady()); + $this->assertSame(true, $group->isSuspended()); + + // Unknown status value + $this->expectException(\Exception::class); + $group->status = 111; + } + + /** + * Test creating a group + */ + public function testCreate(): void + { + Queue::fake(); + + $group = Group::create(['email' => 'GROUP-test@kolabnow.com']); + + $this->assertSame('group-test@kolabnow.com', $group->email); + $this->assertRegExp('/^[0-9]{1,20}$/', $group->id); + $this->assertSame([], $group->members); + $this->assertTrue($group->isNew()); + $this->assertTrue($group->isActive()); + + Queue::assertPushed( + \App\Jobs\Group\CreateJob::class, + function ($job) use ($group) { + $groupEmail = TestCase::getObjectProperty($job, 'groupEmail'); + $groupId = TestCase::getObjectProperty($job, 'groupId'); + + return $groupEmail === $group->email + && $groupId === $group->id; + } + ); + } + + /** + * Test group deletion and force-deletion + */ + public function testDelete(): void + { + Queue::fake(); + + $user = $this->getTestUser('user-test@kolabnow.com'); + $group = $this->getTestGroup('group-test@kolabnow.com'); + $group->assignToWallet($user->wallets->first()); + + $entitlements = \App\Entitlement::where('entitleable_id', $group->id); + + $this->assertSame(1, $entitlements->count()); + + $group->delete(); + + $this->assertTrue($group->fresh()->trashed()); + $this->assertSame(0, $entitlements->count()); + $this->assertSame(1, $entitlements->withTrashed()->count()); + + $group->forceDelete(); + + $this->assertSame(0, $entitlements->withTrashed()->count()); + $this->assertCount(0, Group::withTrashed()->where('id', $group->id)->get()); + + Queue::assertPushed(\App\Jobs\Group\DeleteJob::class, 1); + Queue::assertPushed( + \App\Jobs\Group\DeleteJob::class, + function ($job) use ($group) { + $groupEmail = TestCase::getObjectProperty($job, 'groupEmail'); + $groupId = TestCase::getObjectProperty($job, 'groupId'); + + return $groupEmail === $group->email + && $groupId === $group->id; + } + ); + } + + /** + * Tests for Group::emailExists() + */ + public function testEmailExists(): void + { + Queue::fake(); + + $group = $this->getTestGroup('group-test@kolabnow.com'); + + $this->assertFalse(Group::emailExists('unknown@domain.tld')); + $this->assertTrue(Group::emailExists($group->email)); + + $result = Group::emailExists($group->email, true); + $this->assertSame($result->id, $group->id); + + $group->delete(); + + $this->assertTrue(Group::emailExists($group->email)); + + $result = Group::emailExists($group->email, true); + $this->assertSame($result->id, $group->id); + } + + /** + * Tests for Group::suspend() + */ + public function testSuspend(): void + { + Queue::fake(); + + $group = $this->getTestGroup('group-test@kolabnow.com'); + $group->suspend(); + + $this->assertTrue($group->isSuspended()); + + Queue::assertPushed(\App\Jobs\Group\UpdateJob::class, 1); + Queue::assertPushed( + \App\Jobs\Group\UpdateJob::class, + function ($job) use ($group) { + $groupEmail = TestCase::getObjectProperty($job, 'groupEmail'); + $groupId = TestCase::getObjectProperty($job, 'groupId'); + + return $groupEmail === $group->email + && $groupId === $group->id; + } + ); + } + + /** + * Test updating a group + */ + public function testUpdate(): void + { + Queue::fake(); + + $group = $this->getTestGroup('group-test@kolabnow.com'); + $group->status |= Group::STATUS_DELETED; + $group->save(); + + Queue::assertPushed(\App\Jobs\Group\UpdateJob::class, 1); + Queue::assertPushed( + \App\Jobs\Group\UpdateJob::class, + function ($job) use ($group) { + $groupEmail = TestCase::getObjectProperty($job, 'groupEmail'); + $groupId = TestCase::getObjectProperty($job, 'groupId'); + + return $groupEmail === $group->email + && $groupId === $group->id; + } + ); + } + + /** + * Tests for Group::unsuspend() + */ + public function testUnsuspend(): void + { + Queue::fake(); + + $group = $this->getTestGroup('group-test@kolabnow.com'); + $group->status = Group::STATUS_SUSPENDED; + $group->unsuspend(); + + $this->assertFalse($group->isSuspended()); + + Queue::assertPushed(\App\Jobs\Group\UpdateJob::class, 1); + Queue::assertPushed( + \App\Jobs\Group\UpdateJob::class, + function ($job) use ($group) { + $groupEmail = TestCase::getObjectProperty($job, 'groupEmail'); + $groupId = TestCase::getObjectProperty($job, 'groupId'); + + return $groupEmail === $group->email + && $groupId === $group->id; + } + ); + } +} diff --git a/src/tests/Feature/Jobs/DomainCreateTest.php b/src/tests/Feature/Jobs/DomainCreateTest.php --- a/src/tests/Feature/Jobs/DomainCreateTest.php +++ b/src/tests/Feature/Jobs/DomainCreateTest.php @@ -3,7 +3,6 @@ namespace Tests\Feature\Jobs; use App\Domain; -use Illuminate\Support\Facades\Mail; use Illuminate\Support\Facades\Queue; use Tests\TestCase; diff --git a/src/tests/Feature/Jobs/DomainVerifyTest.php b/src/tests/Feature/Jobs/DomainVerifyTest.php --- a/src/tests/Feature/Jobs/DomainVerifyTest.php +++ b/src/tests/Feature/Jobs/DomainVerifyTest.php @@ -3,7 +3,6 @@ namespace Tests\Feature\Jobs; use App\Domain; -use Illuminate\Support\Facades\Mail; use Tests\TestCase; class DomainVerifyTest extends TestCase diff --git a/src/tests/Feature/Jobs/Group/CreateTest.php b/src/tests/Feature/Jobs/Group/CreateTest.php new file mode 100644 --- /dev/null +++ b/src/tests/Feature/Jobs/Group/CreateTest.php @@ -0,0 +1,43 @@ +deleteTestGroup('group@kolab.org'); + } + + public function tearDown(): void + { + $this->deleteTestGroup('group@kolab.org'); + + parent::tearDown(); + } + + /** + * Test job handle + * + * @group ldap + */ + public function testHandle(): void + { + $group = $this->getTestGroup('group@kolab.org', ['members' => []]); + + $this->assertFalse($group->isLdapReady()); + + $job = new \App\Jobs\Group\CreateJob($group->id); + $job->handle(); + + $this->assertTrue($group->fresh()->isLdapReady()); + } +} diff --git a/src/tests/Feature/Jobs/Group/DeleteTest.php b/src/tests/Feature/Jobs/Group/DeleteTest.php new file mode 100644 --- /dev/null +++ b/src/tests/Feature/Jobs/Group/DeleteTest.php @@ -0,0 +1,53 @@ +deleteTestGroup('group@kolab.org'); + } + + public function tearDown(): void + { + $this->deleteTestGroup('group@kolab.org'); + + parent::tearDown(); + } + + /** + * Test job handle + * + * @group ldap + */ + public function testHandle(): void + { + $group = $this->getTestGroup('group@kolab.org', [ + 'members' => [], + 'status' => Group::STATUS_NEW + ]); + + // create to domain first + $job = new \App\Jobs\Group\CreateJob($group->id); + $job->handle(); + + $this->assertTrue($group->fresh()->isLdapReady()); + + $job = new \App\Jobs\Group\DeleteJob($group->id); + $job->handle(); + + $group->refresh(); + + $this->assertFalse($group->isLdapReady()); + $this->assertTrue($group->isDeleted()); + } +} diff --git a/src/tests/Feature/Jobs/Group/UpdateTest.php b/src/tests/Feature/Jobs/Group/UpdateTest.php new file mode 100644 --- /dev/null +++ b/src/tests/Feature/Jobs/Group/UpdateTest.php @@ -0,0 +1,42 @@ +deleteTestGroup('group@kolab.org'); + } + + public function tearDown(): void + { + $this->deleteTestGroup('group@kolab.org'); + + parent::tearDown(); + } + + /** + * Test job handle + * + * @group ldap + */ + public function testHandle(): void + { + $group = $this->getTestGroup('group@kolab.org', ['members' => []]); + + $job = new \App\Jobs\Group\UpdateJob($group->id); + $job->handle(); + + // TODO: Test if group properties (members) actually changed in LDAP + $this->assertTrue(true); + } +} diff --git a/src/tests/Feature/UserTest.php b/src/tests/Feature/UserTest.php --- a/src/tests/Feature/UserTest.php +++ b/src/tests/Feature/UserTest.php @@ -3,6 +3,7 @@ namespace Tests\Feature; use App\Domain; +use App\Group; use App\User; use Illuminate\Support\Facades\Queue; use Tests\TestCase; @@ -17,6 +18,7 @@ $this->deleteTestUser('UserAccountA@UserAccount.com'); $this->deleteTestUser('UserAccountB@UserAccount.com'); $this->deleteTestUser('UserAccountC@UserAccount.com'); + $this->deleteTestGroup('test-group@UserAccount.com'); $this->deleteTestDomain('UserAccount.com'); } @@ -26,6 +28,7 @@ $this->deleteTestUser('UserAccountA@UserAccount.com'); $this->deleteTestUser('UserAccountB@UserAccount.com'); $this->deleteTestUser('UserAccountC@UserAccount.com'); + $this->deleteTestGroup('test-group@UserAccount.com'); $this->deleteTestDomain('UserAccount.com'); parent::tearDown(); @@ -235,7 +238,7 @@ $this->assertCount(0, User::withTrashed()->where('id', $id)->get()); - // Test an account with users + // Test an account with users, domain, and group $userA = $this->getTestUser('UserAccountA@UserAccount.com'); $userB = $this->getTestUser('UserAccountB@UserAccount.com'); $userC = $this->getTestUser('UserAccountC@UserAccount.com'); @@ -249,15 +252,20 @@ $domain->assignPackage($package_domain, $userA); $userA->assignPackage($package_kolab, $userB); $userA->assignPackage($package_kolab, $userC); + $group = $this->getTestGroup('test-group@UserAccount.com'); + $group->assignToWallet($userA->wallets->first()); $entitlementsA = \App\Entitlement::where('entitleable_id', $userA->id); $entitlementsB = \App\Entitlement::where('entitleable_id', $userB->id); $entitlementsC = \App\Entitlement::where('entitleable_id', $userC->id); $entitlementsDomain = \App\Entitlement::where('entitleable_id', $domain->id); + $entitlementsGroup = \App\Entitlement::where('entitleable_id', $group->id); + $this->assertSame(4, $entitlementsA->count()); $this->assertSame(4, $entitlementsB->count()); $this->assertSame(4, $entitlementsC->count()); $this->assertSame(1, $entitlementsDomain->count()); + $this->assertSame(1, $entitlementsGroup->count()); // Delete non-controller user $userC->delete(); @@ -272,12 +280,54 @@ $this->assertSame(0, $entitlementsA->count()); $this->assertSame(0, $entitlementsB->count()); $this->assertSame(0, $entitlementsDomain->count()); + $this->assertSame(0, $entitlementsGroup->count()); $this->assertTrue($userA->fresh()->trashed()); $this->assertTrue($userB->fresh()->trashed()); $this->assertTrue($domain->fresh()->trashed()); + $this->assertTrue($group->fresh()->trashed()); $this->assertFalse($userA->isDeleted()); $this->assertFalse($userB->isDeleted()); $this->assertFalse($domain->isDeleted()); + $this->assertFalse($group->isDeleted()); + + $userA->forceDelete(); + + $all_entitlements = \App\Entitlement::where('wallet_id', $userA->wallets->first()->id); + + $this->assertSame(0, $all_entitlements->withTrashed()->count()); + $this->assertCount(0, User::withTrashed()->where('id', $userA->id)->get()); + $this->assertCount(0, User::withTrashed()->where('id', $userB->id)->get()); + $this->assertCount(0, User::withTrashed()->where('id', $userC->id)->get()); + $this->assertCount(0, Domain::withTrashed()->where('id', $domain->id)->get()); + $this->assertCount(0, Group::withTrashed()->where('id', $group->id)->get()); + } + + /** + * Test user deletion vs. group membership + */ + public function testDeleteAandGroups(): void + { + Queue::fake(); + + $package_kolab = \App\Package::where('title', 'kolab')->first(); + $userA = $this->getTestUser('UserAccountA@UserAccount.com'); + $userB = $this->getTestUser('UserAccountB@UserAccount.com'); + $userA->assignPackage($package_kolab, $userB); + $group = $this->getTestGroup('test-group@UserAccount.com'); + $group->members = ['test@gmail.com', $userB->email]; + $group->assignToWallet($userA->wallets->first()); + $group->save(); + + $userGroups = $userA->groups()->get(); + $this->assertSame(1, $userGroups->count()); + $this->assertSame($group->id, $userGroups->first()->id); + + $userB->delete(); + + $this->assertSame(['test@gmail.com'], $group->fresh()->members); + + // Twice, one for save() and one for delete() above + Queue::assertPushed(\App\Jobs\Group\UpdateJob::class, 2); } /** diff --git a/src/tests/TestCaseTrait.php b/src/tests/TestCaseTrait.php --- a/src/tests/TestCaseTrait.php +++ b/src/tests/TestCaseTrait.php @@ -3,6 +3,7 @@ namespace Tests; use App\Domain; +use App\Group; use App\Transaction; use App\User; use Carbon\Carbon; @@ -149,6 +150,22 @@ $domain->forceDelete(); } + protected function deleteTestGroup($email) + { + Queue::fake(); + + $group = Group::withTrashed()->where('email', $email)->first(); + + if (!$group) { + return; + } + + $job = new \App\Jobs\Group\DeleteJob($group->id); + $job->handle(); + + $group->forceDelete(); + } + protected function deleteTestUser($email) { Queue::fake(); @@ -177,6 +194,17 @@ } /** + * Get Group object by email, create it if needed. + * Skip LDAP jobs. + */ + protected function getTestGroup($email, $attrib = []) + { + // Disable jobs (i.e. skip LDAP oprations) + Queue::fake(); + return Group::firstOrCreate(['email' => $email], $attrib); + } + + /** * Get User object by email, create it if needed. * Skip LDAP jobs. */