Page Menu
Home
Phorge
Search
Configure Global Search
Log In
Files
F117813241
D2020.1775281056.diff
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Flag For Later
Award Token
Authored By
Unknown
Size
87 KB
Referenced Files
None
Subscribers
None
D2020.1775281056.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
@@ -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 @@
+<?php
+
+namespace App\Console\Commands\Group;
+
+use App\Console\Command;
+
+class AddMemberCommand extends Command
+{
+ /**
+ * The name and signature of the console command.
+ *
+ * @var string
+ */
+ protected $signature = 'group:add-member {group} {member}';
+
+ /**
+ * The console command description.
+ *
+ * @var string
+ */
+ protected $description = "Add a member to a group.";
+
+ /**
+ * Execute the console command.
+ *
+ * @return mixed
+ */
+ public function handle()
+ {
+ $input = $this->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 @@
+<?php
+
+namespace App\Console\Commands\Group;
+
+use App\Console\Command;
+use App\Domain;
+use App\Group;
+use App\User;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Validator;
+
+class CreateCommand extends Command
+{
+ /**
+ * The name and signature of the console command.
+ *
+ * @var string
+ */
+ protected $signature = 'group:create {email} {--member=*}';
+
+ /**
+ * The console command description.
+ *
+ * @var string
+ */
+ protected $description = "Create a group.";
+
+ /**
+ * Execute the console command.
+ *
+ * @return mixed
+ */
+ public function handle()
+ {
+ $email = $this->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 @@
+<?php
+
+namespace App\Console\Commands\Group;
+
+use App\Console\Command;
+
+class DeleteCommand extends Command
+{
+ /**
+ * The name and signature of the console command.
+ *
+ * @var string
+ */
+ protected $signature = 'group:delete {group}';
+
+ /**
+ * The console command description.
+ *
+ * @var string
+ */
+ protected $description = "Delete a group.";
+
+ /**
+ * Execute the console command.
+ *
+ * @return mixed
+ */
+ public function handle()
+ {
+ $input = $this->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 @@
+<?php
+
+namespace App\Console\Commands\Group;
+
+use App\Console\Command;
+
+class InfoCommand extends Command
+{
+ /**
+ * The name and signature of the console command.
+ *
+ * @var string
+ */
+ protected $signature = 'group:info {group}';
+
+ /**
+ * The console command description.
+ *
+ * @var string
+ */
+ protected $description = "Print a group information.";
+
+ /**
+ * Execute the console command.
+ *
+ * @return mixed
+ */
+ public function handle()
+ {
+ $input = $this->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 @@
+<?php
+
+namespace App\Console\Commands\Group;
+
+use App\Console\Command;
+
+class RemoveMemberCommand extends Command
+{
+ /**
+ * The name and signature of the console command.
+ *
+ * @var string
+ */
+ protected $signature = 'group:remove-member {group} {member}';
+
+ /**
+ * The console command description.
+ *
+ * @var string
+ */
+ protected $description = "Remove a member from a group.";
+
+ /**
+ * Execute the console command.
+ *
+ * @return mixed
+ */
+ public function handle()
+ {
+ $input = $this->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 @@
+<?php
+
+namespace App;
+
+use App\Wallet;
+use Illuminate\Database\Eloquent\Model;
+use Illuminate\Database\Eloquent\SoftDeletes;
+
+/**
+ * The eloquent definition of a Group.
+ *
+ * @property int $id The group identifier
+ * @property string $email An email address
+ * @property string $members A comma-separated list of email addresses
+ * @property int $status The group status
+ */
+class Group extends Model
+{
+ use SoftDeletes;
+
+ // we've simply never heard of this domain
+ public const STATUS_NEW = 1 << 0;
+ // it's been activated
+ public const STATUS_ACTIVE = 1 << 1;
+ // domain has been suspended.
+ public const STATUS_SUSPENDED = 1 << 2;
+ // domain has been deleted
+ public const STATUS_DELETED = 1 << 3;
+ // domain has been created in LDAP
+ public const STATUS_LDAP_READY = 1 << 4;
+
+ public $incrementing = false;
+
+ protected $keyType = 'bigint';
+
+ protected $fillable = [
+ 'email',
+ 'status',
+ 'members'
+ ];
+
+ /**
+ * Assign the group to a wallet.
+ *
+ * @param \App\Wallet $wallet The wallet
+ *
+ * @return \App\Group Self
+ * @throws \Exception
+ */
+ public function assignToWallet(Wallet $wallet): Group
+ {
+ if (empty($this->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 @@
+<?php
+
+namespace App\Handlers;
+
+class Group extends \App\Handlers\Base
+{
+ /**
+ * The entitleable class for this handler.
+ *
+ * @return string
+ */
+ public static function entitleableClass(): string
+ {
+ return \App\Group::class;
+ }
+}
diff --git a/src/app/Http/Controllers/API/SignupController.php b/src/app/Http/Controllers/API/SignupController.php
--- a/src/app/Http/Controllers/API/SignupController.php
+++ b/src/app/Http/Controllers/API/SignupController.php
@@ -371,7 +371,7 @@
// Check if user with specified login already exists
$email = $login . '@' . $domain;
- if (User::emailExists($email) || User::aliasExists($email)) {
+ if (User::emailExists($email) || User::aliasExists($email) || \App\Group::emailExists($email)) {
return ['login' => \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 @@
+<?php
+
+namespace App\Jobs\Group;
+
+use App\Jobs\GroupJob;
+
+class CreateJob extends GroupJob
+{
+ /**
+ * Execute the job.
+ *
+ * @return void
+ */
+ public function handle()
+ {
+ $group = $this->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 @@
+<?php
+
+namespace App\Jobs\Group;
+
+use App\Jobs\GroupJob;
+
+class DeleteJob extends GroupJob
+{
+ /**
+ * Execute the job.
+ *
+ * @return void
+ */
+ public function handle()
+ {
+ $group = $this->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 @@
+<?php
+
+namespace App\Jobs\Group;
+
+use App\Jobs\GroupJob;
+
+class UpdateJob extends GroupJob
+{
+ /**
+ * Execute the job.
+ *
+ * @return void
+ */
+ public function handle()
+ {
+ $group = $this->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 @@
+<?php
+
+namespace App\Jobs;
+
+/**
+ * The abstract \App\Jobs\GroupJob implements the logic needed for all dispatchable Jobs related to
+ * \App\Group objects.
+ *
+ * ```php
+ * $job = new \App\Jobs\Group\CreateJob($groupId);
+ * $job->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 @@
+<?php
+
+namespace App\Observers;
+
+use App\Group;
+use Illuminate\Support\Facades\DB;
+
+class GroupObserver
+{
+ /**
+ * Handle the group "created" event.
+ *
+ * @param \App\Group $group The group
+ *
+ * @return void
+ */
+ public function creating(Group $group): void
+ {
+ while (true) {
+ $allegedly_unique = \App\Utils::uuidInt();
+ if (!Group::withTrashed()->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 @@
+<?php
+
+use Illuminate\Support\Facades\Schema;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Database\Migrations\Migration;
+
+// phpcs:ignore
+class CreateGroupsTable extends Migration
+{
+ /**
+ * Run the migrations.
+ *
+ * @return void
+ */
+ public function up()
+ {
+ Schema::create(
+ 'groups',
+ function (Blueprint $table) {
+ $table->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 @@
+<?php
+
+namespace Tests\Feature\Console\Group;
+
+use App\Group;
+use Illuminate\Support\Facades\Queue;
+use Tests\TestCase;
+
+class AddMemberTest extends TestCase
+{
+ /**
+ * {@inheritDoc}
+ */
+ public function setUp(): void
+ {
+ parent::setUp();
+
+ $this->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 @@
+<?php
+
+namespace Tests\Feature\Console\Group;
+
+use App\Group;
+use Illuminate\Support\Facades\Queue;
+use Tests\TestCase;
+
+class CreateTest extends TestCase
+{
+ /**
+ * {@inheritDoc}
+ */
+ public function setUp(): void
+ {
+ parent::setUp();
+
+ $this->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 @@
+<?php
+
+namespace Tests\Feature\Console\Group;
+
+use App\Group;
+use Illuminate\Support\Facades\Queue;
+use Tests\TestCase;
+
+class DeleteTest extends TestCase
+{
+ /**
+ * {@inheritDoc}
+ */
+ public function setUp(): void
+ {
+ parent::setUp();
+
+ $this->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 @@
+<?php
+
+namespace Tests\Feature\Console\Group;
+
+use App\Group;
+use Illuminate\Support\Facades\Queue;
+use Tests\TestCase;
+
+class InfoTest extends TestCase
+{
+ /**
+ * {@inheritDoc}
+ */
+ public function setUp(): void
+ {
+ parent::setUp();
+
+ $this->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 @@
+<?php
+
+namespace Tests\Feature\Console\Group;
+
+use App\Group;
+use Illuminate\Support\Facades\Queue;
+use Tests\TestCase;
+
+class RemoveMemberTest extends TestCase
+{
+ /**
+ * {@inheritDoc}
+ */
+ public function setUp(): void
+ {
+ parent::setUp();
+
+ $this->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 @@
+<?php
+
+namespace Tests\Feature;
+
+use App\Group;
+use Illuminate\Support\Facades\Queue;
+use Tests\TestCase;
+
+class GroupTest extends TestCase
+{
+ public function setUp(): void
+ {
+ parent::setUp();
+
+ $this->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 @@
+<?php
+
+namespace Tests\Feature\Jobs\Group;
+
+use App\Group;
+use Tests\TestCase;
+
+class CreateTest extends TestCase
+{
+ /**
+ * {@inheritDoc}
+ */
+ public function setUp(): void
+ {
+ parent::setUp();
+
+ $this->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 @@
+<?php
+
+namespace Tests\Feature\Jobs\Group;
+
+use App\Group;
+use Tests\TestCase;
+
+class DeleteTest extends TestCase
+{
+ /**
+ * {@inheritDoc}
+ */
+ public function setUp(): void
+ {
+ parent::setUp();
+
+ $this->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 @@
+<?php
+
+namespace Tests\Feature\Jobs\Group;
+
+use App\Group;
+use Tests\TestCase;
+
+class UpdateTest extends TestCase
+{
+ /**
+ * {@inheritDoc}
+ */
+ public function setUp(): void
+ {
+ parent::setUp();
+
+ $this->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.
*/
File Metadata
Details
Attached
Mime Type
text/plain
Expires
Sat, Apr 4, 5:37 AM (1 h, 46 m)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
18828248
Default Alt Text
D2020.1775281056.diff (87 KB)
Attached To
Mode
D2020: Mail enabled distribution groups
Attached
Detach File
Event Timeline