diff --git a/src/app/Http/Controllers/API/SignupController.php b/src/app/Http/Controllers/API/SignupController.php index bc715f2c..d44825b8 100644 --- a/src/app/Http/Controllers/API/SignupController.php +++ b/src/app/Http/Controllers/API/SignupController.php @@ -1,554 +1,596 @@ orderBy('months')->orderByDesc('title')->get() ->map(function ($plan) { $button = self::trans("app.planbutton-{$plan->title}"); if (strpos($button, 'app.planbutton') !== false) { $button = self::trans('app.planbutton', ['plan' => $plan->name]); } return [ 'title' => $plan->title, 'name' => $plan->name, 'button' => $button, 'description' => $plan->description, 'mode' => $plan->mode ?: Plan::MODE_EMAIL, 'isDomain' => $plan->hasDomain(), ]; }) ->all(); return response()->json(['status' => 'success', 'plans' => $plans]); } /** * Returns list of public domains for signup. * * @param \Illuminate\Http\Request $request HTTP request * * @return \Illuminate\Http\JsonResponse JSON response */ public function domains(Request $request) { return response()->json(['status' => 'success', 'domains' => Domain::getPublicDomains()]); } /** * Starts signup process. * * Verifies user name and email/phone, sends verification email/sms message. * Returns the verification code. * * @param \Illuminate\Http\Request $request HTTP request * * @return \Illuminate\Http\JsonResponse JSON response */ public function init(Request $request) { $rules = [ 'first_name' => 'max:128', 'last_name' => 'max:128', 'voucher' => 'max:32', ]; $plan = $this->getPlan(); if ($plan->mode == Plan::MODE_TOKEN) { $rules['token'] = ['required', 'string', new SignupToken()]; } else { $rules['email'] = ['required', 'string', new SignupExternalEmail()]; } // Check required fields, validate input $v = Validator::make($request->all(), $rules); if ($v->fails()) { return response()->json(['status' => 'error', 'errors' => $v->errors()->toArray()], 422); } // Generate the verification code $code = SignupCode::create([ 'email' => $plan->mode == Plan::MODE_TOKEN ? $request->token : $request->email, 'first_name' => $request->first_name, 'last_name' => $request->last_name, 'plan' => $plan->title, 'voucher' => $request->voucher, ]); $response = [ 'status' => 'success', 'code' => $code->code, 'mode' => $plan->mode ?: 'email', ]; if ($plan->mode == Plan::MODE_TOKEN) { // Token verification, jump to the last step $has_domain = $plan->hasDomain(); $response['short_code'] = $code->short_code; $response['is_domain'] = $has_domain; $response['domains'] = $has_domain ? [] : Domain::getPublicDomains(); } else { // External email verification, send an email message SignupVerificationEmail::dispatch($code); } return response()->json($response); } /** * Returns signup invitation information. * * @param string $id Signup invitation identifier * * @return \Illuminate\Http\JsonResponse|void */ public function invitation($id) { $invitation = SignupInvitation::withEnvTenantContext()->find($id); if (empty($invitation) || $invitation->isCompleted()) { return $this->errorResponse(404); } $has_domain = $this->getPlan()->hasDomain(); $result = [ 'id' => $id, 'is_domain' => $has_domain, 'domains' => $has_domain ? [] : Domain::getPublicDomains(), ]; return response()->json($result); } /** * Validation of the verification code. * * @param \Illuminate\Http\Request $request HTTP request * @param bool $update Update the signup code record * * @return \Illuminate\Http\JsonResponse JSON response */ public function verify(Request $request, $update = true) { // Validate the request args $v = Validator::make( $request->all(), [ 'code' => 'required', 'short_code' => 'required', ] ); if ($v->fails()) { return response()->json(['status' => 'error', 'errors' => $v->errors()], 422); } // Validate the verification code $code = SignupCode::find($request->code); if ( empty($code) || $code->isExpired() || Str::upper($request->short_code) !== Str::upper($code->short_code) ) { $errors = ['short_code' => "The code is invalid or expired."]; return response()->json(['status' => 'error', 'errors' => $errors], 422); } // For signup last-step mode remember the code object, so we can delete it // with single SQL query (->delete()) instead of two $request->code = $code; if ($update) { $code->verify_ip_address = $request->ip(); $code->save(); } $has_domain = $this->getPlan()->hasDomain(); // Return user name and email/phone/voucher from the codes database, // domains list for selection and "plan type" flag return response()->json([ 'status' => 'success', 'email' => $code->email, 'first_name' => $code->first_name, 'last_name' => $code->last_name, 'voucher' => $code->voucher, 'is_domain' => $has_domain, 'domains' => $has_domain ? [] : Domain::getPublicDomains(), ]); } /** * Validates the input to the final signup request. * * @param \Illuminate\Http\Request $request HTTP request * * @return \Illuminate\Http\JsonResponse JSON response */ public function signupValidate(Request $request) { // Validate input $v = Validator::make( $request->all(), [ 'login' => 'required|min:2', 'password' => ['required', 'confirmed', new Password()], 'domain' => 'required', 'voucher' => 'max:32', ] ); if ($v->fails()) { return response()->json(['status' => 'error', 'errors' => $v->errors()], 422); } $settings = []; // Plan parameter is required/allowed in mandate mode if (!empty($request->plan) && empty($request->code) && empty($request->invitation)) { $plan = Plan::withEnvTenantContext()->where('title', $request->plan)->first(); if (!$plan || $plan->mode != Plan::MODE_MANDATE) { $msg = \trans('validation.exists', ['attribute' => 'plan']); return response()->json(['status' => 'error', 'errors' => ['plan' => $msg]], 422); } } elseif ($request->invitation) { // Signup via invitation $invitation = SignupInvitation::withEnvTenantContext()->find($request->invitation); if (empty($invitation) || $invitation->isCompleted()) { return $this->errorResponse(404); } // Check required fields $v = Validator::make( $request->all(), [ 'first_name' => 'max:128', 'last_name' => 'max:128', ] ); $errors = $v->fails() ? $v->errors()->toArray() : []; if (!empty($errors)) { return response()->json(['status' => 'error', 'errors' => $errors], 422); } $settings = [ 'external_email' => $invitation->email, 'first_name' => $request->first_name, 'last_name' => $request->last_name, ]; } else { // Validate verification codes (again) $v = $this->verify($request, false); if ($v->status() !== 200) { return $v; } $plan = $this->getPlan(); // Get user name/email from the verification code database $code_data = $v->getData(); $settings = [ 'first_name' => $code_data->first_name, 'last_name' => $code_data->last_name, ]; if ($plan->mode == Plan::MODE_TOKEN) { $settings['signup_token'] = $code_data->email; } else { $settings['external_email'] = $code_data->email; } } // Find the voucher discount if ($request->voucher) { $discount = Discount::where('code', \strtoupper($request->voucher)) ->where('active', true)->first(); if (!$discount) { $errors = ['voucher' => \trans('validation.voucherinvalid')]; return response()->json(['status' => 'error', 'errors' => $errors], 422); } } if (empty($plan)) { $plan = $this->getPlan(); } $is_domain = $plan->hasDomain(); // Validate login if ($errors = self::validateLogin($request->login, $request->domain, $is_domain)) { return response()->json(['status' => 'error', 'errors' => $errors], 422); } // Set some properties for signup() method $request->settings = $settings; $request->plan = $plan; $request->discount = $discount ?? null; $request->invitation = $invitation ?? null; $result = []; if ($plan->mode == Plan::MODE_MANDATE) { $result = $this->mandateForPlan($plan, $request->discount); } return response()->json($result + ['status' => 'success']); } /** * Finishes the signup process by creating the user account. * * @param \Illuminate\Http\Request $request HTTP request * * @return \Illuminate\Http\JsonResponse JSON response */ public function signup(Request $request) { $v = $this->signupValidate($request); if ($v->status() !== 200) { return $v; } $is_domain = $request->plan->hasDomain(); // We allow only ASCII, so we can safely lower-case the email address $login = Str::lower($request->login); $domain_name = Str::lower($request->domain); $domain = null; DB::beginTransaction(); // Create domain record if ($is_domain) { $domain = Domain::create([ 'namespace' => $domain_name, 'type' => Domain::TYPE_EXTERNAL, ]); } // Create user record $user = User::create([ 'email' => $login . '@' . $domain_name, 'password' => $request->password, 'status' => User::STATUS_RESTRICTED, ]); if ($request->discount) { $wallet = $user->wallets()->first(); $wallet->discount()->associate($request->discount); $wallet->save(); } $user->assignPlan($request->plan, $domain); // Save the external email and plan in user settings $user->setSettings($request->settings); // Update the invitation if ($request->invitation) { $request->invitation->status = SignupInvitation::STATUS_COMPLETED; $request->invitation->user_id = $user->id; $request->invitation->save(); } // Soft-delete the verification code, and store some more info with it if ($request->code) { $request->code->user_id = $user->id; $request->code->submit_ip_address = $request->ip(); $request->code->deleted_at = \now(); $request->code->timestamps = false; $request->code->save(); } DB::commit(); $response = AuthController::logonResponse($user, $request->password); if ($request->plan->mode == Plan::MODE_MANDATE) { $data = $response->getData(true); $data['checkout'] = $this->mandateForPlan($request->plan, $request->discount, $user); $response->setData($data); } return $response; } /** * Collects some content to display to the user before redirect to a checkout page. * Optionally creates a recurrent payment mandate for specified user/plan. */ protected function mandateForPlan(Plan $plan, Discount $discount = null, User $user = null): array { $result = []; $min = \App\Payment::MIN_AMOUNT; - $planCost = $plan->cost() * $plan->months; + $planCost = $cost = $plan->cost() * $plan->months; + $disc = 0; if ($discount) { $planCost = (int) ($planCost * (100 - $discount->discount) / 100); + $disc = $cost - $planCost; } if ($planCost > $min) { $min = $planCost; } if ($user) { $wallet = $user->wallets()->first(); $wallet->setSettings([ 'mandate_amount' => sprintf('%.2f', round($min / 100, 2)), 'mandate_balance' => 0, ]); $mandate = [ 'currency' => $wallet->currency, 'description' => \App\Tenant::getConfig($user->tenant_id, 'app.name') . ' Auto-Payment Setup', 'methodId' => PaymentProvider::METHOD_CREDITCARD, - 'redirectUrl' => \App\Utils::serviceUrl('/payment/status', $user->tenant_id), + 'redirectUrl' => Utils::serviceUrl('/payment/status', $user->tenant_id), ]; $provider = PaymentProvider::factory($wallet); $result = $provider->createMandate($wallet, $mandate); } + $country = Utils::countryForRequest(); + $period = $plan->months == 12 ? 'yearly' : 'monthly'; + $currency = \config('app.currency'); + $rate = VatRate::where('country', $country) + ->where('start', '<=', now()->format('Y-m-d h:i:s')) + ->orderByDesc('start') + ->limit(1) + ->first(); + + $summary = '' + . '' . self::trans("app.signup-subscription-{$period}") . '' + . '' . Utils::money($cost, $currency) . '' + . ''; + + if ($discount) { + $summary .= '' + . '' . self::trans('app.discount-code', ['code' => $discount->code]) . '' + . '' . Utils::money(-$disc, $currency) . '' + . ''; + } + + $summary .= '' + . '' + . '' . self::trans('app.total') . '' + . '' . Utils::money($planCost, $currency) . '' + . ''; + + if ($rate && $rate->rate > 0) { + // TODO: app.vat.mode + $vat = round($planCost * $rate->rate / 100); + $content = self::trans('app.vat-incl', [ + 'rate' => Utils::percent($rate->rate), + 'cost' => Utils::money($planCost - $vat, $currency), + 'vat' => Utils::money($vat, $currency), + ]); + + $summary .= '*' . $content . ''; + } + + $trialEnd = $plan->free_months ? now()->copy()->addMonthsWithoutOverflow($plan->free_months) : now(); $params = [ - 'cost' => \App\Utils::money($planCost, \config('app.currency')), - 'period' => \trans($plan->months == 12 ? 'app.period-year' : 'app.period-month'), + 'cost' => Utils::money($planCost, $currency), + 'date' => $trialEnd->toDateString(), ]; - $content = '' . self::trans('app.signup-account-tobecreated') . '

' - . self::trans('app.signup-account-summary', $params) . '

' - . self::trans('app.signup-account-mandate', $params); - - $result['content'] = $content; + $result['title'] = self::trans("app.signup-plan-{$period}"); + $result['content'] = self::trans('app.signup-account-mandate', $params); + $result['summary'] = '' . $summary . '
'; return $result; } /** * Returns plan for the signup process * * @returns \App\Plan Plan object selected for current signup process */ protected function getPlan() { $request = request(); if (!$request->plan || !$request->plan instanceof Plan) { // Get the plan if specified and exists... if (($request->code instanceof SignupCode) && $request->code->plan) { $plan = Plan::withEnvTenantContext()->where('title', $request->code->plan)->first(); } elseif ($request->plan) { $plan = Plan::withEnvTenantContext()->where('title', $request->plan)->first(); } // ...otherwise use the default plan if (empty($plan)) { // TODO: Get default plan title from config $plan = Plan::withEnvTenantContext()->where('title', 'individual')->first(); } $request->plan = $plan; } return $request->plan; } /** * Login (kolab identity) validation * * @param string $login Login (local part of an email address) * @param string $domain Domain name * @param bool $external Enables additional checks for domain part * * @return array Error messages on validation error */ protected static function validateLogin($login, $domain, $external = false): ?array { // Validate login part alone $v = Validator::make( ['login' => $login], ['login' => ['required', 'string', new UserEmailLocal($external)]] ); if ($v->fails()) { return ['login' => $v->errors()->toArray()['login'][0]]; } $domains = $external ? null : Domain::getPublicDomains(); // Validate the domain $v = Validator::make( ['domain' => $domain], ['domain' => ['required', 'string', new UserEmailDomain($domains)]] ); if ($v->fails()) { return ['domain' => $v->errors()->toArray()['domain'][0]]; } $domain = Str::lower($domain); // Check if domain is already registered with us if ($external) { if (Domain::withTrashed()->where('namespace', $domain)->exists()) { return ['domain' => \trans('validation.domainexists')]; } } // Check if user with specified login already exists $email = $login . '@' . $domain; if (User::emailExists($email) || User::aliasExists($email) || \App\Group::emailExists($email)) { return ['login' => \trans('validation.loginexists')]; } return null; } } diff --git a/src/app/Utils.php b/src/app/Utils.php index 0eb21b82..1bd1794b 100644 --- a/src/app/Utils.php +++ b/src/app/Utils.php @@ -1,601 +1,621 @@ country ? $net->country : $fallback; } /** * 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 = 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 int */ public static function uuidInt(): int { $hex = self::uuidStr(); $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 (string) Str::uuid(); } 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/URL * @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 { if (preg_match('|^https?://|i', $route)) { return $route; } $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', 'app.company.copyright', 'app.companion_download_link', 'app.with_signup', '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]); } /** * A helper to display human-readable amount of money using * for specified currency and locale. * * @param int $amount Amount of money (in cents) * @param string $currency Currency code * @param string $locale Output locale * * @return string String representation, e.g. "9.99 CHF" */ public static function money(int $amount, $currency, $locale = 'de_DE'): string { - $amount = round($amount / 100, 2); - $nf = new \NumberFormatter($locale, \NumberFormatter::CURRENCY); - $result = $nf->formatCurrency($amount, $currency); + $result = $nf->formatCurrency(round($amount / 100, 2), $currency); // Replace non-breaking space return str_replace("\xC2\xA0", " ", $result); } + + /** + * A helper to display human-readable percent value + * for specified currency and locale. + * + * @param int|float $percent Percent value (0 to 100) + * @param string $locale Output locale + * + * @return string String representation, e.g. "0 %", "7.7 %" + */ + public static function percent(int|float $amount, $locale = 'de_DE'): string + { + $nf = new \NumberFormatter($locale, \NumberFormatter::PERCENT); + $sep = $nf->getSymbol(\NumberFormatter::DECIMAL_SEPARATOR_SYMBOL); + + $result = sprintf('%.2f', $amount); + $result = preg_replace('/\.00/', '', $result); + $result = preg_replace('/(\.[0-9])0/', '\\1', $result); + $result = str_replace('.', $sep, $result); + + return $result . ' %'; + } } diff --git a/src/resources/lang/en/app.php b/src/resources/lang/en/app.php index c5e6d987..07320f1c 100644 --- a/src/resources/lang/en/app.php +++ b/src/resources/lang/en/app.php @@ -1,157 +1,165 @@ 'Created', 'chart-deleted' => 'Deleted', 'chart-average' => 'average', 'chart-allusers' => 'All Users - last year', 'chart-discounts' => 'Discounts', 'chart-vouchers' => 'Vouchers', 'chart-income' => 'Income in :currency - last 8 weeks', 'chart-payers' => 'Payers - last year', 'chart-users' => 'Users - last 8 weeks', 'companion-create-success' => 'Companion app has been created.', 'companion-delete-success' => 'Companion app has been removed.', 'mandate-delete-success' => 'The auto-payment has been removed.', 'mandate-update-success' => 'The auto-payment has been updated.', 'planbutton' => 'Choose :plan', 'process-async' => 'Setup process has been pushed. Please wait.', 'process-user-new' => 'Registering a user...', 'process-user-ldap-ready' => 'Creating a user...', 'process-user-imap-ready' => 'Creating a mailbox...', 'process-domain-new' => 'Registering a custom domain...', 'process-domain-ldap-ready' => 'Creating a custom domain...', 'process-domain-verified' => 'Verifying a custom domain...', 'process-domain-confirmed' => 'Verifying an ownership of a custom domain...', 'process-success' => 'Setup process finished successfully.', 'process-error-distlist-ldap-ready' => 'Failed to create a distribution list.', 'process-error-domain-ldap-ready' => 'Failed to create a domain.', 'process-error-domain-verified' => 'Failed to verify a domain.', 'process-error-domain-confirmed' => 'Failed to verify an ownership of a domain.', 'process-error-resource-imap-ready' => 'Failed to verify that a shared folder exists.', 'process-error-resource-ldap-ready' => 'Failed to create a resource.', 'process-error-shared-folder-imap-ready' => 'Failed to verify that a shared folder exists.', 'process-error-shared-folder-ldap-ready' => 'Failed to create a shared folder.', 'process-error-user-ldap-ready' => 'Failed to create a user.', 'process-error-user-imap-ready' => 'Failed to verify that a mailbox exists.', 'process-distlist-new' => 'Registering a distribution list...', 'process-distlist-ldap-ready' => 'Creating a distribution list...', 'process-resource-new' => 'Registering a resource...', 'process-resource-imap-ready' => 'Creating a shared folder...', 'process-resource-ldap-ready' => 'Creating a resource...', 'process-shared-folder-new' => 'Registering a shared folder...', 'process-shared-folder-imap-ready' => 'Creating a shared folder...', 'process-shared-folder-ldap-ready' => 'Creating a shared folder...', + 'discount-code' => 'Discount: :code', + 'distlist-update-success' => 'Distribution list updated successfully.', 'distlist-create-success' => 'Distribution list created successfully.', 'distlist-delete-success' => 'Distribution list deleted successfully.', 'distlist-suspend-success' => 'Distribution list suspended successfully.', 'distlist-unsuspend-success' => 'Distribution list unsuspended successfully.', 'distlist-setconfig-success' => 'Distribution list settings updated successfully.', 'domain-create-success' => 'Domain created successfully.', 'domain-delete-success' => 'Domain deleted successfully.', 'domain-notempty-error' => 'Unable to delete a domain with assigned users or other objects.', 'domain-verify-success' => 'Domain verified successfully.', 'domain-verify-error' => 'Domain ownership verification failed.', 'domain-suspend-success' => 'Domain suspended successfully.', 'domain-unsuspend-success' => 'Domain unsuspended successfully.', 'domain-setconfig-success' => 'Domain settings updated successfully.', 'file-create-success' => 'File created successfully.', 'file-delete-success' => 'File deleted successfully.', 'file-update-success' => 'File updated successfully.', 'file-permissions-create-success' => 'File permissions created successfully.', 'file-permissions-update-success' => 'File permissions updated successfully.', 'file-permissions-delete-success' => 'File permissions deleted successfully.', 'payment-status-paid' => 'The payment has been completed successfully.', 'payment-status-canceled' => 'The payment has been canceled.', 'payment-status-failed' => 'The payment failed.', 'payment-status-expired' => 'The payment expired.', 'payment-status-checking' => "The payment hasn't been completed yet. Checking the status...", 'period-year' => 'year', 'period-month' => 'month', 'resource-update-success' => 'Resource updated successfully.', 'resource-create-success' => 'Resource created successfully.', 'resource-delete-success' => 'Resource deleted successfully.', 'resource-setconfig-success' => 'Resource settings updated successfully.', 'room-update-success' => 'Room updated successfully.', 'room-create-success' => 'Room created successfully.', 'room-delete-success' => 'Room deleted successfully.', 'room-setconfig-success' => 'Room configuration updated successfully.', 'room-unsupported-option-error' => 'Invalid room configuration option.', 'shared-folder-update-success' => 'Shared folder updated successfully.', 'shared-folder-create-success' => 'Shared folder created successfully.', 'shared-folder-delete-success' => 'Shared folder deleted successfully.', 'shared-folder-setconfig-success' => 'Shared folder settings updated successfully.', 'user-update-success' => 'User data updated successfully.', 'user-create-success' => 'User created successfully.', 'user-delete-success' => 'User deleted successfully.', 'user-suspend-success' => 'User suspended successfully.', 'user-unsuspend-success' => 'User unsuspended successfully.', 'user-reset-2fa-success' => '2-Factor authentication reset successfully.', 'user-reset-geo-lock-success' => 'Geo-lockin setup reset successfully.', 'user-setconfig-success' => 'User settings updated successfully.', 'user-set-sku-success' => 'The subscription added successfully.', 'user-set-sku-already-exists' => 'The subscription already exists.', 'search-foundxdomains' => ':x domains have been found.', 'search-foundxdistlists' => ':x distribution lists have been found.', 'search-foundxresources' => ':x resources have been found.', 'search-foundxshared-folders' => ':x shared folders have been found.', 'search-foundxusers' => ':x user accounts have been found.', - 'signup-account-tobecreated' => 'The account is about to be created!', 'signup-account-mandate' => 'Now it is required to provide your credit card details.' . ' This way you agree to charge you with an appropriate amount of money according to the plan you signed up for.', - 'signup-account-summary' => 'You signed up for an account with a base cost of :cost per :period.', + 'signup-plan-monthly' => 'You are choosing a monthly subscription.', + 'signup-plan-yearly' => 'You are choosing a yearly subscription.', + 'signup-subscription-monthly' => 'Monthly subscription', + 'signup-subscription-yearly' => 'Yearly subscription', 'signup-invitations-created' => 'The invitation has been created.|:count invitations has been created.', 'signup-invitations-csv-empty' => 'Failed to find any valid email addresses in the uploaded file.', 'signup-invitations-csv-invalid-email' => 'Found an invalid email address (:email) on line :line.', 'signup-invitation-delete-success' => 'Invitation deleted successfully.', 'signup-invitation-resend-success' => 'Invitation added to the sending queue successfully.', 'support-request-success' => 'Support request submitted successfully.', 'support-request-error' => 'Failed to submit the support request.', 'siteuser' => ':site User', + 'total' => 'Total', 'wallet-award-success' => 'The bonus has been added to the wallet successfully.', 'wallet-penalty-success' => 'The penalty has been added to the wallet successfully.', 'wallet-update-success' => 'User wallet updated successfully.', 'password-reset-code-delete-success' => 'Password reset code deleted successfully.', 'password-rule-min' => 'Minimum password length: :param characters', 'password-rule-max' => 'Maximum password length: :param characters', 'password-rule-lower' => 'Password contains a lower-case character', 'password-rule-upper' => 'Password contains an upper-case character', 'password-rule-digit' => 'Password contains a digit', 'password-rule-special' => 'Password contains a special character', 'password-rule-last' => 'Password cannot be the same as the last :param passwords', 'wallet-notice-date' => 'With your current subscriptions your account balance will last until about :date (:days).', 'wallet-notice-nocredit' => 'You are out of credit, top up your balance now.', 'wallet-notice-today' => 'You will run out of credit today, top up your balance now.', 'wallet-notice-trial' => 'You are in your free trial period.', 'wallet-notice-trial-end' => 'Your free trial is about to end, top up to continue.', + + 'vat-incl' => 'Incl. VAT :vat (:rate of :cost)', + ]; diff --git a/src/resources/lang/en/ui.php b/src/resources/lang/en/ui.php index 912c1f62..c353727f 100644 --- a/src/resources/lang/en/ui.php +++ b/src/resources/lang/en/ui.php @@ -1,563 +1,564 @@ [ 'faq' => "FAQ", ], 'btn' => [ 'add' => "Add", 'accept' => "Accept", 'back' => "Back", 'cancel' => "Cancel", 'close' => "Close", 'continue' => "Continue", 'copy' => "Copy", 'delete' => "Delete", 'deny' => "Deny", 'download' => "Download", 'edit' => "Edit", 'file' => "Choose file...", 'moreinfo' => "More information", 'refresh' => "Refresh", 'reset' => "Reset", 'resend' => "Resend", 'save' => "Save", 'search' => "Search", 'share' => "Share", 'signup' => "Sign Up", 'submit' => "Submit", 'suspend' => "Suspend", 'tryagain' => "Try again", 'unsuspend' => "Unsuspend", 'verify' => "Verify", ], 'companion' => [ 'title' => "Companion Apps", 'companion' => "Companion App", 'name' => "Name", 'create' => "Pair new device", 'create-recovery-device' => "Prepare recovery code", 'description' => "Use the Companion App on your mobile phone as multi-factor authentication device.", 'download-description' => "You may download the Companion App for Android here: " . "Download", 'description-detailed' => "Here is how this works: " . "Pairing a device will automatically enable multi-factor autentication for all login attempts. " . "This includes not only the Cockpit, but also logins via Webmail, IMAP, SMPT, DAV and ActiveSync. " . "Any authentication attempt will result in a notification on your device, " . "that you can use to confirm if it was you, or deny otherwise. " . "Once confirmed, the same username + IP address combination will be whitelisted for 8 hours. " . "Unpair all your active devices to disable multi-factor authentication again.", 'description-warning' => "Warning: Loosing access to all your multi-factor authentication devices, " . "will permanently lock you out of your account with no course for recovery. " . "Always make sure you have a recovery QR-Code printed to pair a recovery device.", 'new' => "Pair new device", 'recovery' => "Prepare recovery device", 'paired' => "Paired devices", 'print' => "Print for backup", 'pairing-instructions' => "Pair your device using the following QR-Code.", 'recovery-device' => "Recovery Device", 'new-device' => "New Device", 'deviceid' => "Device ID", 'list-empty' => "There are currently no devices", 'delete' => "Delete/Unpair", 'delete-companion' => "Delete/Unpair", 'delete-text' => "You are about to delete this entry and unpair any paired companion app. " . "This cannot be undone, but you can pair the device again.", 'pairing-successful' => "Your companion app is paired and ready to be used " . "as a multi-factor authentication device.", ], 'dashboard' => [ 'beta' => "beta", 'distlists' => "Distribution lists", 'chat' => "Video chat", 'companion' => "Companion app", 'domains' => "Domains", 'files' => "Files", 'invitations' => "Invitations", 'profile' => "Your profile", 'resources' => "Resources", 'settings' => "Settings", 'shared-folders' => "Shared folders", 'users' => "User accounts", 'wallet' => "Wallet", 'webmail' => "Webmail", 'stats' => "Stats", ], 'distlist' => [ 'list-title' => "Distribution list | Distribution lists", 'create' => "Create list", 'delete' => "Delete list", 'email' => "Email", 'list-empty' => "There are no distribution lists in this account.", 'name' => "Name", 'new' => "New distribution list", 'recipients' => "Recipients", 'sender-policy' => "Sender Access List", 'sender-policy-text' => "With this list you can specify who can send mail to the distribution list." . " You can put a complete email address (jane@kolab.org), domain (kolab.org) or suffix (.org) that the sender email address is compared to." . " If the list is empty, mail from anyone is allowed.", ], 'domain' => [ 'delete' => "Delete domain", 'delete-domain' => "Delete {domain}", 'delete-text' => "Do you really want to delete this domain permanently?" . " This is only possible if there are no users, aliases or other objects in this domain." . " Please note that this action cannot be undone.", 'dns-verify' => "Domain DNS verification sample:", 'dns-config' => "Domain DNS configuration sample:", 'list-empty' => "There are no domains in this account.", 'namespace' => "Namespace", 'spf-whitelist' => "SPF Whitelist", 'spf-whitelist-text' => "The Sender Policy Framework allows a sender domain to disclose, through DNS, " . "which systems are allowed to send emails with an envelope sender address within said domain.", 'spf-whitelist-ex' => "Here you can specify a list of allowed servers, for example: .ess.barracuda.com.", 'verify' => "Domain verification", 'verify-intro' => "In order to confirm that you're the actual holder of the domain, we need to run a verification process before finally activating it for email delivery.", 'verify-dns' => "The domain must have one of the following entries in DNS:", 'verify-dns-txt' => "TXT entry with value:", 'verify-dns-cname' => "or CNAME entry:", 'verify-outro' => "When this is done press the button below to start the verification.", 'verify-sample' => "Here's a sample zone file for your domain:", 'config' => "Domain configuration", 'config-intro' => "In order to let {app} receive email traffic for your domain you need to adjust the DNS settings, more precisely the MX entries, accordingly.", 'config-sample' => "Edit your domain's zone file and replace existing MX entries with the following values:", 'config-hint' => "If you don't know how to set DNS entries for your domain, please contact the registration service where you registered the domain or your web hosting provider.", 'create' => "Create domain", 'new' => "New domain", ], 'error' => [ '400' => "Bad request", '401' => "Unauthorized", '403' => "Access denied", '404' => "Not found", '405' => "Method not allowed", '500' => "Internal server error", 'unknown' => "Unknown Error", 'server' => "Server Error", 'form' => "Form validation error", ], 'file' => [ 'create' => "Create file", 'delete' => "Delete file", 'list-empty' => "There are no files in this account.", 'mimetype' => "Mimetype", 'mtime' => "Modified", 'new' => "New file", 'search' => "File name", 'sharing' => "Sharing", 'sharing-links-text' => "You can share the file with other users by giving them read-only access " . "to the file via a unique link.", ], 'form' => [ 'acl' => "Access rights", 'acl-full' => "All", 'acl-read-only' => "Read-only", 'acl-read-write' => "Read-write", 'amount' => "Amount", 'anyone' => "Anyone", 'code' => "Confirmation Code", 'config' => "Configuration", 'companion' => "Companion App", 'date' => "Date", 'description' => "Description", 'details' => "Details", 'disabled' => "disabled", 'domain' => "Domain", 'email' => "Email Address", 'emails' => "Email Addresses", 'enabled' => "enabled", 'firstname' => "First Name", 'general' => "General", 'geolocation' => "Your current location: {location}", 'lastname' => "Last Name", 'name' => "Name", 'months' => "months", 'none' => "none", 'norestrictions' => "No restrictions", 'or' => "or", 'password' => "Password", 'password-confirm' => "Confirm Password", 'phone' => "Phone", 'selectcountries' => "Select countries", 'settings' => "Settings", 'shared-folder' => "Shared Folder", 'size' => "Size", 'status' => "Status", 'subscriptions' => "Subscriptions", 'surname' => "Surname", 'type' => "Type", 'unknown' => "unknown", 'user' => "User", 'primary-email' => "Primary Email", 'id' => "ID", 'created' => "Created", 'deleted' => "Deleted", ], 'invitation' => [ 'create' => "Create invite(s)", 'create-title' => "Invite for a signup", 'create-email' => "Enter an email address of the person you want to invite.", 'create-csv' => "To send multiple invitations at once, provide a CSV (comma separated) file, or alternatively a plain-text file, containing one email address per line.", 'list-empty' => "There are no invitations in the database.", 'title' => "Signup invitations", 'search' => "Email address or domain", 'send' => "Send invite(s)", 'status-completed' => "User signed up", 'status-failed' => "Sending failed", 'status-sent' => "Sent", 'status-new' => "Not sent yet", ], 'lang' => [ 'en' => "English", 'de' => "German", 'fr' => "French", 'it' => "Italian", ], 'login' => [ '2fa' => "Second factor code", '2fa_desc' => "Second factor code is optional for users with no 2-Factor Authentication setup.", 'forgot_password' => "Forgot password?", 'header' => "Please sign in", 'sign_in' => "Sign in", 'signing_in' => "Signing in...", 'webmail' => "Webmail" ], 'meet' => [ // Room options dialog 'options' => "Room options", 'password' => "Password", 'password-none' => "none", 'password-clear' => "Clear password", 'password-set' => "Set password", 'password-text' => "You can add a password to your meeting. Participants will have to provide the password before they are allowed to join the meeting.", 'lock' => "Locked room", 'lock-text' => "When the room is locked participants have to be approved by a moderator before they could join the meeting.", 'nomedia' => "Subscribers only", 'nomedia-text' => "Forces all participants to join as subscribers (with camera and microphone turned off)." . " Moderators will be able to promote them to publishers throughout the session.", // Room menu 'partcnt' => "Number of participants", 'menu-audio-mute' => "Mute audio", 'menu-audio-unmute' => "Unmute audio", 'menu-video-mute' => "Mute video", 'menu-video-unmute' => "Unmute video", 'menu-screen' => "Share screen", 'menu-hand-lower' => "Lower hand", 'menu-hand-raise' => "Raise hand", 'menu-channel' => "Interpreted language channel", 'menu-chat' => "Chat", 'menu-fullscreen' => "Full screen", 'menu-fullscreen-exit' => "Exit full screen", 'menu-leave' => "Leave session", // Room setup screen 'setup-title' => "Set up your session", 'mic' => "Microphone", 'cam' => "Camera", 'nick' => "Nickname", 'nick-placeholder' => "Your name", 'join' => "JOIN", 'joinnow' => "JOIN NOW", 'imaowner' => "I'm the owner", // Room 'qa' => "Q & A", 'leave-title' => "Room closed", 'leave-body' => "The session has been closed by the room owner.", 'media-title' => "Media setup", 'join-request' => "Join request", 'join-requested' => "{user} requested to join.", // Status messages 'status-init' => "Checking the room...", 'status-323' => "The room is closed. Please, wait for the owner to start the session.", 'status-324' => "The room is closed. It will be open for others after you join.", 'status-325' => "The room is ready. Please, provide a valid password.", 'status-326' => "The room is locked. Please, enter your name and try again.", 'status-327' => "Waiting for permission to join the room.", 'status-404' => "The room does not exist.", 'status-429' => "Too many requests. Please, wait.", 'status-500' => "Failed to connect to the room. Server error.", // Other menus 'media-setup' => "Media setup", 'perm' => "Permissions", 'perm-av' => "Audio & Video publishing", 'perm-mod' => "Moderation", 'lang-int' => "Language interpreter", 'menu-options' => "Options", ], 'menu' => [ 'cockpit' => "Cockpit", 'login' => "Login", 'logout' => "Logout", 'signup' => "Signup", 'toggle' => "Toggle navigation", ], 'msg' => [ 'initializing' => "Initializing...", 'loading' => "Loading...", 'loading-failed' => "Failed to load data.", 'notfound' => "Resource not found.", 'info' => "Information", 'error' => "Error", 'uploading' => "Uploading...", 'warning' => "Warning", 'success' => "Success", ], 'nav' => [ 'more' => "Load more", 'step' => "Step {i}/{n}", ], 'password' => [ 'link-invalid' => "The password reset code is expired or invalid.", 'reset' => "Password Reset", 'reset-step1' => "Enter your email address to reset your password.", 'reset-step1-hint' => "You may need to check your spam folder or unblock {email}.", 'reset-step2' => "We sent out a confirmation code to your external email address." . " Enter the code we sent you, or click the link in the message.", ], 'resource' => [ 'create' => "Create resource", 'delete' => "Delete resource", 'invitation-policy' => "Invitation policy", 'invitation-policy-text' => "Event invitations for a resource are normally accepted automatically" . " if there is no conflicting event on the requested time slot. Invitation policy allows" . " for rejecting such requests or to require a manual acceptance from a specified user.", 'ipolicy-manual' => "Manual (tentative)", 'ipolicy-accept' => "Accept", 'ipolicy-reject' => "Reject", 'list-title' => "Resource | Resources", 'list-empty' => "There are no resources in this account.", 'new' => "New resource", ], 'room' => [ 'create' => "Create room", 'delete' => "Delete room", 'copy-location' => "Copy room location", 'description-hint' => "This is an optional short description for the room, so you can find it more easily on the list.", 'goto' => "Enter the room", 'list-empty' => "There are no conference rooms in this account.", 'list-empty-nocontroller' => "Do you need a room? Ask your account owner to create one and share it with you.", 'list-title' => "Voice & video conferencing rooms", 'moderators' => "Moderators", 'moderators-text' => "You can share your room with other users. They will become the room moderators with all moderator powers and ability to open the room without your presence.", 'new' => "New room", 'new-hint' => "We'll generate a unique name for the room that will then allow you to access the room.", 'title' => "Room: {name}", 'url' => "You can access the room at the URL below. Use this URL to invite people to join you. This room is only open when you (or another room moderator) is in attendance.", ], 'settings' => [ 'password-policy' => "Password Policy", 'password-retention' => "Password Retention", 'password-max-age' => "Require a password change every", ], 'shf' => [ 'aliases-none' => "This shared folder has no email aliases.", 'create' => "Create folder", 'delete' => "Delete folder", 'acl-text' => "Defines user permissions to access the shared folder.", 'list-title' => "Shared folder | Shared folders", 'list-empty' => "There are no shared folders in this account.", 'new' => "New shared folder", 'type-mail' => "Mail", 'type-event' => "Calendar", 'type-contact' => "Address Book", 'type-task' => "Tasks", 'type-note' => "Notes", 'type-file' => "Files", ], 'signup' => [ 'email' => "Existing Email Address", 'login' => "Login", 'title' => "Sign Up", 'step1' => "Sign up to start your free month.", 'step2' => "We sent out a confirmation code to your email address. Enter the code we sent you, or click the link in the message.", 'step3' => "Create your {app} identity (you can choose additional addresses later).", + 'created' => "The account is about to be created!", 'token' => "Signup authorization token", 'voucher' => "Voucher Code", ], 'status' => [ 'prepare-account' => "We are preparing your account.", 'prepare-domain' => "We are preparing the domain.", 'prepare-distlist' => "We are preparing the distribution list.", 'prepare-resource' => "We are preparing the resource.", 'prepare-shared-folder' => "We are preparing the shared folder.", 'prepare-user' => "We are preparing the user account.", 'prepare-hint' => "Some features may be missing or readonly at the moment.", 'prepare-refresh' => "The process never ends? Press the \"Refresh\" button, please.", 'ready-account' => "Your account is almost ready.", 'ready-domain' => "The domain is almost ready.", 'ready-distlist' => "The distribution list is almost ready.", 'ready-resource' => "The resource is almost ready.", 'ready-shared-folder' => "The shared-folder is almost ready.", 'ready-user' => "The user account is almost ready.", 'verify' => "Verify your domain to finish the setup process.", 'verify-domain' => "Verify domain", 'degraded' => "Degraded", 'deleted' => "Deleted", 'restricted' => "Restricted", 'suspended' => "Suspended", 'notready' => "Not Ready", 'active' => "Active", ], 'support' => [ 'title' => "Contact Support", 'id' => "Customer number or email address you have with us", 'id-pl' => "e.g. 12345678 or the affected email address", 'id-hint' => "Leave blank if you are not a customer yet", 'name' => "Name", 'name-pl' => "how we should call you in our reply", 'email' => "Working email address", 'email-pl' => "make sure we can reach you at this address", 'summary' => "Issue Summary", 'summary-pl' => "one sentence that summarizes your issue", 'expl' => "Issue Explanation", ], 'user' => [ '2fa-hint1' => "This will remove 2-Factor Authentication entitlement as well as the user-configured factors.", '2fa-hint2' => "Please, make sure to confirm the user identity properly.", 'add-beta' => "Enable beta program", 'address' => "Address", 'aliases' => "Aliases", 'aliases-none' => "This user has no email aliases.", 'add-bonus' => "Add bonus", 'add-bonus-title' => "Add a bonus to the wallet", 'add-penalty' => "Add penalty", 'add-penalty-title' => "Add a penalty to the wallet", 'auto-payment' => "Auto-payment", 'auto-payment-text' => "Fill up by {amount} when under {balance} using {method}", 'country' => "Country", 'create' => "Create user", 'custno' => "Customer No.", 'degraded-warning' => "The account is degraded. Some features have been disabled.", 'degraded-hint' => "Please, make a payment.", 'delete' => "Delete user", 'delete-account' => "Delete this account?", 'delete-email' => "Delete {email}", 'delete-text' => "Do you really want to delete this user permanently?" . " This will delete all account data and withdraw the permission to access the email account." . " Please note that this action cannot be undone.", 'discount' => "Discount", 'discount-hint' => "applied discount", 'discount-title' => "Account discount", 'distlists' => "Distribution lists", 'domains' => "Domains", 'ext-email' => "External Email", 'email-aliases' => "Email Aliases", 'finances' => "Finances", 'geolimit' => "Geo-lockin", 'geolimit-text' => "Defines a list of locations that are allowed for logon. You will not be able to login from a country that is not listed here.", 'greylisting' => "Greylisting", 'greylisting-text' => "Greylisting is a method of defending users against spam. Any incoming mail from an unrecognized sender " . "is temporarily rejected. The originating server should try again after a delay. " . "This time the email will be accepted. Spammers usually do not reattempt mail delivery.", 'imapproxy' => "IMAP proxy", 'imapproxy-text' => "Enables IMAP proxy that filters out non-mail groupware folders, so your IMAP clients do not see them.", 'list-title' => "User accounts", 'list-empty' => "There are no users in this account.", 'managed-by' => "Managed by", 'new' => "New user account", 'org' => "Organization", 'package' => "Package", 'pass-input' => "Enter password", 'pass-link' => "Set via link", 'pass-link-label' => "Link:", 'pass-link-hint' => "Press Submit to activate the link", 'passwordpolicy' => "Password Policy", 'price' => "Price", 'profile-title' => "Your profile", 'profile-delete' => "Delete account", 'profile-delete-title' => "Delete this account?", 'profile-delete-text1' => "This will delete the account as well as all domains, users and aliases associated with this account.", 'profile-delete-warning' => "This operation is irreversible", 'profile-delete-text2' => "As you will not be able to recover anything after this point, please make sure that you have migrated all data before proceeding.", 'profile-delete-support' => "As we always strive to improve, we would like to ask for 2 minutes of your time. " . "The best tool for improvement is feedback from users, and we would like to ask " . "for a few words about your reasons for leaving our service. Please send your feedback to {email}.", 'profile-delete-contact' => "Also feel free to contact {app} Support with any questions or concerns that you may have in this context.", 'reset-2fa' => "Reset 2-Factor Auth", 'reset-2fa-title' => "2-Factor Authentication Reset", 'resources' => "Resources", 'title' => "User account", 'search' => "User email address or name", 'search-pl' => "User ID, email or domain", 'skureq' => "{sku} requires {list}.", 'subscription' => "Subscription", 'subscriptions-none' => "This user has no subscriptions.", 'users' => "Users", ], 'wallet' => [ 'add-credit' => "Add credit", 'auto-payment-cancel' => "Cancel auto-payment", 'auto-payment-change' => "Change auto-payment", 'auto-payment-failed' => "The setup of automatic payments failed. Restart the process to enable automatic top-ups.", 'auto-payment-hint' => "Here is how it works: Every time your account runs low, we will charge your preferred payment method for an amount you choose." . " You can cancel or change the auto-payment option at any time.", 'auto-payment-setup' => "Set up auto-payment", 'auto-payment-disabled' => "The configured auto-payment has been disabled. Top up your wallet or raise the auto-payment amount.", 'auto-payment-info' => "Auto-payment is set to fill up your account by {amount} every time your account balance gets under {balance}.", 'auto-payment-inprogress' => "The setup of the automatic payment is still in progress.", 'auto-payment-next' => "Next, you will be redirected to the checkout page, where you can provide your credit card details.", 'auto-payment-disabled-next' => "The auto-payment is disabled. Immediately after you submit new settings we'll enable it and attempt to top up your wallet.", 'auto-payment-update' => "Update auto-payment", 'banktransfer-hint' => "Please note that a bank transfer can take several days to complete.", 'coinbase-hint' => "Here is how it works: You specify the amount by which you want to top up your wallet in {wc}." . " We will then create a charge on Coinbase for the specified amount that you can pay using Bitcoin.", 'currency-conv' => "Here is how it works: You specify the amount by which you want to top up your wallet in {wc}." . " We will then convert this to {pc}, and on the next page you will be provided with the bank-details to transfer the amount in {pc}.", 'fill-up' => "Fill up by", 'history' => "History", 'locked-text' => "The account is locked until you set up auto-payment successfully.", 'month' => "month", 'noperm' => "Only account owners can access a wallet.", 'norefund' => "The money in your wallet is non-refundable.", 'payment-amount-hint' => "Choose the amount by which you want to top up your wallet.", 'payment-method' => "Method of payment: {method}", 'payment-warning' => "You will be charged for {price}.", 'pending-payments' => "Pending Payments", 'pending-payments-warning' => "You have payments that are still in progress. See the \"Pending Payments\" tab below.", 'pending-payments-none' => "There are no pending payments for this account.", 'receipts' => "Receipts", 'receipts-hint' => "Here you can download receipts (in PDF format) for payments in specified period. Select the period and press the Download button.", 'receipts-none' => "There are no receipts for payments in this account. Please, note that you can download receipts after the month ends.", 'title' => "Account balance", 'top-up' => "Top up your wallet", 'transactions' => "Transactions", 'transactions-none' => "There are no transactions for this account.", 'when-below' => "when account balance is below", ], ]; diff --git a/src/resources/themes/app.scss b/src/resources/themes/app.scss index 5d0a03bd..76581c36 100644 --- a/src/resources/themes/app.scss +++ b/src/resources/themes/app.scss @@ -1,541 +1,576 @@ html, body, body > .outer-container { height: 100%; } #app { display: flex; flex-direction: column; min-height: 100%; overflow: hidden; & > nav { flex-shrink: 0; z-index: 12; } & > div.container { flex-grow: 1; margin-top: 2rem; margin-bottom: 2rem; } & > .filler { flex-grow: 1; } & > div.container + .filler { display: none; } } .error-page { position: absolute; top: 0; height: 100%; width: 100%; align-content: center; align-items: center; display: flex; flex-wrap: wrap; justify-content: center; color: #636b6f; z-index: 10; background: white; .code { text-align: right; border-right: 2px solid; font-size: 26px; padding: 0 15px; } .message { font-size: 18px; padding: 0 15px; } .hint { margin-top: 3em; text-align: center; width: 100%; } } .app-loader { background-color: $body-bg; height: 100%; width: 100%; position: absolute; top: 0; left: 0; display: flex; align-items: center; justify-content: center; z-index: 8; .spinner-border { width: 120px; height: 120px; border-width: 15px; color: #b2aa99; } &.small .spinner-border { width: 25px; height: 25px; border-width: 3px; } &.fadeOut { visibility: hidden; opacity: 0; transition: visibility 300ms linear, opacity 300ms linear; } } pre { margin: 1rem 0; padding: 1rem; background-color: $menu-bg-color; } .card-title { font-size: 1.2rem; font-weight: bold; } tfoot.table-fake-body { background-color: #f8f8f8; color: grey; text-align: center; td { vertical-align: middle; height: 8em; border: 0; } tbody:not(:empty) + & { display: none; } } table { th { white-space: nowrap; } td .btn-link { vertical-align: initial; } td.email, td.price, td.datetime, td.selection { width: 1%; white-space: nowrap; } td.buttons, th.price, td.price, th.size, td.size { width: 1%; text-align: right; white-space: nowrap; } &.form-list { margin: 0; td { border: 0; &:first-child { padding-left: 0; } &:last-child { padding-right: 0; } } button { line-height: 1; } } .btn-action { line-height: 1; padding: 0; } &.files { table-layout: fixed; td { white-space: nowrap; } td.name { overflow: hidden; text-overflow: ellipsis; } /* td.size, th.size { width: 80px; } td.mtime, th.mtime { width: 140px; @include media-breakpoint-down(sm) { display: none; } } */ td.buttons, th.buttons { width: 50px; } } } .table > :not(:first-child) { // Remove Bootstrap's 2px border border-width: 0; } .list-details { min-height: 1em; & > ul { margin: 0; padding-left: 1.2em; } } .plan-selector { .plan-header { display: flex; } .plan-ico { margin:auto; font-size: 3.8rem; color: $main-color; border: 3px solid $main-color; width: 6rem; height: 6rem; border-radius: 50%; } } .status-message { display: flex; align-items: center; justify-content: center; .app-loader { width: auto; position: initial; .spinner-border { color: $body-color; } } svg { font-size: 1.5em; } :first-child { margin-right: 0.4em; } } .form-separator { position: relative; margin: 1em 0; display: flex; justify-content: center; hr { border-color: #999; margin: 0; position: absolute; top: 0.75em; width: 100%; } span { background: #fff; padding: 0 1em; z-index: 1; } } .modal { .modal-dialog, .modal-content { max-height: calc(100vh - 3.5rem); } .modal-body { overflow: auto !important; } &.fullscreen { .modal-dialog { height: 100%; width: 100%; max-width: calc(100vw - 1rem); } .modal-content { height: 100%; max-height: 100% !important; } .modal-body { padding: 0; margin: 1em; overflow: hidden !important; } } } #status-box { background-color: lighten($green, 35); .progress { background-color: #fff; height: 10px; } .progress-label { font-size: 0.9em; } .progress-bar { background-color: $green; } &.process-failed { background-color: lighten($orange, 30); .progress-bar { background-color: $red; } } } @keyframes blinker { 50% { opacity: 0; } } .blinker { animation: blinker 750ms step-start infinite; } #dashboard-nav { display: flex; flex-wrap: wrap; justify-content: center; & > a { padding: 1rem; text-align: center; white-space: nowrap; margin: 0.25rem; text-decoration: none; width: 150px; &.disabled { pointer-events: none; opacity: 0.6; } // Some icons are too big, scale them down &.link-companionapp, &.link-domains, &.link-resources, &.link-settings, &.link-wallet, &.link-invitations { svg { transform: scale(0.8); } } &.link-distlists, &.link-files, &.link-shared-folders { svg { transform: scale(0.9); } } .badge { position: absolute; top: 0.5rem; right: 0.5rem; } } svg { width: 6rem; height: 6rem; margin: auto; } } #payment-method-selection { display: flex; flex-wrap: wrap; justify-content: center; & > a { padding: 1rem; text-align: center; white-space: nowrap; margin: 0.25rem; text-decoration: none; width: 150px; } svg { width: 6rem; height: 6rem; margin: auto; } .link-banktransfer svg { transform: scale(.8); } } +#summary-cc { + svg { + height: 2.5em; + width: 2.5em; + margin-right: 0.5em; + } +} + +#summary-summary { + padding: 0.5rem; + + table { + width: 100%; + } + + tr { + &.total { + font-weight: bold; + } + + &.vat-summary { + font-size: small; + } + } + + td { + padding: 0.25em; + + &.money { + white-space: nowrap; + text-align: right; + } + } +} + #logon-form { flex-basis: auto; // Bootstrap issue? See logon page with width < 992 } #logon-form-footer { a:not(:first-child) { margin-left: 2em; } } // Various improvements for mobile @include media-breakpoint-down(sm) { .card, .card-footer { border: 0; } .card-body { padding: 0.5rem 0; } .nav-tabs { flex-wrap: nowrap; .nav-link { white-space: nowrap; padding: 0.5rem 0.75rem; } } #app > div.container { margin-bottom: 1rem; margin-top: 1rem; max-width: 100%; } #header-menu-navbar { padding: 0; } #dashboard-nav > a { width: 135px; } .table-sm:not(.form-list) { tbody td { padding: 0.75rem 0.5rem; svg { vertical-align: -0.175em; } & > svg { font-size: 125%; margin-right: 0.25rem; } } } .table.transactions { thead { display: none; } tbody { tr { position: relative; display: flex; flex-wrap: wrap; } td { width: auto; border: 0; padding: 0.5rem; &.datetime { width: 50%; padding-left: 0; } &.description { order: 3; width: 100%; border-bottom: 1px solid $border-color; color: $secondary; padding: 0 1.5em 0.5rem 0; margin-top: -0.25em; } &.selection { position: absolute; right: 0; border: 0; top: 1.7em; padding-right: 0; } &.price { width: 50%; padding-right: 0; } &.email { display: none; } } } } } @include media-breakpoint-down(sm) { .tab-pane > .card-body { padding: 0.5rem; } } diff --git a/src/resources/vue/Signup.vue b/src/resources/vue/Signup.vue index d36ed032..ac9814e2 100644 --- a/src/resources/vue/Signup.vue +++ b/src/resources/vue/Signup.vue @@ -1,412 +1,429 @@ diff --git a/src/tests/Browser/SignupTest.php b/src/tests/Browser/SignupTest.php index 9b4497e7..9c53634f 100644 --- a/src/tests/Browser/SignupTest.php +++ b/src/tests/Browser/SignupTest.php @@ -1,861 +1,864 @@ deleteTestUser('signuptestdusk@' . \config('app.domain')); $this->deleteTestUser('admin@user-domain-signup.com'); $this->deleteTestDomain('user-domain-signup.com'); Plan::whereNot('mode', Plan::MODE_EMAIL)->update(['mode' => Plan::MODE_EMAIL]); } /** * {@inheritDoc} */ public function tearDown(): void { $this->deleteTestUser('signuptestdusk@' . \config('app.domain')); $this->deleteTestUser('admin@user-domain-signup.com'); $this->deleteTestDomain('user-domain-signup.com'); SignupInvitation::truncate(); Plan::whereNot('mode', Plan::MODE_EMAIL)->update(['mode' => Plan::MODE_EMAIL]); @unlink(storage_path('signup-tokens.txt')); parent::tearDown(); } /** * Test signup code verification with a link */ public function testSignupCodeByLink(): void { // Test invalid code (invalid format) $this->browse(function (Browser $browser) { // Register Signup page element selectors we'll be using $browser->onWithoutAssert(new Signup()); // TODO: Test what happens if user is logged in $browser->visit('/signup/invalid-code'); // TODO: According to https://github.com/vuejs/vue-router/issues/977 // it is not yet easily possible to display error page component (route) // without changing the URL // TODO: Instead of css selector we should probably define page/component // and use it instead $browser->waitFor('#error-page'); }); // Test invalid code (valid format) $this->browse(function (Browser $browser) { $browser->visit('/signup/XXXXX-code'); // FIXME: User will not be able to continue anyway, so we should // either display 1st step or 404 error page $browser->waitFor('@step1') ->assertToast(Toast::TYPE_ERROR, 'Form validation error'); }); // Test valid code $this->browse(function (Browser $browser) { $code = SignupCode::create([ 'email' => 'User@example.org', 'first_name' => 'User', 'last_name' => 'Name', 'plan' => 'individual', 'voucher' => '', ]); $browser->visit('/signup/' . $code->short_code . '-' . $code->code) ->waitFor('@step3') ->assertMissing('@step1') ->assertMissing('@step2'); // FIXME: Find a nice way to read javascript data without using hidden inputs $this->assertSame($code->code, $browser->value('@step2 #signup_code')); // TODO: Test if the signup process can be completed }); } /** * Test signup "welcome" page */ public function testSignupStep0(): void { $this->browse(function (Browser $browser) { $browser->visit(new Signup()); $browser->assertVisible('@step0') ->assertMissing('@step1') ->assertMissing('@step2') ->assertMissing('@step3'); $browser->within(new Menu(), function ($browser) { $browser->assertMenuItems(['support', 'signup', 'login', 'lang'], 'signup'); }); $browser->waitFor('@step0 .plan-selector .card'); // Assert first plan box and press the button $browser->with('@step0 .plan-selector .plan-individual', function ($step) { $step->assertVisible('button') ->assertSeeIn('button', 'Individual Account') ->assertVisible('.plan-description') ->click('button'); }); $browser->waitForLocation('/signup/individual') ->assertVisible('@step1') ->assertSeeIn('.card-title', 'Sign Up - Step 1/3') ->assertMissing('@step0') ->assertMissing('@step2') ->assertMissing('@step3') ->assertFocused('@step1 #signup_first_name'); // Click Back button $browser->click('@step1 [type=button]') ->waitForLocation('/signup') ->assertVisible('@step0') ->assertMissing('@step1') ->assertMissing('@step2') ->assertMissing('@step3'); // Choose the group account plan $browser->click('@step0 .plan-selector .plan-group button') ->waitForLocation('/signup/group') ->assertVisible('@step1') ->assertMissing('@step0') ->assertMissing('@step2') ->assertMissing('@step3') ->assertFocused('@step1 #signup_first_name'); // TODO: Test if 'plan' variable is set properly in vue component }); } /** * Test 1st step of the signup process */ public function testSignupStep1(): void { $this->browse(function (Browser $browser) { $browser->visit('/signup/individual') ->onWithoutAssert(new Signup()); // Here we expect two text inputs and Back and Continue buttons $browser->with('@step1', function ($step) { $step->waitFor('#signup_last_name') ->assertSeeIn('.card-title', 'Sign Up - Step 1/3') ->assertVisible('#signup_first_name') ->assertFocused('#signup_first_name') ->assertVisible('#signup_email') ->assertVisible('[type=button]') ->assertVisible('[type=submit]'); }); // Submit empty form // Email is required, so after pressing Submit // we expect focus to be moved to the email input $browser->with('@step1', function ($step) { $step->click('[type=submit]'); $step->assertFocused('#signup_email'); }); $browser->within(new Menu(), function ($browser) { $browser->assertMenuItems(['support', 'signup', 'login', 'lang'], 'signup'); }); // Submit invalid email, and first_name // We expect both inputs to have is-invalid class added, with .invalid-feedback element $browser->with('@step1', function ($step) { $step->type('#signup_first_name', str_repeat('a', 250)) ->type('#signup_email', '@test') ->click('[type=submit]') ->waitFor('#signup_email.is-invalid') ->assertVisible('#signup_first_name.is-invalid') ->assertVisible('#signup_email + .invalid-feedback') ->assertVisible('#signup_last_name + .invalid-feedback') ->assertToast(Toast::TYPE_ERROR, 'Form validation error'); }); // Submit valid data // We expect error state on email input to be removed, and Step 2 form visible $browser->with('@step1', function ($step) { $step->type('#signup_first_name', 'Test') ->type('#signup_last_name', 'User') ->type('#signup_email', 'BrowserSignupTestUser1@kolab.org') ->click('[type=submit]') ->assertMissing('#signup_email.is-invalid') ->assertMissing('#signup_email + .invalid-feedback'); }); $browser->waitUntilMissing('@step2 #signup_code[value=""]'); $browser->waitFor('@step2'); $browser->assertMissing('@step1'); }); } /** * Test 2nd Step of the signup process * * @depends testSignupStep1 */ public function testSignupStep2(): void { $this->browse(function (Browser $browser) { $browser->assertVisible('@step2') ->assertSeeIn('@step2 .card-title', 'Sign Up - Step 2/3') ->assertMissing('@step0') ->assertMissing('@step1') ->assertMissing('@step3'); // Here we expect one text input, Back and Continue buttons $browser->with('@step2', function ($step) { $step->assertVisible('#signup_short_code') ->assertFocused('#signup_short_code') ->assertVisible('[type=button]') ->assertVisible('[type=submit]'); }); // Test Back button functionality $browser->click('@step2 [type=button]') ->waitFor('@step1') ->assertFocused('@step1 #signup_first_name') ->assertMissing('@step2'); // Submit valid Step 1 data (again) $browser->with('@step1', function ($step) { $step->type('#signup_first_name', 'User') ->type('#signup_last_name', 'User') ->type('#signup_email', 'BrowserSignupTestUser1@kolab.org') ->click('[type=submit]'); }); $browser->waitFor('@step2'); $browser->assertMissing('@step1'); // Submit invalid code // We expect code input to have is-invalid class added, with .invalid-feedback element $browser->with('@step2', function ($step) { $step->type('#signup_short_code', 'XXXXX'); $step->click('[type=submit]'); $step->waitFor('#signup_short_code.is-invalid') ->assertVisible('#signup_short_code + .invalid-feedback') ->assertFocused('#signup_short_code') ->assertToast(Toast::TYPE_ERROR, 'Form validation error'); }); // Submit valid code // We expect error state on code input to be removed, and Step 3 form visible $browser->with('@step2', function ($step) { // Get the code and short_code from database // FIXME: Find a nice way to read javascript data without using hidden inputs $code = $step->value('#signup_code'); $this->assertNotEmpty($code); $code = SignupCode::find($code); $step->type('#signup_short_code', $code->short_code); $step->click('[type=submit]'); $step->assertMissing('#signup_short_code.is-invalid'); $step->assertMissing('#signup_short_code + .invalid-feedback'); }); $browser->waitFor('@step3'); $browser->assertMissing('@step2'); }); } /** * Test 3rd Step of the signup process * * @depends testSignupStep2 */ public function testSignupStep3(): void { $this->browse(function (Browser $browser) { $browser->assertVisible('@step3'); // Here we expect 3 text inputs, Back and Continue buttons $browser->with('@step3', function ($step) { $domains = Domain::getPublicDomains(); $domains_count = count($domains); $step->assertSeeIn('.card-title', 'Sign Up - Step 3/3') ->assertMissing('#signup_last_name') ->assertMissing('#signup_first_name') ->assertVisible('#signup_login') ->assertVisible('#signup_password') ->assertVisible('#signup_password_confirmation') ->assertVisible('select#signup_domain') ->assertElementsCount('select#signup_domain option', $domains_count, false) ->assertText('select#signup_domain option:nth-child(1)', $domains[0]) ->assertValue('select#signup_domain option:nth-child(1)', $domains[0]) ->assertText('select#signup_domain option:nth-child(2)', $domains[1]) ->assertValue('select#signup_domain option:nth-child(2)', $domains[1]) ->assertVisible('[type=button]') ->assertVisible('[type=submit]') ->assertSeeIn('[type=submit]', 'Submit') ->assertFocused('#signup_login') ->assertSelected('select#signup_domain', \config('app.domain')) ->assertValue('#signup_login', '') ->assertValue('#signup_password', '') ->assertValue('#signup_password_confirmation', '') ->with('#signup_password_policy', function (Browser $browser) { $browser->assertElementsCount('li', 2) ->assertMissing('li:first-child svg.text-success') ->assertSeeIn('li:first-child small', "Minimum password length: 6 characters") ->assertMissing('li:last-child svg.text-success') ->assertSeeIn('li:last-child small', "Maximum password length: 255 characters"); }); // TODO: Test domain selector }); // Test Back button $browser->click('@step3 [type=button]'); $browser->waitFor('@step2'); $browser->assertFocused('@step2 #signup_short_code'); $browser->assertMissing('@step3'); // TODO: Test form reset when going back // Submit valid code again $browser->with('@step2', function ($step) { $code = $step->value('#signup_code'); $this->assertNotEmpty($code); $code = SignupCode::find($code); $step->type('#signup_short_code', $code->short_code); $step->click('[type=submit]'); }); $browser->waitFor('@step3'); // Submit invalid data $browser->with('@step3', function ($step) { $step->assertFocused('#signup_login') ->type('#signup_login', '*') ->type('#signup_password', '12345678') ->type('#signup_password_confirmation', '123456789') ->with('#signup_password_policy', function (Browser $browser) { $browser->waitFor('li:first-child svg.text-success') ->waitFor('li:last-child svg.text-success'); }) ->click('[type=submit]') ->waitFor('#signup_login.is-invalid') ->assertVisible('#signup_domain + .invalid-feedback') ->assertVisible('#signup_password.is-invalid') ->assertVisible('#signup_password_input .invalid-feedback') ->assertFocused('#signup_login') ->assertToast(Toast::TYPE_ERROR, 'Form validation error'); }); // Submit invalid data (valid login, invalid password) $browser->with('@step3', function ($step) { $step->type('#signup_login', 'SignupTestDusk') ->click('[type=submit]') ->waitFor('#signup_password.is-invalid') ->assertVisible('#signup_password_input .invalid-feedback') ->assertMissing('#signup_login.is-invalid') ->assertMissing('#signup_domain + .invalid-feedback') ->assertFocused('#signup_password') ->assertToast(Toast::TYPE_ERROR, 'Form validation error'); }); // Submit valid data $browser->with('@step3', function ($step) { $step->type('#signup_password_confirmation', '12345678'); $step->click('[type=submit]'); }); // At this point we should be auto-logged-in to dashboard $browser->waitUntilMissing('@step3') ->waitUntilMissing('.app-loader') ->on(new Dashboard()) ->assertUser('signuptestdusk@' . \config('app.domain')) ->assertVisible('@links a.link-profile') ->assertMissing('@links a.link-domains') ->assertVisible('@links a.link-users') ->assertVisible('@links a.link-wallet'); // Logout the user $browser->within(new Menu(), function ($browser) { $browser->clickMenuItem('logout'); }); }); } /** * Test signup for a group account */ public function testSignupGroup(): void { $this->browse(function (Browser $browser) { $browser->visit(new Signup()); // Choose the group account plan $browser->waitFor('@step0 .plan-group button') ->click('@step0 .plan-group button'); // Submit valid data // We expect error state on email input to be removed, and Step 2 form visible $browser->whenAvailable('@step1', function ($step) { $step->type('#signup_first_name', 'Test') ->type('#signup_last_name', 'User') ->type('#signup_email', 'BrowserSignupTestUser1@kolab.org') ->click('[type=submit]'); }); // Submit valid code $browser->whenAvailable('@step2', function ($step) { // Get the code and short_code from database // FIXME: Find a nice way to read javascript data without using hidden inputs $code = $step->value('#signup_code'); $code = SignupCode::find($code); $step->type('#signup_short_code', $code->short_code) ->click('[type=submit]'); }); // Here we expect 4 text inputs, Back and Continue buttons $browser->whenAvailable('@step3', function ($step) { $step->assertVisible('#signup_login') ->assertVisible('#signup_password') ->assertVisible('#signup_password_confirmation') ->assertVisible('input#signup_domain') ->assertVisible('[type=button]') ->assertVisible('[type=submit]') ->assertFocused('#signup_login') ->assertValue('input#signup_domain', '') ->assertValue('#signup_login', '') ->assertValue('#signup_password', '') ->assertValue('#signup_password_confirmation', ''); }); // Submit invalid login and password data $browser->with('@step3', function ($step) { $step->assertFocused('#signup_login') ->type('#signup_login', '*') ->type('#signup_domain', 'test.com') ->type('#signup_password', '12345678') ->type('#signup_password_confirmation', '123456789') ->click('[type=submit]') ->waitFor('#signup_login.is-invalid') ->assertVisible('#signup_domain + .invalid-feedback') ->assertVisible('#signup_password.is-invalid') ->assertVisible('#signup_password_input .invalid-feedback') ->assertFocused('#signup_login') ->assertToast(Toast::TYPE_ERROR, 'Form validation error'); }); // Submit invalid domain $browser->with('@step3', function ($step) { $step->type('#signup_login', 'admin') ->type('#signup_domain', 'aaa') ->type('#signup_password', '12345678') ->type('#signup_password_confirmation', '12345678') ->click('[type=submit]') ->waitUntilMissing('#signup_login.is-invalid') ->waitFor('#signup_domain.is-invalid + .invalid-feedback') ->assertMissing('#signup_password.is-invalid') ->assertMissing('#signup_password_input .invalid-feedback') ->assertFocused('#signup_domain') ->assertToast(Toast::TYPE_ERROR, 'Form validation error'); }); // Submit invalid domain $browser->with('@step3', function ($step) { $step->type('#signup_domain', 'user-domain-signup.com') ->click('[type=submit]'); }); // At this point we should be auto-logged-in to dashboard $browser->waitUntilMissing('@step3') ->waitUntilMissing('.app-loader') ->on(new Dashboard()) ->assertUser('admin@user-domain-signup.com') ->assertVisible('@links a.link-profile') ->assertVisible('@links a.link-domains') ->assertVisible('@links a.link-users') ->assertVisible('@links a.link-wallet'); $browser->within(new Menu(), function ($browser) { $browser->clickMenuItem('logout'); }); }); } /** * Test signup with a mandate plan, also the UI lock * * @group mollie */ public function testSignupMandate(): void { // Test the individual plan $plan = Plan::withEnvTenantContext()->where('title', 'individual')->first(); $plan->mode = Plan::MODE_MANDATE; $plan->save(); $this->browse(function (Browser $browser) { $config = ['paymentProvider' => 'mollie']; $browser->visit(new Signup()) // Force Mollie ->execScript(sprintf('Object.assign(window.config, %s)', \json_encode($config))) ->waitFor('@step0 .plan-individual button') ->click('@step0 .plan-individual button') // Test Back button ->whenAvailable('@step3', function ($browser) { $browser->click('button[type=button]'); }) ->whenAvailable('@step0', function ($browser) { $browser->click('.plan-individual button'); }) // Test submit ->whenAvailable('@step3', function ($browser) { $domains = Domain::getPublicDomains(); $domains_count = count($domains); $browser->assertMissing('.card-title') ->assertElementsCount('select#signup_domain option', $domains_count, false) ->assertText('select#signup_domain option:nth-child(1)', $domains[0]) ->assertValue('select#signup_domain option:nth-child(1)', $domains[0]) ->type('#signup_login', 'signuptestdusk') ->type('#signup_password', '12345678') ->type('#signup_password_confirmation', '12345678') ->click('[type=submit]'); }) ->whenAvailable('@step4', function ($browser) { - $browser->assertSeeIn('.card-text', 'The account is about to be created!') - ->assertSeeIn('.card-text', 'You signed up for an account') + $browser->assertSeeIn('h4', 'The account is about to be created!') + ->assertSeeIn('h5', 'You are choosing a monthly subscription') + ->assertVisible('#summary-content') + ->assertElementsCount('#summary-cc svg', 2) + ->assertElementsCount('#summary-summary tr', 4) ->assertSeeIn('button.btn-primary', 'Continue') ->assertSeeIn('button.btn-secondary', 'Back') ->click('button.btn-secondary'); }) ->whenAvailable('@step3', function ($browser) { $browser->assertValue('#signup_login', 'signuptestdusk') ->click('[type=submit]'); }) ->whenAvailable('@step4', function ($browser) { $browser->click('button.btn-primary'); }) ->on(new PaymentMollie()) ->assertSeeIn('@title', 'Auto-Payment Setup') ->assertMissing('@amount') ->submitPayment('open') ->on(new PaymentStatus()) ->assertSeeIn('@lock-alert', 'The account is locked') ->assertSeeIn('@content', 'Checking the status...') ->assertSeeIn('@button', 'Try again'); }); $user = User::where('email', 'signuptestdusk@' . \config('app.domain'))->first(); $this->assertSame($plan->id, $user->getSetting('plan_id')); $this->assertFalse($user->isActive()); // Refresh and see that the account is still locked $this->browse(function (Browser $browser) use ($user) { $browser->visit('/dashboard') ->on(new PaymentStatus()) ->assertSeeIn('@lock-alert', 'The account is locked') ->assertSeeIn('@content', 'Checking the status...'); // Mark the payment paid, and activate the user in background, // expect unlock and redirect to the dashboard // TODO: Move this to a separate tests file for PaymentStatus page $payment = $user->wallets()->first()->payments()->first(); $payment->credit('Test'); $payment->status = \App\Payment::STATUS_PAID; $payment->save(); $this->assertTrue($user->fresh()->isActive()); $browser->waitForLocation('/dashboard', 10) ->within(new Menu(), function ($browser) { $browser->clickMenuItem('logout'); }); }); // TODO: Test the 'Try again' button on /payment/status page } /** * Test signup with a token plan */ public function testSignupToken(): void { // Test the individual plan Plan::where('title', 'individual')->update(['mode' => Plan::MODE_TOKEN]); // Register some valid tokens $tokens = ['1234567890', 'abcdefghijk']; file_put_contents(storage_path('signup-tokens.txt'), implode("\n", $tokens)); $this->browse(function (Browser $browser) use ($tokens) { $browser->visit(new Signup()) ->waitFor('@step0 .plan-individual button') ->click('@step0 .plan-individual button') // Step 1 ->whenAvailable('@step1', function ($browser) use ($tokens) { $browser->assertSeeIn('.card-title', 'Sign Up - Step 1/2') ->type('#signup_first_name', 'Test') ->type('#signup_last_name', 'User') ->assertMissing('#signup_email') ->type('#signup_token', '1234') // invalid token ->click('[type=submit]') ->waitFor('#signup_token.is-invalid') ->assertVisible('#signup_token + .invalid-feedback') ->assertFocused('#signup_token') ->assertToast(Toast::TYPE_ERROR, 'Form validation error') // valid token ->type('#signup_token', $tokens[0]) ->click('[type=submit]'); }) // Step 2 ->whenAvailable('@step3', function ($browser) { $domains = Domain::getPublicDomains(); $domains_count = count($domains); $browser->assertSeeIn('.card-title', 'Sign Up - Step 2/2') ->assertElementsCount('select#signup_domain option', $domains_count, false) ->assertText('select#signup_domain option:nth-child(1)', $domains[0]) ->assertValue('select#signup_domain option:nth-child(1)', $domains[0]) ->type('#signup_login', 'signuptestdusk') ->type('#signup_password', '12345678') ->type('#signup_password_confirmation', '12345678') ->click('[type=submit]'); }) ->waitUntilMissing('@step3') ->on(new Dashboard()) ->within(new Menu(), function ($browser) { $browser->clickMenuItem('logout'); }); }); $user = User::where('email', 'signuptestdusk@' . \config('app.domain'))->first(); $this->assertSame($tokens[0], $user->getSetting('signup_token')); $this->assertSame(null, $user->getSetting('external_email')); // Test the group plan Plan::where('title', 'group')->update(['mode' => Plan::MODE_TOKEN]); $this->browse(function (Browser $browser) use ($tokens) { $browser->visit(new Signup()) ->waitFor('@step0 .plan-group button') ->click('@step0 .plan-group button') // Step 1 ->whenAvailable('@step1', function ($browser) use ($tokens) { $browser->assertSeeIn('.card-title', 'Sign Up - Step 1/2') ->type('#signup_first_name', 'Test') ->type('#signup_last_name', 'User') ->assertMissing('#signup_email') ->type('#signup_token', '1234') // invalid token ->click('[type=submit]') ->waitFor('#signup_token.is-invalid') ->assertVisible('#signup_token + .invalid-feedback') ->assertFocused('#signup_token') ->assertToast(Toast::TYPE_ERROR, 'Form validation error') // valid token ->type('#signup_token', $tokens[1]) ->click('[type=submit]'); }) // Step 2 ->whenAvailable('@step3', function ($browser) { $browser->assertSeeIn('.card-title', 'Sign Up - Step 2/2') ->type('input#signup_domain', 'user-domain-signup.com') ->type('#signup_login', 'admin') ->type('#signup_password', '12345678') ->type('#signup_password_confirmation', '12345678') ->click('[type=submit]'); }) ->waitUntilMissing('@step3') ->on(new Dashboard()) ->within(new Menu(), function ($browser) { $browser->clickMenuItem('logout'); }); }); $user = User::where('email', 'admin@user-domain-signup.com')->first(); $this->assertSame($tokens[1], $user->getSetting('signup_token')); $this->assertSame(null, $user->getSetting('external_email')); } /** * Test signup with voucher */ public function testSignupVoucherLink(): void { $this->browse(function (Browser $browser) { $browser->visit('/signup/voucher/TEST') ->onWithoutAssert(new Signup()) ->waitUntilMissing('.app-loader') ->waitFor('@step0') ->click('.plan-individual button') ->whenAvailable('@step1', function (Browser $browser) { $browser->type('#signup_first_name', 'Test') ->type('#signup_last_name', 'User') ->type('#signup_email', 'BrowserSignupTestUser1@kolab.org') ->click('[type=submit]'); }) ->whenAvailable('@step2', function (Browser $browser) { // Get the code and short_code from database // FIXME: Find a nice way to read javascript data without using hidden inputs $code = $browser->value('#signup_code'); $this->assertNotEmpty($code); $code = SignupCode::find($code); $browser->type('#signup_short_code', $code->short_code) ->click('[type=submit]'); }) ->whenAvailable('@step3', function (Browser $browser) { // Assert that the code is filled in the input // Change it and test error handling $browser->assertValue('#signup_voucher', 'TEST') ->type('#signup_voucher', 'TESTXX') ->type('#signup_login', 'signuptestdusk') ->type('#signup_password', '123456789') ->type('#signup_password_confirmation', '123456789') ->click('[type=submit]') ->waitFor('#signup_voucher.is-invalid') ->assertVisible('#signup_voucher + .invalid-feedback') ->assertFocused('#signup_voucher') ->assertToast(Toast::TYPE_ERROR, 'Form validation error') // Submit the correct code ->type('#signup_voucher', 'TEST') ->click('[type=submit]'); }) ->waitUntilMissing('@step3') ->waitUntilMissing('.app-loader') ->on(new Dashboard()) ->assertUser('signuptestdusk@' . \config('app.domain')) // Logout the user ->within(new Menu(), function ($browser) { $browser->clickMenuItem('logout'); }); }); $user = $this->getTestUser('signuptestdusk@' . \config('app.domain')); $discount = Discount::where('code', 'TEST')->first(); $this->assertSame($discount->id, $user->wallets()->first()->discount_id); } /** * Test signup via invitation link */ public function testSignupInvitation(): void { // Test non-existing invitation $this->browse(function (Browser $browser) { $browser->visit('/signup/invite/TEST') ->onWithoutAssert(new Signup()) ->waitFor('#app > #error-page') ->assertErrorPage(404); }); $invitation = SignupInvitation::create(['email' => 'test@domain.org']); $this->browse(function (Browser $browser) use ($invitation) { $browser->visit('/signup/invite/' . $invitation->id) ->onWithoutAssert(new Signup()) ->waitUntilMissing('.app-loader') ->with('@step3', function ($step) { $domains_count = count(Domain::getPublicDomains()); $step->assertMissing('.card-title') ->assertVisible('#signup_last_name') ->assertVisible('#signup_first_name') ->assertVisible('#signup_login') ->assertVisible('#signup_password') ->assertVisible('#signup_password_confirmation') ->assertVisible('select#signup_domain') ->assertElementsCount('select#signup_domain option', $domains_count, false) ->assertVisible('[type=submit]') ->assertMissing('[type=button]') // Back button ->assertSeeIn('[type=submit]', 'Sign Up') ->assertFocused('#signup_first_name') ->assertValue('select#signup_domain', \config('app.domain')) ->assertValue('#signup_first_name', '') ->assertValue('#signup_last_name', '') ->assertValue('#signup_login', '') ->assertValue('#signup_password', '') ->assertValue('#signup_password_confirmation', ''); // Submit invalid data $step->type('#signup_login', '*') ->type('#signup_password', '12345678') ->type('#signup_password_confirmation', '123456789') ->click('[type=submit]') ->waitFor('#signup_login.is-invalid') ->assertVisible('#signup_domain + .invalid-feedback') ->assertVisible('#signup_password.is-invalid') ->assertVisible('#signup_password_input .invalid-feedback') ->assertFocused('#signup_login') ->assertToast(Toast::TYPE_ERROR, 'Form validation error'); // Submit valid data $step->type('#signup_password_confirmation', '12345678') ->type('#signup_login', 'signuptestdusk') ->type('#signup_first_name', 'First') ->type('#signup_last_name', 'Last') ->click('[type=submit]'); }) // At this point we should be auto-logged-in to dashboard ->waitUntilMissing('@step3') ->waitUntilMissing('.app-loader') ->on(new Dashboard()) ->assertUser('signuptestdusk@' . \config('app.domain')) // Logout the user ->within(new Menu(), function ($browser) { $browser->clickMenuItem('logout'); }); }); $invitation->refresh(); $user = User::where('email', 'signuptestdusk@' . \config('app.domain'))->first(); $this->assertTrue($invitation->isCompleted()); $this->assertSame($user->id, $invitation->user_id); $this->assertSame('First', $user->getSetting('first_name')); $this->assertSame('Last', $user->getSetting('last_name')); $this->assertSame($invitation->email, $user->getSetting('external_email')); } } diff --git a/src/tests/Feature/Controller/SignupTest.php b/src/tests/Feature/Controller/SignupTest.php index cd6e5de9..d4af6e38 100644 --- a/src/tests/Feature/Controller/SignupTest.php +++ b/src/tests/Feature/Controller/SignupTest.php @@ -1,1080 +1,1098 @@ domain = $this->getPublicDomain(); $this->deleteTestUser("SignupControllerTest1@$this->domain"); $this->deleteTestUser("signuplogin@$this->domain"); $this->deleteTestUser("admin@external.com"); $this->deleteTestUser("test-inv@kolabnow.com"); $this->deleteTestDomain('external.com'); $this->deleteTestDomain('signup-domain.com'); $this->deleteTestGroup('group-test@kolabnow.com'); SI::truncate(); Plan::where('title', 'test')->delete(); } /** * {@inheritDoc} */ public function tearDown(): void { $this->deleteTestUser("SignupControllerTest1@$this->domain"); $this->deleteTestUser("signuplogin@$this->domain"); $this->deleteTestUser("admin@external.com"); $this->deleteTestUser("test-inv@kolabnow.com"); $this->deleteTestDomain('external.com'); $this->deleteTestDomain('signup-domain.com'); $this->deleteTestGroup('group-test@kolabnow.com'); SI::truncate(); Plan::where('title', 'test')->delete(); parent::tearDown(); } /** * Return a public domain for signup tests */ private function getPublicDomain(): string { if (!$this->domain) { $this->refreshApplication(); $public_domains = Domain::getPublicDomains(); $this->domain = reset($public_domains); if (empty($this->domain)) { $this->domain = 'signup-domain.com'; Domain::create([ 'namespace' => $this->domain, 'status' => Domain::STATUS_ACTIVE, 'type' => Domain::TYPE_PUBLIC, ]); } } return $this->domain; } /** * Test fetching public domains for signup */ public function testSignupDomains(): void { $response = $this->get('/api/auth/signup/domains'); $json = $response->json(); $response->assertStatus(200); $this->assertCount(2, $json); $this->assertSame('success', $json['status']); $this->assertSame(Domain::getPublicDomains(), $json['domains']); } /** * Test fetching plans for signup */ public function testSignupPlans(): void { $individual = Plan::withEnvTenantContext()->where('title', 'individual')->first(); $group = Plan::withEnvTenantContext()->where('title', 'group')->first(); $response = $this->get('/api/auth/signup/plans'); $json = $response->json(); $response->assertStatus(200); $this->assertSame('success', $json['status']); $this->assertCount(2, $json['plans']); $this->assertSame($individual->title, $json['plans'][0]['title']); $this->assertSame($individual->name, $json['plans'][0]['name']); $this->assertSame($individual->description, $json['plans'][0]['description']); $this->assertFalse($json['plans'][0]['isDomain']); $this->assertArrayHasKey('button', $json['plans'][0]); $this->assertSame($group->title, $json['plans'][1]['title']); $this->assertSame($group->name, $json['plans'][1]['name']); $this->assertSame($group->description, $json['plans'][1]['description']); $this->assertTrue($json['plans'][1]['isDomain']); $this->assertArrayHasKey('button', $json['plans'][1]); } /** * Test fetching invitation */ public function testSignupInvitations(): void { Queue::fake(); $invitation = SI::create(['email' => 'email1@ext.com']); // Test existing invitation $response = $this->get("/api/auth/signup/invitations/{$invitation->id}"); $response->assertStatus(200); $json = $response->json(); $this->assertSame($invitation->id, $json['id']); // Test non-existing invitation $response = $this->get("/api/auth/signup/invitations/abc"); $response->assertStatus(404); // Test completed invitation SI::where('id', $invitation->id)->update(['status' => SI::STATUS_COMPLETED]); $response = $this->get("/api/auth/signup/invitations/{$invitation->id}"); $response->assertStatus(404); } /** * Test signup initialization with invalid input */ public function testSignupInitInvalidInput(): void { // Empty input data $data = []; $response = $this->post('/api/auth/signup/init', $data); $json = $response->json(); $response->assertStatus(422); $this->assertSame('error', $json['status']); $this->assertCount(1, $json['errors']); $this->assertArrayHasKey('email', $json['errors']); // Data with missing name $data = [ 'email' => 'UsersApiControllerTest1@UsersApiControllerTest.com', 'first_name' => str_repeat('a', 250), 'last_name' => str_repeat('a', 250), ]; $response = $this->post('/api/auth/signup/init', $data); $json = $response->json(); $response->assertStatus(422); $this->assertSame('error', $json['status']); $this->assertCount(2, $json['errors']); $this->assertArrayHasKey('first_name', $json['errors']); $this->assertArrayHasKey('last_name', $json['errors']); // Data with invalid email (but not phone number) $data = [ 'email' => '@example.org', 'first_name' => 'Signup', 'last_name' => 'User', ]; $response = $this->post('/api/auth/signup/init', $data); $json = $response->json(); $response->assertStatus(422); $this->assertSame('error', $json['status']); $this->assertCount(1, $json['errors']); $this->assertArrayHasKey('email', $json['errors']); // Sanity check on voucher code, last/first name is optional $data = [ 'voucher' => '123456789012345678901234567890123', 'email' => 'valid@email.com', ]; $response = $this->post('/api/auth/signup/init', $data); $json = $response->json(); $response->assertStatus(422); $this->assertSame('error', $json['status']); $this->assertCount(1, $json['errors']); $this->assertArrayHasKey('voucher', $json['errors']); // Email address too long $data = [ 'email' => str_repeat('a', 190) . '@example.org', 'first_name' => 'Signup', 'last_name' => 'User', ]; $response = $this->post('/api/auth/signup/init', $data); $json = $response->json(); $response->assertStatus(422); $this->assertSame('error', $json['status']); $this->assertCount(1, $json['errors']); $this->assertSame(["The specified email address is invalid."], $json['errors']['email']); SignupCode::truncate(); // Email address limit check $data = [ 'email' => 'test@example.org', 'first_name' => 'Signup', 'last_name' => 'User', ]; \config(['app.signup.email_limit' => 0]); $response = $this->post('/api/auth/signup/init', $data); $json = $response->json(); $response->assertStatus(200); \config(['app.signup.email_limit' => 1]); $response = $this->post('/api/auth/signup/init', $data); $json = $response->json(); $response->assertStatus(422); $this->assertSame('error', $json['status']); $this->assertCount(1, $json['errors']); // TODO: This probably should be a different message? $this->assertSame(["The specified email address is invalid."], $json['errors']['email']); // IP address limit check $data = [ 'email' => 'ip@example.org', 'first_name' => 'Signup', 'last_name' => 'User', ]; \config(['app.signup.email_limit' => 0]); \config(['app.signup.ip_limit' => 0]); $response = $this->post('/api/auth/signup/init', $data, ['REMOTE_ADDR' => '10.1.1.1']); $json = $response->json(); $response->assertStatus(200); \config(['app.signup.ip_limit' => 1]); $response = $this->post('/api/auth/signup/init', $data, ['REMOTE_ADDR' => '10.1.1.1']); $json = $response->json(); $response->assertStatus(422); $this->assertSame('error', $json['status']); $this->assertCount(1, $json['errors']); // TODO: This probably should be a different message? $this->assertSame(["The specified email address is invalid."], $json['errors']['email']); // TODO: Test phone validation } /** * Test signup initialization with valid input */ public function testSignupInitValidInput(): array { Queue::fake(); // Assert that no jobs were pushed... Queue::assertNothingPushed(); $data = [ 'email' => 'testuser@external.com', 'first_name' => 'Signup', 'last_name' => 'User', 'plan' => 'individual', ]; $response = $this->post('/api/auth/signup/init', $data, ['REMOTE_ADDR' => '10.1.1.2']); $json = $response->json(); $response->assertStatus(200); $this->assertCount(3, $json); $this->assertSame('success', $json['status']); $this->assertSame('email', $json['mode']); $this->assertNotEmpty($json['code']); $code = SignupCode::find($json['code']); $this->assertSame('10.1.1.2', $code->ip_address); $this->assertSame(null, $code->verify_ip_address); $this->assertSame(null, $code->submit_ip_address); // Assert the email sending job was pushed once Queue::assertPushed(\App\Jobs\SignupVerificationEmail::class, 1); // Assert the job has proper data assigned Queue::assertPushed(\App\Jobs\SignupVerificationEmail::class, function ($job) use ($data, $json) { $code = TestCase::getObjectProperty($job, 'code'); return $code->code === $json['code'] && $code->plan === $data['plan'] && $code->email === $data['email'] && $code->first_name === $data['first_name'] && $code->last_name === $data['last_name']; }); // Try the same with voucher $data['voucher'] = 'TEST'; $response = $this->post('/api/auth/signup/init', $data); $json = $response->json(); $response->assertStatus(200); $this->assertCount(3, $json); $this->assertSame('success', $json['status']); $this->assertSame('email', $json['mode']); $this->assertNotEmpty($json['code']); // Assert the job has proper data assigned Queue::assertPushed(\App\Jobs\SignupVerificationEmail::class, function ($job) use ($data, $json) { $code = TestCase::getObjectProperty($job, 'code'); return $code->code === $json['code'] && $code->plan === $data['plan'] && $code->email === $data['email'] && $code->voucher === $data['voucher'] && $code->first_name === $data['first_name'] && $code->last_name === $data['last_name']; }); return [ 'code' => $json['code'], 'email' => $data['email'], 'first_name' => $data['first_name'], 'last_name' => $data['last_name'], 'plan' => $data['plan'], 'voucher' => $data['voucher'] ]; } /** * Test signup code verification with invalid input * * @depends testSignupInitValidInput */ public function testSignupVerifyInvalidInput(array $result): void { // Empty data $data = []; $response = $this->post('/api/auth/signup/verify', $data); $json = $response->json(); $response->assertStatus(422); $this->assertSame('error', $json['status']); $this->assertCount(2, $json['errors']); $this->assertArrayHasKey('code', $json['errors']); $this->assertArrayHasKey('short_code', $json['errors']); // Data with existing code but missing short_code $data = [ 'code' => $result['code'], ]; $response = $this->post('/api/auth/signup/verify', $data); $json = $response->json(); $response->assertStatus(422); $this->assertSame('error', $json['status']); $this->assertCount(1, $json['errors']); $this->assertArrayHasKey('short_code', $json['errors']); // Data with invalid short_code $data = [ 'code' => $result['code'], 'short_code' => 'XXXX', ]; $response = $this->post('/api/auth/signup/verify', $data); $json = $response->json(); $response->assertStatus(422); $this->assertSame('error', $json['status']); $this->assertCount(1, $json['errors']); $this->assertArrayHasKey('short_code', $json['errors']); // TODO: Test expired code } /** * Test signup code verification with valid input * * @depends testSignupInitValidInput */ public function testSignupVerifyValidInput(array $result): array { $code = SignupCode::find($result['code']); $code->ip_address = '10.1.1.2'; $code->save(); $data = [ 'code' => $code->code, 'short_code' => $code->short_code, ]; $response = $this->post('/api/auth/signup/verify', $data, ['REMOTE_ADDR' => '10.1.1.3']); $json = $response->json(); $response->assertStatus(200); $this->assertCount(7, $json); $this->assertSame('success', $json['status']); $this->assertSame($result['email'], $json['email']); $this->assertSame($result['first_name'], $json['first_name']); $this->assertSame($result['last_name'], $json['last_name']); $this->assertSame($result['voucher'], $json['voucher']); $this->assertSame(false, $json['is_domain']); $this->assertTrue(is_array($json['domains']) && !empty($json['domains'])); $code->refresh(); $this->assertSame('10.1.1.2', $code->ip_address); $this->assertSame('10.1.1.3', $code->verify_ip_address); $this->assertSame(null, $code->submit_ip_address); return $result; } /** * Test last signup step with invalid input * * @depends testSignupVerifyValidInput */ public function testSignupInvalidInput(array $result): void { // Empty data $data = []; $response = $this->post('/api/auth/signup', $data); $json = $response->json(); $response->assertStatus(422); $this->assertSame('error', $json['status']); $this->assertCount(3, $json['errors']); $this->assertArrayHasKey('login', $json['errors']); $this->assertArrayHasKey('password', $json['errors']); $this->assertArrayHasKey('domain', $json['errors']); $domain = $this->getPublicDomain(); // Passwords do not match and missing domain $data = [ 'login' => 'test', 'password' => 'test', 'password_confirmation' => 'test2', ]; $response = $this->post('/api/auth/signup', $data); $json = $response->json(); $response->assertStatus(422); $this->assertSame('error', $json['status']); $this->assertCount(2, $json['errors']); $this->assertArrayHasKey('password', $json['errors']); $this->assertArrayHasKey('domain', $json['errors']); $domain = $this->getPublicDomain(); // Login too short, password too short $data = [ 'login' => '1', 'domain' => $domain, 'password' => 'test', 'password_confirmation' => 'test', ]; $response = $this->post('/api/auth/signup', $data); $json = $response->json(); $response->assertStatus(422); $this->assertSame('error', $json['status']); $this->assertCount(2, $json['errors']); $this->assertArrayHasKey('login', $json['errors']); $this->assertArrayHasKey('password', $json['errors']); // Missing codes $data = [ 'login' => 'login-valid', 'domain' => $domain, 'password' => 'testtest', 'password_confirmation' => 'testtest', ]; $response = $this->post('/api/auth/signup', $data); $json = $response->json(); $response->assertStatus(422); $this->assertSame('error', $json['status']); $this->assertCount(2, $json['errors']); $this->assertArrayHasKey('code', $json['errors']); $this->assertArrayHasKey('short_code', $json['errors']); // Data with invalid short_code $data = [ 'login' => 'TestLogin', 'domain' => $domain, 'password' => 'testtest', 'password_confirmation' => 'testtest', 'code' => $result['code'], 'short_code' => 'XXXX', ]; $response = $this->post('/api/auth/signup', $data); $json = $response->json(); $response->assertStatus(422); $this->assertSame('error', $json['status']); $this->assertCount(1, $json['errors']); $this->assertArrayHasKey('short_code', $json['errors']); $code = SignupCode::find($result['code']); // Data with invalid voucher $data = [ 'login' => 'TestLogin', 'domain' => $domain, 'password' => 'testtest', 'password_confirmation' => 'testtest', 'code' => $result['code'], 'short_code' => $code->short_code, 'voucher' => 'XXX', ]; $response = $this->post('/api/auth/signup', $data); $json = $response->json(); $response->assertStatus(422); $this->assertSame('error', $json['status']); $this->assertCount(1, $json['errors']); $this->assertArrayHasKey('voucher', $json['errors']); // Valid code, invalid login $data = [ 'login' => 'żżżżżż', 'domain' => $domain, 'password' => 'testtest', 'password_confirmation' => 'testtest', 'code' => $result['code'], 'short_code' => $code->short_code, ]; $response = $this->post('/api/auth/signup', $data); $json = $response->json(); $response->assertStatus(422); $this->assertSame('error', $json['status']); $this->assertCount(1, $json['errors']); $this->assertArrayHasKey('login', $json['errors']); } /** * Test last signup step with valid input (user creation) * * @depends testSignupVerifyValidInput */ public function testSignupValidInput(array $result): void { $queue = Queue::fake(); $domain = $this->getPublicDomain(); $identity = \strtolower('SignupLogin@') . $domain; $code = SignupCode::find($result['code']); $code->ip_address = '10.1.1.2'; $code->verify_ip_address = '10.1.1.3'; $code->save(); $data = [ 'login' => 'SignupLogin', 'domain' => $domain, 'password' => 'testtest', 'password_confirmation' => 'testtest', 'code' => $code->code, 'short_code' => $code->short_code, 'voucher' => 'TEST', ]; $response = $this->post('/api/auth/signup', $data, ['REMOTE_ADDR' => '10.1.1.4']); $json = $response->json(); $response->assertStatus(200); $this->assertSame('success', $json['status']); $this->assertSame('bearer', $json['token_type']); $this->assertTrue(!empty($json['expires_in']) && is_int($json['expires_in']) && $json['expires_in'] > 0); $this->assertNotEmpty($json['access_token']); $this->assertSame($identity, $json['email']); Queue::assertPushed(\App\Jobs\User\CreateJob::class, 1); Queue::assertPushed( \App\Jobs\User\CreateJob::class, function ($job) use ($data) { $userEmail = TestCase::getObjectProperty($job, 'userEmail'); return $userEmail === \strtolower($data['login'] . '@' . $data['domain']); } ); $code->refresh(); // Check if the user has been created $user = User::where('email', $identity)->first(); $this->assertNotEmpty($user); $this->assertSame($identity, $user->email); $this->assertTrue($user->isRestricted()); // Check if the code has been updated and soft-deleted $this->assertTrue($code->trashed()); $this->assertSame('10.1.1.2', $code->ip_address); $this->assertSame('10.1.1.3', $code->verify_ip_address); $this->assertSame('10.1.1.4', $code->submit_ip_address); $this->assertSame($user->id, $code->user_id); // Check user settings $this->assertSame($result['first_name'], $user->getSetting('first_name')); $this->assertSame($result['last_name'], $user->getSetting('last_name')); $this->assertSame($result['email'], $user->getSetting('external_email')); // Discount $discount = Discount::where('code', 'TEST')->first(); $this->assertSame($discount->id, $user->wallets()->first()->discount_id); // TODO: Check SKUs/Plan // TODO: Check if the access token works } /** * Test signup for a group (custom domain) account */ public function testSignupGroupAccount(): void { Queue::fake(); // Initial signup request $user_data = $data = [ 'email' => 'testuser@external.com', 'first_name' => 'Signup', 'last_name' => 'User', 'plan' => 'group', ]; $response = $this->withoutMiddleware()->post('/api/auth/signup/init', $data); $json = $response->json(); $response->assertStatus(200); $this->assertCount(3, $json); $this->assertSame('success', $json['status']); $this->assertSame('email', $json['mode']); $this->assertNotEmpty($json['code']); // Assert the email sending job was pushed once Queue::assertPushed(\App\Jobs\SignupVerificationEmail::class, 1); // Assert the job has proper data assigned Queue::assertPushed(\App\Jobs\SignupVerificationEmail::class, function ($job) use ($data, $json) { $code = TestCase::getObjectProperty($job, 'code'); return $code->code === $json['code'] && $code->plan === $data['plan'] && $code->email === $data['email'] && $code->first_name === $data['first_name'] && $code->last_name === $data['last_name']; }); // Verify the code $code = SignupCode::find($json['code']); $data = [ 'code' => $code->code, 'short_code' => $code->short_code, ]; $response = $this->post('/api/auth/signup/verify', $data); $result = $response->json(); $response->assertStatus(200); $this->assertCount(7, $result); $this->assertSame('success', $result['status']); $this->assertSame($user_data['email'], $result['email']); $this->assertSame($user_data['first_name'], $result['first_name']); $this->assertSame($user_data['last_name'], $result['last_name']); $this->assertSame(null, $result['voucher']); $this->assertSame(true, $result['is_domain']); $this->assertSame([], $result['domains']); // Final signup request $login = 'admin'; $domain = 'external.com'; $data = [ 'login' => $login, 'domain' => $domain, 'password' => 'testtest', 'password_confirmation' => 'testtest', 'code' => $code->code, 'short_code' => $code->short_code, ]; $response = $this->post('/api/auth/signup', $data); $result = $response->json(); $response->assertStatus(200); $this->assertSame('success', $result['status']); $this->assertSame('bearer', $result['token_type']); $this->assertTrue(!empty($result['expires_in']) && is_int($result['expires_in']) && $result['expires_in'] > 0); $this->assertNotEmpty($result['access_token']); $this->assertSame("$login@$domain", $result['email']); Queue::assertPushed(\App\Jobs\Domain\CreateJob::class, 1); Queue::assertPushed( \App\Jobs\Domain\CreateJob::class, function ($job) use ($domain) { $domainNamespace = TestCase::getObjectProperty($job, 'domainNamespace'); return $domainNamespace === $domain; } ); Queue::assertPushed(\App\Jobs\User\CreateJob::class, 1); Queue::assertPushed( \App\Jobs\User\CreateJob::class, function ($job) use ($data) { $userEmail = TestCase::getObjectProperty($job, 'userEmail'); return $userEmail === $data['login'] . '@' . $data['domain']; } ); // Check if the code has been removed $code->refresh(); $this->assertTrue($code->trashed()); // Check if the user has been created $user = User::where('email', $login . '@' . $domain)->first(); $this->assertNotEmpty($user); $this->assertTrue($user->isRestricted()); // Check user settings $this->assertSame($user_data['email'], $user->getSetting('external_email')); $this->assertSame($user_data['first_name'], $user->getSetting('first_name')); $this->assertSame($user_data['last_name'], $user->getSetting('last_name')); // TODO: Check domain record // TODO: Check SKUs/Plan // TODO: Check if the access token works } /** * Test signup with mode=mandate + * + * @group mollie */ public function testSignupMandateMode(): void { Queue::fake(); + \config(['services.payment_provider' => 'mollie']); + $plan = Plan::create([ 'title' => 'test', 'name' => 'Test Account', 'description' => 'Test', 'free_months' => 1, 'discount_qty' => 0, 'discount_rate' => 0, 'mode' => Plan::MODE_MANDATE, ]); $packages = [ Package::where(['title' => 'kolab', 'tenant_id' => \config('app.tenant_id')])->first() ]; $plan->packages()->saveMany($packages); $post = [ 'plan' => 'abc', 'login' => 'test-inv', 'domain' => 'kolabnow.com', 'password' => 'testtest', 'password_confirmation' => 'testtest', ]; // Test invalid plan identifier $response = $this->post('/api/auth/signup', $post); $response->assertStatus(422); $json = $response->json(); $this->assertSame('error', $json['status']); $this->assertCount(1, $json['errors']); $this->assertSame("The selected plan is invalid.", $json['errors']['plan']); // Test valid input $post['plan'] = $plan->title; $response = $this->post('/api/auth/signup', $post); $json = $response->json(); $response->assertStatus(200); $this->assertSame('success', $json['status']); $this->assertNotEmpty($json['access_token']); $this->assertSame('test-inv@kolabnow.com', $json['email']); $this->assertTrue($json['isLocked']); $user = User::where('email', 'test-inv@kolabnow.com')->first(); $this->assertNotEmpty($user); $this->assertSame($plan->id, $user->getSetting('plan_id')); + $this->assertSame('You are choosing a monthly subscription.', $json['checkout']['title']); + $this->assertTrue(!empty($json['checkout']['id'])); } /** * Test signup via invitation */ public function testSignupInvitation(): void { Queue::fake(); $invitation = SI::create(['email' => 'email1@ext.com']); $post = [ 'invitation' => 'abc', 'first_name' => 'Signup', 'last_name' => 'User', 'login' => 'test-inv', 'domain' => 'kolabnow.com', 'password' => 'testtest', 'password_confirmation' => 'testtest', ]; // Test invalid invitation identifier $response = $this->post('/api/auth/signup', $post); $response->assertStatus(404); // Test valid input $post['invitation'] = $invitation->id; $response = $this->post('/api/auth/signup', $post); $result = $response->json(); $response->assertStatus(200); $this->assertSame('success', $result['status']); $this->assertSame('bearer', $result['token_type']); $this->assertTrue(!empty($result['expires_in']) && is_int($result['expires_in']) && $result['expires_in'] > 0); $this->assertNotEmpty($result['access_token']); $this->assertSame('test-inv@kolabnow.com', $result['email']); // Check if the user has been created $user = User::where('email', 'test-inv@kolabnow.com')->first(); $this->assertNotEmpty($user); // Check user settings $this->assertSame($invitation->email, $user->getSetting('external_email')); $this->assertSame($post['first_name'], $user->getSetting('first_name')); $this->assertSame($post['last_name'], $user->getSetting('last_name')); $invitation->refresh(); $this->assertSame($user->id, $invitation->user_id); $this->assertTrue($invitation->isCompleted()); // TODO: Test POST params validation } /** * Test signup validation (POST /signup/validate) */ public function testSignupValidate(): void { Queue::fake(); $plan = Plan::create([ 'title' => 'test', 'name' => 'Test Account', 'description' => 'Test', 'free_months' => 1, 'months' => 12, 'discount_qty' => 0, 'discount_rate' => 0, 'mode' => Plan::MODE_MANDATE, ]); $packages = [ Package::where(['title' => 'kolab', 'tenant_id' => \config('app.tenant_id')])->first() ]; $plan->packages()->saveMany($packages); $post = [ 'login' => 'i', 'password' => 'testtest', 'password_confirmation' => 'testtest1', 'voucher' => str_repeat('a', 33), ]; // Test basic input validation $response = $this->post('/api/auth/signup/validate', $post); $response->assertStatus(422); $json = $response->json(); $this->assertSame('error', $json['status']); $this->assertCount(4, $json['errors']); $this->assertSame(["The login must be at least 2 characters."], $json['errors']['login']); $this->assertSame(["The password confirmation does not match."], $json['errors']['password']); $this->assertSame(["The domain field is required."], $json['errors']['domain']); $this->assertSame(["The voucher may not be greater than 32 characters."], $json['errors']['voucher']); // Test with mode=mandate plan, but invalid voucher code $post = [ 'login' => 'test-inv', 'domain' => 'kolabnow.com', 'password' => 'testtest', 'password_confirmation' => 'testtest', 'plan' => $plan->title, 'voucher' => 'non-existing', ]; $response = $this->post('/api/auth/signup/validate', $post); $response->assertStatus(422); $json = $response->json(); $this->assertSame('error', $json['status']); $this->assertCount(1, $json['errors']); $this->assertSame("The voucher code is invalid or expired.", $json['errors']['voucher']); // Test with mode=mandate plan, and valid voucher code $post['voucher'] = 'TEST'; $response = $this->post('/api/auth/signup/validate', $post); $response->assertStatus(200); $json = $response->json(); $this->assertSame('success', $json['status']); - $this->assertStringContainsString('106,92 CHF', $json['content']); + $this->assertTrue(empty($json['id'])); + $this->assertTrue(!empty($json['content'])); + $this->assertSame('You are choosing a yearly subscription.', $json['title']); + $this->assertSame( + '' + . '' + . '' + . '' + . '' + . '' + . '
Yearly subscription118,80 CHF
Discount: TEST-11,88 CHF
Total106,92 CHF
*Incl. VAT 8,23 CHF (7,7 % of 98,69 CHF)
', + $json['summary'] + ); // TODO: Test other plan modes } /** * List of login/domain validation cases for testValidateLogin() * * @return array Arguments for testValidateLogin() */ public function dataValidateLogin(): array { $domain = $this->getPublicDomain(); return [ // Individual account ['', $domain, false, ['login' => 'The login field is required.']], ['test123456', 'localhost', false, ['domain' => 'The specified domain is invalid.']], ['test123456', 'unknown-domain.org', false, ['domain' => 'The specified domain is invalid.']], ['test.test', $domain, false, null], ['test_test', $domain, false, null], ['test-test', $domain, false, null], ['admin', $domain, false, ['login' => 'The specified login is not available.']], ['administrator', $domain, false, ['login' => 'The specified login is not available.']], ['sales', $domain, false, ['login' => 'The specified login is not available.']], ['root', $domain, false, ['login' => 'The specified login is not available.']], // Domain account ['admin', 'kolabsys.com', true, null], ['testnonsystemdomain', 'invalid', true, ['domain' => 'The specified domain is invalid.']], ['testnonsystemdomain', '.com', true, ['domain' => 'The specified domain is invalid.']], ]; } /** * Signup login/domain validation. * * Note: Technically these include unit tests, but let's keep it here for now. * FIXME: Shall we do a http request for each case? * * @dataProvider dataValidateLogin */ public function testValidateLogin($login, $domain, $external, $expected_result): void { $result = $this->invokeMethod(new SignupController(), 'validateLogin', [$login, $domain, $external]); $this->assertSame($expected_result, $result); } /** * Signup login/domain validation, more cases */ public function testValidateLoginMore(): void { Queue::fake(); // Test registering for an email of an existing group $login = 'group-test'; $domain = 'kolabnow.com'; $group = $this->getTestGroup("{$login}@{$domain}"); $external = false; $result = $this->invokeMethod(new SignupController(), 'validateLogin', [$login, $domain, $external]); $this->assertSame(['login' => 'The specified login is not available.'], $result); // Test registering for an email of an existing, but soft-deleted group $group->delete(); $result = $this->invokeMethod(new SignupController(), 'validateLogin', [$login, $domain, $external]); $this->assertSame(['login' => 'The specified login is not available.'], $result); // Test registering for an email of an existing user $domain = $this->getPublicDomain(); $login = 'signuplogin'; $user = $this->getTestUser("{$login}@{$domain}"); $external = false; $result = $this->invokeMethod(new SignupController(), 'validateLogin', [$login, $domain, $external]); $this->assertSame(['login' => 'The specified login is not available.'], $result); // Test registering for an email of an existing, but soft-deleted user $user->delete(); $result = $this->invokeMethod(new SignupController(), 'validateLogin', [$login, $domain, $external]); $this->assertSame(['login' => 'The specified login is not available.'], $result); // Test registering for a domain that exists $external = true; $domain = $this->getTestDomain( 'external.com', ['status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_EXTERNAL] ); $result = $this->invokeMethod(new SignupController(), 'validateLogin', [$login, $domain->namespace, $external]); $this->assertSame(['domain' => 'The specified domain is not available.'], $result); // Test registering for a domain that exists but is soft-deleted $domain->delete(); $result = $this->invokeMethod(new SignupController(), 'validateLogin', [$login, $domain->namespace, $external]); $this->assertSame(['domain' => 'The specified domain is not available.'], $result); } } diff --git a/src/tests/Unit/UtilsTest.php b/src/tests/Unit/UtilsTest.php index e4debe70..c6838e95 100644 --- a/src/tests/Unit/UtilsTest.php +++ b/src/tests/Unit/UtilsTest.php @@ -1,206 +1,216 @@ delete(); \App\IP6Net::where('net_number', inet_pton('2001:db8::ff00:42:0'))->delete(); $this->assertSame('', Utils::countryForIP('127.0.0.1', '')); $this->assertSame('CH', Utils::countryForIP('127.0.0.1')); $this->assertSame('', Utils::countryForIP('2001:db8::ff00:42:1', '')); $this->assertSame('CH', Utils::countryForIP('2001:db8::ff00:42:1')); \App\IP4Net::create([ 'net_number' => '127.0.0.0', 'net_broadcast' => '127.255.255.255', 'net_mask' => 8, 'country' => 'US', 'rir_name' => 'test', 'serial' => 1, ]); \App\IP6Net::create([ 'net_number' => '2001:db8::ff00:42:0', 'net_broadcast' => \App\Utils::ip6Broadcast('2001:db8::ff00:42:0', 8), 'net_mask' => 8, 'country' => 'PL', 'rir_name' => 'test', 'serial' => 1, ]); $this->assertSame('US', Utils::countryForIP('127.0.0.1', '')); $this->assertSame('US', Utils::countryForIP('127.0.0.1')); $this->assertSame('PL', Utils::countryForIP('2001:db8::ff00:42:1', '')); $this->assertSame('PL', Utils::countryForIP('2001:db8::ff00:42:1')); \App\IP4Net::where('net_number', inet_pton('127.0.0.0'))->delete(); \App\IP6Net::where('net_number', inet_pton('2001:db8::ff00:42:0'))->delete(); } /** * Test for Utils::emailToLower() */ public function testEmailToLower(): void { $this->assertSame('test@test.tld', Utils::emailToLower('test@Test.Tld')); $this->assertSame('test@test.tld', Utils::emailToLower('Test@Test.Tld')); $this->assertSame('shared+shared/Test@test.tld', Utils::emailToLower('shared+shared/Test@Test.Tld')); } /** * Test for Utils::money() */ public function testMoney(): void { $this->assertSame('-0,01 CHF', Utils::money(-1, 'CHF')); $this->assertSame('0,00 CHF', Utils::money(0, 'CHF')); $this->assertSame('1,11 €', Utils::money(111, 'EUR')); $this->assertSame('1,00 CHF', Utils::money(100, 'CHF')); $this->assertSame('€0.00', Utils::money(0, 'EUR', 'en_US')); } + /** + * Test for Utils::percent() + */ + public function testPercent(): void + { + $this->assertSame('0 %', Utils::percent(0)); + $this->assertSame('10 %', Utils::percent(10.0)); + $this->assertSame('10,12 %', Utils::percent(10.12)); + } + /** * Test for Utils::normalizeAddress() */ public function testNormalizeAddress(): void { $this->assertSame('', Utils::normalizeAddress('')); $this->assertSame('', Utils::normalizeAddress(null)); $this->assertSame('test', Utils::normalizeAddress('TEST')); $this->assertSame('test@domain.tld', Utils::normalizeAddress('Test@Domain.TLD')); $this->assertSame('test@domain.tld', Utils::normalizeAddress('Test+Trash@Domain.TLD')); $this->assertSame(['', ''], Utils::normalizeAddress('', true)); $this->assertSame(['', ''], Utils::normalizeAddress(null, true)); $this->assertSame(['test', ''], Utils::normalizeAddress('TEST', true)); $this->assertSame(['test', 'domain.tld'], Utils::normalizeAddress('Test@Domain.TLD', true)); $this->assertSame(['test', 'domain.tld'], Utils::normalizeAddress('Test+Trash@Domain.TLD', true)); } /** * Test for Utils::powerSet() */ public function testPowerSet(): void { $set = []; $result = Utils::powerSet($set); $this->assertIsArray($result); $this->assertCount(0, $result); $set = ["a1"]; $result = Utils::powerSet($set); $this->assertIsArray($result); $this->assertCount(1, $result); $this->assertTrue(in_array(["a1"], $result)); $set = ["a1", "a2"]; $result = 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 = 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")); } }