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,166 @@ +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.domaininvalid'); + } + + // 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 "Domain not available."; + } + + // 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,280 @@ +id)) { + throw new \Exception("Group not yet exists"); + } + + if (!empty($this->entitlement)) { + 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 apropriately 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}"); + } + + if ($new_status & self::STATUS_DELETED && $new_status & self::STATUS_ACTIVE) { + $new_status ^= self::STATUS_ACTIVE; + } + + if ($new_status & self::STATUS_SUSPENDED && $new_status & self::STATUS_ACTIVE) { + $new_status ^= self::STATUS_ACTIVE; + } + + // if the domain is now active, it is not new anymore. + if ($new_status & self::STATUS_ACTIVE && $new_status & self::STATUS_NEW) { + $new_status ^= self::STATUS_NEW; + } + + $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->status |= Group::STATUS_ACTIVE; + + $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/GroupObserver.php b/src/app/Observers/GroupObserver.php new file mode 100644 --- /dev/null +++ b/src/app/Observers/GroupObserver.php @@ -0,0 +1,105 @@ +{$group->getKeyName()} = $allegedly_unique; + break; + } + } + + $group->status |= Group::STATUS_NEW; + } + + /** + * 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) + { + \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 deleted" event. + * + * @param \App\Group $group The group + * + * @return void + */ + public function forceDeleted(Group $group) + { + // + } +} 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/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,112 @@ +deleteTestGroup('group-test@kolabnow.com'); + $this->deleteTestGroup('group-testm@kolabnow.com'); + $this->deleteTestUser('group-owner@kolabnow.com'); + } + + /** + * {@inheritDoc} + */ + public function tearDown(): void + { + $this->deleteTestGroup('group-test@kolabnow.com'); + $this->deleteTestGroup('group-testm@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 + + // 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('group-owner@kolabnow.com'); + + // Domain not available + $code = \Artisan::call("group:create testgroup@kolab.org --user={$user->id}"); + $output = trim(\Artisan::output()); + $this->assertSame(1, $code); + $this->assertSame("testgroup@kolab.org: Domain not available.", $output); + + // Existing email + $code = \Artisan::call("group:create jack@kolab.org --user=john@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 --user=john@kolab.org"); + $output = trim(\Artisan::output()); + $this->assertSame(1, $code); + $this->assertSame("jack.daniels@kolab.org: The specified email is not available.", $output); + + // Create a group without members + $code = \Artisan::call("group:create group-test@kolabnow.com --user={$user->email}"); + $output = trim(\Artisan::output()); + $group = Group::where('email', 'group-test@kolabnow.com')->first(); + + $this->assertSame(0, $code); + $this->assertEquals($group->id, $output); + $this->assertSame('group-test@kolabnow.com', $group->email); + $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@kolabnow.com --user={$user->email}"); + $output = trim(\Artisan::output()); + $this->assertSame(1, $code); + $this->assertSame("group-test@kolabnow.com: The specified email is not available.", $output); + + // Invalid member + $code = \Artisan::call("group:create group-testm@kolabnow.com --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@kolabnow.com --user={$user->email}" + . " --member=member1@kolabnow.com --member=member2@gmail.com" + ); + $output = trim(\Artisan::output()); + $group = Group::where('email', 'group-testm@kolabnow.com')->first(); + $this->assertSame(0, $code); + $this->assertEquals($group->id, $output); + $this->assertSame('group-testm@kolabnow.com', $group->email); + $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,49 @@ +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 + { + // Non-existing group + $this->artisan("group:info unknown@unknown.org") + ->assertExitCode(1) + ->expectsOutput("Group unknown@unknown.org does not exist."); + + $group = $this->getTestGroup('group-test@kolabnow.com'); + + // A group with no members + $this->artisan("group:info {$group->email}") + ->assertExitCode(0); + + // TODO: Test output + // $expected = "Id: {$group->id}\nEmail: {$group->email}\nStatus: {$group->status}\n"; + } +} 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/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/UserTest.php b/src/tests/Feature/UserTest.php --- a/src/tests/Feature/UserTest.php +++ b/src/tests/Feature/UserTest.php @@ -17,6 +17,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 +27,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 +237,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 +251,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 +279,15 @@ $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()); } /** 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.