diff --git a/src/app/Backends/PGP.php b/src/app/Backends/PGP.php index 88f0fd55..6e6e799f 100644 --- a/src/app/Backends/PGP.php +++ b/src/app/Backends/PGP.php @@ -1,208 +1,254 @@ deleteDirectory($homedir); } else { Storage::disk('pgp')->delete(Storage::disk('pgp')->files($homedir)); foreach (Storage::disk('pgp')->files($homedir) as $subdir) { Storage::disk('pgp')->deleteDirectory($subdir); } } // Remove all files from the Enigma database // Note: This will cause existing files in the Roundcube filesystem // to be removed, but only if the user used the Enigma functionality Roundcube::enigmaCleanup($user->email); } /** * Generate a keypair. * This will also initialize the user GPG homedir content. * * @param \App\User $user User object * @param string $email Email address to use for the key * * @throws \Exception */ public static function keypairCreate(User $user, string $email): void { self::initGPG($user, true); if ($user->email === $email) { // Make sure the homedir is empty for a new user self::homedirCleanup($user); } $keygen = new \Crypt_GPG_KeyGenerator(self::$config); $key = $keygen // ->setPassphrase() // ->setExpirationDate(0) ->setKeyParams(\Crypt_GPG_SubKey::ALGORITHM_RSA, \config('pgp.length')) ->setSubKeyParams(\Crypt_GPG_SubKey::ALGORITHM_RSA, \config('pgp.length')) ->generateKey(null, $email); // Store the keypair in Roundcube Enigma storage self::dbSave(true); // Get the ASCII armored data of the public key $armor = self::$gpg->exportPublicKey((string) $key, true); // Register the public key in DNS self::keyRegister($email, $armor); // FIXME: Should we remove the files from the worker filesystem? // They are still in database and Roundcube hosts' filesystem } + /** + * Deleta a keypair from DNS and Enigma keyring. + * + * @param \App\User $user User object + * @param string $email Email address of the key + * + * @throws \Exception + */ + public static function keyDelete(User $user, string $email): void + { + // Start with the DNS, it's more important + self::keyUnregister($email); + + // Remove the whole Enigma keyring (if it's a delete user account) + if ($user->email === $email) { + self::homedirCleanup($user); + } else { + // TODO: remove only the alias key from Enigma keyring + } + } + /** * List (public and private) keys from a user keyring. * * @param \App\User $user User object * * @returns \Crypt_GPG_Key[] List of keys * @throws \Exception */ public static function listKeys(User $user): array { self::initGPG($user); return self::$gpg->getKeys(''); } /** * Debug logging callback */ public static function logDebug($msg): void { \Log::debug("[GPG] $msg"); } /** * Register the key in the WOAT DNS system * * @param string $email Email address * @param string $key The ASCII-armored key content */ - public static function keyRegister(string $email, string $key) + private static function keyRegister(string $email, string $key): void { - // TODO + list($local, $domain) = \App\Utils::normalizeAddress($email, true); + + DB::beginTransaction(); + + $domain = \App\PowerDNS\Domain::firstOrCreate([ + 'name' => '_woat.' . $domain, + ]); + + \App\PowerDNS\Record::create([ + 'domain_id' => $domain->id, + 'name' => sha1($local) . '.' . $domain->name, + 'type' => 'TXT', + 'content' => 'v=woat1,public_key=' . $key + ]); + + DB::commit(); } /** * Remove the key from the WOAT DNS system * * @param string $email Email address */ - public static function keyUnregister(string $email) + private static function keyUnregister(string $email): void { - // TODO + list($local, $domain) = \App\Utils::normalizeAddress($email, true); + + $domain = \App\PowerDNS\Domain::where('name', '_woat.' . $domain)->first(); + + if ($domain) { + $fqdn = sha1($local) . '.' . $domain->name; + + // For now we support only one WOAT key record + $domain->records()->where('name', $fqdn)->delete(); + } } /** * Prepare Crypt_GPG configuration */ private static function initConfig(User $user, $nosync = false): void { if (!empty(self::$config) && self::$config['email'] == $user->email) { return; } $debug = \config('app.debug'); $binary = \config('pgp.binary'); $agent = \config('pgp.agent'); $gpgconf = \config('pgp.gpgconf'); $dir = self::setHomedir($user); $options = [ 'email' => $user->email, // this one is not a Crypt_GPG option 'dir' => $dir, // this one is not a Crypt_GPG option 'homedir' => \config('filesystems.disks.pgp.root') . '/' . $dir, 'debug' => $debug ? 'App\Backends\PGP::logDebug' : null, ]; if ($binary) { $options['binary'] = $binary; } if ($agent) { $options['agent'] = $agent; } if ($gpgconf) { $options['gpgconf'] = $gpgconf; } self::$config = $options; // Sync the homedir directory content with the Enigma storage if (!$nosync) { self::dbSync(); } } /** * Initialize Crypt_GPG */ private static function initGPG(User $user, $nosync = false): void { self::initConfig($user, $nosync); self::$gpg = new \Crypt_GPG(self::$config); } /** * Prepare a homedir for the user */ private static function setHomedir(User $user): string { // Create a subfolder using two first digits of the user ID $dir = sprintf('%02d', substr((string) $user->id, 0, 2)) . '/' . $user->email; Storage::disk('pgp')->makeDirectory($dir); return $dir; } /** * Synchronize keys database of a user */ private static function dbSync(): void { Roundcube::enigmaSync(self::$config['email'], self::$config['dir']); } /** * Save the keys database */ private static function dbSave($is_empty = false): void { Roundcube::enigmaSave(self::$config['email'], self::$config['dir'], $is_empty); } } diff --git a/src/app/Jobs/PGP/KeyDeleteJob.php b/src/app/Jobs/PGP/KeyDeleteJob.php new file mode 100644 index 00000000..6b0a9a9b --- /dev/null +++ b/src/app/Jobs/PGP/KeyDeleteJob.php @@ -0,0 +1,43 @@ +userId = $userId; + $this->userEmail = $userEmail; + } + + /** + * Execute the job. + * + * @return void + * + * @throws \Exception + */ + public function handle() + { + $user = $this->getUser(); + + if (!$user) { + return; + } + + \App\Backends\PGP::keyDelete($user, $this->userEmail); + } +} diff --git a/src/app/Jobs/PGP/KeyUnregisterJob.php b/src/app/Jobs/PGP/KeyUnregisterJob.php deleted file mode 100644 index 4c17f477..00000000 --- a/src/app/Jobs/PGP/KeyUnregisterJob.php +++ /dev/null @@ -1,42 +0,0 @@ -email = $email; - } - - /** - * Execute the job. - * - * @return void - * - * @throws \Exception - */ - public function handle() - { - \App\Backends\PGP::keyUnregister($this->email); - } -} diff --git a/src/app/Observers/UserAliasObserver.php b/src/app/Observers/UserAliasObserver.php index 791e06e6..bd013cf6 100644 --- a/src/app/Observers/UserAliasObserver.php +++ b/src/app/Observers/UserAliasObserver.php @@ -1,93 +1,93 @@ alias = \strtolower($alias->alias); list($login, $domain) = explode('@', $alias->alias); $domain = Domain::where('namespace', $domain)->first(); if (!$domain) { \Log::error("Failed creating alias {$alias->alias}. Domain does not exist."); return false; } if ($alias->user) { if ($alias->user->tenant_id != $domain->tenant_id) { \Log::error("Reseller for user '{$alias->user->email}' and domain '{$domain->namespace}' differ."); return false; } } return true; } /** * Handle the user alias "created" event. * * @param \App\UserAlias $alias User email alias * * @return void */ public function created(UserAlias $alias) { if ($alias->user) { \App\Jobs\User\UpdateJob::dispatch($alias->user_id); if (Tenant::getConfig($alias->user->tenant_id, 'pgp.enable')) { \App\Jobs\PGP\KeyCreateJob::dispatch($alias->user_id, $alias->alias); } } } /** * Handle the user setting "updated" event. * * @param \App\UserAlias $alias User email alias * * @return void */ public function updated(UserAlias $alias) { if ($alias->user) { \App\Jobs\User\UpdateJob::dispatch($alias->user_id); } } /** * Handle the user setting "deleted" event. * * @param \App\UserAlias $alias User email alias * * @return void */ public function deleted(UserAlias $alias) { if ($alias->user) { \App\Jobs\User\UpdateJob::dispatch($alias->user_id); if (Tenant::getConfig($alias->user->tenant_id, 'pgp.enable')) { - \App\Jobs\PGP\KeyUnregisterJob::dispatch($alias->alias); + \App\Jobs\PGP\KeyDeleteJob::dispatch($alias->user_id, $alias->alias); } } } } diff --git a/src/app/Observers/UserObserver.php b/src/app/Observers/UserObserver.php index c8822688..9c846442 100644 --- a/src/app/Observers/UserObserver.php +++ b/src/app/Observers/UserObserver.php @@ -1,366 +1,370 @@ 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; } // 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 = []; $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; } 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(); } } 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 = []; $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; } } // 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(); } } // 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(); } // 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) { $wallets = $user->wallets()->pluck('id')->all(); // Restore user entitlements // 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 = \App\Entitlement::withTrashed() ->where('entitleable_id', $user->id) ->where('entitleable_type', User::class) ->max('deleted_at'); if ($deleted_at) { $threshold = (new \Carbon\Carbon($deleted_at))->subMinute(); // 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(); } // Restore user entitlements \App\Entitlement::withTrashed() ->where('entitleable_id', $user->id) ->where('entitleable_type', User::class) ->where('deleted_at', '>=', $threshold) ->update(['updated_at' => now(), 'deleted_at' => null]); // Note: We're assuming that cost of entitlements was correct // on user deletion, so we don't have to re-calculate it again. } // 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. * * @todo This is useful for audit. * * @return void */ public function retrieving(User $user) { // TODO \App\Jobs\User\ReadJob::dispatch($user->id); } /** * Handle the "updating" event. * * @param User $user The user that is being updated. * * @return void */ public function updating(User $user) { \App\Jobs\User\UpdateJob::dispatch($user->id); } } diff --git a/src/app/PowerDNS/Domain.php b/src/app/PowerDNS/Domain.php index 868a27de..0049a20e 100644 --- a/src/app/PowerDNS/Domain.php +++ b/src/app/PowerDNS/Domain.php @@ -1,51 +1,72 @@ records()->where('type', 'SOA')->first(); list($ns, $hm, $serial, $a, $b, $c, $d) = explode(" ", $soa->content); $today = \Carbon\Carbon::now()->format('Ymd'); $date = substr($serial, 0, 8); if ($date != $today) { $serial = $today . '01'; } else { $change = (int)(substr($serial, 8, 2)); $serial = sprintf("%s%02s", $date, ($change + 1)); } $soa->content = "{$ns} {$hm} {$serial} {$a} {$b} {$c} {$d}"; $soa->save(); } - public function getSerial() + /** + * Returns the SOA record serial + * + * @return string + */ + public function getSerial(): string { $soa = $this->records()->where('type', 'SOA')->first(); list($ns, $hm, $serial, $a, $b, $c, $d) = explode(" ", $soa->content); return $serial; } + /** + * Any DNS records assigned to this domain. + * + * @return \Illuminate\Database\Eloquent\Relations\HasMany + */ public function records() { - return $this->hasMany('App\PowerDNS\Record', 'domain_id'); + return $this->hasMany(Record::class, 'domain_id'); } - //public function setSerial() { } + /** + * Any (additional) properties of this domain. + * + * @return \Illuminate\Database\Eloquent\Relations\HasMany + */ + public function settings() + { + return $this->hasMany(DomainSetting::class, 'domain_id'); + } } diff --git a/src/app/Utils.php b/src/app/Utils.php index 1ea99b6d..5f852fb2 100644 --- a/src/app/Utils.php +++ b/src/app/Utils.php @@ -1,556 +1,557 @@ country ? $net->country : 'CH'; } /** * Return the country ISO code for the current request. */ public static function countryForRequest() { $request = \request(); $ip = $request->ip(); return self::countryForIP($ip); } /** * Shortcut to creating a progress bar of a particular format with a particular message. * * @param \Illuminate\Console\OutputStyle $output Console output object * @param int $count Number of progress steps * @param string $message The description * * @return \Symfony\Component\Console\Helper\ProgressBar */ public static function createProgressBar($output, $count, $message = null) { $bar = $output->createProgressBar($count); $bar->setFormat( '%current:7s%/%max:7s% [%bar%] %percent:3s%% %elapsed:7s%/%estimated:-7s% %message% ' ); if ($message) { $bar->setMessage($message . " ..."); } $bar->start(); return $bar; } /** * Return the number of days in the month prior to this one. * * @return int */ public static function daysInLastMonth() { $start = new Carbon('first day of last month'); $end = new Carbon('last day of last month'); return $start->diffInDays($end) + 1; } /** * Download a file from the interwebz and store it locally. * * @param string $source The source location * @param string $target The target location * @param bool $force Force the download (and overwrite target) * * @return void */ public static function downloadFile($source, $target, $force = false) { if (is_file($target) && !$force) { return; } \Log::info("Retrieving {$source}"); $fp = fopen($target, 'w'); $curl = curl_init(); curl_setopt($curl, CURLOPT_URL, $source); curl_setopt($curl, CURLOPT_RETURNTRANSFER, 1); curl_setopt($curl, CURLOPT_FILE, $fp); curl_exec($curl); if (curl_errno($curl)) { \Log::error("Request error on {$source}: " . curl_error($curl)); curl_close($curl); fclose($fp); unlink($target); return; } curl_close($curl); fclose($fp); } /** * Generate a passphrase. Not intended for use in production, so limited to environments that are not production. * * @return string */ public static function generatePassphrase() { if (\config('app.env') == 'production') { throw new \Exception("Thou shall not pass!"); } if (\config('app.passphrase')) { return \config('app.passphrase'); } $alphaLow = 'abcdefghijklmnopqrstuvwxyz'; $alphaUp = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'; $num = '0123456789'; $stdSpecial = '~`!@#$%^&*()-_+=[{]}\\|\'";:/?.>,<'; $source = $alphaLow . $alphaUp . $num . $stdSpecial; $result = ''; for ($x = 0; $x < 16; $x++) { $result .= substr($source, rand(0, (strlen($source) - 1)), 1); } return $result; } /** * Find an object that is the recipient for the specified address. * * @param string $address * * @return array */ public static function findObjectsByRecipientAddress($address) { $address = \App\Utils::normalizeAddress($address); list($local, $domainName) = explode('@', $address); $domain = \App\Domain::where('namespace', $domainName)->first(); if (!$domain) { return []; } $user = \App\User::where('email', $address)->first(); if ($user) { return [$user]; } $userAliases = \App\UserAlias::where('alias', $address)->get(); if (count($userAliases) > 0) { $users = []; foreach ($userAliases as $userAlias) { $users[] = $userAlias->user; } return $users; } $userAliases = \App\UserAlias::where('alias', "catchall@{$domain->namespace}")->get(); if (count($userAliases) > 0) { $users = []; foreach ($userAliases as $userAlias) { $users[] = $userAlias->user; } return $users; } return []; } /** * Retrieve the network ID and Type from a client address * * @param string $clientAddress The IPv4 or IPv6 address. * * @return array An array of ID and class or null and null. */ public static function getNetFromAddress($clientAddress) { if (strpos($clientAddress, ':') === false) { $net = \App\IP4Net::getNet($clientAddress); if ($net) { return [$net->id, \App\IP4Net::class]; } } else { $net = \App\IP6Net::getNet($clientAddress); if ($net) { return [$net->id, \App\IP6Net::class]; } } return [null, null]; } /** * Calculate the broadcast address provided a net number and a prefix. * * @param string $net A valid IPv6 network number. * @param int $prefix The network prefix. * * @return string */ public static function ip6Broadcast($net, $prefix) { $netHex = bin2hex(inet_pton($net)); // Overwriting first address string to make sure notation is optimal $net = inet_ntop(hex2bin($netHex)); // Calculate the number of 'flexible' bits $flexbits = 128 - $prefix; // Build the hexadecimal string of the last address $lastAddrHex = $netHex; // We start at the end of the string (which is always 32 characters long) $pos = 31; while ($flexbits > 0) { // Get the character at this position $orig = substr($lastAddrHex, $pos, 1); // Convert it to an integer $origval = hexdec($orig); // OR it with (2^flexbits)-1, with flexbits limited to 4 at a time $newval = $origval | (pow(2, min(4, $flexbits)) - 1); // Convert it back to a hexadecimal character $new = dechex($newval); // And put that character back in the string $lastAddrHex = substr_replace($lastAddrHex, $new, $pos, 1); // We processed one nibble, move to previous position $flexbits -= 4; $pos -= 1; } // Convert the hexadecimal string to a binary string $lastaddrbin = hex2bin($lastAddrHex); // And create an IPv6 address from the binary string $lastaddrstr = inet_ntop($lastaddrbin); return $lastaddrstr; } /** * Normalize an email address. * * This means to lowercase and strip components separated with recipient delimiters. * - * @param string $address The address to normalize. + * @param ?string $address The address to normalize + * @param bool $asArray Return an array with local and domain part * - * @return string + * @return string|array Normalized email address as string or array */ - public static function normalizeAddress($address) + public static function normalizeAddress(?string $address, bool $asArray = false) { - $address = strtolower($address); + if ($address === null || $address === '') { + return $asArray ? ['', ''] : ''; + } + + $address = \strtolower($address); if (strpos($address, '@') === false) { - return $address; + return $asArray ? [$address, ''] : $address; } list($local, $domain) = explode('@', $address); - if (strpos($local, '+') === false) { - return "{$local}@{$domain}"; + if (strpos($local, '+') !== false) { + $local = explode('+', $local)[0]; } - $localComponents = explode('+', $local); - - $local = array_shift($localComponents); - - return "{$local}@{$domain}"; + return $asArray ? [$local, $domain] : "{$local}@{$domain}"; } /** * Provide all unique combinations of elements in $input, with order and duplicates irrelevant. * * @param array $input The input array of elements. * * @return array[] */ public static function powerSet(array $input): array { $output = []; for ($x = 0; $x < count($input); $x++) { self::combine($input, $x + 1, 0, [], 0, $output); } return $output; } /** * Returns the current user's email address or null. * * @return string */ public static function userEmailOrNull(): ?string { $user = Auth::user(); if (!$user) { return null; } return $user->email; } /** * Returns a random string consisting of a quantity of segments of a certain length joined. * * Example: * * ```php * $roomName = strtolower(\App\Utils::randStr(3, 3, '-'); * // $roomName == '3qb-7cs-cjj' * ``` * * @param int $length The length of each segment * @param int $qty The quantity of segments * @param string $join The string to use to join the segments * * @return string */ public static function randStr($length, $qty = 1, $join = '') { $chars = env('SHORTCODE_CHARS', self::CHARS); $randStrs = []; for ($x = 0; $x < $qty; $x++) { $randStrs[$x] = []; for ($y = 0; $y < $length; $y++) { $randStrs[$x][] = $chars[rand(0, strlen($chars) - 1)]; } shuffle($randStrs[$x]); $randStrs[$x] = implode('', $randStrs[$x]); } return implode($join, $randStrs); } /** * Returns a UUID in the form of an integer. * * @return integer */ public static function uuidInt(): int { $hex = Uuid::uuid4(); $bin = pack('h*', str_replace('-', '', $hex)); $ids = unpack('L', $bin); $id = array_shift($ids); return $id; } /** * Returns a UUID in the form of a string. * * @return string */ public static function uuidStr(): string { return Uuid::uuid4()->toString(); } private static function combine($input, $r, $index, $data, $i, &$output): void { $n = count($input); // Current cobination is ready if ($index == $r) { $output[] = array_slice($data, 0, $r); return; } // When no more elements are there to put in data[] if ($i >= $n) { return; } // current is included, put next at next location $data[$index] = $input[$i]; self::combine($input, $r, $index + 1, $data, $i + 1, $output); // current is excluded, replace it with next (Note that i+1 // is passed, but index is not changed) self::combine($input, $r, $index, $data, $i + 1, $output); } /** * Create self URL * * @param string $route Route/Path * @param int|null $tenantId Current tenant * * @todo Move this to App\Http\Controllers\Controller * * @return string Full URL */ public static function serviceUrl(string $route, $tenantId = null): string { $url = \App\Tenant::getConfig($tenantId, 'app.public_url'); if (!$url) { $url = \App\Tenant::getConfig($tenantId, 'app.url'); } return rtrim(trim($url, '/') . '/' . ltrim($route, '/'), '/'); } /** * Create a configuration/environment data to be passed to * the UI * * @todo Move this to App\Http\Controllers\Controller * * @return array Configuration data */ public static function uiEnv(): array { $countries = include resource_path('countries.php'); $req_domain = preg_replace('/:[0-9]+$/', '', request()->getHttpHost()); $sys_domain = \config('app.domain'); $opts = [ 'app.name', 'app.url', 'app.domain', 'app.theme', 'app.webmail_url', 'app.support_email', 'mail.from.address' ]; $env = \app('config')->getMany($opts); $env['countries'] = $countries ?: []; $env['view'] = 'root'; $env['jsapp'] = 'user.js'; if ($req_domain == "admin.$sys_domain") { $env['jsapp'] = 'admin.js'; } elseif ($req_domain == "reseller.$sys_domain") { $env['jsapp'] = 'reseller.js'; } $env['paymentProvider'] = \config('services.payment_provider'); $env['stripePK'] = \config('services.stripe.public_key'); $env['languages'] = \App\Http\Controllers\ContentController::locales(); $env['menu'] = \App\Http\Controllers\ContentController::menu(); return $env; } /** * Retrieve an exchange rate. * * @param string $sourceCurrency: Currency from which to convert * @param string $targetCurrency: Currency to convert to * * @return float Exchange rate */ public static function exchangeRate(string $sourceCurrency, string $targetCurrency): float { if (strcasecmp($sourceCurrency, $targetCurrency) == 0) { return 1.0; } $currencyFile = resource_path("exchangerates-$sourceCurrency.php"); //Attempt to find the reverse exchange rate, if we don't have the file for the source currency if (!file_exists($currencyFile)) { $rates = include resource_path("exchangerates-$targetCurrency.php"); if (!isset($rates[$sourceCurrency])) { throw new \Exception("Failed to find the reverse exchange rate for " . $sourceCurrency); } return 1.0 / floatval($rates[$sourceCurrency]); } $rates = include $currencyFile; if (!isset($rates[$targetCurrency])) { throw new \Exception("Failed to find exchange rate for " . $targetCurrency); } return floatval($rates[$targetCurrency]); } } diff --git a/src/tests/Feature/Jobs/PGP/KeyCreateTest.php b/src/tests/Feature/Jobs/PGP/KeyCreateTest.php index 2f3e6c86..2fcdda96 100644 --- a/src/tests/Feature/Jobs/PGP/KeyCreateTest.php +++ b/src/tests/Feature/Jobs/PGP/KeyCreateTest.php @@ -1,123 +1,141 @@ getTestUser('john@kolab.org'); UserAlias::where('alias', 'test-alias@kolab.org')->delete(); PGP::homedirCleanup($user); + \App\PowerDNS\Domain::where('name', '_woat.kolab.org')->delete(); } /** * {@inheritDoc} */ public function tearDown(): void { $user = $this->getTestUser('john@kolab.org'); UserAlias::where('alias', 'test-alias@kolab.org')->delete(); PGP::homedirCleanup($user); + \App\PowerDNS\Domain::where('name', '_woat.kolab.org')->delete(); parent::tearDown(); } /** * Test job handle * * @group pgp */ public function testHandle(): void { $user = $this->getTestUser('john@kolab.org'); $job = new \App\Jobs\PGP\KeyCreateJob($user->id, $user->email); $job->handle(); // Assert the Enigma storage has been initialized and contains the key $files = Roundcube::enigmaList($user->email); // TODO: More detailed asserts on the filestore content, but it's specific to GPG version $this->assertTrue(count($files) > 1); // Assert the created keypair parameters $keys = PGP::listKeys($user); $this->assertCount(1, $keys); $userIds = $keys[0]->getUserIds(); $this->assertCount(1, $userIds); $this->assertSame($user->email, $userIds[0]->getEmail()); $this->assertSame('', $userIds[0]->getName()); $this->assertSame('', $userIds[0]->getComment()); $this->assertSame(true, $userIds[0]->isValid()); $this->assertSame(false, $userIds[0]->isRevoked()); $key = $keys[0]->getPrimaryKey(); $this->assertSame(\Crypt_GPG_SubKey::ALGORITHM_RSA, $key->getAlgorithm()); $this->assertSame(0, $key->getExpirationDate()); $this->assertSame((int) \config('pgp.length'), $key->getLength()); $this->assertSame(true, $key->hasPrivate()); $this->assertSame(true, $key->canSign()); $this->assertSame(false, $key->canEncrypt()); $this->assertSame(false, $key->isRevoked()); $key = $keys[0]->getSubKeys()[1]; $this->assertSame(\Crypt_GPG_SubKey::ALGORITHM_RSA, $key->getAlgorithm()); $this->assertSame(0, $key->getExpirationDate()); $this->assertSame((int) \config('pgp.length'), $key->getLength()); $this->assertSame(false, $key->canSign()); $this->assertSame(true, $key->canEncrypt()); $this->assertSame(false, $key->isRevoked()); - // TODO: Assert the public key in DNS? + // Assert the public key in DNS + $dns_domain = \App\PowerDNS\Domain::where('name', '_woat.kolab.org')->first(); + $this->assertNotNull($dns_domain); + $dns_record = $dns_domain->records()->where('type', 'TXT')->first(); + $this->assertNotNull($dns_record); + $this->assertSame('TXT', $dns_record->type); + $this->assertSame(sha1('john') . '._woat.kolab.org', $dns_record->name); + $this->assertMatchesRegularExpression( + '/^v=woat1,public_key=' + . '-----BEGIN PGP PUBLIC KEY BLOCK-----' + . '[a-zA-Z0-9\n\/+=]+' + . '-----END PGP PUBLIC KEY BLOCK-----' + . '$/', + $dns_record->content + ); // Test an alias Queue::fake(); UserAlias::create(['user_id' => $user->id, 'alias' => 'test-alias@kolab.org']); $job = new \App\Jobs\PGP\KeyCreateJob($user->id, 'test-alias@kolab.org'); $job->handle(); // Assert the created keypair parameters $keys = PGP::listKeys($user); $this->assertCount(2, $keys); $userIds = $keys[1]->getUserIds(); $this->assertCount(1, $userIds); $this->assertSame('test-alias@kolab.org', $userIds[0]->getEmail()); $this->assertSame('', $userIds[0]->getName()); $this->assertSame('', $userIds[0]->getComment()); $this->assertSame(true, $userIds[0]->isValid()); $this->assertSame(false, $userIds[0]->isRevoked()); $key = $keys[1]->getPrimaryKey(); $this->assertSame(\Crypt_GPG_SubKey::ALGORITHM_RSA, $key->getAlgorithm()); $this->assertSame(0, $key->getExpirationDate()); $this->assertSame((int) \config('pgp.length'), $key->getLength()); $this->assertSame(true, $key->hasPrivate()); $this->assertSame(true, $key->canSign()); $this->assertSame(false, $key->canEncrypt()); $this->assertSame(false, $key->isRevoked()); $key = $keys[1]->getSubKeys()[1]; $this->assertSame(\Crypt_GPG_SubKey::ALGORITHM_RSA, $key->getAlgorithm()); $this->assertSame(0, $key->getExpirationDate()); $this->assertSame((int) \config('pgp.length'), $key->getLength()); $this->assertSame(false, $key->canSign()); $this->assertSame(true, $key->canEncrypt()); $this->assertSame(false, $key->isRevoked()); + + $this->assertSame(2, $dns_domain->records()->where('type', 'TXT')->count()); } } diff --git a/src/tests/Feature/Jobs/PGP/KeyDeleteTest.php b/src/tests/Feature/Jobs/PGP/KeyDeleteTest.php new file mode 100644 index 00000000..5b835811 --- /dev/null +++ b/src/tests/Feature/Jobs/PGP/KeyDeleteTest.php @@ -0,0 +1,71 @@ +getTestUser('john@kolab.org'); + UserAlias::where('alias', 'test-alias@kolab.org')->delete(); + PGP::homedirCleanup($user); + \App\PowerDNS\Domain::where('name', '_woat.kolab.org')->delete(); + } + + /** + * {@inheritDoc} + */ + public function tearDown(): void + { + $user = $this->getTestUser('john@kolab.org'); + UserAlias::where('alias', 'test-alias@kolab.org')->delete(); + PGP::homedirCleanup($user); + \App\PowerDNS\Domain::where('name', '_woat.kolab.org')->delete(); + + parent::tearDown(); + } + + /** + * Test job handle + * + * @group pgp + */ + public function testHandle(): void + { + Queue::fake(); + + $user = $this->getTestUser('john@kolab.org'); + + // First run the key create job + $job = new \App\Jobs\PGP\KeyCreateJob($user->id, $user->email); + $job->handle(); + + // Assert the public key in DNS exist at this point + $dns_domain = \App\PowerDNS\Domain::where('name', '_woat.kolab.org')->first(); + $this->assertNotNull($dns_domain); + $this->assertSame(1, $dns_domain->records()->where('type', 'TXT')->count()); + + // Run the job + $job = new \App\Jobs\PGP\KeyDeleteJob($user->id, $user->email); + $job->handle(); + + $this->assertSame(0, $dns_domain->records()->where('type', 'TXT')->count()); + + // Assert the created keypair parameters + $keys = PGP::listKeys($user); + + $this->assertCount(0, $keys); + } +} diff --git a/src/tests/Feature/PowerDNS/DomainTest.php b/src/tests/Feature/PowerDNS/DomainTest.php index 3ae6a9cc..a4663d30 100644 --- a/src/tests/Feature/PowerDNS/DomainTest.php +++ b/src/tests/Feature/PowerDNS/DomainTest.php @@ -1,70 +1,48 @@ domain = Domain::firstOrCreate( - [ - 'name' => 'test-domain.com' - ] - ); + $this->domain = Domain::firstOrCreate(['name' => 'test-domain.com']); } /** * {@inheritDoc} */ public function tearDown(): void { $this->domain->delete(); parent::tearDown(); } + /** + * Test domain record creation (observer) + */ public function testDomainCreate(): void { $this->assertCount(1, $this->domain->records()->where('type', 'SOA')->get()); $this->assertCount(2, $this->domain->records()->where('type', 'NS')->get()); - } - - public function testCreateRecord(): void - { - $before = $this->domain->getSerial(); - - Record::create( - [ - 'domain_id' => $this->domain->id, - 'name' => $this->domain->{'name'}, - 'type' => "MX", - 'content' => '10 mx01.' . $this->domain->{'name'} . '.' - ] - ); - - Record::create( - [ - 'domain_id' => $this->domain->id, - 'name' => 'mx01.' . $this->domain->{'name'}, - 'type' => "A", - 'content' => '127.0.0.1' - ] - ); + $this->assertCount(2, $this->domain->records()->where('type', 'A')->get()); + $this->assertCount(5, $this->domain->records()->get()); - $after = $this->domain->getSerial(); + $this->assertCount(1, $this->domain->settings()->get()); - $this->assertTrue($before < $after); + // TODO: Test content of every domain record/setting } } diff --git a/src/tests/Feature/PowerDNS/RecordTest.php b/src/tests/Feature/PowerDNS/RecordTest.php new file mode 100644 index 00000000..58dacb1e --- /dev/null +++ b/src/tests/Feature/PowerDNS/RecordTest.php @@ -0,0 +1,95 @@ +domain = Domain::firstOrCreate(['name' => 'test-domain.com']); + } + + /** + * {@inheritDoc} + */ + public function tearDown(): void + { + $this->domain->delete(); + + parent::tearDown(); + } + + /** + * Test creating DNS records + */ + public function testCreateRecord(): void + { + $before = $this->domain->getSerial(); + + Record::create([ + 'domain_id' => $this->domain->id, + 'name' => $this->domain->{'name'}, + 'type' => "MX", + 'content' => '10 mx01.' . $this->domain->{'name'} . '.' + ]); + + $after = $this->domain->getSerial(); + + $this->assertTrue($before < $after); + } + + /** + * Test updating DNS records + */ + public function testUpdateRecord(): void + { + $record = Record::create([ + 'domain_id' => $this->domain->id, + 'name' => $this->domain->{'name'}, + 'type' => "MX", + 'content' => '10 mx01.' . $this->domain->{'name'} . '.' + ]); + + $before = $this->domain->getSerial(); + + $record->content = 'test'; + $record->save(); + + $after = $this->domain->getSerial(); + + $this->assertTrue($before < $after); + } + + /** + * Test deleting DNS records + */ + public function testDeleteRecord(): void + { + $record = Record::create([ + 'domain_id' => $this->domain->id, + 'name' => $this->domain->{'name'}, + 'type' => "MX", + 'content' => '10 mx01.' . $this->domain->{'name'} . '.' + ]); + + $before = $this->domain->getSerial(); + + $record->delete(); + + $after = $this->domain->getSerial(); + + $this->assertTrue($before < $after); + } +} diff --git a/src/tests/Feature/UserTest.php b/src/tests/Feature/UserTest.php index eaa61f92..271b4d11 100644 --- a/src/tests/Feature/UserTest.php +++ b/src/tests/Feature/UserTest.php @@ -1,970 +1,1012 @@ 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 { \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->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@' . \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 = collect($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 = collect($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 = collect($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 $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()); $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(7, $entitlementsA->count()); $this->assertSame(7, $entitlementsB->count()); $this->assertSame(7, $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::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 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\KeyUnregisterJob::class, 1); + 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); $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(); } } diff --git a/src/tests/Unit/UtilsTest.php b/src/tests/Unit/UtilsTest.php index 21acde70..85b0a619 100644 --- a/src/tests/Unit/UtilsTest.php +++ b/src/tests/Unit/UtilsTest.php @@ -1,125 +1,143 @@ assertSame('', \App\Utils::normalizeAddress('')); + $this->assertSame('', \App\Utils::normalizeAddress(null)); + $this->assertSame('test', \App\Utils::normalizeAddress('TEST')); + $this->assertSame('test@domain.tld', \App\Utils::normalizeAddress('Test@Domain.TLD')); + $this->assertSame('test@domain.tld', \App\Utils::normalizeAddress('Test+Trash@Domain.TLD')); + + $this->assertSame(['', ''], \App\Utils::normalizeAddress('', true)); + $this->assertSame(['', ''], \App\Utils::normalizeAddress(null, true)); + $this->assertSame(['test', ''], \App\Utils::normalizeAddress('TEST', true)); + $this->assertSame(['test', 'domain.tld'], \App\Utils::normalizeAddress('Test@Domain.TLD', true)); + $this->assertSame(['test', 'domain.tld'], \App\Utils::normalizeAddress('Test+Trash@Domain.TLD', true)); + } + /** * Test for Utils::powerSet() */ public function testPowerSet(): void { $set = []; $result = \App\Utils::powerSet($set); $this->assertIsArray($result); $this->assertCount(0, $result); $set = ["a1"]; $result = \App\Utils::powerSet($set); $this->assertIsArray($result); $this->assertCount(1, $result); $this->assertTrue(in_array(["a1"], $result)); $set = ["a1", "a2"]; $result = \App\Utils::powerSet($set); $this->assertIsArray($result); $this->assertCount(3, $result); $this->assertTrue(in_array(["a1"], $result)); $this->assertTrue(in_array(["a2"], $result)); $this->assertTrue(in_array(["a1", "a2"], $result)); $set = ["a1", "a2", "a3"]; $result = \App\Utils::powerSet($set); $this->assertIsArray($result); $this->assertCount(7, $result); $this->assertTrue(in_array(["a1"], $result)); $this->assertTrue(in_array(["a2"], $result)); $this->assertTrue(in_array(["a3"], $result)); $this->assertTrue(in_array(["a1", "a2"], $result)); $this->assertTrue(in_array(["a1", "a3"], $result)); $this->assertTrue(in_array(["a2", "a3"], $result)); $this->assertTrue(in_array(["a1", "a2", "a3"], $result)); } /** * Test for Utils::serviceUrl() */ public function testServiceUrl(): void { $public_href = 'https://public.url/cockpit'; $local_href = 'https://local.url/cockpit'; \config([ 'app.url' => $local_href, 'app.public_url' => '', ]); $this->assertSame($local_href, Utils::serviceUrl('')); $this->assertSame($local_href . '/unknown', Utils::serviceUrl('unknown')); $this->assertSame($local_href . '/unknown', Utils::serviceUrl('/unknown')); \config([ 'app.url' => $local_href, 'app.public_url' => $public_href, ]); $this->assertSame($public_href, Utils::serviceUrl('')); $this->assertSame($public_href . '/unknown', Utils::serviceUrl('unknown')); $this->assertSame($public_href . '/unknown', Utils::serviceUrl('/unknown')); } /** * Test for Utils::uuidInt() */ public function testUuidInt(): void { $result = Utils::uuidInt(); $this->assertTrue(is_int($result)); $this->assertTrue($result > 0); } /** * Test for Utils::uuidStr() */ public function testUuidStr(): void { $result = Utils::uuidStr(); $this->assertTrue(is_string($result)); $this->assertTrue(strlen($result) === 36); $this->assertTrue(preg_match('/[^a-f0-9-]/i', $result) === 0); } /** * Test for Utils::exchangeRate() */ public function testExchangeRate(): void { $this->assertSame(1.0, Utils::exchangeRate("DUMMY", "dummy")); // Exchange rates are volatile, can't test with high accuracy. $this->assertTrue(Utils::exchangeRate("CHF", "EUR") >= 0.88); //$this->assertEqualsWithDelta(0.90503424978382, Utils::exchangeRate("CHF", "EUR"), PHP_FLOAT_EPSILON); $this->assertTrue(Utils::exchangeRate("EUR", "CHF") <= 1.12); //$this->assertEqualsWithDelta(1.1049305595217682, Utils::exchangeRate("EUR", "CHF"), PHP_FLOAT_EPSILON); $this->expectException(\Exception::class); $this->assertSame(1.0, Utils::exchangeRate("CHF", "FOO")); $this->expectException(\Exception::class); $this->assertSame(1.0, Utils::exchangeRate("FOO", "CHF")); } }