diff --git a/src/app/Domain.php b/src/app/Domain.php index b71bc149..a66db419 100644 --- a/src/app/Domain.php +++ b/src/app/Domain.php @@ -1,454 +1,454 @@ isPublic()) { return $this; } // See if this domain is already owned by another user. $wallet = $this->wallet(); if ($wallet) { \Log::error( "Domain {$this->namespace} is already assigned to {$wallet->owner->email}" ); return $this; } $wallet_id = $user->wallets()->first()->id; foreach ($package->skus as $sku) { for ($i = $sku->pivot->qty; $i > 0; $i--) { \App\Entitlement::create( [ 'wallet_id' => $wallet_id, 'sku_id' => $sku->id, 'cost' => $sku->pivot->cost(), 'fee' => $sku->pivot->fee(), 'entitleable_id' => $this->id, 'entitleable_type' => Domain::class ] ); } } return $this; } /** * The domain entitlement. * * @return \Illuminate\Database\Eloquent\Relations\MorphOne */ public function entitlement() { return $this->morphOne('App\Entitlement', 'entitleable'); } /** - * Return list of public+active domain names + * Return list of public+active domain names (for current tenant) */ public static function getPublicDomains(): array { - $where = sprintf('(type & %s)', Domain::TYPE_PUBLIC); - - return self::whereRaw($where)->get(['namespace'])->pluck('namespace')->toArray(); + return self::withEnvTenant() + ->whereRaw(sprintf('(type & %s)', Domain::TYPE_PUBLIC)) + ->get(['namespace'])->pluck('namespace')->toArray(); } /** * Returns whether this domain is active. * * @return bool */ public function isActive(): bool { return ($this->status & self::STATUS_ACTIVE) > 0; } /** * Returns whether this domain is confirmed the ownership of. * * @return bool */ public function isConfirmed(): bool { return ($this->status & self::STATUS_CONFIRMED) > 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 registered with us. * * @return bool */ public function isExternal(): bool { return ($this->type & self::TYPE_EXTERNAL) > 0; } /** * Returns whether this domain is hosted with us. * * @return bool */ public function isHosted(): bool { return ($this->type & self::TYPE_HOSTED) > 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 public. * * @return bool */ public function isPublic(): bool { return ($this->type & self::TYPE_PUBLIC) > 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; } /** * Returns whether this (external) domain has been verified * to exist in DNS. * * @return bool */ public function isVerified(): bool { return ($this->status & self::STATUS_VERIFIED) > 0; } /** * Ensure the namespace is appropriately cased. */ public function setNamespaceAttribute($namespace) { $this->attributes['namespace'] = strtolower($namespace); } /** * Domain 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_CONFIRMED, self::STATUS_VERIFIED, self::STATUS_LDAP_READY, ]; foreach ($allowed_values as $value) { if ($status & $value) { $new_status |= $value; $status ^= $value; } } if ($status > 0) { throw new \Exception("Invalid domain status: {$status}"); } if ($this->isPublic()) { $this->attributes['status'] = $new_status; return; } if ($new_status & self::STATUS_CONFIRMED) { // if we have confirmed ownership of or management access to the domain, then we have // also confirmed the domain exists in DNS. $new_status |= self::STATUS_VERIFIED; $new_status |= self::STATUS_ACTIVE; } 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; } /** * Ownership verification by checking for a TXT (or CNAME) record * in the domain's DNS (that matches the verification hash). * * @return bool True if verification was successful, false otherwise * @throws \Exception Throws exception on DNS or DB errors */ public function confirm(): bool { if ($this->isConfirmed()) { return true; } $hash = $this->hash(self::HASH_TEXT); $confirmed = false; // Get DNS records and find a matching TXT entry $records = \dns_get_record($this->namespace, DNS_TXT); if ($records === false) { throw new \Exception("Failed to get DNS record for {$this->namespace}"); } foreach ($records as $record) { if ($record['txt'] === $hash) { $confirmed = true; break; } } // Get DNS records and find a matching CNAME entry // Note: some servers resolve every non-existing name // so we need to define left and right side of the CNAME record // i.e.: kolab-verify IN CNAME .domain.tld. if (!$confirmed) { $cname = $this->hash(self::HASH_CODE) . '.' . $this->namespace; $records = \dns_get_record('kolab-verify.' . $this->namespace, DNS_CNAME); if ($records === false) { throw new \Exception("Failed to get DNS record for {$this->namespace}"); } foreach ($records as $records) { if ($records['target'] === $cname) { $confirmed = true; break; } } } if ($confirmed) { $this->status |= Domain::STATUS_CONFIRMED; $this->save(); } return $confirmed; } /** * Generate a verification hash for this domain * * @param int $mod One of: HASH_CNAME, HASH_CODE (Default), HASH_TEXT * * @return string Verification hash */ public function hash($mod = null): string { $cname = 'kolab-verify'; if ($mod === self::HASH_CNAME) { return $cname; } $hash = \md5('hkccp-verify-' . $this->namespace); return $mod === self::HASH_TEXT ? "$cname=$hash" : $hash; } /** * Suspend this domain. * * @return void */ public function suspend(): void { if ($this->isSuspended()) { return; } $this->status |= Domain::STATUS_SUSPENDED; $this->save(); } /** * Unsuspend this domain. * * The domain is unsuspended through either of the following courses of actions; * * * The account balance has been topped up, or * * a suspected spammer has resolved their issues, or * * the command-line is triggered. * * Therefore, we can also confidently set the domain status to 'active' should the ownership of or management * access to have been confirmed before. * * @return void */ public function unsuspend(): void { if (!$this->isSuspended()) { return; } $this->status ^= Domain::STATUS_SUSPENDED; if ($this->isConfirmed() && $this->isVerified()) { $this->status |= Domain::STATUS_ACTIVE; } $this->save(); } /** * Verify if a domain exists in DNS * * @return bool True if registered, False otherwise * @throws \Exception Throws exception on DNS or DB errors */ public function verify(): bool { if ($this->isVerified()) { return true; } $records = \dns_get_record($this->namespace, DNS_ANY); if ($records === false) { throw new \Exception("Failed to get DNS record for {$this->namespace}"); } // It may happen that result contains other domains depending on the host DNS setup // that's why in_array() and not just !empty() if (in_array($this->namespace, array_column($records, 'host'))) { $this->status |= Domain::STATUS_VERIFIED; $this->save(); return true; } return false; } /** * Returns the wallet by which the domain 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/Http/Controllers/API/V4/DomainsController.php b/src/app/Http/Controllers/API/V4/DomainsController.php index 46652db2..c33308ce 100644 --- a/src/app/Http/Controllers/API/V4/DomainsController.php +++ b/src/app/Http/Controllers/API/V4/DomainsController.php @@ -1,381 +1,381 @@ user(); $list = []; foreach ($user->domains() as $domain) { if (!$domain->isPublic()) { $data = $domain->toArray(); $data = array_merge($data, self::domainStatuses($domain)); $list[] = $data; } } return response()->json($list); } /** * Show the form for creating a new resource. * * @return \Illuminate\Http\JsonResponse */ public function create() { return $this->errorResponse(404); } /** * Confirm ownership of the specified domain (via DNS check). * * @param int $id Domain identifier * * @return \Illuminate\Http\JsonResponse|void */ public function confirm($id) { $domain = Domain::findOrFail($id); // Only owner (or admin) has access to the domain if (!Auth::guard()->user()->canRead($domain)) { return $this->errorResponse(403); } if (!$domain->confirm()) { return response()->json([ 'status' => 'error', 'message' => \trans('app.domain-verify-error'), ]); } return response()->json([ 'status' => 'success', 'statusInfo' => self::statusInfo($domain), 'message' => \trans('app.domain-verify-success'), ]); } /** * Remove the specified resource from storage. * * @param int $id * * @return \Illuminate\Http\JsonResponse */ public function destroy($id) { return $this->errorResponse(404); } /** * Show the form for editing the specified resource. * * @param int $id * * @return \Illuminate\Http\JsonResponse */ public function edit($id) { return $this->errorResponse(404); } /** * Store a newly created resource in storage. * * @param \Illuminate\Http\Request $request * * @return \Illuminate\Http\JsonResponse */ public function store(Request $request) { return $this->errorResponse(404); } /** * Get the information about the specified domain. * * @param int $id Domain identifier * * @return \Illuminate\Http\JsonResponse|void */ public function show($id) { $domain = Domain::withEnvTenant()->findOrFail($id); // Only owner (or admin) has access to the domain if (!Auth::guard()->user()->canRead($domain)) { return $this->errorResponse(403); } $response = $domain->toArray(); // Add hash information to the response $response['hash_text'] = $domain->hash(Domain::HASH_TEXT); $response['hash_cname'] = $domain->hash(Domain::HASH_CNAME); $response['hash_code'] = $domain->hash(Domain::HASH_CODE); // Add DNS/MX configuration for the domain $response['dns'] = self::getDNSConfig($domain); $response['config'] = self::getMXConfig($domain->namespace); // Status info $response['statusInfo'] = self::statusInfo($domain); $response = array_merge($response, self::domainStatuses($domain)); return response()->json($response); } /** * Fetch domain status (and reload setup process) * * @param int $id Domain identifier * * @return \Illuminate\Http\JsonResponse */ public function status($id) { - $domain = Domain::find($id); + $domain = Domain::withEnvTenant()->findOrFail($id); // Only owner (or admin) has access to the domain if (!Auth::guard()->user()->canRead($domain)) { return $this->errorResponse(403); } $response = self::statusInfo($domain); if (!empty(request()->input('refresh'))) { $updated = false; $last_step = 'none'; foreach ($response['process'] as $idx => $step) { $last_step = $step['label']; if (!$step['state']) { if (!$this->execProcessStep($domain, $step['label'])) { break; } $updated = true; } } if ($updated) { $response = self::statusInfo($domain); } $success = $response['isReady']; $suffix = $success ? 'success' : 'error-' . $last_step; $response['status'] = $success ? 'success' : 'error'; $response['message'] = \trans('app.process-' . $suffix); } $response = array_merge($response, self::domainStatuses($domain)); return response()->json($response); } /** * Update the specified resource in storage. * * @param \Illuminate\Http\Request $request * @param int $id * * @return \Illuminate\Http\JsonResponse */ public function update(Request $request, $id) { return $this->errorResponse(404); } /** * Provide DNS MX information to configure specified domain for */ protected static function getMXConfig(string $namespace): array { $entries = []; // copy MX entries from an existing domain if ($master = \config('dns.copyfrom')) { // TODO: cache this lookup foreach ((array) dns_get_record($master, DNS_MX) as $entry) { $entries[] = sprintf( "@\t%s\t%s\tMX\t%d %s.", \config('dns.ttl', $entry['ttl']), $entry['class'], $entry['pri'], $entry['target'] ); } } elseif ($static = \config('dns.static')) { $entries[] = strtr($static, array('\n' => "\n", '%s' => $namespace)); } // display SPF settings if ($spf = \config('dns.spf')) { $entries[] = ';'; foreach (['TXT', 'SPF'] as $type) { $entries[] = sprintf( "@\t%s\tIN\t%s\t\"%s\"", \config('dns.ttl'), $type, $spf ); } } return $entries; } /** * Provide sample DNS config for domain confirmation */ protected static function getDNSConfig(Domain $domain): array { $serial = date('Ymd01'); $hash_txt = $domain->hash(Domain::HASH_TEXT); $hash_cname = $domain->hash(Domain::HASH_CNAME); $hash = $domain->hash(Domain::HASH_CODE); return [ "@ IN SOA ns1.dnsservice.com. hostmaster.{$domain->namespace}. (", " {$serial} 10800 3600 604800 86400 )", ";", "@ IN A ", "www IN A ", ";", "{$hash_cname}.{$domain->namespace}. IN CNAME {$hash}.{$domain->namespace}.", "@ 3600 TXT \"{$hash_txt}\"", ]; } /** * Prepare domain statuses for the UI * * @param \App\Domain $domain Domain object * * @return array Statuses array */ protected static function domainStatuses(Domain $domain): array { return [ 'isLdapReady' => $domain->isLdapReady(), 'isConfirmed' => $domain->isConfirmed(), 'isVerified' => $domain->isVerified(), 'isSuspended' => $domain->isSuspended(), 'isActive' => $domain->isActive(), 'isDeleted' => $domain->isDeleted() || $domain->trashed(), ]; } /** * Domain status (extended) information. * * @param \App\Domain $domain Domain object * * @return array Status information */ public static function statusInfo(Domain $domain): array { $process = []; // If that is not a public domain, add domain specific steps $steps = [ 'domain-new' => true, 'domain-ldap-ready' => $domain->isLdapReady(), 'domain-verified' => $domain->isVerified(), 'domain-confirmed' => $domain->isConfirmed(), ]; $count = count($steps); // Create a process check list foreach ($steps as $step_name => $state) { $step = [ 'label' => $step_name, 'title' => \trans("app.process-{$step_name}"), 'state' => $state, ]; if ($step_name == 'domain-confirmed' && !$state) { $step['link'] = "/domain/{$domain->id}"; } $process[] = $step; if ($state) { $count--; } } $state = $count === 0 ? 'done' : 'running'; // After 180 seconds assume the process is in failed state, // this should unlock the Refresh button in the UI if ($count > 0 && $domain->created_at->diffInSeconds(Carbon::now()) > 180) { $state = 'failed'; } return [ 'process' => $process, 'processState' => $state, 'isReady' => $count === 0, ]; } /** * Execute (synchronously) specified step in a domain setup process. * * @param \App\Domain $domain Domain object * @param string $step Step identifier (as in self::statusInfo()) * * @return bool True if the execution succeeded, False otherwise */ public static function execProcessStep(Domain $domain, string $step): bool { try { switch ($step) { case 'domain-ldap-ready': // Domain not in LDAP, create it if (!$domain->isLdapReady()) { LDAP::createDomain($domain); $domain->status |= Domain::STATUS_LDAP_READY; $domain->save(); } return $domain->isLdapReady(); case 'domain-verified': // Domain existence not verified $domain->verify(); return $domain->isVerified(); case 'domain-confirmed': // Domain ownership confirmation $domain->confirm(); return $domain->isConfirmed(); } } catch (\Exception $e) { \Log::error($e); } return false; } } diff --git a/src/app/User.php b/src/app/User.php index 7810548d..6702e531 100644 --- a/src/app/User.php +++ b/src/app/User.php @@ -1,751 +1,758 @@ belongsToMany( 'App\Wallet', // The foreign object definition 'user_accounts', // The table name 'user_id', // The local foreign key 'wallet_id' // The remote foreign key ); } /** * Email aliases of this user. * * @return \Illuminate\Database\Eloquent\Relations\HasMany */ public function aliases() { return $this->hasMany('App\UserAlias', 'user_id'); } /** * Assign a package to a user. The user should not have any existing entitlements. * * @param \App\Package $package The package to assign. * @param \App\User|null $user Assign the package to another user. * * @return \App\User */ public function assignPackage($package, $user = null) { if (!$user) { $user = $this; } $wallet_id = $this->wallets()->first()->id; foreach ($package->skus as $sku) { for ($i = $sku->pivot->qty; $i > 0; $i--) { \App\Entitlement::create( [ 'wallet_id' => $wallet_id, 'sku_id' => $sku->id, 'cost' => $sku->pivot->cost(), 'fee' => $sku->pivot->fee(), 'entitleable_id' => $user->id, 'entitleable_type' => User::class ] ); } } return $user; } /** * Assign a package plan to a user. * * @param \App\Plan $plan The plan to assign * @param \App\Domain $domain Optional domain object * * @return \App\User Self */ public function assignPlan($plan, $domain = null): User { $this->setSetting('plan_id', $plan->id); foreach ($plan->packages as $package) { if ($package->isDomain()) { $domain->assignPackage($package, $this); } else { $this->assignPackage($package); } } return $this; } /** * Assign a Sku to a user. * * @param \App\Sku $sku The sku to assign. * @param int $count Count of entitlements to add * * @return \App\User Self * @throws \Exception */ public function assignSku(Sku $sku, int $count = 1): User { // TODO: I guess wallet could be parametrized in future $wallet = $this->wallet(); $exists = $this->entitlements()->where('sku_id', $sku->id)->count(); while ($count > 0) { \App\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' => User::class ]); $exists++; $count--; } return $this; } /** * Check if current user can delete another object. * * @param mixed $object A user|domain|wallet|group object * * @return bool True if he can, False otherwise */ public function canDelete($object): bool { if (!method_exists($object, 'wallet')) { return false; } $wallet = $object->wallet(); // TODO: For now controller can delete/update the account owner, // this may change in future, controllers are not 0-regression feature return $this->wallets->contains($wallet) || $this->accounts->contains($wallet); } /** * Check if current user can read data of another object. * * @param mixed $object A user|domain|wallet|group object * * @return bool True if he can, False otherwise */ public function canRead($object): bool { if ($this->role == 'admin') { return true; } if ($object instanceof User && $this->id == $object->id) { return true; } if ($this->role == 'reseller') { if ($object instanceof User && $object->role == 'admin') { return false; } if ($object instanceof Wallet && !empty($object->owner)) { $object = $object->owner; } return isset($object->tenant_id) && $object->tenant_id == $this->tenant_id; } if ($object instanceof Wallet) { return $object->user_id == $this->id || $object->controllers->contains($this); } if (!method_exists($object, 'wallet')) { return false; } $wallet = $object->wallet(); return $wallet && ($this->wallets->contains($wallet) || $this->accounts->contains($wallet)); } /** * Check if current user can update data of another object. * * @param mixed $object A user|domain|wallet|group object * * @return bool True if he can, False otherwise */ public function canUpdate($object): bool { if ($object instanceof User && $this->id == $object->id) { return true; } if ($this->role == 'admin') { return true; } if ($this->role == 'reseller') { if ($object instanceof User && $object->role == 'admin') { return false; } if ($object instanceof Wallet && !empty($object->owner)) { $object = $object->owner; } return isset($object->tenant_id) && $object->tenant_id == $this->tenant_id; } return $this->canDelete($object); } /** * Return the \App\Domain for this user. * * @return \App\Domain|null */ public function domain() { list($local, $domainName) = explode('@', $this->email); $domain = \App\Domain::withTrashed()->where('namespace', $domainName)->first(); return $domain; } /** * List the domains to which this user is entitled. + * Note: Active public domains are also returned (for the user tenant). * - * @return Domain[] + * @return Domain[] List of Domain objects */ - public function domains() + public function domains(): array { - $domains = Domain::whereRaw(sprintf('(type & %s)', Domain::TYPE_PUBLIC)) + if ($this->tenant_id) { + $domains = Domain::where('tenant_id', $this->tenant_id); + } else { + $domains = Domain::withEnvTenant(); + } + + $domains = $domains->whereRaw(sprintf('(type & %s)', Domain::TYPE_PUBLIC)) ->whereRaw(sprintf('(status & %s)', Domain::STATUS_ACTIVE)) ->get() ->all(); foreach ($this->wallets as $wallet) { $entitlements = $wallet->entitlements()->where('entitleable_type', Domain::class)->get(); foreach ($entitlements as $entitlement) { $domains[] = $entitlement->entitleable; } } foreach ($this->accounts as $wallet) { $entitlements = $wallet->entitlements()->where('entitleable_type', Domain::class)->get(); foreach ($entitlements as $entitlement) { $domains[] = $entitlement->entitleable; } } return $domains; } /** * The user entitlement. * * @return \Illuminate\Database\Eloquent\Relations\MorphOne */ public function entitlement() { return $this->morphOne('App\Entitlement', 'entitleable'); } /** * Entitlements for this user. * * Note that these are entitlements that apply to the user account, and not entitlements that * this user owns. * * @return \Illuminate\Database\Eloquent\Relations\HasMany */ public function entitlements() { return $this->hasMany('App\Entitlement', 'entitleable_id', 'id') ->where('entitleable_type', User::class); } /** * Find whether an email address exists as a user (including deleted users). * * @param string $email Email address * @param bool $return_user Return User instance instead of boolean * * @return \App\User|bool True or User model object if found, False otherwise */ public static function emailExists(string $email, bool $return_user = false) { if (strpos($email, '@') === false) { return false; } $email = \strtolower($email); $user = self::withTrashed()->where('email', $email)->first(); if ($user) { return $return_user ? $user : true; } return false; } /** * Helper to find user by email address, whether it is * main email address, alias or an external email. * * If there's more than one alias NULL will be returned. * * @param string $email Email address * @param bool $external Search also for an external email * * @return \App\User User model object if found */ public static function findByEmail(string $email, bool $external = false): ?User { if (strpos($email, '@') === false) { return null; } $email = \strtolower($email); $user = self::where('email', $email)->first(); if ($user) { return $user; } $aliases = UserAlias::where('alias', $email)->get(); if (count($aliases) == 1) { return $aliases->first()->user; } // TODO: External email return null; } public function getJWTIdentifier() { return $this->getKey(); } public function getJWTCustomClaims() { return []; } /** * Return groups controlled by the current user. * * @param bool $with_accounts Include groups assigned to wallets * the current user controls but not owns. * * @return \Illuminate\Database\Eloquent\Builder Query builder */ public function groups($with_accounts = true) { $wallets = $this->wallets()->pluck('id')->all(); if ($with_accounts) { $wallets = array_merge($wallets, $this->accounts()->pluck('wallet_id')->all()); } return Group::select(['groups.*', 'entitlements.wallet_id']) ->distinct() ->join('entitlements', 'entitlements.entitleable_id', '=', 'groups.id') ->whereIn('entitlements.wallet_id', $wallets) ->where('entitlements.entitleable_type', Group::class); } /** * Check if user has an entitlement for the specified SKU. * * @param string $title The SKU title * * @return bool True if specified SKU entitlement exists */ public function hasSku($title): bool { $sku = Sku::where('title', $title)->first(); if (!$sku) { return false; } return $this->entitlements()->where('sku_id', $sku->id)->count() > 0; } /** * 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 (external) domain has been verified * to exist in DNS. * * @return bool */ public function isImapReady(): bool { return ($this->status & self::STATUS_IMAP_READY) > 0; } /** * Returns whether this user is registered in LDAP. * * @return bool */ public function isLdapReady(): bool { return ($this->status & self::STATUS_LDAP_READY) > 0; } /** * Returns whether this user is new. * * @return bool */ public function isNew(): bool { return ($this->status & self::STATUS_NEW) > 0; } /** * Returns whether this domain is suspended. * * @return bool */ public function isSuspended(): bool { return ($this->status & self::STATUS_SUSPENDED) > 0; } /** * A shortcut to get the user name. * * @param bool $fallback Return " User" if there's no name * * @return string Full user name */ public function name(bool $fallback = false): string { $firstname = $this->getSetting('first_name'); $lastname = $this->getSetting('last_name'); $name = trim($firstname . ' ' . $lastname); if (empty($name) && $fallback) { return \config('app.name') . ' User'; } return $name; } /** * Remove a number of entitlements for the SKU. * * @param \App\Sku $sku The SKU * @param int $count The number of entitlements to remove * * @return User Self */ public function removeSku(Sku $sku, int $count = 1): User { $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; } /** * Any (additional) properties of this user. * * @return \Illuminate\Database\Eloquent\Relations\HasMany */ public function settings() { return $this->hasMany('App\UserSetting', 'user_id'); } /** * Suspend this domain. * * @return void */ public function suspend(): void { if ($this->isSuspended()) { return; } $this->status |= User::STATUS_SUSPENDED; $this->save(); } /** * The tenant for this user account. * * @return \Illuminate\Database\Eloquent\Relations\BelongsTo */ public function tenant() { return $this->belongsTo('App\Tenant', 'tenant_id', 'id'); } /** * Unsuspend this domain. * * @return void */ public function unsuspend(): void { if (!$this->isSuspended()) { return; } $this->status ^= User::STATUS_SUSPENDED; $this->save(); } /** * Return users controlled by the current user. * * @param bool $with_accounts Include users assigned to wallets * the current user controls but not owns. * * @return \Illuminate\Database\Eloquent\Builder Query builder */ public function users($with_accounts = true) { $wallets = $this->wallets()->pluck('id')->all(); if ($with_accounts) { $wallets = array_merge($wallets, $this->accounts()->pluck('wallet_id')->all()); } return $this->select(['users.*', 'entitlements.wallet_id']) ->distinct() ->leftJoin('entitlements', 'entitlements.entitleable_id', '=', 'users.id') ->whereIn('entitlements.wallet_id', $wallets) ->where('entitlements.entitleable_type', User::class); } /** * Verification codes for this user. * * @return \Illuminate\Database\Eloquent\Relations\HasMany */ public function verificationcodes() { return $this->hasMany('App\VerificationCode', 'user_id', 'id'); } /** * Returns the wallet by which the user is controlled * * @return ?\App\Wallet A wallet object */ public function wallet(): ?Wallet { $entitlement = $this->entitlement()->withTrashed()->first(); // TODO: No entitlement should not happen, but in tests we have // such cases, so we fallback to the user's wallet in this case return $entitlement ? $entitlement->wallet : $this->wallets()->first(); } /** * Wallets this user owns. * * @return \Illuminate\Database\Eloquent\Relations\HasMany */ public function wallets() { return $this->hasMany('App\Wallet'); } /** * User password mutator * * @param string $password The password in plain text. * * @return void */ public function setPasswordAttribute($password) { if (!empty($password)) { $this->attributes['password'] = bcrypt($password, [ "rounds" => 12 ]); $this->attributes['password_ldap'] = '{SSHA512}' . base64_encode( pack('H*', hash('sha512', $password)) ); } } /** * User LDAP password mutator * * @param string $password The password in plain text. * * @return void */ public function setPasswordLdapAttribute($password) { $this->setPasswordAttribute($password); } /** * User 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, self::STATUS_IMAP_READY, ]; foreach ($allowed_values as $value) { if ($status & $value) { $new_status |= $value; $status ^= $value; } } if ($status > 0) { throw new \Exception("Invalid user status: {$status}"); } $this->attributes['status'] = $new_status; } } diff --git a/src/tests/Feature/DomainTest.php b/src/tests/Feature/DomainTest.php index a183e2e3..1c4a4d75 100644 --- a/src/tests/Feature/DomainTest.php +++ b/src/tests/Feature/DomainTest.php @@ -1,300 +1,306 @@ domains as $domain) { $this->deleteTestDomain($domain); } $this->deleteTestUser('user@gmail.com'); } /** * {@inheritDoc} */ public function tearDown(): void { foreach ($this->domains as $domain) { $this->deleteTestDomain($domain); } $this->deleteTestUser('user@gmail.com'); parent::tearDown(); } /** * Test domain create/creating observer */ public function testCreate(): void { Queue::fake(); $domain = Domain::create([ 'namespace' => 'GMAIL.COM', 'status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_EXTERNAL, ]); $result = Domain::where('namespace', 'gmail.com')->first(); $this->assertSame('gmail.com', $result->namespace); $this->assertSame($domain->id, $result->id); $this->assertSame($domain->type, $result->type); $this->assertSame(Domain::STATUS_NEW, $result->status); } /** * Test domain creating jobs */ public function testCreateJobs(): void { // Fake the queue, assert that no jobs were pushed... Queue::fake(); Queue::assertNothingPushed(); $domain = Domain::create([ 'namespace' => 'gmail.com', 'status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_EXTERNAL, ]); Queue::assertPushed(\App\Jobs\Domain\CreateJob::class, 1); Queue::assertPushed( \App\Jobs\Domain\CreateJob::class, function ($job) use ($domain) { $domainId = TestCase::getObjectProperty($job, 'domainId'); $domainNamespace = TestCase::getObjectProperty($job, 'domainNamespace'); return $domainId === $domain->id && $domainNamespace === $domain->namespace; } ); $job = new \App\Jobs\Domain\CreateJob($domain->id); $job->handle(); } /** * Tests getPublicDomains() method */ public function testGetPublicDomains(): void { $public_domains = Domain::getPublicDomains(); $this->assertNotContains('public-active.com', $public_domains); $queue = Queue::fake(); $domain = Domain::create([ 'namespace' => 'public-active.com', 'status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_EXTERNAL, ]); // External domains should not be returned $public_domains = Domain::getPublicDomains(); $this->assertNotContains('public-active.com', $public_domains); - $domain = Domain::where('namespace', 'public-active.com')->first(); $domain->type = Domain::TYPE_PUBLIC; $domain->save(); $public_domains = Domain::getPublicDomains(); $this->assertContains('public-active.com', $public_domains); + + // Domains of other tenants should not be returned + $domain->tenant_id = 2; + $domain->save(); + + $public_domains = Domain::getPublicDomains(); + $this->assertNotContains('public-active.com', $public_domains); } /** * Test domain (ownership) confirmation * * @group dns */ public function testConfirm(): void { /* DNS records for positive and negative tests - kolab.org: ci-success-cname A 212.103.80.148 ci-success-cname MX 10 mx01.kolabnow.com. ci-success-cname TXT "v=spf1 mx -all" kolab-verify.ci-success-cname CNAME 2b719cfa4e1033b1e1e132977ed4fe3e.ci-success-cname ci-failure-cname A 212.103.80.148 ci-failure-cname MX 10 mx01.kolabnow.com. kolab-verify.ci-failure-cname CNAME 2b719cfa4e1033b1e1e132977ed4fe3e.ci-failure-cname ci-success-txt A 212.103.80.148 ci-success-txt MX 10 mx01.kolabnow.com. ci-success-txt TXT "v=spf1 mx -all" ci-success-txt TXT "kolab-verify=de5d04ababb52d52e2519a2f16d11422" ci-failure-txt A 212.103.80.148 ci-failure-txt MX 10 mx01.kolabnow.com. kolab-verify.ci-failure-txt TXT "kolab-verify=de5d04ababb52d52e2519a2f16d11422" ci-failure-none A 212.103.80.148 ci-failure-none MX 10 mx01.kolabnow.com. */ $queue = Queue::fake(); $domain_props = ['status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_EXTERNAL]; $domain = $this->getTestDomain('ci-failure-none.kolab.org', $domain_props); $this->assertTrue($domain->confirm() === false); $this->assertFalse($domain->isConfirmed()); $domain = $this->getTestDomain('ci-failure-txt.kolab.org', $domain_props); $this->assertTrue($domain->confirm() === false); $this->assertFalse($domain->isConfirmed()); $domain = $this->getTestDomain('ci-failure-cname.kolab.org', $domain_props); $this->assertTrue($domain->confirm() === false); $this->assertFalse($domain->isConfirmed()); $domain = $this->getTestDomain('ci-success-txt.kolab.org', $domain_props); $this->assertTrue($domain->confirm()); $this->assertTrue($domain->isConfirmed()); $domain = $this->getTestDomain('ci-success-cname.kolab.org', $domain_props); $this->assertTrue($domain->confirm()); $this->assertTrue($domain->isConfirmed()); } /** * Test domain deletion */ public function testDelete(): void { Queue::fake(); $domain = $this->getTestDomain('gmail.com', [ 'status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_PUBLIC, ]); $domain->delete(); $this->assertTrue($domain->fresh()->trashed()); $this->assertFalse($domain->fresh()->isDeleted()); // Delete the domain for real $job = new \App\Jobs\Domain\DeleteJob($domain->id); $job->handle(); $this->assertTrue(Domain::withTrashed()->where('id', $domain->id)->first()->isDeleted()); $domain->forceDelete(); $this->assertCount(0, Domain::withTrashed()->where('id', $domain->id)->get()); } /** * Test domain restoring */ public function testRestore(): void { Queue::fake(); $domain = $this->getTestDomain('gmail.com', [ 'status' => Domain::STATUS_NEW | Domain::STATUS_SUSPENDED | Domain::STATUS_LDAP_READY | Domain::STATUS_CONFIRMED, 'type' => Domain::TYPE_PUBLIC, ]); $user = $this->getTestUser('user@gmail.com'); $sku = \App\Sku::where('title', 'domain-hosting')->first(); $now = \Carbon\Carbon::now(); // Assign two entitlements to the domain, so we can assert that only the // ones deleted last will be restored $ent1 = \App\Entitlement::create([ 'wallet_id' => $user->wallets->first()->id, 'sku_id' => $sku->id, 'cost' => 0, 'entitleable_id' => $domain->id, 'entitleable_type' => Domain::class, ]); $ent2 = \App\Entitlement::create([ 'wallet_id' => $user->wallets->first()->id, 'sku_id' => $sku->id, 'cost' => 0, 'entitleable_id' => $domain->id, 'entitleable_type' => Domain::class, ]); $domain->delete(); $this->assertTrue($domain->fresh()->trashed()); $this->assertFalse($domain->fresh()->isDeleted()); $this->assertTrue($ent1->fresh()->trashed()); $this->assertTrue($ent2->fresh()->trashed()); // Backdate some properties \App\Entitlement::withTrashed()->where('id', $ent2->id)->update(['deleted_at' => $now->subMinutes(2)]); \App\Entitlement::withTrashed()->where('id', $ent1->id)->update(['updated_at' => $now->subMinutes(10)]); Queue::fake(); $domain->restore(); $domain->refresh(); $this->assertFalse($domain->trashed()); $this->assertFalse($domain->isDeleted()); $this->assertFalse($domain->isSuspended()); $this->assertFalse($domain->isLdapReady()); $this->assertTrue($domain->isActive()); $this->assertTrue($domain->isConfirmed()); // Assert entitlements $this->assertTrue($ent2->fresh()->trashed()); $this->assertFalse($ent1->fresh()->trashed()); $this->assertTrue($ent1->updated_at->greaterThan(\Carbon\Carbon::now()->subSeconds(5))); // We expect only one CreateJob and one UpdateJob // 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(2, 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\Domain\CreateJob::class, function ($job) use ($domain) { return $domain->id === TestCase::getObjectProperty($job, 'domainId'); } ); } } diff --git a/src/tests/Feature/UserTest.php b/src/tests/Feature/UserTest.php index f4fa41ff..62d1c0ca 100644 --- a/src/tests/Feature/UserTest.php +++ b/src/tests/Feature/UserTest.php @@ -1,878 +1,884 @@ 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->deleteTestDomain('UserAccount.com'); $this->deleteTestDomain('UserAccountAdd.com'); } public function tearDown(): void { $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->deleteTestDomain('UserAccount.com'); $this->deleteTestDomain('UserAccountAdd.com'); parent::tearDown(); } /** * Tests for User::assignPackage() */ public function testAssignPackage(): void { $this->markTestIncomplete(); } /** * 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@kolabnow.com'); $reseller2 = $this->getTestUser('reseller@reseller.com'); $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@kolabnow.com'); $reseller2 = $this->getTestUser('reseller@reseller.com'); $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 { // Fake the queue, assert that no jobs were pushed... Queue::fake(); Queue::assertNothingPushed(); $user = User::create([ 'email' => 'user-test@' . \config('app.domain') ]); Queue::assertPushed(\App\Jobs\User\CreateJob::class, 1); 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; }); */ } /** * Tests for User::domains() */ public function testDomains(): void { $user = $this->getTestUser('john@kolab.org'); - $domains = []; + $domain = $this->getTestDomain('useraccount.com', [ + 'status' => Domain::STATUS_NEW | Domain::STATUS_ACTIVE, + 'type' => Domain::TYPE_PUBLIC, + ]); - foreach ($user->domains() as $domain) { - $domains[] = $domain->namespace; - } + $domains = collect($user->domains())->pluck('namespace')->all(); - $this->assertContains(\config('app.domain'), $domains); + $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 = []; - foreach ($user->domains() as $domain) { - $domains[] = $domain->namespace; - } + $domains = collect($user->domains())->pluck('namespace')->all(); - $this->assertContains(\config('app.domain'), $domains); + $this->assertContains($domain->namespace, $domains); $this->assertNotContains('kolab.org', $domains); + + // Public domains of other tenants should not be returned + $domain->tenant_id = 2; + $domain->save(); + + $domains = collect($user->domains())->pluck('namespace')->all(); + + $this->assertNotContains($domain->namespace, $domains); } 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::where('title', 'storage')->first(); $count = 0; foreach ($user->entitlements()->get() as $entitlement) { if ($entitlement->sku_id == $storage_sku->id) { $count += 1; } } $this->assertTrue($count == 2); } /** * Test user deletion */ public function testDelete(): void { Queue::fake(); $user = $this->getTestUser('user-test@' . \config('app.domain')); $package = \App\Package::where('title', 'kolab')->first(); $user->assignPackage($package); $id = $user->id; $this->assertCount(4, $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 $userA = $this->getTestUser('UserAccountA@UserAccount.com'); $userB = $this->getTestUser('UserAccountB@UserAccount.com'); $userC = $this->getTestUser('UserAccountC@UserAccount.com'); $package_kolab = \App\Package::where('title', 'kolab')->first(); $package_domain = \App\Package::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()); $entitlementsA = \App\Entitlement::where('entitleable_id', $userA->id); $entitlementsB = \App\Entitlement::where('entitleable_id', $userB->id); $entitlementsC = \App\Entitlement::where('entitleable_id', $userC->id); $entitlementsDomain = \App\Entitlement::where('entitleable_id', $domain->id); $entitlementsGroup = \App\Entitlement::where('entitleable_id', $group->id); $this->assertSame(4, $entitlementsA->count()); $this->assertSame(4, $entitlementsB->count()); $this->assertSame(4, $entitlementsC->count()); $this->assertSame(1, $entitlementsDomain->count()); $this->assertSame(1, $entitlementsGroup->count()); // Delete non-controller user $userC->delete(); $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->assertTrue($userA->fresh()->trashed()); $this->assertTrue($userB->fresh()->trashed()); $this->assertTrue($domain->fresh()->trashed()); $this->assertTrue($group->fresh()->trashed()); $this->assertFalse($userA->isDeleted()); $this->assertFalse($userB->isDeleted()); $this->assertFalse($domain->isDeleted()); $this->assertFalse($group->isDeleted()); $userA->forceDelete(); $all_entitlements = \App\Entitlement::where('wallet_id', $userA->wallets->first()->id); $this->assertSame(0, $all_entitlements->withTrashed()->count()); $this->assertCount(0, User::withTrashed()->where('id', $userA->id)->get()); $this->assertCount(0, User::withTrashed()->where('id', $userB->id)->get()); $this->assertCount(0, User::withTrashed()->where('id', $userC->id)->get()); $this->assertCount(0, Domain::withTrashed()->where('id', $domain->id)->get()); $this->assertCount(0, Group::withTrashed()->where('id', $group->id)->get()); } /** * Test user deletion vs. group membership */ public function testDeleteAndGroups(): void { Queue::fake(); $package_kolab = \App\Package::where('title', 'kolab')->first(); $userA = $this->getTestUser('UserAccountA@UserAccount.com'); $userB = $this->getTestUser('UserAccountB@UserAccount.com'); $userA->assignPackage($package_kolab, $userB); $group = $this->getTestGroup('test-group@UserAccount.com'); $group->members = ['test@gmail.com', $userB->email]; $group->assignToWallet($userA->wallets->first()); $group->save(); 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); } /** * 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); // 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(\config('app.name') . ' 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 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::where('title', 'kolab')->first(); $package_domain = \App\Package::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::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(4, $entitlementsA->count()); // mailbox + groupware + 2 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()); // Add an alias $user->setAliases(['UserAlias1@UserAccount.com']); Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 1); $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); $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); // Remove an alias $user->setAliases(['UserAlias1@UserAccount.com']); Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 3); $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() */ 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); } /** * 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); $this->assertSame($wallet->id, $users[0]->wallet_id); $this->assertSame($wallet->id, $users[1]->wallet_id); $this->assertSame($wallet->id, $users[2]->wallet_id); $this->assertSame($wallet->id, $users[3]->wallet_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(); } }