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 @@ -213,6 +214,18 @@ } } + /** + * Create a group in LDAP. + * + * @param \App\Group $group The group to create. + * + * @throws \Exception + */ + public static function createGroup(Group $group): void + { + // TODO + } + /** * Create a user in LDAP. * @@ -279,7 +292,7 @@ /** * Delete a domain from LDAP. * - * @param \App\Domain $domain The domain to update. + * @param \App\Domain $domain The domain to delete * * @throws \Exception */ @@ -322,10 +335,22 @@ } } + /** + * Delete a group from LDAP. + * + * @param \App\Group $group The group to delete. + * + * @throws \Exception + */ + public static function deleteGroup(Group $group): void + { + // TODO + } + /** * 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 */ @@ -442,6 +467,18 @@ } } + /** + * Update a group in LDAP. + * + * @param \App\Group $group The group to update + * + * @throws \Exception + */ + public static function updateGroup(Group $group): void + { + // TODO + } + /** * Update a user in LDAP. * diff --git a/src/app/Console/Commands/Group/AddMember.php b/src/app/Console/Commands/Group/AddMember.php new file mode 100644 --- /dev/null +++ b/src/app/Console/Commands/Group/AddMember.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 = Create::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/Create.php b/src/app/Console/Commands/Group/Create.php new file mode 100644 --- /dev/null +++ b/src/app/Console/Commands/Group/Create.php @@ -0,0 +1,170 @@ +argument('email'); + $user = $this->option('user'); + $members = $this->option('member'); + + if (empty($user)) { + $this->error("The --user option is required."); + return 1; + } + + $owner = $this->getUser($user); + + if (empty($owner)) { + $this->error("User {$user} does not exist."); + return 1; + } + + // 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/Delete.php b/src/app/Console/Commands/Group/Delete.php new file mode 100644 --- /dev/null +++ b/src/app/Console/Commands/Group/Delete.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/Info.php b/src/app/Console/Commands/Group/Info.php new file mode 100644 --- /dev/null +++ b/src/app/Console/Commands/Group/Info.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/RemoveMember.php b/src/app/Console/Commands/Group/RemoveMember.php new file mode 100644 --- /dev/null +++ b/src/app/Console/Commands/Group/RemoveMember.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,265 @@ +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; + } + + /** + * 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 @@ +{$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; @@ -132,6 +133,7 @@ $assignments = Entitlement::whereIn('wallet_id', $wallets)->get(); $users = []; $domains = []; + $groups = []; $entitlements = []; foreach ($assignments as $entitlement) { @@ -139,30 +141,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 +193,7 @@ $assignments = Entitlement::withTrashed()->whereIn('wallet_id', $wallets)->get(); $entitlements = []; $domains = []; + $groups = []; $users = []; foreach ($assignments as $entitlement) { @@ -198,12 +206,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 +220,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/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 @@ -109,6 +109,16 @@ $this->assertSame(null, LDAP::getDomain($domain->namespace)); } + /** + * Test creating/updating/deleting a group record + * + * @group ldap + */ + public function testGroup(): void + { + $this->markTestIncomplete(); + } + /** * Test creating/editing/deleting a user record * 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,120 @@ +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 + + // Missing group owner argument + $code = \Artisan::call("group:create group-test@kolabnow.com"); + $output = trim(\Artisan::output()); + $this->assertSame(1, $code); + $this->assertSame("The --user option is required.", $output); + + // Invalid group owner argument + $code = \Artisan::call("group:create group-test@kolabnow.com --user=nonexisting@nonexisting.org"); + $output = trim(\Artisan::output()); + $this->assertSame(1, $code); + $this->assertSame("User nonexisting@nonexisting.org does not exist.", $output); + + $user = $this->getTestUser('john@kolab.org'); + + // Domain not existing + $code = \Artisan::call("group:create testgroup@unknown.org --user={$user->id}"); + $output = trim(\Artisan::output()); + $this->assertSame(1, $code); + $this->assertSame("testgroup@unknown.org: The specified domain is not available.", $output); + + // Domain not available to the user + $code = \Artisan::call("group:create testgroup@kolab.org --user=jack@kolab.org"); + $output = trim(\Artisan::output()); + $this->assertSame(1, $code); + $this->assertSame("testgroup@kolab.org: The specified domain is not available.", $output); + + // Existing email + $code = \Artisan::call("group:create jack@kolab.org --user={$user->email}"); + $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 --user={$user->email}"); + $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 --user={$user->email}"); + $output = trim(\Artisan::output()); + $this->assertSame(1, $code); + $this->assertSame("group-test@kolabnow.com: The specified domain is not available.", $output); + + // Create a group without members + $code = \Artisan::call("group:create group-test@kolab.org --user={$user->email}"); + $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->entitlement->wallet_id); + + // Existing email (of a group) + $code = \Artisan::call("group:create group-test@kolab.org --user={$user->email}"); + $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 --user={$user->email} --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 --user={$user->email}" + . " --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); + } +} 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(); @@ -1125,6 +1129,45 @@ $this->assertSame(null, $deleted); } + /** + * 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() * @@ -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/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,26 @@ $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()); } /** 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(); @@ -176,6 +193,17 @@ return Domain::firstOrCreate(['namespace' => $name], $attrib); } + /** + * 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.