diff --git a/src/app/Console/Commands/SharedFolder/AddAliasCommand.php b/src/app/Console/Commands/SharedFolder/AddAliasCommand.php index 83e1339d..b120664e 100644 --- a/src/app/Console/Commands/SharedFolder/AddAliasCommand.php +++ b/src/app/Console/Commands/SharedFolder/AddAliasCommand.php @@ -1,58 +1,60 @@ getSharedFolder($this->argument('folder')); if (!$folder) { $this->error("Folder not found."); return 1; } $alias = \strtolower($this->argument('alias')); // Check if the alias already exists if ($folder->aliases()->where('alias', $alias)->first()) { $this->error("Address is already assigned to the folder."); return 1; } // Validate the alias - $error = UsersController::validateAlias($alias, $folder->walletOwner()); + $domain = explode('@', $folder->email, 2)[1]; + + $error = SharedFoldersController::validateAlias($alias, $folder->walletOwner(), $folder->name, $domain); if ($error) { if (!$this->option('force')) { $this->error($error); return 1; } } $folder->aliases()->create(['alias' => $alias]); } } diff --git a/src/app/Entitlement.php b/src/app/Entitlement.php index 82674cf9..47fb1e88 100644 --- a/src/app/Entitlement.php +++ b/src/app/Entitlement.php @@ -1,171 +1,171 @@ The attributes that are mass assignable */ protected $fillable = [ 'sku_id', 'wallet_id', 'entitleable_id', 'entitleable_type', 'cost', 'description', 'fee', ]; /** @var array The attributes that should be cast */ protected $casts = [ 'cost' => 'integer', 'fee' => 'integer' ]; /** * Return the costs per day for this entitlement. * * @return float */ public function costsPerDay() { if ($this->cost == 0) { return (float) 0; } $discount = $this->wallet->getDiscountRate(); $daysInLastMonth = \App\Utils::daysInLastMonth(); $costsPerDay = (float) ($this->cost * $discount) / $daysInLastMonth; return $costsPerDay; } /** * Create a transaction record for this entitlement. * * @param string $type The type of transaction ('created', 'billed', 'deleted'), but use the * \App\Transaction constants. * @param int $amount The amount involved in cents * * @return string The transaction ID */ public function createTransaction($type, $amount = null) { $transaction = Transaction::create( [ 'object_id' => $this->id, 'object_type' => Entitlement::class, 'type' => $type, 'amount' => $amount ] ); return $transaction->id; } /** * Principally entitleable object such as Domain, User, Group. * Note that it may be trashed (soft-deleted). * * @return mixed */ public function entitleable() { return $this->morphTo()->withTrashed(); // @phpstan-ignore-line } /** * Returns entitleable object title (e.g. email or domain name). * * @return string|null An object title/name */ public function entitleableTitle(): ?string { if ($this->entitleable instanceof Domain) { return $this->entitleable->namespace; } return $this->entitleable->email; } /** * Simplified Entitlement/SKU information for a specified entitleable object * * @param object $object Entitleable object * * @return array Skus list with some metadata */ public static function objectEntitlementsSummary($object): array { $skus = []; // TODO: I agree this format may need to be extended in future foreach ($object->entitlements as $ent) { $sku = $ent->sku; if (!isset($skus[$sku->id])) { $skus[$sku->id] = ['costs' => [], 'count' => 0]; } $skus[$sku->id]['count']++; $skus[$sku->id]['costs'][] = $ent->cost; } return $skus; } /** * The SKU concerned. * * @return \Illuminate\Database\Eloquent\Relations\BelongsTo */ public function sku() { return $this->belongsTo(Sku::class); } /** * The wallet this entitlement is being billed to * * @return \Illuminate\Database\Eloquent\Relations\BelongsTo */ public function wallet() { return $this->belongsTo(Wallet::class); } /** * Cost mutator. Make sure cost is integer. */ public function setCostAttribute($cost): void { $this->attributes['cost'] = round($cost); } } diff --git a/src/app/Http/Controllers/API/V4/SharedFoldersController.php b/src/app/Http/Controllers/API/V4/SharedFoldersController.php index 179d38c4..9ab26183 100644 --- a/src/app/Http/Controllers/API/V4/SharedFoldersController.php +++ b/src/app/Http/Controllers/API/V4/SharedFoldersController.php @@ -1,262 +1,285 @@ true, 'shared-folder-ldap-ready' => $folder->isLdapReady(), 'shared-folder-imap-ready' => $folder->isImapReady(), ] ); } /** * Create a new shared folder record. * * @param \Illuminate\Http\Request $request The API request. * * @return \Illuminate\Http\JsonResponse The response */ public function store(Request $request) { $current_user = $this->guard()->user(); $owner = $current_user->walletOwner(); if (empty($owner) || $owner->id != $current_user->id) { return $this->errorResponse(403); } if ($error_response = $this->validateFolderRequest($request, null, $owner)) { return $error_response; } DB::beginTransaction(); // Create the shared folder $folder = new SharedFolder(); $folder->name = $request->input('name'); $folder->type = $request->input('type'); $folder->domainName = $request->input('domain'); $folder->save(); if (!empty($request->aliases) && $folder->type === 'mail') { $folder->setAliases($request->aliases); } $folder->assignToWallet($owner->wallets->first()); DB::commit(); return response()->json([ 'status' => 'success', 'message' => \trans('app.shared-folder-create-success'), ]); } /** * Update a shared folder. * * @param \Illuminate\Http\Request $request The API request. * @param string $id Shared folder identifier * * @return \Illuminate\Http\JsonResponse The response */ public function update(Request $request, $id) { $folder = SharedFolder::find($id); if (!$this->checkTenant($folder)) { return $this->errorResponse(404); } $current_user = $this->guard()->user(); if (!$current_user->canUpdate($folder)) { return $this->errorResponse(403); } if ($error_response = $this->validateFolderRequest($request, $folder, $folder->walletOwner())) { return $error_response; } $name = $request->input('name'); DB::beginTransaction(); if ($name && $name != $folder->name) { $folder->name = $name; } $folder->save(); if (isset($request->aliases) && $folder->type === 'mail') { $folder->setAliases($request->aliases); } DB::commit(); return response()->json([ 'status' => 'success', 'message' => \trans('app.shared-folder-update-success'), ]); } /** * Execute (synchronously) specified step in a shared folder setup process. * * @param \App\SharedFolder $folder Shared folder object * @param string $step Step identifier (as in self::statusInfo()) * * @return bool|null True if the execution succeeded, False if not, Null when * the job has been sent to the worker (result unknown) */ public static function execProcessStep(SharedFolder $folder, string $step): ?bool { try { if (strpos($step, 'domain-') === 0) { return DomainsController::execProcessStep($folder->domain(), $step); } switch ($step) { case 'shared-folder-ldap-ready': // Shared folder not in LDAP, create it $job = new \App\Jobs\SharedFolder\CreateJob($folder->id); $job->handle(); $folder->refresh(); return $folder->isLdapReady(); case 'shared-folder-imap-ready': // Shared folder not in IMAP? Verify again // Do it synchronously if the imap admin credentials are available // otherwise let the worker do the job if (!\config('imap.admin_password')) { \App\Jobs\SharedFolder\VerifyJob::dispatch($folder->id); return null; } $job = new \App\Jobs\SharedFolder\VerifyJob($folder->id); $job->handle(); $folder->refresh(); return $folder->isImapReady(); } } catch (\Exception $e) { \Log::error($e); } return false; } /** * Validate shared folder input * * @param \Illuminate\Http\Request $request The API request. * @param \App\SharedFolder|null $folder Shared folder * @param \App\User|null $owner Account owner * * @return \Illuminate\Http\JsonResponse|null The error response on error */ protected function validateFolderRequest(Request $request, $folder, $owner) { $errors = []; if (empty($folder)) { + $name = $request->input('name'); $domain = $request->input('domain'); $rules = [ 'name' => ['required', 'string', new SharedFolderName($owner, $domain)], 'type' => ['required', 'string', new SharedFolderType()], ]; } else { // On update validate the folder name (if changed) $name = $request->input('name'); + $domain = explode('@', $folder->email, 2)[1]; + if ($name !== null && $name != $folder->name) { - $domain = explode('@', $folder->email, 2)[1]; $rules = ['name' => ['required', 'string', new SharedFolderName($owner, $domain)]]; } } if (!empty($rules)) { $v = Validator::make($request->all(), $rules); if ($v->fails()) { $errors = $v->errors()->toArray(); } } // Validate aliases input if (isset($request->aliases)) { $aliases = []; $existing_aliases = $owner->aliases()->get()->pluck('alias')->toArray(); foreach ($request->aliases as $idx => $alias) { if (is_string($alias) && !empty($alias)) { // Alias cannot be the same as the email address if (!empty($folder) && Str::lower($alias) == Str::lower($folder->email)) { continue; } // validate new aliases if ( !in_array($alias, $existing_aliases) - && ($error = UsersController::validateAlias($alias, $owner)) + && ($error = self::validateAlias($alias, $owner, $name, $domain)) ) { if (!isset($errors['aliases'])) { $errors['aliases'] = []; } $errors['aliases'][$idx] = $error; continue; } $aliases[] = $alias; } } $request->aliases = $aliases; } if (!empty($errors)) { return response()->json(['status' => 'error', 'errors' => $errors], 422); } return null; } + + /** + * Email address validation for use as a shared folder alias. + * + * @param string $alias Email address + * @param \App\User $owner The account owner + * @param string $folderName Folder name + * @param string $domain Folder domain + * + * @return ?string Error message on validation error + */ + public static function validateAlias(string $alias, \App\User $owner, string $folderName, string $domain): ?string + { + $lmtp_alias = "shared+shared/{$folderName}@{$domain}"; + + if ($alias === $lmtp_alias) { + return null; + } + + return UsersController::validateAlias($alias, $owner); + } } diff --git a/src/app/Observers/SharedFolderAliasObserver.php b/src/app/Observers/SharedFolderAliasObserver.php index 2e863706..dd5a412a 100644 --- a/src/app/Observers/SharedFolderAliasObserver.php +++ b/src/app/Observers/SharedFolderAliasObserver.php @@ -1,82 +1,82 @@ alias = \strtolower($alias->alias); + $alias->alias = \App\Utils::emailToLower($alias->alias); $domainName = explode('@', $alias->alias)[1]; $domain = Domain::where('namespace', $domainName)->first(); if (!$domain) { \Log::error("Failed creating alias {$alias->alias}. Domain does not exist."); return false; } if ($alias->sharedFolder) { if ($alias->sharedFolder->tenant_id != $domain->tenant_id) { - \Log::error("Reseller for folder '{$alias->sharedFolder->email}' and domain '{$domainName}' differ."); + \Log::error("Tenant for folder '{$alias->sharedFolder->email}' and domain '{$domainName}' differ."); return false; } } return true; } /** * Handle the shared folder alias "created" event. * * @param \App\SharedFolderAlias $alias Shared folder email alias * * @return void */ public function created(SharedFolderAlias $alias) { if ($alias->sharedFolder) { \App\Jobs\SharedFolder\UpdateJob::dispatch($alias->shared_folder_id); } } /** * Handle the shared folder alias "updated" event. * * @param \App\SharedFolderAlias $alias Shared folder email alias * * @return void */ public function updated(SharedFolderAlias $alias) { if ($alias->sharedFolder) { \App\Jobs\SharedFolder\UpdateJob::dispatch($alias->shared_folder_id); } } /** * Handle the shared folder alias "deleted" event. * * @param \App\SharedFolderAlias $alias Shared folder email alias * * @return void */ public function deleted(SharedFolderAlias $alias) { if ($alias->sharedFolder) { \App\Jobs\SharedFolder\UpdateJob::dispatch($alias->shared_folder_id); } } } diff --git a/src/app/Observers/WalletObserver.php b/src/app/Observers/WalletObserver.php index e7dd89ee..46877c7f 100644 --- a/src/app/Observers/WalletObserver.php +++ b/src/app/Observers/WalletObserver.php @@ -1,108 +1,111 @@ currency = \config('app.currency'); } /** * Handle the wallet "deleting" event. * * Ensures that a wallet with a non-zero balance can not be deleted. * * Ensures that the wallet being deleted is not the last wallet for the user. * * Ensures that no entitlements are being billed to the wallet currently. * * @param Wallet $wallet The wallet being deleted. * * @return bool */ public function deleting(Wallet $wallet): bool { // can't delete a wallet that has any balance on it (positive and negative). if ($wallet->balance != 0.00) { return false; } if (!$wallet->owner) { throw new \Exception("Wallet: " . var_export($wallet, true)); } // can't remove the last wallet for the owner. if ($wallet->owner->wallets()->count() <= 1) { return false; } // can't remove a wallet that has billable entitlements attached. if ($wallet->entitlements()->count() > 0) { return false; } /* // can't remove a wallet that has payments attached. if ($wallet->payments()->count() > 0) { return false; } */ return true; } /** * Handle the wallet "updated" event. * * @param \App\Wallet $wallet The wallet. * * @return void */ public function updated(Wallet $wallet) { $negative_since = $wallet->getSetting('balance_negative_since'); if ($wallet->balance < 0) { if (!$negative_since) { $now = \Carbon\Carbon::now()->toDateTimeString(); $wallet->setSetting('balance_negative_since', $now); } } elseif ($negative_since) { $wallet->setSettings([ 'balance_negative_since' => null, 'balance_warning_initial' => null, 'balance_warning_reminder' => null, 'balance_warning_suspended' => null, 'balance_warning_before_delete' => null, ]); // FIXME: Since we use account degradation, should we leave suspended state untouched? // Un-suspend and un-degrade the account owner if ($wallet->owner) { $wallet->owner->unsuspend(); $wallet->owner->undegrade(); } // Un-suspend domains/users foreach ($wallet->entitlements as $entitlement) { - if (method_exists($entitlement->entitleable_type, 'unsuspend')) { + if ( + method_exists($entitlement->entitleable_type, 'unsuspend') + && !empty($entitlement->entitleable) + ) { $entitlement->entitleable->unsuspend(); } } } } } diff --git a/src/app/Rules/SharedFolderName.php b/src/app/Rules/SharedFolderName.php index f67b2403..9bac6090 100644 --- a/src/app/Rules/SharedFolderName.php +++ b/src/app/Rules/SharedFolderName.php @@ -1,85 +1,88 @@ owner = $owner; $this->domain = Str::lower($domain); } /** * Determine if the validation rule passes. * * @param string $attribute Attribute name * @param mixed $name Shared folder name input * * @return bool */ public function passes($attribute, $name): bool { - if (empty($name) || !is_string($name) || $name == 'Resources') { + if (empty($name) || !is_string($name) || $name == 'Resources' || \str_starts_with($name, 'Resources/')) { $this->message = \trans('validation.nameinvalid'); return false; } - if (strcspn($name, self::FORBIDDEN_CHARS) < strlen($name)) { - $this->message = \trans('validation.nameinvalid'); - return false; + foreach (explode('/', $name) as $subfolder) { + $length = strlen($subfolder); + if (!$length || strcspn($subfolder, self::FORBIDDEN_CHARS) < $length) { + $this->message = \trans('validation.nameinvalid'); + return false; + } } // Check the max length, according to the database column length if (strlen($name) > 191) { $this->message = \trans('validation.max.string', ['max' => 191]); return false; } // Check if specified domain belongs to the user if (!$this->owner->domains(true, false)->where('namespace', $this->domain)->exists()) { $this->message = \trans('validation.domaininvalid'); return false; } // Check if the name is unique in the domain // FIXME: Maybe just using the whole shared_folders table would be faster than sharedFolders()? $exists = $this->owner->sharedFolders() ->where('name', $name) ->where('email', 'like', '%@' . $this->domain) ->exists(); if ($exists) { $this->message = \trans('validation.nameexists'); return false; } return true; } /** * Get the validation error message. * * @return string */ public function message(): ?string { return $this->message; } } diff --git a/src/app/SharedFolderAlias.php b/src/app/SharedFolderAlias.php index f3796ee9..13f299e6 100644 --- a/src/app/SharedFolderAlias.php +++ b/src/app/SharedFolderAlias.php @@ -1,39 +1,39 @@ attributes['alias'] = \strtolower($alias); + $this->attributes['alias'] = \App\Utils::emailToLower($alias); } /** * The shared folder to which this alias belongs. * * @return \Illuminate\Database\Eloquent\Relations\BelongsTo */ public function sharedFolder() { return $this->belongsTo(SharedFolder::class, 'shared_folder_id', 'id'); } } diff --git a/src/app/Traits/AliasesTrait.php b/src/app/Traits/AliasesTrait.php index 5731afa5..03c53aee 100644 --- a/src/app/Traits/AliasesTrait.php +++ b/src/app/Traits/AliasesTrait.php @@ -1,72 +1,69 @@ hasMany(static::class . 'Alias'); } /** * Find whether an email address exists as an alias * * @param string $email Email address * * @return bool True if found, False otherwise */ public static function aliasExists(string $email): bool { if (strpos($email, '@') === false) { return false; } - $email = \strtolower($email); + $email = \App\Utils::emailToLower($email); $class = static::class . 'Alias'; return $class::where('alias', $email)->count() > 0; } /** * A helper to update object's aliases list. * * Example Usage: * * ```php * $user = User::firstOrCreate(['email' => 'some@other.org']); * $user->setAliases(['alias1@other.org', 'alias2@other.org']); * ``` * * @param array $aliases An array of email addresses * * @return void */ public function setAliases(array $aliases): void { - $aliases = array_map('strtolower', $aliases); + $aliases = array_map('\App\Utils::emailToLower', $aliases); $aliases = array_unique($aliases); $existing_aliases = []; foreach ($this->aliases()->get() as $alias) { /** @var \App\UserAlias|\App\SharedFolderAlias $alias */ if (!in_array($alias->alias, $aliases)) { $alias->delete(); } else { $existing_aliases[] = $alias->alias; } } - foreach (array_diff($aliases, $existing_aliases) as $alias) { $this->aliases()->create(['alias' => $alias]); } } } diff --git a/src/app/Utils.php b/src/app/Utils.php index ad6cb3d6..5e33b354 100644 --- a/src/app/Utils.php +++ b/src/app/Utils.php @@ -1,550 +1,571 @@ 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); } /** * 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); } + /** + * Converts an email address to lower case. Keeps the LMTP shared folder + * addresses character case intact. + * + * @param string $email Email address + * + * @return string Email address + */ + public static function emailToLower(string $email): string + { + // For LMTP shared folder address lower case the domain part only + if (str_starts_with($email, 'shared+shared/')) { + $pos = strrpos($email, '@'); + $domain = substr($email, $pos + 1); + $local = substr($email, 0, strlen($email) - strlen($domain) - 1); + + return $local . '@' . strtolower($domain); + } + + return strtolower($email); + } /** * 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 bool $asArray Return an array with local and domain part * * @return string|array Normalized email address as string or array */ public static function normalizeAddress(?string $address, bool $asArray = false) { if ($address === null || $address === '') { return $asArray ? ['', ''] : ''; } - $address = \strtolower($address); + $address = self::emailToLower($address); if (strpos($address, '@') === false) { return $asArray ? [$address, ''] : $address; } list($local, $domain) = explode('@', $address); if (strpos($local, '+') !== false) { $local = explode('+', $local)[0]; } 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; } /** * Set test exchange rates. * * @param array $rates: Exchange rates */ public static function setTestExchangeRates(array $rates): void { self::$testRates = $rates; } /** * 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; } if (isset(self::$testRates[$targetCurrency])) { return floatval(self::$testRates[$targetCurrency]); } $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/Controller/SharedFoldersTest.php b/src/tests/Feature/Controller/SharedFoldersTest.php index f469d054..96d84400 100644 --- a/src/tests/Feature/Controller/SharedFoldersTest.php +++ b/src/tests/Feature/Controller/SharedFoldersTest.php @@ -1,562 +1,576 @@ deleteTestSharedFolder('folder-test@kolab.org'); - SharedFolder::where('name', 'Test Folder')->delete(); + SharedFolder::where('name', 'like', 'Test_Folder')->forceDelete(); } /** * {@inheritDoc} */ public function tearDown(): void { $this->deleteTestSharedFolder('folder-test@kolab.org'); - SharedFolder::where('name', 'Test Folder')->delete(); + SharedFolder::where('name', 'like', 'Test_Folder')->forceDelete(); parent::tearDown(); } /** * Test resource deleting (DELETE /api/v4/resources/) */ public function testDestroy(): void { $john = $this->getTestUser('john@kolab.org'); $jack = $this->getTestUser('jack@kolab.org'); $folder = $this->getTestSharedFolder('folder-test@kolab.org'); $folder->assignToWallet($john->wallets->first()); // Test unauth access $response = $this->delete("api/v4/shared-folders/{$folder->id}"); $response->assertStatus(401); // Test non-existing folder $response = $this->actingAs($john)->delete("api/v4/shared-folders/abc"); $response->assertStatus(404); // Test access to other user's folder $response = $this->actingAs($jack)->delete("api/v4/shared-folders/{$folder->id}"); $response->assertStatus(403); $json = $response->json(); $this->assertSame('error', $json['status']); $this->assertSame("Access denied", $json['message']); $this->assertCount(2, $json); // Test removing a folder $response = $this->actingAs($john)->delete("api/v4/shared-folders/{$folder->id}"); $response->assertStatus(200); $json = $response->json(); $this->assertEquals('success', $json['status']); $this->assertEquals("Shared folder deleted successfully.", $json['message']); } /** * Test shared folders listing (GET /api/v4/shared-folders) */ public function testIndex(): void { $jack = $this->getTestUser('jack@kolab.org'); $john = $this->getTestUser('john@kolab.org'); $ned = $this->getTestUser('ned@kolab.org'); // Test unauth access $response = $this->get("api/v4/shared-folders"); $response->assertStatus(401); // Test a user with no shared folders $response = $this->actingAs($jack)->get("/api/v4/shared-folders"); $response->assertStatus(200); $json = $response->json(); $this->assertCount(4, $json); $this->assertSame(0, $json['count']); $this->assertSame(false, $json['hasMore']); $this->assertSame("0 shared folders have been found.", $json['message']); $this->assertSame([], $json['list']); // Test a user with two shared folders $response = $this->actingAs($john)->get("/api/v4/shared-folders"); $response->assertStatus(200); $json = $response->json(); $folder = SharedFolder::where('name', 'Calendar')->first(); $this->assertCount(4, $json); $this->assertSame(2, $json['count']); $this->assertSame(false, $json['hasMore']); $this->assertSame("2 shared folders have been found.", $json['message']); $this->assertCount(2, $json['list']); $this->assertSame($folder->id, $json['list'][0]['id']); $this->assertSame($folder->email, $json['list'][0]['email']); $this->assertSame($folder->name, $json['list'][0]['name']); $this->assertSame($folder->type, $json['list'][0]['type']); $this->assertArrayHasKey('isDeleted', $json['list'][0]); $this->assertArrayHasKey('isActive', $json['list'][0]); $this->assertArrayHasKey('isLdapReady', $json['list'][0]); $this->assertArrayHasKey('isImapReady', $json['list'][0]); // Test that another wallet controller has access to shared folders $response = $this->actingAs($ned)->get("/api/v4/shared-folders"); $response->assertStatus(200); $json = $response->json(); $this->assertCount(4, $json); $this->assertSame(2, $json['count']); $this->assertSame(false, $json['hasMore']); $this->assertSame("2 shared folders have been found.", $json['message']); $this->assertCount(2, $json['list']); $this->assertSame($folder->email, $json['list'][0]['email']); } /** * Test shared folder config update (POST /api/v4/shared-folders//config) */ public function testSetConfig(): void { $john = $this->getTestUser('john@kolab.org'); $jack = $this->getTestUser('jack@kolab.org'); $folder = $this->getTestSharedFolder('folder-test@kolab.org'); $folder->assignToWallet($john->wallets->first()); // Test unknown resource id $post = ['acl' => ['john@kolab.org, full']]; $response = $this->actingAs($john)->post("/api/v4/shared-folders/123/config", $post); $json = $response->json(); $response->assertStatus(404); // Test access by user not being a wallet controller $response = $this->actingAs($jack)->post("/api/v4/shared-folders/{$folder->id}/config", $post); $json = $response->json(); $response->assertStatus(403); $this->assertSame('error', $json['status']); $this->assertSame("Access denied", $json['message']); $this->assertCount(2, $json); // Test some invalid data $post = ['test' => 1]; $response = $this->actingAs($john)->post("/api/v4/shared-folders/{$folder->id}/config", $post); $response->assertStatus(422); $json = $response->json(); $this->assertSame('error', $json['status']); $this->assertCount(2, $json); $this->assertCount(1, $json['errors']); $this->assertSame('The requested configuration parameter is not supported.', $json['errors']['test']); $folder->refresh(); $this->assertNull($folder->getSetting('test')); $this->assertNull($folder->getSetting('acl')); // Test some valid data $post = ['acl' => ['john@kolab.org, full']]; $response = $this->actingAs($john)->post("/api/v4/shared-folders/{$folder->id}/config", $post); $response->assertStatus(200); $json = $response->json(); $this->assertCount(2, $json); $this->assertSame('success', $json['status']); $this->assertSame("Shared folder settings updated successfully.", $json['message']); $this->assertSame(['acl' => $post['acl']], $folder->fresh()->getConfig()); // Test input validation $post = ['acl' => ['john@kolab.org, full', 'test, full']]; $response = $this->actingAs($john)->post("/api/v4/shared-folders/{$folder->id}/config", $post); $response->assertStatus(422); $json = $response->json(); $this->assertCount(2, $json); $this->assertSame('error', $json['status']); $this->assertCount(1, $json['errors']); $this->assertCount(1, $json['errors']['acl']); $this->assertSame( "The specified email address is invalid.", $json['errors']['acl'][1] ); $this->assertSame(['acl' => ['john@kolab.org, full']], $folder->fresh()->getConfig()); } /** * Test fetching shared folder data/profile (GET /api/v4/shared-folders/) */ public function testShow(): void { $jack = $this->getTestUser('jack@kolab.org'); $john = $this->getTestUser('john@kolab.org'); $ned = $this->getTestUser('ned@kolab.org'); $folder = $this->getTestSharedFolder('folder-test@kolab.org'); $folder->assignToWallet($john->wallets->first()); $folder->setSetting('acl', '["anyone, full"]'); $folder->setAliases(['folder-alias@kolab.org']); // Test unauthenticated access $response = $this->get("/api/v4/shared-folders/{$folder->id}"); $response->assertStatus(401); // Test unauthorized access to a shared folder of another user $response = $this->actingAs($jack)->get("/api/v4/shared-folders/{$folder->id}"); $response->assertStatus(403); // John: Account owner - non-existing folder $response = $this->actingAs($john)->get("/api/v4/shared-folders/abc"); $response->assertStatus(404); // John: Account owner $response = $this->actingAs($john)->get("/api/v4/shared-folders/{$folder->id}"); $response->assertStatus(200); $json = $response->json(); $this->assertSame($folder->id, $json['id']); $this->assertSame($folder->email, $json['email']); $this->assertSame($folder->name, $json['name']); $this->assertSame($folder->type, $json['type']); $this->assertSame(['folder-alias@kolab.org'], $json['aliases']); $this->assertTrue(!empty($json['statusInfo'])); $this->assertArrayHasKey('isDeleted', $json); $this->assertArrayHasKey('isActive', $json); $this->assertArrayHasKey('isLdapReady', $json); $this->assertArrayHasKey('isImapReady', $json); $this->assertSame(['acl' => ['anyone, full']], $json['config']); } /** * Test fetching a shared folder status (GET /api/v4/shared-folders//status) * and forcing setup process update (?refresh=1) */ public function testStatus(): void { Queue::fake(); $john = $this->getTestUser('john@kolab.org'); $jack = $this->getTestUser('jack@kolab.org'); $folder = $this->getTestSharedFolder('folder-test@kolab.org'); $folder->assignToWallet($john->wallets->first()); // Test unauthorized access $response = $this->get("/api/v4/shared-folders/abc/status"); $response->assertStatus(401); // Test unauthorized access $response = $this->actingAs($jack)->get("/api/v4/shared-folders/{$folder->id}/status"); $response->assertStatus(403); $folder->status = SharedFolder::STATUS_NEW | SharedFolder::STATUS_ACTIVE; $folder->save(); // Get resource status $response = $this->actingAs($john)->get("/api/v4/shared-folders/{$folder->id}/status"); $response->assertStatus(200); $json = $response->json(); $this->assertFalse($json['isLdapReady']); $this->assertFalse($json['isImapReady']); $this->assertFalse($json['isReady']); $this->assertFalse($json['isDeleted']); $this->assertTrue($json['isActive']); $this->assertCount(7, $json['process']); $this->assertSame('shared-folder-new', $json['process'][0]['label']); $this->assertSame(true, $json['process'][0]['state']); $this->assertSame('shared-folder-ldap-ready', $json['process'][1]['label']); $this->assertSame(false, $json['process'][1]['state']); $this->assertTrue(empty($json['status'])); $this->assertTrue(empty($json['message'])); $this->assertSame('running', $json['processState']); // Make sure the domain is confirmed (other test might unset that status) $domain = $this->getTestDomain('kolab.org'); $domain->status |= \App\Domain::STATUS_CONFIRMED; $domain->save(); $folder->status |= SharedFolder::STATUS_IMAP_READY; $folder->save(); // Now "reboot" the process and get the folder status $response = $this->actingAs($john)->get("/api/v4/shared-folders/{$folder->id}/status?refresh=1"); $response->assertStatus(200); $json = $response->json(); $this->assertTrue($json['isLdapReady']); $this->assertTrue($json['isImapReady']); $this->assertTrue($json['isReady']); $this->assertCount(7, $json['process']); $this->assertSame('shared-folder-ldap-ready', $json['process'][1]['label']); $this->assertSame(true, $json['process'][1]['state']); $this->assertSame('shared-folder-imap-ready', $json['process'][2]['label']); $this->assertSame(true, $json['process'][2]['state']); $this->assertSame('success', $json['status']); $this->assertSame('Setup process finished successfully.', $json['message']); $this->assertSame('done', $json['processState']); // Test a case when a domain is not ready $domain->status ^= \App\Domain::STATUS_CONFIRMED; $domain->save(); $response = $this->actingAs($john)->get("/api/v4/shared-folders/{$folder->id}/status?refresh=1"); $response->assertStatus(200); $json = $response->json(); $this->assertTrue($json['isLdapReady']); $this->assertTrue($json['isReady']); $this->assertCount(7, $json['process']); $this->assertSame('shared-folder-ldap-ready', $json['process'][1]['label']); $this->assertSame(true, $json['process'][1]['state']); $this->assertSame('success', $json['status']); $this->assertSame('Setup process finished successfully.', $json['message']); } /** * Test SharedFoldersController::statusInfo() */ public function testStatusInfo(): void { $john = $this->getTestUser('john@kolab.org'); $folder = $this->getTestSharedFolder('folder-test@kolab.org'); $folder->assignToWallet($john->wallets->first()); $folder->status = SharedFolder::STATUS_NEW | SharedFolder::STATUS_ACTIVE; $folder->save(); $domain = $this->getTestDomain('kolab.org'); $domain->status |= \App\Domain::STATUS_CONFIRMED; $domain->save(); $result = SharedFoldersController::statusInfo($folder); $this->assertFalse($result['isReady']); $this->assertCount(7, $result['process']); $this->assertSame('shared-folder-new', $result['process'][0]['label']); $this->assertSame(true, $result['process'][0]['state']); $this->assertSame('shared-folder-ldap-ready', $result['process'][1]['label']); $this->assertSame(false, $result['process'][1]['state']); $this->assertSame('running', $result['processState']); $folder->created_at = Carbon::now()->subSeconds(181); $folder->save(); $result = SharedFoldersController::statusInfo($folder); $this->assertSame('failed', $result['processState']); $folder->status |= SharedFolder::STATUS_LDAP_READY | SharedFolder::STATUS_IMAP_READY; $folder->save(); $result = SharedFoldersController::statusInfo($folder); $this->assertTrue($result['isReady']); $this->assertCount(7, $result['process']); $this->assertSame('shared-folder-new', $result['process'][0]['label']); $this->assertSame(true, $result['process'][0]['state']); $this->assertSame('shared-folder-ldap-ready', $result['process'][1]['label']); $this->assertSame(true, $result['process'][1]['state']); $this->assertSame('shared-folder-ldap-ready', $result['process'][1]['label']); $this->assertSame(true, $result['process'][1]['state']); $this->assertSame('done', $result['processState']); } /** * Test shared folder creation (POST /api/v4/shared-folders) */ public function testStore(): void { Queue::fake(); $jack = $this->getTestUser('jack@kolab.org'); $john = $this->getTestUser('john@kolab.org'); // Test unauth request $response = $this->post("/api/v4/shared-folders", []); $response->assertStatus(401); // Test non-controller user $response = $this->actingAs($jack)->post("/api/v4/shared-folders", []); $response->assertStatus(403); // Test empty request $response = $this->actingAs($john)->post("/api/v4/shared-folders", []); $response->assertStatus(422); $json = $response->json(); $this->assertSame('error', $json['status']); $this->assertSame("The name field is required.", $json['errors']['name'][0]); $this->assertSame("The type field is required.", $json['errors']['type'][0]); $this->assertCount(2, $json); $this->assertCount(2, $json['errors']); // Test too long name, invalid alias domain $post = [ 'domain' => 'kolab.org', 'name' => str_repeat('A', 192), 'type' => 'unknown', 'aliases' => ['folder-alias@unknown.org'], ]; $response = $this->actingAs($john)->post("/api/v4/shared-folders", $post); $response->assertStatus(422); $json = $response->json(); $this->assertSame('error', $json['status']); $this->assertCount(2, $json); $this->assertSame(["The name may not be greater than 191 characters."], $json['errors']['name']); $this->assertSame(["The specified type is invalid."], $json['errors']['type']); $this->assertSame(["The specified domain is invalid."], $json['errors']['aliases']); $this->assertCount(3, $json['errors']); // Test successful folder creation $post['name'] = 'Test Folder'; $post['type'] = 'event'; - $post['aliases'] = ['folder-alias@kolab.org']; // expected to be ignored + $post['aliases'] = []; $response = $this->actingAs($john)->post("/api/v4/shared-folders", $post); $json = $response->json(); $response->assertStatus(200); $this->assertSame('success', $json['status']); $this->assertSame("Shared folder created successfully.", $json['message']); $this->assertCount(2, $json); $folder = SharedFolder::where('name', $post['name'])->first(); $this->assertInstanceOf(SharedFolder::class, $folder); $this->assertSame($post['type'], $folder->type); $this->assertTrue($john->sharedFolders()->get()->contains($folder)); $this->assertSame([], $folder->aliases()->pluck('alias')->all()); // Shared folder name must be unique within a domain $post['type'] = 'mail'; $response = $this->actingAs($john)->post("/api/v4/shared-folders", $post); $response->assertStatus(422); $json = $response->json(); $this->assertSame('error', $json['status']); $this->assertCount(2, $json); $this->assertCount(1, $json['errors']); $this->assertSame("The specified name is not available.", $json['errors']['name'][0]); $folder->forceDelete(); // Test successful folder creation with aliases $post['name'] = 'Test Folder'; $post['type'] = 'mail'; $post['aliases'] = ['folder-alias@kolab.org']; $response = $this->actingAs($john)->post("/api/v4/shared-folders", $post); $json = $response->json(); $response->assertStatus(200); $folder = SharedFolder::where('name', $post['name'])->first(); $this->assertSame(['folder-alias@kolab.org'], $folder->aliases()->pluck('alias')->all()); + + $folder->forceDelete(); + + // Test handling subfolders and lmtp alias email + $post['name'] = 'Test/Folder'; + $post['type'] = 'mail'; + $post['aliases'] = ['shared+shared/Test/Folder@kolab.org']; + $response = $this->actingAs($john)->post("/api/v4/shared-folders", $post); + $json = $response->json(); + + $response->assertStatus(200); + + $folder = SharedFolder::where('name', $post['name'])->first(); + $this->assertSame(['shared+shared/Test/Folder@kolab.org'], $folder->aliases()->pluck('alias')->all()); } /** * Test shared folder update (PUT /api/v4/shared-folders/getTestUser('jack@kolab.org'); $john = $this->getTestUser('john@kolab.org'); $ned = $this->getTestUser('ned@kolab.org'); $folder = $this->getTestSharedFolder('folder-test@kolab.org'); $folder->assignToWallet($john->wallets->first()); // Test unauthorized update $response = $this->get("/api/v4/shared-folders/{$folder->id}", []); $response->assertStatus(401); // Test unauthorized update $response = $this->actingAs($jack)->get("/api/v4/shared-folders/{$folder->id}", []); $response->assertStatus(403); // Name change $post = [ 'name' => 'Test Res', ]; $response = $this->actingAs($john)->put("/api/v4/shared-folders/{$folder->id}", $post); $json = $response->json(); $response->assertStatus(200); $this->assertSame('success', $json['status']); $this->assertSame("Shared folder updated successfully.", $json['message']); $this->assertCount(2, $json); $folder->refresh(); $this->assertSame($post['name'], $folder->name); // Aliases with error $post['aliases'] = ['folder-alias1@kolab.org', 'folder-alias2@unknown.com']; $response = $this->actingAs($john)->put("/api/v4/shared-folders/{$folder->id}", $post); $json = $response->json(); $response->assertStatus(422); $this->assertSame('error', $json['status']); $this->assertCount(2, $json); $this->assertCount(1, $json['errors']); $this->assertCount(1, $json['errors']['aliases']); $this->assertSame("The specified domain is invalid.", $json['errors']['aliases'][1]); $this->assertSame([], $folder->aliases()->pluck('alias')->all()); // Aliases with success expected $post['aliases'] = ['folder-alias1@kolab.org', 'folder-alias2@kolab.org']; $response = $this->actingAs($john)->put("/api/v4/shared-folders/{$folder->id}", $post); $json = $response->json(); $response->assertStatus(200); $this->assertSame('success', $json['status']); $this->assertSame("Shared folder updated successfully.", $json['message']); $this->assertCount(2, $json); $this->assertSame($post['aliases'], $folder->aliases()->pluck('alias')->all()); // All aliases removal $post['aliases'] = []; $response = $this->actingAs($john)->put("/api/v4/shared-folders/{$folder->id}", $post); $response->assertStatus(200); $this->assertSame($post['aliases'], $folder->aliases()->pluck('alias')->all()); } } diff --git a/src/tests/Feature/Controller/WalletsTest.php b/src/tests/Feature/Controller/WalletsTest.php index b72796ef..72785b3e 100644 --- a/src/tests/Feature/Controller/WalletsTest.php +++ b/src/tests/Feature/Controller/WalletsTest.php @@ -1,356 +1,356 @@ deleteTestUser('wallets-controller@kolabnow.com'); } /** * {@inheritDoc} */ public function tearDown(): void { $this->deleteTestUser('wallets-controller@kolabnow.com'); parent::tearDown(); } /** * Test for getWalletNotice() method */ public function testGetWalletNotice(): void { $user = $this->getTestUser('wallets-controller@kolabnow.com'); $package = \App\Package::withObjectTenantContext($user)->where('title', 'kolab')->first(); $user->assignPackage($package); $wallet = $user->wallets()->first(); $controller = new WalletsController(); $method = new \ReflectionMethod($controller, 'getWalletNotice'); $method->setAccessible(true); // User/entitlements created today, balance=0 $notice = $method->invoke($controller, $wallet); $this->assertSame('You are in your free trial period.', $notice); $wallet->owner->created_at = Carbon::now()->subDays(15); $wallet->owner->save(); $notice = $method->invoke($controller, $wallet); $this->assertSame('Your free trial is about to end, top up to continue.', $notice); // User/entitlements created today, balance=-10 CHF $wallet->balance = -1000; $notice = $method->invoke($controller, $wallet); $this->assertSame('You are out of credit, top up your balance now.', $notice); // User/entitlements created slightly more than a month ago, balance=9,99 CHF (monthly) $wallet->owner->created_at = Carbon::now()->subMonthsWithoutOverflow(1)->subDays(1); $wallet->owner->save(); // test "1 month" $wallet->balance = 990; $notice = $method->invoke($controller, $wallet); - $this->assertMatchesRegularExpression('/\((1 month)\)/', $notice); + $this->assertMatchesRegularExpression('/\((1 month|4 weeks)\)/', $notice); // test "2 months" $wallet->balance = 990 * 2.6; $notice = $method->invoke($controller, $wallet); $this->assertMatchesRegularExpression('/\(2 months 2 weeks\)/', $notice); // Change locale to make sure the text is localized by Carbon \app()->setLocale('de'); // test "almost 2 years" $wallet->balance = 990 * 23.5; $notice = $method->invoke($controller, $wallet); $this->assertMatchesRegularExpression('/\(1 Jahr 11 Monate\)/', $notice); // Old entitlements, 100% discount $this->backdateEntitlements($wallet->entitlements, Carbon::now()->subDays(40)); $discount = \App\Discount::withObjectTenantContext($user)->where('discount', 100)->first(); $wallet->discount()->associate($discount); $notice = $method->invoke($controller, $wallet->refresh()); $this->assertSame(null, $notice); } /** * Test fetching pdf receipt */ public function testReceiptDownload(): void { $user = $this->getTestUser('wallets-controller@kolabnow.com'); $john = $this->getTestUser('john@kolab.org'); $wallet = $user->wallets()->first(); // Unauth access not allowed $response = $this->get("api/v4/wallets/{$wallet->id}/receipts/2020-05"); $response->assertStatus(401); $response = $this->actingAs($john)->get("api/v4/wallets/{$wallet->id}/receipts/2020-05"); $response->assertStatus(403); // Invalid receipt id (current month) $receiptId = date('Y-m'); $response = $this->actingAs($user)->get("api/v4/wallets/{$wallet->id}/receipts/{$receiptId}"); $response->assertStatus(404); // Invalid receipt id $receiptId = '1000-03'; $response = $this->actingAs($user)->get("api/v4/wallets/{$wallet->id}/receipts/{$receiptId}"); $response->assertStatus(404); // Valid receipt id $year = intval(date('Y')) - 1; $receiptId = "$year-12"; $filename = \config('app.name') . " Receipt for $year-12"; $response = $this->actingAs($user)->get("api/v4/wallets/{$wallet->id}/receipts/{$receiptId}"); $response->assertStatus(200); $response->assertHeader('content-type', 'application/pdf'); $response->assertHeader('content-disposition', 'attachment; filename="' . $filename . '"'); $response->assertHeader('content-length'); $length = $response->headers->get('content-length'); $content = $response->content(); $this->assertStringStartsWith("%PDF-1.", $content); $this->assertEquals(strlen($content), $length); } /** * Test fetching list of receipts */ public function testReceipts(): void { $user = $this->getTestUser('wallets-controller@kolabnow.com'); $john = $this->getTestUser('john@kolab.org'); $wallet = $user->wallets()->first(); $wallet->payments()->delete(); // Unauth access not allowed $response = $this->get("api/v4/wallets/{$wallet->id}/receipts"); $response->assertStatus(401); $response = $this->actingAs($john)->get("api/v4/wallets/{$wallet->id}/receipts"); $response->assertStatus(403); // Empty list expected $response = $this->actingAs($user)->get("api/v4/wallets/{$wallet->id}/receipts"); $response->assertStatus(200); $json = $response->json(); $this->assertCount(5, $json); $this->assertSame('success', $json['status']); $this->assertSame([], $json['list']); $this->assertSame(1, $json['page']); $this->assertSame(0, $json['count']); $this->assertSame(false, $json['hasMore']); // Insert a payment to the database $date = Carbon::create(intval(date('Y')) - 1, 4, 30); $payment = Payment::create([ 'id' => 'AAA1', 'status' => PaymentProvider::STATUS_PAID, 'type' => PaymentProvider::TYPE_ONEOFF, 'description' => 'Paid in April', 'wallet_id' => $wallet->id, 'provider' => 'stripe', 'amount' => 1111, 'currency' => 'CHF', 'currency_amount' => 1111, ]); $payment->updated_at = $date; $payment->save(); $response = $this->actingAs($user)->get("api/v4/wallets/{$wallet->id}/receipts"); $response->assertStatus(200); $json = $response->json(); $this->assertCount(5, $json); $this->assertSame('success', $json['status']); $this->assertSame([$date->format('Y-m')], $json['list']); $this->assertSame(1, $json['page']); $this->assertSame(1, $json['count']); $this->assertSame(false, $json['hasMore']); } /** * Test fetching a wallet (GET /api/v4/wallets/:id) */ public function testShow(): void { $john = $this->getTestUser('john@kolab.org'); $jack = $this->getTestUser('jack@kolab.org'); $wallet = $john->wallets()->first(); $wallet->balance = -100; $wallet->save(); // Accessing a wallet of someone else $response = $this->actingAs($jack)->get("api/v4/wallets/{$wallet->id}"); $response->assertStatus(403); // Accessing non-existing wallet $response = $this->actingAs($jack)->get("api/v4/wallets/aaa"); $response->assertStatus(404); // Wallet owner $response = $this->actingAs($john)->get("api/v4/wallets/{$wallet->id}"); $response->assertStatus(200); $json = $response->json(); $this->assertSame($wallet->id, $json['id']); $this->assertSame('CHF', $json['currency']); $this->assertSame($wallet->balance, $json['balance']); $this->assertTrue(empty($json['description'])); $this->assertTrue(!empty($json['notice'])); } /** * Test fetching wallet transactions */ public function testTransactions(): void { $package_kolab = \App\Package::where('title', 'kolab')->first(); $user = $this->getTestUser('wallets-controller@kolabnow.com'); $user->assignPackage($package_kolab); $john = $this->getTestUser('john@kolab.org'); $wallet = $user->wallets()->first(); // Unauth access not allowed $response = $this->get("api/v4/wallets/{$wallet->id}/transactions"); $response->assertStatus(401); $response = $this->actingAs($john)->get("api/v4/wallets/{$wallet->id}/transactions"); $response->assertStatus(403); // Expect empty list $response = $this->actingAs($user)->get("api/v4/wallets/{$wallet->id}/transactions"); $response->assertStatus(200); $json = $response->json(); $this->assertCount(5, $json); $this->assertSame('success', $json['status']); $this->assertSame([], $json['list']); $this->assertSame(1, $json['page']); $this->assertSame(0, $json['count']); $this->assertSame(false, $json['hasMore']); // Create some sample transactions $transactions = $this->createTestTransactions($wallet); $transactions = array_reverse($transactions); $pages = array_chunk($transactions, 10 /* page size*/); // Get the first page $response = $this->actingAs($user)->get("api/v4/wallets/{$wallet->id}/transactions"); $response->assertStatus(200); $json = $response->json(); $this->assertCount(5, $json); $this->assertSame('success', $json['status']); $this->assertSame(1, $json['page']); $this->assertSame(10, $json['count']); $this->assertSame(true, $json['hasMore']); $this->assertCount(10, $json['list']); foreach ($pages[0] as $idx => $transaction) { $this->assertSame($transaction->id, $json['list'][$idx]['id']); $this->assertSame($transaction->type, $json['list'][$idx]['type']); $this->assertSame(\config('app.currency'), $json['list'][$idx]['currency']); $this->assertSame($transaction->shortDescription(), $json['list'][$idx]['description']); $this->assertFalse($json['list'][$idx]['hasDetails']); $this->assertFalse(array_key_exists('user', $json['list'][$idx])); } $search = null; // Get the second page $response = $this->actingAs($user)->get("api/v4/wallets/{$wallet->id}/transactions?page=2"); $response->assertStatus(200); $json = $response->json(); $this->assertCount(5, $json); $this->assertSame('success', $json['status']); $this->assertSame(2, $json['page']); $this->assertSame(2, $json['count']); $this->assertSame(false, $json['hasMore']); $this->assertCount(2, $json['list']); foreach ($pages[1] as $idx => $transaction) { $this->assertSame($transaction->id, $json['list'][$idx]['id']); $this->assertSame($transaction->type, $json['list'][$idx]['type']); $this->assertSame($transaction->shortDescription(), $json['list'][$idx]['description']); $this->assertSame( $transaction->type == Transaction::WALLET_DEBIT, $json['list'][$idx]['hasDetails'] ); $this->assertFalse(array_key_exists('user', $json['list'][$idx])); if ($transaction->type == Transaction::WALLET_DEBIT) { $search = $transaction->id; } } // Get a non-existing page $response = $this->actingAs($user)->get("api/v4/wallets/{$wallet->id}/transactions?page=3"); $response->assertStatus(200); $json = $response->json(); $this->assertCount(5, $json); $this->assertSame('success', $json['status']); $this->assertSame(3, $json['page']); $this->assertSame(0, $json['count']); $this->assertSame(false, $json['hasMore']); $this->assertCount(0, $json['list']); // Sub-transaction searching $response = $this->actingAs($user)->get("api/v4/wallets/{$wallet->id}/transactions?transaction=123"); $response->assertStatus(404); $response = $this->actingAs($user)->get("api/v4/wallets/{$wallet->id}/transactions?transaction={$search}"); $response->assertStatus(200); $json = $response->json(); $this->assertCount(5, $json); $this->assertSame('success', $json['status']); $this->assertSame(1, $json['page']); $this->assertSame(2, $json['count']); $this->assertSame(false, $json['hasMore']); $this->assertCount(2, $json['list']); $this->assertSame(Transaction::ENTITLEMENT_BILLED, $json['list'][0]['type']); $this->assertSame(Transaction::ENTITLEMENT_BILLED, $json['list'][1]['type']); // Test that John gets 404 if he tries to access // someone else's transaction ID on his wallet's endpoint $wallet = $john->wallets()->first(); $response = $this->actingAs($john)->get("api/v4/wallets/{$wallet->id}/transactions?transaction={$search}"); $response->assertStatus(404); } } diff --git a/src/tests/Unit/Rules/SharedFolderNameTest.php b/src/tests/Unit/Rules/SharedFolderNameTest.php index fdaab65c..82dee268 100644 --- a/src/tests/Unit/Rules/SharedFolderNameTest.php +++ b/src/tests/Unit/Rules/SharedFolderNameTest.php @@ -1,47 +1,66 @@ getTestUser('john@kolab.org'); $rules = ['name' => ['present', new SharedFolderName($user, 'kolab.org')]]; // Empty/invalid input $v = Validator::make(['name' => null], $rules); $this->assertSame(['name' => ["The specified name is invalid."]], $v->errors()->toArray()); $v = Validator::make(['name' => []], $rules); $this->assertSame(['name' => ["The specified name is invalid."]], $v->errors()->toArray()); + $v = Validator::make(['name' => ['Resources']], $rules); + $this->assertSame(['name' => ["The specified name is invalid."]], $v->errors()->toArray()); + + $v = Validator::make(['name' => ['Resources/Test']], $rules); + $this->assertSame(['name' => ["The specified name is invalid."]], $v->errors()->toArray()); + // Forbidden chars $v = Validator::make(['name' => 'Test@'], $rules); $this->assertSame(['name' => ["The specified name is invalid."]], $v->errors()->toArray()); // Length limit $v = Validator::make(['name' => str_repeat('a', 192)], $rules); $this->assertSame(['name' => ["The name may not be greater than 191 characters."]], $v->errors()->toArray()); // Existing resource $v = Validator::make(['name' => 'Calendar'], $rules); $this->assertSame(['name' => ["The specified name is not available."]], $v->errors()->toArray()); // Valid name $v = Validator::make(['name' => 'TestRule'], $rules); $this->assertSame([], $v->errors()->toArray()); // Invalid domain $rules = ['name' => ['present', new SharedFolderName($user, 'kolabnow.com')]]; $v = Validator::make(['name' => 'TestRule'], $rules); $this->assertSame(['name' => ["The specified domain is invalid."]], $v->errors()->toArray()); + + // Invalid subfolders + $rules = ['name' => ['present', new SharedFolderName($user, 'kolab.org')]]; + $v = Validator::make(['name' => 'Test//Rule'], $rules); + $this->assertSame(['name' => ["The specified name is invalid."]], $v->errors()->toArray()); + $v = Validator::make(['name' => '/TestRule'], $rules); + $this->assertSame(['name' => ["The specified name is invalid."]], $v->errors()->toArray()); + $v = Validator::make(['name' => 'TestRule/'], $rules); + $this->assertSame(['name' => ["The specified name is invalid."]], $v->errors()->toArray()); + + // Valid subfolder + $v = Validator::make(['name' => 'Test/Rule/Folder'], $rules); + $this->assertSame([], $v->errors()->toArray()); } } diff --git a/src/tests/Unit/UtilsTest.php b/src/tests/Unit/UtilsTest.php index 85b0a619..d1065d5b 100644 --- a/src/tests/Unit/UtilsTest.php +++ b/src/tests/Unit/UtilsTest.php @@ -1,143 +1,153 @@ assertSame('test@test.tld', \App\Utils::emailToLower('test@Test.Tld')); + $this->assertSame('test@test.tld', \App\Utils::emailToLower('Test@Test.Tld')); + $this->assertSame('shared+shared/Test@test.tld', \App\Utils::emailToLower('shared+shared/Test@Test.Tld')); + } + /** * Test for Utils::normalizeAddress() */ public function testNormalizeAddress(): void { $this->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")); } }