diff --git a/src/app/Entitlement.php b/src/app/Entitlement.php index 48ee81bf..ec14dd04 100644 --- a/src/app/Entitlement.php +++ b/src/app/Entitlement.php @@ -1,201 +1,174 @@ 'integer', 'fee' => 'integer' ]; /** * Return the costs per day for this entitlement. * * @return float */ public function costsPerDay() { if ($this->cost == 0) { return (float) 0; } $discount = $this->wallet->getDiscountRate(); $daysInLastMonth = \App\Utils::daysInLastMonth(); $costsPerDay = (float) ($this->cost * $discount) / $daysInLastMonth; return $costsPerDay; } /** * Create a transaction record for this entitlement. * * @param string $type The type of transaction ('created', 'billed', 'deleted'), but use the * \App\Transaction constants. * @param int $amount The amount involved in cents * * @return string The transaction ID */ public function createTransaction($type, $amount = null) { $transaction = \App\Transaction::create( [ 'object_id' => $this->id, 'object_type' => \App\Entitlement::class, 'type' => $type, 'amount' => $amount ] ); return $transaction->id; } /** * Principally entitleable object such as Domain, User, Group. * Note that it may be trashed (soft-deleted). * * @return mixed */ public function entitleable() { return $this->morphTo()->withTrashed(); // @phpstan-ignore-line } /** * Returns entitleable object title (e.g. email or domain name). * * @return string|null An object title/name */ public function entitleableTitle(): ?string { if ($this->entitleable instanceof \App\Domain) { return $this->entitleable->namespace; } return $this->entitleable->email; } /** * Simplified Entitlement/SKU information for a specified entitleable object * * @param object $object Entitleable object * * @return array Skus list with some metadata */ public static function objectEntitlementsSummary($object): array { $skus = []; // TODO: I agree this format may need to be extended in future foreach ($object->entitlements as $ent) { $sku = $ent->sku; if (!isset($skus[$sku->id])) { $skus[$sku->id] = ['costs' => [], 'count' => 0]; } $skus[$sku->id]['count']++; $skus[$sku->id]['costs'][] = $ent->cost; } return $skus; } - /** - * Restore object entitlements. - * - * @param \App\User|\App\Domain|\App\Group $object The user|domain|group object - */ - public static function restoreEntitlementsFor($object): void - { - // We'll restore only these that were deleted last. So, first we get - // the maximum deleted_at timestamp and then use it to select - // entitlements for restore - $deleted_at = $object->entitlements()->withTrashed()->max('deleted_at'); - - if ($deleted_at) { - $threshold = (new \Carbon\Carbon($deleted_at))->subMinute(); - - // Restore object entitlements - $object->entitlements()->withTrashed() - ->where('deleted_at', '>=', $threshold) - ->update(['updated_at' => now(), 'deleted_at' => null]); - - // Note: We're assuming that cost of entitlements was correct - // on deletion, so we don't have to re-calculate it again. - // TODO: We should probably re-calculate the cost - } - } - /** * The SKU concerned. * * @return \Illuminate\Database\Eloquent\Relations\BelongsTo */ public function sku() { return $this->belongsTo('App\Sku'); } /** * The wallet this entitlement is being billed to * * @return \Illuminate\Database\Eloquent\Relations\BelongsTo */ public function wallet() { return $this->belongsTo('App\Wallet'); } /** * Cost mutator. Make sure cost is integer. */ public function setCostAttribute($cost): void { $this->attributes['cost'] = round($cost); } } diff --git a/src/app/Observers/DomainObserver.php b/src/app/Observers/DomainObserver.php index 5f4664de..1ccbc466 100644 --- a/src/app/Observers/DomainObserver.php +++ b/src/app/Observers/DomainObserver.php @@ -1,135 +1,104 @@ namespace = \strtolower($domain->namespace); $domain->status |= Domain::STATUS_NEW; } /** * Handle the domain "created" event. * * @param \App\Domain $domain The domain. * * @return void */ public function created(Domain $domain) { // Create domain record in LDAP // Note: DomainCreate job will dispatch DomainVerify job \App\Jobs\Domain\CreateJob::dispatch($domain->id); } - /** - * Handle the domain "deleting" event. - * - * @param \App\Domain $domain The domain. - * - * @return void - */ - public function deleting(Domain $domain) - { - // 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', $domain->id) - ->where('entitleable_type', Domain::class) - ->delete(); - } - /** * Handle the domain "deleted" event. * * @param \App\Domain $domain The domain. * * @return void */ public function deleted(Domain $domain) { if ($domain->isForceDeleting()) { return; } \App\Jobs\Domain\DeleteJob::dispatch($domain->id); } /** * Handle the domain "updated" event. * * @param \App\Domain $domain The domain. * * @return void */ public function updated(Domain $domain) { \App\Jobs\Domain\UpdateJob::dispatch($domain->id); } /** * Handle the domain "restoring" event. * * @param \App\Domain $domain The domain. * * @return void */ public function restoring(Domain $domain) { // Make sure it's not DELETED/LDAP_READY/SUSPENDED if ($domain->isDeleted()) { $domain->status ^= Domain::STATUS_DELETED; } if ($domain->isLdapReady()) { $domain->status ^= Domain::STATUS_LDAP_READY; } if ($domain->isSuspended()) { $domain->status ^= Domain::STATUS_SUSPENDED; } if ($domain->isConfirmed() && $domain->isVerified()) { $domain->status |= Domain::STATUS_ACTIVE; } // Note: $domain->save() is invoked between 'restoring' and 'restored' events } /** * Handle the domain "restored" event. * * @param \App\Domain $domain The domain. * * @return void */ public function restored(Domain $domain) { - // Restore domain entitlements - \App\Entitlement::restoreEntitlementsFor($domain); - // Create the domain in LDAP again \App\Jobs\Domain\CreateJob::dispatch($domain->id); } - - /** - * Handle the domain "force deleted" event. - * - * @param \App\Domain $domain The domain. - * - * @return void - */ - public function forceDeleted(Domain $domain) - { - // - } } diff --git a/src/app/Observers/EntitlementObserver.php b/src/app/Observers/EntitlementObserver.php index 7464094b..c1ba762c 100644 --- a/src/app/Observers/EntitlementObserver.php +++ b/src/app/Observers/EntitlementObserver.php @@ -1,167 +1,169 @@ wallet_id); if (!$wallet || !$wallet->owner) { return false; } $sku = \App\Sku::find($entitlement->sku_id); if (!$sku) { return false; } $result = $sku->handler_class::preReq($entitlement, $wallet->owner); if (!$result) { return false; } return true; } /** * Handle the entitlement "created" event. * * @param \App\Entitlement $entitlement The entitlement. * * @return void */ public function created(Entitlement $entitlement) { $entitlement->entitleable->updated_at = Carbon::now(); $entitlement->entitleable->save(); $entitlement->createTransaction(\App\Transaction::ENTITLEMENT_CREATED); } /** * Handle the entitlement "deleted" event. * * @param \App\Entitlement $entitlement The entitlement. * * @return void */ public function deleted(Entitlement $entitlement) { // Remove all configured 2FA methods from Roundcube database if ($entitlement->sku->title == '2fa') { // FIXME: Should that be an async job? $sf = new \App\Auth\SecondFactor($entitlement->entitleable); $sf->removeFactors(); } - $entitlement->entitleable->updated_at = Carbon::now(); - $entitlement->entitleable->save(); + if ($entitlement->entitleable && !$entitlement->entitleable->trashed()) { + $entitlement->entitleable->updated_at = Carbon::now(); + $entitlement->entitleable->save(); - $entitlement->createTransaction(\App\Transaction::ENTITLEMENT_DELETED); + $entitlement->createTransaction(\App\Transaction::ENTITLEMENT_DELETED); + } } /** * Handle the entitlement "deleting" event. * * @param \App\Entitlement $entitlement The entitlement. * * @return void */ public function deleting(Entitlement $entitlement) { if ($entitlement->trashed()) { return; } // Start calculating the costs for the consumption of this entitlement if the // existing consumption spans >= 14 days. // // Effect is that anything's free for the first 14 days if ($entitlement->created_at >= Carbon::now()->subDays(14)) { return; } $owner = $entitlement->wallet->owner; // Determine if we're still within the free first month $freeMonthEnds = $owner->created_at->copy()->addMonthsWithoutOverflow(1); if ($freeMonthEnds >= Carbon::now()) { return; } $now = Carbon::now(); // get the discount rate applied to the wallet. $discount = $entitlement->wallet->getDiscountRate(); // just in case this had not been billed yet, ever $diffInMonths = $entitlement->updated_at->diffInMonths($now); $cost = (int) ($entitlement->cost * $discount * $diffInMonths); $fee = (int) ($entitlement->fee * $diffInMonths); // this moves the hypothetical updated at forward to however many months past the original $updatedAt = $entitlement->updated_at->copy()->addMonthsWithoutOverflow($diffInMonths); // now we have the diff in days since the last "billed" period end. // This may be an entitlement paid up until February 28th, 2020, with today being March // 12th 2020. Calculating the costs for the entitlement is based on the daily price // the price per day is based on the number of days in the last month // or the current month if the period does not overlap with the previous month // FIXME: This really should be simplified to $daysInMonth=30 $diffInDays = $updatedAt->diffInDays($now); if ($now->day >= $diffInDays) { $daysInMonth = $now->daysInMonth; } else { $daysInMonth = \App\Utils::daysInLastMonth(); } $pricePerDay = $entitlement->cost / $daysInMonth; $feePerDay = $entitlement->fee / $daysInMonth; $cost += (int) (round($pricePerDay * $discount * $diffInDays, 0)); $fee += (int) (round($feePerDay * $diffInDays, 0)); $profit = $cost - $fee; if ($profit != 0 && $owner->tenant && ($wallet = $owner->tenant->wallet())) { $desc = "Charged user {$owner->email}"; $method = $profit > 0 ? 'credit' : 'debit'; $wallet->{$method}(abs($profit), $desc); } if ($cost == 0) { return; } $entitlement->wallet->debit($cost); } } diff --git a/src/app/Observers/GroupObserver.php b/src/app/Observers/GroupObserver.php index 4262805f..8d50b211 100644 --- a/src/app/Observers/GroupObserver.php +++ b/src/app/Observers/GroupObserver.php @@ -1,137 +1,102 @@ status |= Group::STATUS_NEW | Group::STATUS_ACTIVE; if (!isset($group->name) && isset($group->email)) { $group->name = explode('@', $group->email)[0]; } } /** * 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 "restoring" event. * * @param \App\Group $group The group * * @return void */ public function restoring(Group $group) { // Make sure it's not DELETED/LDAP_READY/SUSPENDED anymore if ($group->isDeleted()) { $group->status ^= Group::STATUS_DELETED; } if ($group->isLdapReady()) { $group->status ^= Group::STATUS_LDAP_READY; } if ($group->isSuspended()) { $group->status ^= Group::STATUS_SUSPENDED; } $group->status |= Group::STATUS_ACTIVE; // Note: $group->save() is invoked between 'restoring' and 'restored' events } /** * Handle the group "restored" event. * * @param \App\Group $group The group * * @return void */ public function restored(Group $group) { - // Restore group entitlements - \App\Entitlement::restoreEntitlementsFor($group); - \App\Jobs\Group\CreateJob::dispatch($group->id); } - - /** - * 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/ResourceObserver.php b/src/app/Observers/ResourceObserver.php index 1e6f13e2..0b5a5c15 100644 --- a/src/app/Observers/ResourceObserver.php +++ b/src/app/Observers/ResourceObserver.php @@ -1,136 +1,104 @@ email)) { if (!isset($resource->name)) { throw new \Exception("Missing 'domain' property for a new resource"); } $domainName = \strtolower($resource->domain); $resource->email = "resource-{$resource->id}@{$domainName}"; } else { $resource->email = \strtolower($resource->email); } $resource->status |= Resource::STATUS_NEW | Resource::STATUS_ACTIVE; } /** * Handle the resource "created" event. * * @param \App\Resource $resource The resource * * @return void */ public function created(Resource $resource) { $domainName = explode('@', $resource->email, 2)[1]; $settings = [ 'folder' => "shared/Resources/{$resource->name}@{$domainName}", ]; foreach ($settings as $key => $value) { $settings[$key] = [ 'key' => $key, 'value' => $value, 'resource_id' => $resource->id, ]; } // Note: Don't use setSettings() here to bypass ResourceSetting observers // Note: This is a single multi-insert query $resource->settings()->insert(array_values($settings)); // Create resource record in LDAP, then check if it is created in IMAP $chain = [ new \App\Jobs\Resource\VerifyJob($resource->id), ]; \App\Jobs\Resource\CreateJob::withChain($chain)->dispatch($resource->id); } - /** - * Handle the resource "deleting" event. - * - * @param \App\Resource $resource The resource - * - * @return void - */ - public function deleting(Resource $resource) - { - // 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', $resource->id) - ->where('entitleable_type', Resource::class) - ->delete(); - } - /** * Handle the resource "deleted" event. * * @param \App\Resource $resource The resource * * @return void */ public function deleted(Resource $resource) { if ($resource->isForceDeleting()) { return; } \App\Jobs\Resource\DeleteJob::dispatch($resource->id); } /** * Handle the resource "updated" event. * * @param \App\Resource $resource The resource * * @return void */ public function updated(Resource $resource) { \App\Jobs\Resource\UpdateJob::dispatch($resource->id); // Update the folder property if name changed if ($resource->name != $resource->getOriginal('name')) { $domainName = explode('@', $resource->email, 2)[1]; $folder = "shared/Resources/{$resource->name}@{$domainName}"; // Note: This does not invoke ResourceSetting observer events, good. $resource->settings()->where('key', 'folder')->update(['value' => $folder]); } } - - /** - * Handle the resource "force deleted" event. - * - * @param \App\Resource $resource The resource - * - * @return void - */ - public function forceDeleted(Resource $resource) - { - // A group can be force-deleted separately from the owner - // we have to force-delete entitlements - \App\Entitlement::where('entitleable_id', $resource->id) - ->where('entitleable_type', Resource::class) - ->forceDelete(); - } } diff --git a/src/app/Observers/SharedFolderObserver.php b/src/app/Observers/SharedFolderObserver.php index d1a5d64c..a7330a6e 100644 --- a/src/app/Observers/SharedFolderObserver.php +++ b/src/app/Observers/SharedFolderObserver.php @@ -1,140 +1,108 @@ type)) { $folder->type = 'mail'; } if (empty($folder->email)) { if (!isset($folder->name)) { throw new \Exception("Missing 'domain' property for a new shared folder"); } $domainName = \strtolower($folder->domain); $folder->email = "{$folder->type}-{$folder->id}@{$domainName}"; } else { $folder->email = \strtolower($folder->email); } $folder->status |= SharedFolder::STATUS_NEW | SharedFolder::STATUS_ACTIVE; } /** * Handle the shared folder "created" event. * * @param \App\SharedFolder $folder The folder * * @return void */ public function created(SharedFolder $folder) { $domainName = explode('@', $folder->email, 2)[1]; $settings = [ 'folder' => "shared/{$folder->name}@{$domainName}", ]; foreach ($settings as $key => $value) { $settings[$key] = [ 'key' => $key, 'value' => $value, 'shared_folder_id' => $folder->id, ]; } // Note: Don't use setSettings() here to bypass SharedFolderSetting observers // Note: This is a single multi-insert query $folder->settings()->insert(array_values($settings)); // Create folder record in LDAP, then check if it is created in IMAP $chain = [ new \App\Jobs\SharedFolder\VerifyJob($folder->id), ]; \App\Jobs\SharedFolder\CreateJob::withChain($chain)->dispatch($folder->id); } - /** - * Handle the shared folder "deleting" event. - * - * @param \App\SharedFolder $folder The folder - * - * @return void - */ - public function deleting(SharedFolder $folder) - { - // 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', $folder->id) - ->where('entitleable_type', SharedFolder::class) - ->delete(); - } - /** * Handle the shared folder "deleted" event. * * @param \App\SharedFolder $folder The folder * * @return void */ public function deleted(SharedFolder $folder) { if ($folder->isForceDeleting()) { return; } \App\Jobs\SharedFolder\DeleteJob::dispatch($folder->id); } /** * Handle the shared folder "updated" event. * * @param \App\SharedFolder $folder The folder * * @return void */ public function updated(SharedFolder $folder) { \App\Jobs\SharedFolder\UpdateJob::dispatch($folder->id); // Update the folder property if name changed if ($folder->name != $folder->getOriginal('name')) { $domainName = explode('@', $folder->email, 2)[1]; $folderName = "shared/{$folder->name}@{$domainName}"; // Note: This does not invoke SharedFolderSetting observer events, good. $folder->settings()->where('key', 'folder')->update(['value' => $folderName]); } } - - /** - * Handle the shared folder "force deleted" event. - * - * @param \App\SharedFolder $folder The folder - * - * @return void - */ - public function forceDeleted(SharedFolder $folder) - { - // A folder can be force-deleted separately from the owner - // we have to force-delete entitlements - \App\Entitlement::where('entitleable_id', $folder->id) - ->where('entitleable_type', SharedFolder::class) - ->forceDelete(); - } } diff --git a/src/app/Observers/UserObserver.php b/src/app/Observers/UserObserver.php index 72927071..825a7b66 100644 --- a/src/app/Observers/UserObserver.php +++ b/src/app/Observers/UserObserver.php @@ -1,384 +1,241 @@ email = \strtolower($user->email); // only users that are not imported get the benefit of the doubt. $user->status |= User::STATUS_NEW | User::STATUS_ACTIVE; } /** * Handle the "created" event. * * Ensures the user has at least one wallet. * * Should ensure some basic settings are available as well. * * @param \App\User $user The user created. * * @return void */ public function created(User $user) { $settings = [ 'country' => \App\Utils::countryForRequest(), 'currency' => \config('app.currency'), /* 'first_name' => '', 'last_name' => '', 'billing_address' => '', 'organization' => '', 'phone' => '', 'external_email' => '', */ ]; foreach ($settings as $key => $value) { $settings[$key] = [ 'key' => $key, 'value' => $value, 'user_id' => $user->id, ]; } // Note: Don't use setSettings() here to bypass UserSetting observers // Note: This is a single multi-insert query $user->settings()->insert(array_values($settings)); $user->wallets()->create(); // Create user record in LDAP, then check if the account is created in IMAP $chain = [ new \App\Jobs\User\VerifyJob($user->id), ]; \App\Jobs\User\CreateJob::withChain($chain)->dispatch($user->id); if (\App\Tenant::getConfig($user->tenant_id, 'pgp.enable')) { \App\Jobs\PGP\KeyCreateJob::dispatch($user->id, $user->email); } } /** * Handle the "deleted" event. * * @param \App\User $user The user deleted. * * @return void */ public function deleted(User $user) { // Remove the user from existing groups $wallet = $user->wallet(); if ($wallet && $wallet->owner) { $wallet->owner->groups()->each(function ($group) use ($user) { if (in_array($user->email, $group->members)) { $group->members = array_diff($group->members, [$user->email]); $group->save(); } }); } - - // Debit the reseller's wallet with the user negative balance - $balance = 0; - foreach ($user->wallets as $wallet) { - // Note: here we assume all user wallets are using the same currency. - // It might get changed in the future - $balance += $wallet->balance; - } - - if ($balance < 0 && $user->tenant && ($wallet = $user->tenant->wallet())) { - $wallet->debit($balance * -1, "Deleted user {$user->email}"); - } } /** * Handle the "deleting" event. * * @param User $user The user that is being deleted. * * @return void */ public function deleting(User $user) { - if ($user->isForceDeleting()) { - $this->forceDeleting($user); - return; - } + // Remove owned users/domains/groups/resources/etc + self::removeRelatedObjects($user, $user->isForceDeleting()); // TODO: Especially in tests we're doing delete() on a already deleted user. // Should we escape here - for performance reasons? - // TODO: I think all of this should use database transactions - - // Entitlements do not have referential integrity on the entitled object, so this is our - // way of doing an onDelete('cascade') without the foreign key. - $entitlements = Entitlement::where('entitleable_id', $user->id) - ->where('entitleable_type', User::class)->get(); - - foreach ($entitlements as $entitlement) { - $entitlement->delete(); - } - - // Remove owned users/domains - $wallets = $user->wallets()->pluck('id')->all(); - $assignments = Entitlement::whereIn('wallet_id', $wallets)->get(); - $users = []; - $domains = []; - $groups = []; - $resources = []; - $folders = []; - $entitlements = []; - - foreach ($assignments as $entitlement) { - if ($entitlement->entitleable_type == Domain::class) { - $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; - } elseif ($entitlement->entitleable_type == Resource::class) { - $resources[] = $entitlement->entitleable_id; - } elseif ($entitlement->entitleable_type == SharedFolder::class) { - $folders[] = $entitlement->entitleable_id; - } else { - $entitlements[] = $entitlement; - } - } - - // 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', array_unique($users))->get() as $_user) { - $_user->delete(); - } - } - - if (!empty($domains)) { - foreach (Domain::whereIn('id', array_unique($domains))->get() as $_domain) { - $_domain->delete(); - } - } - if (!empty($groups)) { - foreach (Group::whereIn('id', array_unique($groups))->get() as $_group) { - $_group->delete(); - } - } - - if (!empty($resources)) { - foreach (Resource::whereIn('id', array_unique($resources))->get() as $_resource) { - $_resource->delete(); - } - } + if (!$user->isForceDeleting()) { + \App\Jobs\User\DeleteJob::dispatch($user->id); - if (!empty($folders)) { - foreach (SharedFolder::whereIn('id', array_unique($folders))->get() as $_folder) { - $_folder->delete(); + if (\App\Tenant::getConfig($user->tenant_id, 'pgp.enable')) { + \App\Jobs\PGP\KeyDeleteJob::dispatch($user->id, $user->email); } - } - - foreach ($entitlements as $entitlement) { - $entitlement->delete(); - } - - // FIXME: What do we do with user wallets? - - \App\Jobs\User\DeleteJob::dispatch($user->id); - - if (\App\Tenant::getConfig($user->tenant_id, 'pgp.enable')) { - \App\Jobs\PGP\KeyDeleteJob::dispatch($user->id, $user->email); - } - } - - /** - * Handle the "deleting" event on forceDelete() call. - * - * @param User $user The user that is being deleted. - * - * @return void - */ - public function forceDeleting(User $user) - { - // TODO: We assume that at this moment all belongings are already soft-deleted. - - // Remove owned users/domains - $wallets = $user->wallets()->pluck('id')->all(); - $assignments = Entitlement::withTrashed()->whereIn('wallet_id', $wallets)->get(); - $entitlements = []; - $domains = []; - $groups = []; - $resources = []; - $folders = []; - $users = []; - foreach ($assignments as $entitlement) { - $entitlements[] = $entitlement->id; - - if ($entitlement->entitleable_type == Domain::class) { - $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; - } elseif ($entitlement->entitleable_type == Resource::class) { - $resources[] = $entitlement->entitleable_id; - } elseif ($entitlement->entitleable_type == SharedFolder::class) { - $folders[] = $entitlement->entitleable_id; + // Debit the reseller's wallet with the user negative balance + $balance = 0; + foreach ($user->wallets as $wallet) { + // Note: here we assume all user wallets are using the same currency. + // It might get changed in the future + $balance += $wallet->balance; } - } - // 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() - ->where('entitleable_id', $user->id) - ->where('entitleable_type', User::class) - ->forceDelete(); - - // 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', array_unique($users))->get() as $_user) { - $_user->forceDelete(); + if ($balance < 0 && $user->tenant && ($wallet = $user->tenant->wallet())) { + $wallet->debit($balance * -1, "Deleted user {$user->email}"); } } - - // Domains can be just removed - if (!empty($domains)) { - Domain::withTrashed()->whereIn('id', array_unique($domains))->forceDelete(); - } - - // Groups can be just removed - if (!empty($groups)) { - Group::withTrashed()->whereIn('id', array_unique($groups))->forceDelete(); - } - - // Resources can be just removed - if (!empty($resources)) { - Resource::withTrashed()->whereIn('id', array_unique($resources))->forceDelete(); - } - - // Shared folders can be just removed - if (!empty($folders)) { - SharedFolder::withTrashed()->whereIn('id', array_unique($folders))->forceDelete(); - } - - // Remove transactions, they also have no foreign key constraint - Transaction::where('object_type', Entitlement::class) - ->whereIn('object_id', $entitlements) - ->delete(); - - Transaction::where('object_type', Wallet::class) - ->whereIn('object_id', $wallets) - ->delete(); } /** * Handle the user "restoring" event. * * @param \App\User $user The user * * @return void */ public function restoring(User $user) { // Make sure it's not DELETED/LDAP_READY/IMAP_READY/SUSPENDED anymore if ($user->isDeleted()) { $user->status ^= User::STATUS_DELETED; } if ($user->isLdapReady()) { $user->status ^= User::STATUS_LDAP_READY; } if ($user->isImapReady()) { $user->status ^= User::STATUS_IMAP_READY; } if ($user->isSuspended()) { $user->status ^= User::STATUS_SUSPENDED; } $user->status |= User::STATUS_ACTIVE; // Note: $user->save() is invoked between 'restoring' and 'restored' events } /** * Handle the user "restored" event. * * @param \App\User $user The user * * @return void */ public function restored(User $user) { - // Restore user entitlements - \App\Entitlement::restoreEntitlementsFor($user); - // We need at least the user domain so it can be created in ldap. // FIXME: What if the domain is owned by someone else? $domain = $user->domain(); if ($domain->trashed() && !$domain->isPublic()) { // Note: Domain entitlements will be restored by the DomainObserver $domain->restore(); } // FIXME: Should we reset user aliases? or re-validate them in any way? // Create user record in LDAP, then run the verification process $chain = [ new \App\Jobs\User\VerifyJob($user->id), ]; \App\Jobs\User\CreateJob::withChain($chain)->dispatch($user->id); } /** - * Handle the "retrieving" event. - * - * @param User $user The user that is being retrieved. + * Handle the "updating" event. * - * @todo This is useful for audit. + * @param User $user The user that is being updated. * * @return void */ - public function retrieving(User $user) + public function updating(User $user) { - // TODO \App\Jobs\User\ReadJob::dispatch($user->id); + \App\Jobs\User\UpdateJob::dispatch($user->id); } /** - * Handle the "updating" event. - * - * @param User $user The user that is being updated. + * Remove entitleables/transactions related to the user (in user's wallets) * - * @return void + * @param \App\User $user The user + * @param bool $force Force-delete mode */ - public function updating(User $user) + private static function removeRelatedObjects(User $user, $force = false): void { - \App\Jobs\User\UpdateJob::dispatch($user->id); + $wallets = $user->wallets->pluck('id')->all(); + + \App\Entitlement::withTrashed() + ->select('entitleable_id', 'entitleable_type') + ->distinct() + ->whereIn('wallet_id', $wallets) + ->get() + ->each(function ($entitlement) use ($user, $force) { + // Skip the current user (infinite recursion loop) + if ($entitlement->entitleable_type == User::class && $entitlement->entitleable_id == $user->id) { + return; + } + + // Objects need to be deleted one by one to make sure observers can do the proper cleanup + if ($entitlement->entitleable) { + if ($force) { + $entitlement->entitleable->forceDelete(); + } elseif (!$entitlement->entitleable->trashed()) { + $entitlement->entitleable->delete(); + } + } + }); + + if ($force) { + // Remove "wallet" transactions, they have no foreign key constraint + \App\Transaction::where('object_type', Wallet::class) + ->whereIn('object_id', $wallets) + ->delete(); + } } } diff --git a/src/app/Traits/EntitleableTrait.php b/src/app/Traits/EntitleableTrait.php index bef0c947..535db87a 100644 --- a/src/app/Traits/EntitleableTrait.php +++ b/src/app/Traits/EntitleableTrait.php @@ -1,195 +1,258 @@ skus as $sku) { for ($i = $sku->pivot->qty; $i > 0; $i--) { Entitlement::create([ 'wallet_id' => $wallet->id, 'sku_id' => $sku->id, 'cost' => $sku->pivot->cost(), 'fee' => $sku->pivot->fee(), 'entitleable_id' => $this->id, 'entitleable_type' => self::class ]); } } return $this; } /** * Assign a Sku to an entitleable object. * * @param \App\Sku $sku The sku to assign. * @param int $count Count of entitlements to add * * @return $this * @throws \Exception */ public function assignSku(Sku $sku, int $count = 1) { // TODO: I guess wallet could be parametrized in future $wallet = $this->wallet(); $exists = $this->entitlements()->where('sku_id', $sku->id)->count(); // TODO: Make sure the SKU can be assigned to the object while ($count > 0) { Entitlement::create([ 'wallet_id' => $wallet->id, 'sku_id' => $sku->id, 'cost' => $exists >= $sku->units_free ? $sku->cost : 0, 'fee' => $exists >= $sku->units_free ? $sku->fee : 0, 'entitleable_id' => $this->id, 'entitleable_type' => self::class ]); $exists++; $count--; } return $this; } /** * Assign the object to a wallet. * * @param \App\Wallet $wallet The wallet * * @return $this * @throws \Exception */ public function assignToWallet(Wallet $wallet) { if (empty($this->id)) { throw new \Exception("Object not yet exists"); } if ($this->entitlements()->count()) { throw new \Exception("Object already assigned to a wallet"); } // Find the SKU title, e.g. \App\SharedFolder -> shared-folder // Note: it does not work with User/Domain model (yet) $title = Str::kebab(\class_basename(self::class)); $sku = Sku::withObjectTenantContext($this)->where('title', $title)->first(); $exists = $wallet->entitlements()->where('sku_id', $sku->id)->count(); Entitlement::create([ 'wallet_id' => $wallet->id, 'sku_id' => $sku->id, 'cost' => $exists >= $sku->units_free ? $sku->cost : 0, 'fee' => $exists >= $sku->units_free ? $sku->fee : 0, 'entitleable_id' => $this->id, 'entitleable_type' => self::class ]); return $this; } + /** + * Boot function from Laravel. + */ + protected static function bootEntitleableTrait() + { + // Soft-delete and force-delete object's entitlements on object's delete + static::deleting(function ($model) { + $force = $model->isForceDeleting(); + $entitlements = $model->entitlements(); + + if ($force) { + $entitlements = $entitlements->withTrashed(); + } + + $list = $entitlements->get() + ->map(function ($entitlement) use ($force) { + if ($force) { + $entitlement->forceDelete(); + } else { + $entitlement->delete(); + } + return $entitlement->id; + }) + ->all(); + + // Remove transactions, they have no foreign key constraint + if ($force && !empty($list)) { + \App\Transaction::where('object_type', \App\Entitlement::class) + ->whereIn('object_id', $list) + ->delete(); + } + }); + + // Restore object's entitlements on restore + static::restored(function ($model) { + $model->restoreEntitlements(); + }); + } + /** * Entitlements for this object. * * @return \Illuminate\Database\Eloquent\Relations\HasMany */ public function entitlements() { return $this->hasMany(Entitlement::class, 'entitleable_id', 'id') ->where('entitleable_type', self::class); } /** * Check if an entitlement for the specified SKU exists. * * @param string $title The SKU title * * @return bool True if specified SKU entitlement exists */ public function hasSku(string $title): bool { $sku = Sku::withObjectTenantContext($this)->where('title', $title)->first(); if (!$sku) { return false; } return $this->entitlements()->where('sku_id', $sku->id)->count() > 0; } /** * Remove a number of entitlements for the SKU. * * @param \App\Sku $sku The SKU * @param int $count The number of entitlements to remove * * @return $this */ public function removeSku(Sku $sku, int $count = 1) { $entitlements = $this->entitlements() ->where('sku_id', $sku->id) ->orderBy('cost', 'desc') ->orderBy('created_at') ->get(); $entitlements_count = count($entitlements); foreach ($entitlements as $entitlement) { if ($entitlements_count <= $sku->units_free) { continue; } if ($count > 0) { $entitlement->delete(); $entitlements_count--; $count--; } } return $this; } + /** + * Restore object entitlements. + */ + public function restoreEntitlements(): void + { + // We'll restore only these that were deleted last. So, first we get + // the maximum deleted_at timestamp and then use it to select + // entitlements for restore + $deleted_at = $this->entitlements()->withTrashed()->max('deleted_at'); + + if ($deleted_at) { + $threshold = (new \Carbon\Carbon($deleted_at))->subMinute(); + + // Restore object entitlements + $this->entitlements()->withTrashed() + ->where('deleted_at', '>=', $threshold) + ->update(['updated_at' => now(), 'deleted_at' => null]); + + // Note: We're assuming that cost of entitlements was correct + // on deletion, so we don't have to re-calculate it again. + // TODO: We should probably re-calculate the cost + } + } + /** * Returns the wallet by which the object is controlled * * @return ?\App\Wallet A wallet object */ public function wallet(): ?Wallet { $entitlement = $this->entitlements()->withTrashed()->orderBy('created_at', 'desc')->first(); if ($entitlement) { return $entitlement->wallet; } // TODO: No entitlement should not happen, but in tests we have // such cases, so we fallback to the user's wallet in this case if ($this instanceof \App\User) { return $this->wallets()->first(); } return null; } } diff --git a/src/app/Transaction.php b/src/app/Transaction.php index 41ec8578..ec936232 100644 --- a/src/app/Transaction.php +++ b/src/app/Transaction.php @@ -1,196 +1,196 @@ 'integer', ]; /** * Returns the entitlement to which the transaction is assigned (if any) * * @return \App\Entitlement|null The entitlement */ public function entitlement(): ?Entitlement { if ($this->object_type !== Entitlement::class) { return null; } return Entitlement::withTrashed()->find($this->object_id); } /** * Transaction type mutator * * @throws \Exception */ public function setTypeAttribute($value): void { switch ($value) { case self::ENTITLEMENT_BILLED: case self::ENTITLEMENT_CREATED: case self::ENTITLEMENT_DELETED: // TODO: Must be an entitlement. $this->attributes['type'] = $value; break; case self::WALLET_AWARD: case self::WALLET_CREDIT: case self::WALLET_DEBIT: case self::WALLET_PENALTY: case self::WALLET_REFUND: case self::WALLET_CHARGEBACK: // TODO: This must be a wallet. $this->attributes['type'] = $value; break; default: throw new \Exception("Invalid type value"); } } /** * Returns a short text describing the transaction. * * @return string The description */ public function shortDescription(): string { $label = $this->objectTypeToLabelString() . '-' . $this->{'type'} . '-short'; $result = \trans("transactions.{$label}", $this->descriptionParams()); return trim($result, ': '); } /** * Returns a text describing the transaction. * * @return string The description */ public function toString(): string { $label = $this->objectTypeToLabelString() . '-' . $this->{'type'}; return \trans("transactions.{$label}", $this->descriptionParams()); } /** * Returns a wallet to which the transaction is assigned (if any) * * @return \App\Wallet|null The wallet */ public function wallet(): ?Wallet { if ($this->object_type !== Wallet::class) { return null; } return Wallet::find($this->object_id); } /** * Collect transaction parameters used in (localized) descriptions * * @return array Parameters */ private function descriptionParams(): array { $result = [ 'user_email' => $this->user_email, - 'description' => $this->{'description'}, + 'description' => $this->description, ]; $amount = $this->amount * ($this->amount < 0 ? -1 : 1); if ($entitlement = $this->entitlement()) { $wallet = $entitlement->wallet; $cost = $entitlement->cost; $discount = $entitlement->wallet->getDiscountRate(); $result['entitlement_cost'] = $cost * $discount; $result['object'] = $entitlement->entitleableTitle(); - $result['sku_title'] = $entitlement->sku->{'title'}; + $result['sku_title'] = $entitlement->sku->title; } else { $wallet = $this->wallet(); } - $result['wallet'] = $wallet->{'description'} ?: 'Default wallet'; + $result['wallet'] = $wallet->description ?: 'Default wallet'; $result['amount'] = $wallet->money($amount); return $result; } /** * Get a string for use in translation tables derived from the object type. * * @return string|null */ private function objectTypeToLabelString(): ?string { if ($this->object_type == Entitlement::class) { return 'entitlement'; } if ($this->object_type == Wallet::class) { return 'wallet'; } return null; } } diff --git a/src/tests/Feature/Console/User/ForceDeleteTest.php b/src/tests/Feature/Console/User/ForceDeleteTest.php index 795bc3b7..f4a06fba 100644 --- a/src/tests/Feature/Console/User/ForceDeleteTest.php +++ b/src/tests/Feature/Console/User/ForceDeleteTest.php @@ -1,98 +1,99 @@ deleteTestUser('user@force-delete.com'); $this->deleteTestDomain('force-delete.com'); } /** * {@inheritDoc} */ public function tearDown(): void { $this->deleteTestUser('user@force-delete.com'); $this->deleteTestDomain('force-delete.com'); parent::tearDown(); } /** * Test the command */ public function testHandle(): void { // Non-existing user $this->artisan('user:force-delete unknown@unknown.org') ->assertExitCode(1); Queue::fake(); $user = $this->getTestUser('user@force-delete.com'); $domain = $this->getTestDomain('force-delete.com', [ 'status' => \App\Domain::STATUS_NEW, 'type' => \App\Domain::TYPE_HOSTED, ]); $package_kolab = \App\Package::withEnvTenantContext()->where('title', 'kolab')->first(); $package_domain = \App\Package::withEnvTenantContext()->where('title', 'domain-hosting')->first(); $user->assignPackage($package_kolab); $domain->assignPackage($package_domain, $user); $wallet = $user->wallets()->first(); $entitlements = $wallet->entitlements->pluck('id')->all(); $this->assertCount(8, $entitlements); // Non-deleted user $this->artisan('user:force-delete user@force-delete.com') ->assertExitCode(1); $user->delete(); $this->assertTrue($user->trashed()); $this->assertTrue($domain->fresh()->trashed()); // Deleted user $this->artisan('user:force-delete user@force-delete.com') ->assertExitCode(0); $this->assertCount( 0, \App\User::withTrashed()->where('email', 'user@force-delete.com')->get() ); $this->assertCount( 0, \App\Domain::withTrashed()->where('namespace', 'force-delete.com')->get() ); $this->assertCount( 0, \App\Wallet::where('id', $wallet->id)->get() ); $this->assertCount( 0, \App\Entitlement::withTrashed()->where('wallet_id', $wallet->id)->get() ); $this->assertCount( 0, \App\Entitlement::withTrashed()->where('entitleable_id', $user->id)->get() ); + $this->assertCount( 0, \App\Transaction::whereIn('object_id', $entitlements) ->where('object_type', \App\Entitlement::class) ->get() ); // TODO: Test that it also deletes users in a group account } } diff --git a/src/tests/Feature/EntitlementTest.php b/src/tests/Feature/EntitlementTest.php index e89c9019..ff2be6d2 100644 --- a/src/tests/Feature/EntitlementTest.php +++ b/src/tests/Feature/EntitlementTest.php @@ -1,186 +1,160 @@ deleteTestUser('entitlement-test@kolabnow.com'); $this->deleteTestUser('entitled-user@custom-domain.com'); $this->deleteTestGroup('test-group@custom-domain.com'); $this->deleteTestDomain('custom-domain.com'); } /** * {@inheritDoc} */ public function tearDown(): void { $this->deleteTestUser('entitlement-test@kolabnow.com'); $this->deleteTestUser('entitled-user@custom-domain.com'); $this->deleteTestGroup('test-group@custom-domain.com'); $this->deleteTestDomain('custom-domain.com'); parent::tearDown(); } /** * Test for Entitlement::costsPerDay() */ public function testCostsPerDay(): void { // 500 // 28 days: 17.86 // 31 days: 16.129 $user = $this->getTestUser('entitlement-test@kolabnow.com'); $package = Package::withEnvTenantContext()->where('title', 'kolab')->first(); $mailbox = Sku::withEnvTenantContext()->where('title', 'mailbox')->first(); $user->assignPackage($package); $entitlement = $user->entitlements->where('sku_id', $mailbox->id)->first(); $costsPerDay = $entitlement->costsPerDay(); $this->assertTrue($costsPerDay < 17.86); $this->assertTrue($costsPerDay > 16.12); } /** * Tests for entitlements * @todo This really should be in User or Wallet tests file */ public function testEntitlements(): void { $packageDomain = Package::withEnvTenantContext()->where('title', 'domain-hosting')->first(); $packageKolab = Package::withEnvTenantContext()->where('title', 'kolab')->first(); $skuDomain = Sku::withEnvTenantContext()->where('title', 'domain-hosting')->first(); $skuMailbox = Sku::withEnvTenantContext()->where('title', 'mailbox')->first(); $owner = $this->getTestUser('entitlement-test@kolabnow.com'); $user = $this->getTestUser('entitled-user@custom-domain.com'); $domain = $this->getTestDomain( 'custom-domain.com', [ 'status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_EXTERNAL, ] ); $domain->assignPackage($packageDomain, $owner); $owner->assignPackage($packageKolab); $owner->assignPackage($packageKolab, $user); $wallet = $owner->wallets->first(); $this->assertCount(7, $owner->entitlements()->get()); $this->assertCount(1, $skuDomain->entitlements()->where('wallet_id', $wallet->id)->get()); $this->assertCount(2, $skuMailbox->entitlements()->where('wallet_id', $wallet->id)->get()); $this->assertCount(15, $wallet->entitlements); $this->backdateEntitlements( $owner->entitlements, Carbon::now()->subMonthsWithoutOverflow(1) ); $wallet->chargeEntitlements(); $this->assertTrue($wallet->fresh()->balance < 0); } - /** - * @todo This really should be in User tests file - */ - public function testEntitlementFunctions(): void - { - $user = $this->getTestUser('entitlement-test@kolabnow.com'); - - $package = \App\Package::withEnvTenantContext()->where('title', 'kolab')->first(); - - $user->assignPackage($package); - - $wallet = $user->wallets()->first(); - $this->assertNotNull($wallet); - - $sku = \App\Sku::withEnvTenantContext()->where('title', 'mailbox')->first(); - - $entitlement = Entitlement::where('wallet_id', $wallet->id) - ->where('sku_id', $sku->id)->first(); - - $this->assertNotNull($entitlement); - $this->assertSame($sku->id, $entitlement->sku->id); - $this->assertSame($wallet->id, $entitlement->wallet->id); - $this->assertEquals($user->id, $entitlement->entitleable->id); - $this->assertTrue($entitlement->entitleable instanceof \App\User); - } - /** * Test Entitlement::entitleableTitle() */ public function testEntitleableTitle(): void { Queue::fake(); $packageDomain = Package::withEnvTenantContext()->where('title', 'domain-hosting')->first(); $packageKolab = Package::withEnvTenantContext()->where('title', 'kolab')->first(); $user = $this->getTestUser('entitled-user@custom-domain.com'); $group = $this->getTestGroup('test-group@custom-domain.com'); $domain = $this->getTestDomain( 'custom-domain.com', [ 'status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_EXTERNAL, ] ); $wallet = $user->wallets->first(); $domain->assignPackage($packageDomain, $user); $user->assignPackage($packageKolab); $group->assignToWallet($wallet); $sku_mailbox = \App\Sku::withEnvTenantContext()->where('title', 'mailbox')->first(); $sku_group = \App\Sku::withEnvTenantContext()->where('title', 'group')->first(); $sku_domain = \App\Sku::withEnvTenantContext()->where('title', 'domain-hosting')->first(); $entitlement = Entitlement::where('wallet_id', $wallet->id) ->where('sku_id', $sku_mailbox->id)->first(); $this->assertSame($user->email, $entitlement->entitleableTitle()); $entitlement = Entitlement::where('wallet_id', $wallet->id) ->where('sku_id', $sku_group->id)->first(); $this->assertSame($group->email, $entitlement->entitleableTitle()); $entitlement = Entitlement::where('wallet_id', $wallet->id) ->where('sku_id', $sku_domain->id)->first(); $this->assertSame($domain->namespace, $entitlement->entitleableTitle()); // Make sure it still works if the entitleable is deleted $domain->delete(); $entitlement->refresh(); $this->assertSame($domain->namespace, $entitlement->entitleableTitle()); $this->assertNotNull($entitlement->entitleable); } } diff --git a/src/tests/Feature/UserTest.php b/src/tests/Feature/UserTest.php index 9864fa6f..a4c52dac 100644 --- a/src/tests/Feature/UserTest.php +++ b/src/tests/Feature/UserTest.php @@ -1,1080 +1,1106 @@ deleteTestUser('user-test@' . \config('app.domain')); $this->deleteTestUser('UserAccountA@UserAccount.com'); $this->deleteTestUser('UserAccountB@UserAccount.com'); $this->deleteTestUser('UserAccountC@UserAccount.com'); $this->deleteTestGroup('test-group@UserAccount.com'); $this->deleteTestResource('test-resource@UserAccount.com'); $this->deleteTestSharedFolder('test-folder@UserAccount.com'); $this->deleteTestDomain('UserAccount.com'); $this->deleteTestDomain('UserAccountAdd.com'); } public function tearDown(): void { \App\TenantSetting::truncate(); $this->deleteTestUser('user-test@' . \config('app.domain')); $this->deleteTestUser('UserAccountA@UserAccount.com'); $this->deleteTestUser('UserAccountB@UserAccount.com'); $this->deleteTestUser('UserAccountC@UserAccount.com'); $this->deleteTestGroup('test-group@UserAccount.com'); $this->deleteTestResource('test-resource@UserAccount.com'); $this->deleteTestSharedFolder('test-folder@UserAccount.com'); $this->deleteTestDomain('UserAccount.com'); $this->deleteTestDomain('UserAccountAdd.com'); parent::tearDown(); } /** * Tests for User::assignPackage() */ public function testAssignPackage(): void { - $this->markTestIncomplete(); + $user = $this->getTestUser('user-test@' . \config('app.domain')); + $wallet = $user->wallets()->first(); + + $package = \App\Package::withEnvTenantContext()->where('title', 'kolab')->first(); + + $user->assignPackage($package); + + $sku = \App\Sku::withEnvTenantContext()->where('title', 'mailbox')->first(); + + $entitlement = \App\Entitlement::where('wallet_id', $wallet->id) + ->where('sku_id', $sku->id)->first(); + + $this->assertNotNull($entitlement); + $this->assertSame($sku->id, $entitlement->sku->id); + $this->assertSame($wallet->id, $entitlement->wallet->id); + $this->assertEquals($user->id, $entitlement->entitleable->id); + $this->assertTrue($entitlement->entitleable instanceof \App\User); + $this->assertCount(7, $user->entitlements()->get()); } /** * Tests for User::assignPlan() */ public function testAssignPlan(): void { $this->markTestIncomplete(); } /** * Tests for User::assignSku() */ public function testAssignSku(): void { $this->markTestIncomplete(); } /** * Verify a wallet assigned a controller is among the accounts of the assignee. */ public function testAccounts(): void { $userA = $this->getTestUser('UserAccountA@UserAccount.com'); $userB = $this->getTestUser('UserAccountB@UserAccount.com'); $this->assertTrue($userA->wallets()->count() == 1); $userA->wallets()->each( function ($wallet) use ($userB) { $wallet->addController($userB); } ); $this->assertTrue($userB->accounts()->get()[0]->id === $userA->wallets()->get()[0]->id); } public function testCanDelete(): void { $this->markTestIncomplete(); } /** * Test User::canRead() method */ public function testCanRead(): void { $john = $this->getTestUser('john@kolab.org'); $ned = $this->getTestUser('ned@kolab.org'); $jack = $this->getTestUser('jack@kolab.org'); $reseller1 = $this->getTestUser('reseller@' . \config('app.domain')); $reseller2 = $this->getTestUser('reseller@sample-tenant.dev-local'); $admin = $this->getTestUser('jeroen@jeroen.jeroen'); $domain = $this->getTestDomain('kolab.org'); // Admin $this->assertTrue($admin->canRead($admin)); $this->assertTrue($admin->canRead($john)); $this->assertTrue($admin->canRead($jack)); $this->assertTrue($admin->canRead($reseller1)); $this->assertTrue($admin->canRead($reseller2)); $this->assertTrue($admin->canRead($domain)); $this->assertTrue($admin->canRead($domain->wallet())); // Reseller - kolabnow $this->assertTrue($reseller1->canRead($john)); $this->assertTrue($reseller1->canRead($jack)); $this->assertTrue($reseller1->canRead($reseller1)); $this->assertTrue($reseller1->canRead($domain)); $this->assertTrue($reseller1->canRead($domain->wallet())); $this->assertFalse($reseller1->canRead($reseller2)); $this->assertFalse($reseller1->canRead($admin)); // Reseller - different tenant $this->assertTrue($reseller2->canRead($reseller2)); $this->assertFalse($reseller2->canRead($john)); $this->assertFalse($reseller2->canRead($jack)); $this->assertFalse($reseller2->canRead($reseller1)); $this->assertFalse($reseller2->canRead($domain)); $this->assertFalse($reseller2->canRead($domain->wallet())); $this->assertFalse($reseller2->canRead($admin)); // Normal user - account owner $this->assertTrue($john->canRead($john)); $this->assertTrue($john->canRead($ned)); $this->assertTrue($john->canRead($jack)); $this->assertTrue($john->canRead($domain)); $this->assertTrue($john->canRead($domain->wallet())); $this->assertFalse($john->canRead($reseller1)); $this->assertFalse($john->canRead($reseller2)); $this->assertFalse($john->canRead($admin)); // Normal user - a non-owner and non-controller $this->assertTrue($jack->canRead($jack)); $this->assertFalse($jack->canRead($john)); $this->assertFalse($jack->canRead($domain)); $this->assertFalse($jack->canRead($domain->wallet())); $this->assertFalse($jack->canRead($reseller1)); $this->assertFalse($jack->canRead($reseller2)); $this->assertFalse($jack->canRead($admin)); // Normal user - John's wallet controller $this->assertTrue($ned->canRead($ned)); $this->assertTrue($ned->canRead($john)); $this->assertTrue($ned->canRead($jack)); $this->assertTrue($ned->canRead($domain)); $this->assertTrue($ned->canRead($domain->wallet())); $this->assertFalse($ned->canRead($reseller1)); $this->assertFalse($ned->canRead($reseller2)); $this->assertFalse($ned->canRead($admin)); } /** * Test User::canUpdate() method */ public function testCanUpdate(): void { $john = $this->getTestUser('john@kolab.org'); $ned = $this->getTestUser('ned@kolab.org'); $jack = $this->getTestUser('jack@kolab.org'); $reseller1 = $this->getTestUser('reseller@' . \config('app.domain')); $reseller2 = $this->getTestUser('reseller@sample-tenant.dev-local'); $admin = $this->getTestUser('jeroen@jeroen.jeroen'); $domain = $this->getTestDomain('kolab.org'); // Admin $this->assertTrue($admin->canUpdate($admin)); $this->assertTrue($admin->canUpdate($john)); $this->assertTrue($admin->canUpdate($jack)); $this->assertTrue($admin->canUpdate($reseller1)); $this->assertTrue($admin->canUpdate($reseller2)); $this->assertTrue($admin->canUpdate($domain)); $this->assertTrue($admin->canUpdate($domain->wallet())); // Reseller - kolabnow $this->assertTrue($reseller1->canUpdate($john)); $this->assertTrue($reseller1->canUpdate($jack)); $this->assertTrue($reseller1->canUpdate($reseller1)); $this->assertTrue($reseller1->canUpdate($domain)); $this->assertTrue($reseller1->canUpdate($domain->wallet())); $this->assertFalse($reseller1->canUpdate($reseller2)); $this->assertFalse($reseller1->canUpdate($admin)); // Reseller - different tenant $this->assertTrue($reseller2->canUpdate($reseller2)); $this->assertFalse($reseller2->canUpdate($john)); $this->assertFalse($reseller2->canUpdate($jack)); $this->assertFalse($reseller2->canUpdate($reseller1)); $this->assertFalse($reseller2->canUpdate($domain)); $this->assertFalse($reseller2->canUpdate($domain->wallet())); $this->assertFalse($reseller2->canUpdate($admin)); // Normal user - account owner $this->assertTrue($john->canUpdate($john)); $this->assertTrue($john->canUpdate($ned)); $this->assertTrue($john->canUpdate($jack)); $this->assertTrue($john->canUpdate($domain)); $this->assertFalse($john->canUpdate($domain->wallet())); $this->assertFalse($john->canUpdate($reseller1)); $this->assertFalse($john->canUpdate($reseller2)); $this->assertFalse($john->canUpdate($admin)); // Normal user - a non-owner and non-controller $this->assertTrue($jack->canUpdate($jack)); $this->assertFalse($jack->canUpdate($john)); $this->assertFalse($jack->canUpdate($domain)); $this->assertFalse($jack->canUpdate($domain->wallet())); $this->assertFalse($jack->canUpdate($reseller1)); $this->assertFalse($jack->canUpdate($reseller2)); $this->assertFalse($jack->canUpdate($admin)); // Normal user - John's wallet controller $this->assertTrue($ned->canUpdate($ned)); $this->assertTrue($ned->canUpdate($john)); $this->assertTrue($ned->canUpdate($jack)); $this->assertTrue($ned->canUpdate($domain)); $this->assertFalse($ned->canUpdate($domain->wallet())); $this->assertFalse($ned->canUpdate($reseller1)); $this->assertFalse($ned->canUpdate($reseller2)); $this->assertFalse($ned->canUpdate($admin)); } /** * Test user create/creating observer */ public function testCreate(): void { Queue::fake(); $domain = \config('app.domain'); $user = User::create(['email' => 'USER-test@' . \strtoupper($domain)]); $result = User::where('email', 'user-test@' . $domain)->first(); $this->assertSame('user-test@' . $domain, $result->email); $this->assertSame($user->id, $result->id); $this->assertSame(User::STATUS_NEW | User::STATUS_ACTIVE, $result->status); } /** * Verify user creation process */ public function testCreateJobs(): void { Queue::fake(); $user = User::create([ 'email' => 'user-test@' . \config('app.domain') ]); Queue::assertPushed(\App\Jobs\User\CreateJob::class, 1); Queue::assertPushed(\App\Jobs\PGP\KeyCreateJob::class, 0); Queue::assertPushed( \App\Jobs\User\CreateJob::class, function ($job) use ($user) { $userEmail = TestCase::getObjectProperty($job, 'userEmail'); $userId = TestCase::getObjectProperty($job, 'userId'); return $userEmail === $user->email && $userId === $user->id; } ); Queue::assertPushedWithChain( \App\Jobs\User\CreateJob::class, [ \App\Jobs\User\VerifyJob::class, ] ); /* FIXME: Looks like we can't really do detailed assertions on chained jobs Another thing to consider is if we maybe should run these jobs independently (not chained) and make sure there's no race-condition in status update Queue::assertPushed(\App\Jobs\User\VerifyJob::class, 1); Queue::assertPushed(\App\Jobs\User\VerifyJob::class, function ($job) use ($user) { $userEmail = TestCase::getObjectProperty($job, 'userEmail'); $userId = TestCase::getObjectProperty($job, 'userId'); return $userEmail === $user->email && $userId === $user->id; }); */ } /** * Verify user creation process invokes the PGP keys creation job (if configured) */ public function testCreatePGPJob(): void { Queue::fake(); \App\Tenant::find(\config('app.tenant_id'))->setSetting('pgp.enable', 1); $user = User::create([ 'email' => 'user-test@' . \config('app.domain') ]); Queue::assertPushed(\App\Jobs\PGP\KeyCreateJob::class, 1); Queue::assertPushed( \App\Jobs\PGP\KeyCreateJob::class, function ($job) use ($user) { $userEmail = TestCase::getObjectProperty($job, 'userEmail'); $userId = TestCase::getObjectProperty($job, 'userId'); return $userEmail === $user->email && $userId === $user->id; } ); } /** * Tests for User::domains() */ public function testDomains(): void { $user = $this->getTestUser('john@kolab.org'); $domain = $this->getTestDomain('useraccount.com', [ 'status' => Domain::STATUS_NEW | Domain::STATUS_ACTIVE, 'type' => Domain::TYPE_PUBLIC, ]); $domains = $user->domains()->pluck('namespace')->all(); $this->assertContains($domain->namespace, $domains); $this->assertContains('kolab.org', $domains); // Jack is not the wallet controller, so for him the list should not // include John's domains, kolab.org specifically $user = $this->getTestUser('jack@kolab.org'); $domains = $user->domains()->pluck('namespace')->all(); $this->assertContains($domain->namespace, $domains); $this->assertNotContains('kolab.org', $domains); // Public domains of other tenants should not be returned $tenant = \App\Tenant::where('id', '!=', \config('app.tenant_id'))->first(); $domain->tenant_id = $tenant->id; $domain->save(); $domains = $user->domains()->pluck('namespace')->all(); $this->assertNotContains($domain->namespace, $domains); } /** * Test User::getConfig() and setConfig() methods */ public function testConfigTrait(): void { $john = $this->getTestUser('john@kolab.org'); $john->setSetting('greylist_enabled', null); $this->assertSame(['greylist_enabled' => true], $john->getConfig()); $result = $john->setConfig(['greylist_enabled' => false, 'unknown' => false]); $this->assertSame(['greylist_enabled' => false], $john->getConfig()); $this->assertSame('false', $john->getSetting('greylist_enabled')); $result = $john->setConfig(['greylist_enabled' => true]); $this->assertSame(['greylist_enabled' => true], $john->getConfig()); $this->assertSame('true', $john->getSetting('greylist_enabled')); } /** * Test User::hasSku() method */ public function testHasSku(): void { $john = $this->getTestUser('john@kolab.org'); $this->assertTrue($john->hasSku('mailbox')); $this->assertTrue($john->hasSku('storage')); $this->assertFalse($john->hasSku('beta')); $this->assertFalse($john->hasSku('unknown')); } public function testUserQuota(): void { // TODO: This test does not test much, probably could be removed // or moved to somewhere else, or extended with // other entitlements() related cases. $user = $this->getTestUser('john@kolab.org'); $storage_sku = \App\Sku::withEnvTenantContext()->where('title', 'storage')->first(); $count = 0; foreach ($user->entitlements()->get() as $entitlement) { if ($entitlement->sku_id == $storage_sku->id) { $count += 1; } } $this->assertTrue($count == 5); } /** * Test user deletion */ public function testDelete(): void { Queue::fake(); $user = $this->getTestUser('user-test@' . \config('app.domain')); $package = \App\Package::withEnvTenantContext()->where('title', 'kolab')->first(); $user->assignPackage($package); $id = $user->id; $this->assertCount(7, $user->entitlements()->get()); $user->delete(); $this->assertCount(0, $user->entitlements()->get()); $this->assertTrue($user->fresh()->trashed()); $this->assertFalse($user->fresh()->isDeleted()); // Delete the user for real $job = new \App\Jobs\User\DeleteJob($id); $job->handle(); $this->assertTrue(User::withTrashed()->where('id', $id)->first()->isDeleted()); $user->forceDelete(); $this->assertCount(0, User::withTrashed()->where('id', $id)->get()); // Test an account with users, domain, and group, and resource $userA = $this->getTestUser('UserAccountA@UserAccount.com'); $userB = $this->getTestUser('UserAccountB@UserAccount.com'); $userC = $this->getTestUser('UserAccountC@UserAccount.com'); $package_kolab = \App\Package::withEnvTenantContext()->where('title', 'kolab')->first(); $package_domain = \App\Package::withEnvTenantContext()->where('title', 'domain-hosting')->first(); $domain = $this->getTestDomain('UserAccount.com', [ 'status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_HOSTED, ]); $userA->assignPackage($package_kolab); $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()); $resource = $this->getTestResource('test-resource@UserAccount.com', ['name' => 'test']); $resource->assignToWallet($userA->wallets->first()); $folder = $this->getTestSharedFolder('test-folder@UserAccount.com', ['name' => 'test']); $folder->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); $entitlementsResource = \App\Entitlement::where('entitleable_id', $resource->id); $entitlementsFolder = \App\Entitlement::where('entitleable_id', $folder->id); $this->assertSame(7, $entitlementsA->count()); $this->assertSame(7, $entitlementsB->count()); $this->assertSame(7, $entitlementsC->count()); $this->assertSame(1, $entitlementsDomain->count()); $this->assertSame(1, $entitlementsGroup->count()); $this->assertSame(1, $entitlementsResource->count()); $this->assertSame(1, $entitlementsFolder->count()); // Delete non-controller user $userC->delete(); $this->assertTrue($userC->fresh()->trashed()); $this->assertFalse($userC->fresh()->isDeleted()); $this->assertSame(0, $entitlementsC->count()); // Delete the controller (and expect "sub"-users to be deleted too) $userA->delete(); $this->assertSame(0, $entitlementsA->count()); $this->assertSame(0, $entitlementsB->count()); $this->assertSame(0, $entitlementsDomain->count()); $this->assertSame(0, $entitlementsGroup->count()); $this->assertSame(0, $entitlementsResource->count()); $this->assertSame(0, $entitlementsFolder->count()); + $this->assertSame(7, $entitlementsA->withTrashed()->count()); + $this->assertSame(7, $entitlementsB->withTrashed()->count()); + $this->assertSame(7, $entitlementsC->withTrashed()->count()); + $this->assertSame(1, $entitlementsDomain->withTrashed()->count()); + $this->assertSame(1, $entitlementsGroup->withTrashed()->count()); + $this->assertSame(1, $entitlementsResource->withTrashed()->count()); + $this->assertSame(1, $entitlementsFolder->withTrashed()->count()); $this->assertTrue($userA->fresh()->trashed()); $this->assertTrue($userB->fresh()->trashed()); $this->assertTrue($domain->fresh()->trashed()); $this->assertTrue($group->fresh()->trashed()); $this->assertTrue($resource->fresh()->trashed()); $this->assertTrue($folder->fresh()->trashed()); $this->assertFalse($userA->isDeleted()); $this->assertFalse($userB->isDeleted()); $this->assertFalse($domain->isDeleted()); $this->assertFalse($group->isDeleted()); $this->assertFalse($resource->isDeleted()); $this->assertFalse($folder->isDeleted()); $userA->forceDelete(); $all_entitlements = \App\Entitlement::where('wallet_id', $userA->wallets->first()->id); + $transactions = \App\Transaction::where('object_id', $userA->wallets->first()->id); $this->assertSame(0, $all_entitlements->withTrashed()->count()); + $this->assertSame(0, $transactions->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()); $this->assertCount(0, \App\Resource::withTrashed()->where('id', $resource->id)->get()); $this->assertCount(0, \App\SharedFolder::withTrashed()->where('id', $folder->id)->get()); } /** * Test user deletion vs. group membership */ public function testDeleteAndGroups(): void { Queue::fake(); $package_kolab = \App\Package::withEnvTenantContext()->where('title', 'kolab')->first(); $userA = $this->getTestUser('UserAccountA@UserAccount.com'); $userB = $this->getTestUser('UserAccountB@UserAccount.com'); $userA->assignPackage($package_kolab, $userB); $group = $this->getTestGroup('test-group@UserAccount.com'); $group->members = ['test@gmail.com', $userB->email]; $group->assignToWallet($userA->wallets->first()); $group->save(); Queue::assertPushed(\App\Jobs\Group\UpdateJob::class, 1); $userGroups = $userA->groups()->get(); $this->assertSame(1, $userGroups->count()); $this->assertSame($group->id, $userGroups->first()->id); $userB->delete(); $this->assertSame(['test@gmail.com'], $group->fresh()->members); // Twice, one for save() and one for delete() above Queue::assertPushed(\App\Jobs\Group\UpdateJob::class, 2); } /** * Test handling negative balance on user deletion */ public function testDeleteWithNegativeBalance(): void { $user = $this->getTestUser('user-test@' . \config('app.domain')); $wallet = $user->wallets()->first(); $wallet->balance = -1000; $wallet->save(); $reseller_wallet = $user->tenant->wallet(); $reseller_wallet->balance = 0; $reseller_wallet->save(); \App\Transaction::where('object_id', $reseller_wallet->id)->where('object_type', \App\Wallet::class)->delete(); $user->delete(); $reseller_transactions = \App\Transaction::where('object_id', $reseller_wallet->id) ->where('object_type', \App\Wallet::class)->get(); $this->assertSame(-1000, $reseller_wallet->fresh()->balance); $this->assertCount(1, $reseller_transactions); $trans = $reseller_transactions[0]; $this->assertSame("Deleted user {$user->email}", $trans->description); $this->assertSame(-1000, $trans->amount); $this->assertSame(\App\Transaction::WALLET_DEBIT, $trans->type); } /** * Test handling positive balance on user deletion */ public function testDeleteWithPositiveBalance(): void { $user = $this->getTestUser('user-test@' . \config('app.domain')); $wallet = $user->wallets()->first(); $wallet->balance = 1000; $wallet->save(); $reseller_wallet = $user->tenant->wallet(); $reseller_wallet->balance = 0; $reseller_wallet->save(); $user->delete(); $this->assertSame(0, $reseller_wallet->fresh()->balance); } /** * Test user deletion with PGP/WOAT enabled */ public function testDeleteWithPGP(): void { Queue::fake(); // Test with PGP disabled $user = $this->getTestUser('user-test@' . \config('app.domain')); $user->tenant->setSetting('pgp.enable', 0); $user->delete(); Queue::assertPushed(\App\Jobs\PGP\KeyDeleteJob::class, 0); // Test with PGP enabled $this->deleteTestUser('user-test@' . \config('app.domain')); $user = $this->getTestUser('user-test@' . \config('app.domain')); $user->tenant->setSetting('pgp.enable', 1); $user->delete(); $user->tenant->setSetting('pgp.enable', 0); Queue::assertPushed(\App\Jobs\PGP\KeyDeleteJob::class, 1); Queue::assertPushed( \App\Jobs\PGP\KeyDeleteJob::class, function ($job) use ($user) { $userId = TestCase::getObjectProperty($job, 'userId'); $userEmail = TestCase::getObjectProperty($job, 'userEmail'); return $userId == $user->id && $userEmail === $user->email; } ); } /** * Tests for User::aliasExists() */ public function testAliasExists(): void { $this->assertTrue(User::aliasExists('jack.daniels@kolab.org')); $this->assertFalse(User::aliasExists('j.daniels@kolab.org')); $this->assertFalse(User::aliasExists('john@kolab.org')); } /** * Tests for User::emailExists() */ public function testEmailExists(): void { $this->assertFalse(User::emailExists('jack.daniels@kolab.org')); $this->assertFalse(User::emailExists('j.daniels@kolab.org')); $this->assertTrue(User::emailExists('john@kolab.org')); $user = User::emailExists('john@kolab.org', true); $this->assertSame('john@kolab.org', $user->email); } /** * Tests for User::findByEmail() */ public function testFindByEmail(): void { $user = $this->getTestUser('john@kolab.org'); $result = User::findByEmail('john'); $this->assertNull($result); $result = User::findByEmail('non-existing@email.com'); $this->assertNull($result); $result = User::findByEmail('john@kolab.org'); $this->assertInstanceOf(User::class, $result); $this->assertSame($user->id, $result->id); // Use an alias $result = User::findByEmail('john.doe@kolab.org'); $this->assertInstanceOf(User::class, $result); $this->assertSame($user->id, $result->id); Queue::fake(); // A case where two users have the same alias $ned = $this->getTestUser('ned@kolab.org'); $ned->setAliases(['joe.monster@kolab.org']); $result = User::findByEmail('joe.monster@kolab.org'); $this->assertNull($result); $ned->setAliases([]); // TODO: searching by external email (setting) $this->markTestIncomplete(); } /** * Test User::name() */ public function testName(): void { Queue::fake(); $user = $this->getTestUser('user-test@' . \config('app.domain')); $this->assertSame('', $user->name()); $this->assertSame($user->tenant->title . ' User', $user->name(true)); $user->setSetting('first_name', 'First'); $this->assertSame('First', $user->name()); $this->assertSame('First', $user->name(true)); $user->setSetting('last_name', 'Last'); $this->assertSame('First Last', $user->name()); $this->assertSame('First Last', $user->name(true)); } /** * Test resources() method */ public function testResources(): void { $john = $this->getTestUser('john@kolab.org'); $ned = $this->getTestUser('ned@kolab.org'); $jack = $this->getTestUser('jack@kolab.org'); $resources = $john->resources()->orderBy('email')->get(); $this->assertSame(2, $resources->count()); $this->assertSame('resource-test1@kolab.org', $resources[0]->email); $this->assertSame('resource-test2@kolab.org', $resources[1]->email); $resources = $ned->resources()->orderBy('email')->get(); $this->assertSame(2, $resources->count()); $this->assertSame('resource-test1@kolab.org', $resources[0]->email); $this->assertSame('resource-test2@kolab.org', $resources[1]->email); $resources = $jack->resources()->get(); $this->assertSame(0, $resources->count()); } /** * Test sharedFolders() method */ public function testSharedFolders(): void { $john = $this->getTestUser('john@kolab.org'); $ned = $this->getTestUser('ned@kolab.org'); $jack = $this->getTestUser('jack@kolab.org'); $folders = $john->sharedFolders()->orderBy('email')->get(); $this->assertSame(2, $folders->count()); $this->assertSame('folder-contact@kolab.org', $folders[0]->email); $this->assertSame('folder-event@kolab.org', $folders[1]->email); $folders = $ned->sharedFolders()->orderBy('email')->get(); $this->assertSame(2, $folders->count()); $this->assertSame('folder-contact@kolab.org', $folders[0]->email); $this->assertSame('folder-event@kolab.org', $folders[1]->email); $folders = $jack->sharedFolders()->get(); $this->assertSame(0, $folders->count()); } /** * Test user restoring */ public function testRestore(): void { Queue::fake(); // Test an account with users and domain $userA = $this->getTestUser('UserAccountA@UserAccount.com', [ 'status' => User::STATUS_LDAP_READY | User::STATUS_IMAP_READY | User::STATUS_SUSPENDED, ]); $userB = $this->getTestUser('UserAccountB@UserAccount.com'); $package_kolab = \App\Package::withEnvTenantContext()->where('title', 'kolab')->first(); $package_domain = \App\Package::withEnvTenantContext()->where('title', 'domain-hosting')->first(); $domainA = $this->getTestDomain('UserAccount.com', [ 'status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_HOSTED, ]); $domainB = $this->getTestDomain('UserAccountAdd.com', [ 'status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_HOSTED, ]); $userA->assignPackage($package_kolab); $domainA->assignPackage($package_domain, $userA); $domainB->assignPackage($package_domain, $userA); $userA->assignPackage($package_kolab, $userB); $storage_sku = \App\Sku::withEnvTenantContext()->where('title', 'storage')->first(); $now = \Carbon\Carbon::now(); $wallet_id = $userA->wallets->first()->id; // add an extra storage entitlement $ent1 = \App\Entitlement::create([ 'wallet_id' => $wallet_id, 'sku_id' => $storage_sku->id, 'cost' => 0, 'entitleable_id' => $userA->id, 'entitleable_type' => User::class, ]); $entitlementsA = \App\Entitlement::where('entitleable_id', $userA->id); $entitlementsB = \App\Entitlement::where('entitleable_id', $userB->id); $entitlementsDomain = \App\Entitlement::where('entitleable_id', $domainA->id); // First delete the user $userA->delete(); $this->assertSame(0, $entitlementsA->count()); $this->assertSame(0, $entitlementsB->count()); $this->assertSame(0, $entitlementsDomain->count()); $this->assertTrue($userA->fresh()->trashed()); $this->assertTrue($userB->fresh()->trashed()); $this->assertTrue($domainA->fresh()->trashed()); $this->assertTrue($domainB->fresh()->trashed()); $this->assertFalse($userA->isDeleted()); $this->assertFalse($userB->isDeleted()); $this->assertFalse($domainA->isDeleted()); // Backdate one storage entitlement (it's not expected to be restored) \App\Entitlement::withTrashed()->where('id', $ent1->id) ->update(['deleted_at' => $now->copy()->subMinutes(2)]); // Backdate entitlements to assert that they were restored with proper updated_at timestamp \App\Entitlement::withTrashed()->where('wallet_id', $wallet_id) ->update(['updated_at' => $now->subMinutes(10)]); Queue::fake(); // Then restore it $userA->restore(); $userA->refresh(); $this->assertFalse($userA->trashed()); $this->assertFalse($userA->isDeleted()); $this->assertFalse($userA->isSuspended()); $this->assertFalse($userA->isLdapReady()); $this->assertFalse($userA->isImapReady()); $this->assertTrue($userA->isActive()); $this->assertTrue($userB->fresh()->trashed()); $this->assertTrue($domainB->fresh()->trashed()); $this->assertFalse($domainA->fresh()->trashed()); // Assert entitlements $this->assertSame(7, $entitlementsA->count()); // mailbox + groupware + 5 x storage $this->assertTrue($ent1->fresh()->trashed()); $entitlementsA->get()->each(function ($ent) { $this->assertTrue($ent->updated_at->greaterThan(\Carbon\Carbon::now()->subSeconds(5))); }); // We expect only CreateJob + UpdateJob pair for both user and domain. // Because how Illuminate/Database/Eloquent/SoftDeletes::restore() method // is implemented we cannot skip the UpdateJob in any way. // I don't want to overwrite this method, the extra job shouldn't do any harm. $this->assertCount(4, Queue::pushedJobs()); // @phpstan-ignore-line Queue::assertPushed(\App\Jobs\Domain\UpdateJob::class, 1); Queue::assertPushed(\App\Jobs\Domain\CreateJob::class, 1); Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 1); Queue::assertPushed(\App\Jobs\User\CreateJob::class, 1); Queue::assertPushed( \App\Jobs\User\CreateJob::class, function ($job) use ($userA) { return $userA->id === TestCase::getObjectProperty($job, 'userId'); } ); Queue::assertPushedWithChain( \App\Jobs\User\CreateJob::class, [ \App\Jobs\User\VerifyJob::class, ] ); } /** * Tests for UserAliasesTrait::setAliases() */ public function testSetAliases(): void { Queue::fake(); Queue::assertNothingPushed(); $user = $this->getTestUser('UserAccountA@UserAccount.com'); $domain = $this->getTestDomain('UserAccount.com', [ 'status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_HOSTED, ]); $this->assertCount(0, $user->aliases->all()); $user->tenant->setSetting('pgp.enable', 1); // Add an alias $user->setAliases(['UserAlias1@UserAccount.com']); Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 1); Queue::assertPushed(\App\Jobs\PGP\KeyCreateJob::class, 1); $user->tenant->setSetting('pgp.enable', 0); $aliases = $user->aliases()->get(); $this->assertCount(1, $aliases); $this->assertSame('useralias1@useraccount.com', $aliases[0]['alias']); // Add another alias $user->setAliases(['UserAlias1@UserAccount.com', 'UserAlias2@UserAccount.com']); Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 2); Queue::assertPushed(\App\Jobs\PGP\KeyCreateJob::class, 1); $aliases = $user->aliases()->orderBy('alias')->get(); $this->assertCount(2, $aliases); $this->assertSame('useralias1@useraccount.com', $aliases[0]->alias); $this->assertSame('useralias2@useraccount.com', $aliases[1]->alias); $user->tenant->setSetting('pgp.enable', 1); // Remove an alias $user->setAliases(['UserAlias1@UserAccount.com']); $user->tenant->setSetting('pgp.enable', 0); Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 3); Queue::assertPushed(\App\Jobs\PGP\KeyDeleteJob::class, 1); Queue::assertPushed( \App\Jobs\PGP\KeyDeleteJob::class, function ($job) use ($user) { $userId = TestCase::getObjectProperty($job, 'userId'); $userEmail = TestCase::getObjectProperty($job, 'userEmail'); return $userId == $user->id && $userEmail === 'useralias2@useraccount.com'; } ); $aliases = $user->aliases()->get(); $this->assertCount(1, $aliases); $this->assertSame('useralias1@useraccount.com', $aliases[0]['alias']); // Remove all aliases $user->setAliases([]); Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 4); $this->assertCount(0, $user->aliases()->get()); } /** * Tests for UserSettingsTrait::setSettings() and getSetting() and getSettings() */ public function testUserSettings(): void { Queue::fake(); Queue::assertNothingPushed(); $user = $this->getTestUser('UserAccountA@UserAccount.com'); Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 0); // Test default settings // Note: Technicly this tests UserObserver::created() behavior $all_settings = $user->settings()->orderBy('key')->get(); $this->assertCount(2, $all_settings); $this->assertSame('country', $all_settings[0]->key); $this->assertSame('CH', $all_settings[0]->value); $this->assertSame('currency', $all_settings[1]->key); $this->assertSame('CHF', $all_settings[1]->value); // Add a setting $user->setSetting('first_name', 'Firstname'); Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 1); // Note: We test both current user as well as fresh user object // to make sure cache works as expected $this->assertSame('Firstname', $user->getSetting('first_name')); $this->assertSame('Firstname', $user->fresh()->getSetting('first_name')); // Update a setting $user->setSetting('first_name', 'Firstname1'); Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 2); // Note: We test both current user as well as fresh user object // to make sure cache works as expected $this->assertSame('Firstname1', $user->getSetting('first_name')); $this->assertSame('Firstname1', $user->fresh()->getSetting('first_name')); // Delete a setting (null) $user->setSetting('first_name', null); Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 3); // Note: We test both current user as well as fresh user object // to make sure cache works as expected $this->assertSame(null, $user->getSetting('first_name')); $this->assertSame(null, $user->fresh()->getSetting('first_name')); // Delete a setting (empty string) $user->setSetting('first_name', 'Firstname1'); $user->setSetting('first_name', ''); Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 5); // Note: We test both current user as well as fresh user object // to make sure cache works as expected $this->assertSame(null, $user->getSetting('first_name')); $this->assertSame(null, $user->fresh()->getSetting('first_name')); // Set multiple settings at once $user->setSettings([ 'first_name' => 'Firstname2', 'last_name' => 'Lastname2', 'country' => null, ]); // TODO: This really should create a single UserUpdate job, not 3 Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 7); // Note: We test both current user as well as fresh user object // to make sure cache works as expected $this->assertSame('Firstname2', $user->getSetting('first_name')); $this->assertSame('Firstname2', $user->fresh()->getSetting('first_name')); $this->assertSame('Lastname2', $user->getSetting('last_name')); $this->assertSame('Lastname2', $user->fresh()->getSetting('last_name')); $this->assertSame(null, $user->getSetting('country')); $this->assertSame(null, $user->fresh()->getSetting('country')); $all_settings = $user->settings()->orderBy('key')->get(); $this->assertCount(3, $all_settings); // Test getSettings() method $this->assertSame( [ 'first_name' => 'Firstname2', 'last_name' => 'Lastname2', 'unknown' => null, ], $user->getSettings(['first_name', 'last_name', 'unknown']) ); } /** * Tests for User::users() */ public function testUsers(): void { $jack = $this->getTestUser('jack@kolab.org'); $joe = $this->getTestUser('joe@kolab.org'); $john = $this->getTestUser('john@kolab.org'); $ned = $this->getTestUser('ned@kolab.org'); $wallet = $john->wallets()->first(); $users = $john->users()->orderBy('email')->get(); $this->assertCount(4, $users); $this->assertEquals($jack->id, $users[0]->id); $this->assertEquals($joe->id, $users[1]->id); $this->assertEquals($john->id, $users[2]->id); $this->assertEquals($ned->id, $users[3]->id); $users = $jack->users()->orderBy('email')->get(); $this->assertCount(0, $users); $users = $ned->users()->orderBy('email')->get(); $this->assertCount(4, $users); } public function testWallets(): void { $this->markTestIncomplete(); } } diff --git a/src/tests/Unit/EntitlementTest.php b/src/tests/Unit/EntitlementTest.php new file mode 100644 index 00000000..e8ece142 --- /dev/null +++ b/src/tests/Unit/EntitlementTest.php @@ -0,0 +1,28 @@ +cost = 1.1; // @phpstan-ignore-line + $this->assertSame(1, $ent->cost); + + $ent->cost = 1.5; // @phpstan-ignore-line + $this->assertSame(2, $ent->cost); + + $ent->cost = '10'; // @phpstan-ignore-line + $this->assertSame(10, $ent->cost); + } +}