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,50 @@ } } + /** + * Create a group in LDAP. + * + * @param \App\Group $group The group to create. + * + * @throws \Exception + */ + public static function createGroup(Group $group): void + { + $config = self::getConfig('admin'); + $ldap = self::initLDAP($config); + + list($cn, $domainName) = explode('@', $group->email); + + $domain = $group->domain(); + + $hostedRootDN = \config('ldap.hosted.root_dn'); + + $domainBaseDN = "ou={$domain->namespace},{$hostedRootDN}"; + + $groupBaseDN = "ou=Groups,{$domainBaseDN}"; + + $dn = "cn={$cn},{$groupBaseDN}"; + + $entry = [ + 'cn' => $cn, + 'mail' => $group->email, + 'objectclass' => [ + 'top', + 'groupofuniquenames', + 'kolabgroupofuniquenames' + ], + 'uniqueMember' => [] + ]; + + self::setGroupAttributes($group, $entry); + + $ldap->add_entry($dn, $entry); + + if (empty(self::$ldap)) { + $ldap->close(); + } + } + /** * Create a user in LDAP. * @@ -279,7 +324,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 +367,38 @@ } } + /** + * Delete a group from LDAP. + * + * @param \App\Group $group The group to delete. + * + * @throws \Exception + */ + public static function deleteGroup(Group $group): void + { + $config = self::getConfig('admin'); + $ldap = self::initLDAP($config); + + if (self::getGroupEntry($ldap, $group->email, $dn)) { + $result = $ldap->delete_entry($dn); + + if (!$result) { + self::throwException( + $ldap, + "Failed to delete group {$group->email} from LDAP (" . __LINE__ . ")" + ); + } + } + + if (empty(self::$ldap)) { + $ldap->close(); + } + } + /** * Delete a user from LDAP. * - * @param \App\User $user The user account to update. + * @param \App\User $user The user account to delete. * * @throws \Exception */ @@ -442,6 +515,52 @@ } } + /** + * Update a group in LDAP. + * + * @param \App\Group $group The group to update + * + * @throws \Exception + */ + public static function updateGroup(Group $group): void + { + $config = self::getConfig('admin'); + $ldap = self::initLDAP($config); + + list($cn, $domainName) = explode('@', $group->email); + + $domain = $group->domain(); + + $hostedRootDN = \config('ldap.hosted.root_dn'); + + $domainBaseDN = "ou={$domain->namespace},{$hostedRootDN}"; + + $groupBaseDN = "ou=Groups,{$domainBaseDN}"; + + $dn = "cn={$cn},{$groupBaseDN}"; + + $entry = [ + 'cn' => $cn, + 'mail' => $group->email, + 'objectclass' => [ + 'top', + 'groupofuniquenames', + 'kolabgroupofuniquenames' + ], + 'uniqueMember' => [] + ]; + + $oldEntry = $ldap->get_entry($dn); + + self::setGroupAttributes($group, $entry); + + $ldap->modify_entry($dn, $oldEntry, $entry); + + if (empty(self::$ldap)) { + $ldap->close(); + } + } + /** * Update a user in LDAP. * @@ -530,6 +649,58 @@ $entry['inetdomainstatus'] = $domain->status; } + /** + * Convert group member addresses in to valid entries. + */ + private static function setGroupAttributes(Group $group, &$entry) + { + $config = self::getConfig('admin'); + $ldap = self::initLDAP($config); + + $validMembers = []; + + $domain = $group->domain(); + + $hostedRootDN = \config('ldap.hosted.root_dn'); + + $domainBaseDN = "ou={$domain->namespace},{$hostedRootDN}"; + + foreach ($group->members as $member) { + list($local, $domainName) = explode('@', $member); + + $memberDN = "uid={$member},ou=People,{$domainBaseDN}"; + + // if the member is in the local domain but doesn't exist, drop it + if ($domainName == $domain->namespace) { + if (!$ldap->get_entry($memberDN)) { + continue; + } + } + + // add the member if not in the local domain + if (!$ldap->get_entry($memberDN)) { + $memberEntry = [ + 'cn' => $member, + 'mail' => $member, + 'objectclass' => [ + 'top', + 'inetorgperson', + 'organizationalperson', + 'person' + ], + 'sn' => 'unknown' + ]; + + $ldap->add_entry($memberDN, $memberEntry); + } + + $entry['uniquemember'][] = $memberDN; + $validMembers[] = $member; + } + + $group->members = $validMembers; + } + /** * Set common user attributes */ @@ -624,6 +795,33 @@ return $config; } + /** + * Get group entry from LDAP. + * + * @param \Net_LDAP3 $ldap Ldap connection + * @param string $email Group email (mail) + * @param string $dn Reference to group DN + * + * @return false|null|array Group entry, False on error, NULL if not found + */ + private static function getGroupEntry($ldap, $email, &$dn = null) + { + list($_local, $_domain) = explode('@', $email, 2); + + $domain = $ldap->find_domain($_domain); + + if (!$domain) { + return $domain; + } + + $base_dn = $ldap->domain_root_dn($_domain); + $dn = "cn={$_local},ou=Groups,{$base_dn}"; + + $entry = $ldap->get_entry($dn); + + return $entry ?: null; + } + /** * Get user entry from LDAP. * 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,287 @@ +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; + } + + public function domain(): Domain + { + list($local, $domainName) = explode('@', $this->email); + + return Domain::where('namespace', $domainName)->first(); + } + + /** + * Find whether an email address exists as a group (including deleted groups). + * + * @param string $email Email address + * @param bool $return_group Return Group instance instead of boolean + * + * @return \App\Group|bool True or Group model object if found, False otherwise + */ + public static function emailExists(string $email, bool $return_group = false) + { + if (strpos($email, '@') === false) { + return false; + } + + $email = \strtolower($email); + + $group = self::withTrashed()->where('email', $email)->first(); + + if ($group) { + return $return_group ? $group : true; + } + + return false; + } + + /** + * The group entitlement. + * + * @return \Illuminate\Database\Eloquent\Relations\MorphOne + */ + public function entitlement() + { + return $this->morphOne('App\Entitlement', 'entitleable'); + } + + /** + * Group members propert accessor. Converts internal comma-separated list into an array + * + * @param string $members Comma-separated list of email addresses + * + * @return array Email addresses of the group members, as an array + */ + public function getMembersAttribute($members): array + { + return $members ? explode(',', $members) : []; + } + + /** + * Returns whether this domain is active. + * + * @return bool + */ + public function isActive(): bool + { + return ($this->status & self::STATUS_ACTIVE) > 0; + } + + /** + * Returns whether this domain is deleted. + * + * @return bool + */ + public function isDeleted(): bool + { + return ($this->status & self::STATUS_DELETED) > 0; + } + + /** + * Returns whether this domain is new. + * + * @return bool + */ + public function isNew(): bool + { + return ($this->status & self::STATUS_NEW) > 0; + } + + /** + * Returns whether this domain is registered in LDAP. + * + * @return bool + */ + public function isLdapReady(): bool + { + return ($this->status & self::STATUS_LDAP_READY) > 0; + } + + /** + * Returns whether this domain is suspended. + * + * @return bool + */ + public function isSuspended(): bool + { + return ($this->status & self::STATUS_SUSPENDED) > 0; + } + + /** + * Ensure the email is appropriately cased. + * + * @param string $email Group email address + */ + public function setEmailAttribute(string $email) + { + $this->attributes['email'] = strtolower($email); + } + + /** + * Ensure the members are 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 @@ +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/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,85 @@ +forceDelete(); + } + + /** + * {@inheritDoc} + */ + public function tearDown(): void + { + Queue::fake(); + + Group::where('email', 'group-test@kolabnow.com')->forceDelete(); + + 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,116 @@ +forceDelete(); + Group::where('email', 'group-testm@kolabnow.com')->forceDelete(); + $this->deleteTestUser('group-owner@kolabnow.com'); + } + + /** + * {@inheritDoc} + */ + public function tearDown(): void + { + Queue::fake(); + + Group::where('email', 'group-test@kolabnow.com')->forceDelete(); + Group::where('email', 'group-testm@kolabnow.com')->forceDelete(); + $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,65 @@ +forceDelete(); + $this->deleteTestUser('group-owner@kolabnow.com'); + } + + /** + * {@inheritDoc} + */ + public function tearDown(): void + { + Queue::fake(); + + Group::where('email', 'group-test@kolabnow.com')->forceDelete(); + $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 = Group::create(['email' => '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,56 @@ +forceDelete(); + } + + /** + * {@inheritDoc} + */ + public function tearDown(): void + { + Queue::fake(); + + Group::where('email', 'group-test@kolabnow.com')->forceDelete(); + + parent::tearDown(); + } + + /** + * Test command runs + */ + public function testHandle(): void + { + Queue::fake(); + + // Non-existing group + $this->artisan("group:info unknown@unknown.org") + ->assertExitCode(1) + ->expectsOutput("Group unknown@unknown.org does not exist."); + + $group = Group::create(['email' => '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,80 @@ +forceDelete(); + } + + /** + * {@inheritDoc} + */ + public function tearDown(): void + { + Queue::fake(); + + Group::where('email', 'group-test@kolabnow.com')->forceDelete(); + + 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); + } +}