diff --git a/src/.eslintrc.js b/src/.eslintrc.js index d715bd40..55281a0e 100644 --- a/src/.eslintrc.js +++ b/src/.eslintrc.js @@ -1,20 +1,21 @@ module.exports = { extends: [ // add more generic rulesets here, such as: // 'eslint:recommended', 'plugin:vue/recommended' ], parserOptions: { parser: "@babel/eslint-parser", requireConfigFile: false }, rules: { "vue/attributes-order": "off", "vue/html-indent": ["error", 4], "vue/html-self-closing": "off", "vue/max-attributes-per-line": "off", + "vue/no-unused-components": "off", "vue/no-v-html": "off", "vue/singleline-html-element-content-newline": "off", "vue/multiline-html-element-content-newline": "off" } } diff --git a/src/app/Http/Controllers/API/V4/PaymentsController.php b/src/app/Http/Controllers/API/V4/PaymentsController.php index 76e4e8cb..28b77266 100644 --- a/src/app/Http/Controllers/API/V4/PaymentsController.php +++ b/src/app/Http/Controllers/API/V4/PaymentsController.php @@ -1,480 +1,482 @@ <?php namespace App\Http\Controllers\API\V4; use App\Http\Controllers\Controller; use App\Providers\PaymentProvider; use App\Tenant; use App\Wallet; use App\Payment; use Illuminate\Http\Request; use Illuminate\Support\Facades\Validator; class PaymentsController extends Controller { /** * Get the auto-payment mandate info. * * @return \Illuminate\Http\JsonResponse The response */ public function mandate() { $user = $this->guard()->user(); // TODO: Wallet selection $wallet = $user->wallets()->first(); $mandate = self::walletMandate($wallet); return response()->json($mandate); } /** * Create a new auto-payment mandate. * * @param \Illuminate\Http\Request $request The API request. * * @return \Illuminate\Http\JsonResponse The response */ public function mandateCreate(Request $request) { $user = $this->guard()->user(); // TODO: Wallet selection $wallet = $user->wallets()->first(); // Input validation if ($errors = self::mandateValidate($request, $wallet)) { return response()->json(['status' => 'error', 'errors' => $errors], 422); } $wallet->setSettings([ 'mandate_amount' => $request->amount, 'mandate_balance' => $request->balance, ]); $mandate = [ 'currency' => $wallet->currency, 'description' => Tenant::getConfig($user->tenant_id, 'app.name') . ' Auto-Payment Setup', 'methodId' => $request->methodId ]; // Normally the auto-payment setup operation is 0, if the balance is below the threshold // we'll top-up the wallet with the configured auto-payment amount if ($wallet->balance < intval($request->balance * 100)) { $mandate['amount'] = intval($request->amount * 100); } $provider = PaymentProvider::factory($wallet); $result = $provider->createMandate($wallet, $mandate); $result['status'] = 'success'; return response()->json($result); } /** * Revoke the auto-payment mandate. * * @return \Illuminate\Http\JsonResponse The response */ public function mandateDelete() { $user = $this->guard()->user(); // TODO: Wallet selection $wallet = $user->wallets()->first(); $provider = PaymentProvider::factory($wallet); $provider->deleteMandate($wallet); $wallet->setSetting('mandate_disabled', null); return response()->json([ 'status' => 'success', 'message' => \trans('app.mandate-delete-success'), ]); } /** * Update a new auto-payment mandate. * * @param \Illuminate\Http\Request $request The API request. * * @return \Illuminate\Http\JsonResponse The response */ public function mandateUpdate(Request $request) { $user = $this->guard()->user(); // TODO: Wallet selection $wallet = $user->wallets()->first(); // Input validation if ($errors = self::mandateValidate($request, $wallet)) { return response()->json(['status' => 'error', 'errors' => $errors], 422); } $wallet->setSettings([ 'mandate_amount' => $request->amount, 'mandate_balance' => $request->balance, // Re-enable the mandate to give it a chance to charge again // after it has been disabled (e.g. because the mandate amount was too small) 'mandate_disabled' => null, ]); // Trigger auto-payment if the balance is below the threshold if ($wallet->balance < intval($request->balance * 100)) { \App\Jobs\WalletCharge::dispatch($wallet); } $result = self::walletMandate($wallet); $result['status'] = 'success'; $result['message'] = \trans('app.mandate-update-success'); return response()->json($result); } /** * Validate an auto-payment mandate request. * * @param \Illuminate\Http\Request $request The API request. * @param \App\Wallet $wallet The wallet * * @return array|null List of errors on error or Null on success */ protected static function mandateValidate(Request $request, Wallet $wallet) { $rules = [ 'amount' => 'required|numeric', 'balance' => 'required|numeric|min:0', ]; // Check required fields $v = Validator::make($request->all(), $rules); // TODO: allow comma as a decimal point? if ($v->fails()) { return $v->errors()->toArray(); } $amount = (int) ($request->amount * 100); // Validate the minimum value // It has to be at least minimum payment amount and must cover current debt if ( $wallet->balance < 0 && $wallet->balance * -1 > PaymentProvider::MIN_AMOUNT && $wallet->balance + $amount < 0 ) { return ['amount' => \trans('validation.minamountdebt')]; } if ($amount < PaymentProvider::MIN_AMOUNT) { $min = $wallet->money(PaymentProvider::MIN_AMOUNT); return ['amount' => \trans('validation.minamount', ['amount' => $min])]; } return null; } /** * Create a new payment. * * @param \Illuminate\Http\Request $request The API request. * * @return \Illuminate\Http\JsonResponse The response */ public function store(Request $request) { $user = $this->guard()->user(); // TODO: Wallet selection $wallet = $user->wallets()->first(); $rules = [ 'amount' => 'required|numeric', ]; // Check required fields $v = Validator::make($request->all(), $rules); // TODO: allow comma as a decimal point? if ($v->fails()) { return response()->json(['status' => 'error', 'errors' => $v->errors()], 422); } $amount = (int) ($request->amount * 100); // Validate the minimum value if ($amount < PaymentProvider::MIN_AMOUNT) { $min = $wallet->money(PaymentProvider::MIN_AMOUNT); $errors = ['amount' => \trans('validation.minamount', ['amount' => $min])]; return response()->json(['status' => 'error', 'errors' => $errors], 422); } $request = [ 'type' => PaymentProvider::TYPE_ONEOFF, 'currency' => $request->currency, 'amount' => $amount, 'methodId' => $request->methodId, 'description' => Tenant::getConfig($user->tenant_id, 'app.name') . ' Payment', ]; $provider = PaymentProvider::factory($wallet); $result = $provider->payment($wallet, $request); $result['status'] = 'success'; return response()->json($result); } /** * Delete a pending payment. * * @param \Illuminate\Http\Request $request The API request. * * @return \Illuminate\Http\JsonResponse The response */ // TODO currently unused // public function cancel(Request $request) // { // $user = $this->guard()->user(); // // TODO: Wallet selection // $wallet = $user->wallets()->first(); // $paymentId = $request->payment; // $user_owns_payment = Payment::where('id', $paymentId) // ->where('wallet_id', $wallet->id) // ->exists(); // if (!$user_owns_payment) { // return $this->errorResponse(404); // } // $provider = PaymentProvider::factory($wallet); // if ($provider->cancel($wallet, $paymentId)) { // $result = ['status' => 'success']; // return response()->json($result); // } // return $this->errorResponse(404); // } /** * Update payment status (and balance). * * @param string $provider Provider name * * @return \Illuminate\Http\Response The response */ public function webhook($provider) { $code = 200; if ($provider = PaymentProvider::factory($provider)) { $code = $provider->webhook(); } return response($code < 400 ? 'Success' : 'Server error', $code); } /** * Top up a wallet with a "recurring" payment. * * @param \App\Wallet $wallet The wallet to charge * * @return bool True if the payment has been initialized */ public static function topUpWallet(Wallet $wallet): bool { $settings = $wallet->getSettings(['mandate_disabled', 'mandate_balance', 'mandate_amount']); if (!empty($settings['mandate_disabled'])) { return false; } $min_balance = (int) (floatval($settings['mandate_balance']) * 100); $amount = (int) (floatval($settings['mandate_amount']) * 100); // The wallet balance is greater than the auto-payment threshold if ($wallet->balance >= $min_balance) { // Do nothing return false; } $provider = PaymentProvider::factory($wallet); $mandate = (array) $provider->getMandate($wallet); if (empty($mandate['isValid'])) { return false; } // The defined top-up amount is not enough // Disable auto-payment and notify the user if ($wallet->balance + $amount < 0) { // Disable (not remove) the mandate $wallet->setSetting('mandate_disabled', 1); \App\Jobs\PaymentMandateDisabledEmail::dispatch($wallet); return false; } $request = [ 'type' => PaymentProvider::TYPE_RECURRING, 'currency' => $wallet->currency, 'amount' => $amount, 'methodId' => PaymentProvider::METHOD_CREDITCARD, 'description' => Tenant::getConfig($wallet->owner->tenant_id, 'app.name') . ' Recurring Payment', ]; $result = $provider->payment($wallet, $request); return !empty($result); } /** * Returns auto-payment mandate info for the specified wallet * * @param \App\Wallet $wallet A wallet object * * @return array A mandate metadata */ public static function walletMandate(Wallet $wallet): array { $provider = PaymentProvider::factory($wallet); $settings = $wallet->getSettings(['mandate_disabled', 'mandate_balance', 'mandate_amount']); // Get the Mandate info $mandate = (array) $provider->getMandate($wallet); $mandate['amount'] = (int) (PaymentProvider::MIN_AMOUNT / 100); $mandate['balance'] = 0; $mandate['isDisabled'] = !empty($mandate['id']) && $settings['mandate_disabled']; foreach (['amount', 'balance'] as $key) { if (($value = $settings["mandate_{$key}"]) !== null) { $mandate[$key] = $value; } } return $mandate; } /** * List supported payment methods. * * @param \Illuminate\Http\Request $request The API request. * * @return \Illuminate\Http\JsonResponse The response */ public function paymentMethods(Request $request) { $user = $this->guard()->user(); // TODO: Wallet selection $wallet = $user->wallets()->first(); $methods = PaymentProvider::paymentMethods($wallet, $request->type); \Log::debug("Provider methods" . var_export(json_encode($methods), true)); return response()->json($methods); } /** * Check for pending payments. * * @param \Illuminate\Http\Request $request The API request. * * @return \Illuminate\Http\JsonResponse The response */ public function hasPayments(Request $request) { $user = $this->guard()->user(); // TODO: Wallet selection $wallet = $user->wallets()->first(); $exists = Payment::where('wallet_id', $wallet->id) ->where('type', PaymentProvider::TYPE_ONEOFF) ->whereIn('status', [ - PaymentProvider::STATUS_OPEN, - PaymentProvider::STATUS_PENDING, - PaymentProvider::STATUS_AUTHORIZED]) + PaymentProvider::STATUS_OPEN, + PaymentProvider::STATUS_PENDING, + PaymentProvider::STATUS_AUTHORIZED + ]) ->exists(); return response()->json([ 'status' => 'success', 'hasPending' => $exists ]); } /** * List pending payments. * * @param \Illuminate\Http\Request $request The API request. * * @return \Illuminate\Http\JsonResponse The response */ public function payments(Request $request) { $user = $this->guard()->user(); // TODO: Wallet selection $wallet = $user->wallets()->first(); $pageSize = 10; $page = intval(request()->input('page')) ?: 1; $hasMore = false; $result = Payment::where('wallet_id', $wallet->id) ->where('type', PaymentProvider::TYPE_ONEOFF) ->whereIn('status', [ - PaymentProvider::STATUS_OPEN, - PaymentProvider::STATUS_PENDING, - PaymentProvider::STATUS_AUTHORIZED]) + PaymentProvider::STATUS_OPEN, + PaymentProvider::STATUS_PENDING, + PaymentProvider::STATUS_AUTHORIZED + ]) ->orderBy('created_at', 'desc') ->limit($pageSize + 1) ->offset($pageSize * ($page - 1)) ->get(); if (count($result) > $pageSize) { $result->pop(); $hasMore = true; } $result = $result->map(function ($item) use ($wallet) { $provider = PaymentProvider::factory($item->provider); $payment = $provider->getPayment($item->id); $entry = [ 'id' => $item->id, 'createdAt' => $item->created_at->format('Y-m-d H:i'), 'type' => $item->type, 'description' => $item->description, 'amount' => $item->amount, 'currency' => $wallet->currency, // note: $item->currency/$item->currency_amount might be different 'status' => $item->status, 'isCancelable' => $payment['isCancelable'], 'checkoutUrl' => $payment['checkoutUrl'] ]; return $entry; }); return response()->json([ 'status' => 'success', 'list' => $result, 'count' => count($result), 'hasMore' => $hasMore, 'page' => $page, ]); } } diff --git a/src/app/Http/Controllers/API/V4/UsersController.php b/src/app/Http/Controllers/API/V4/UsersController.php index f87417da..b511d406 100644 --- a/src/app/Http/Controllers/API/V4/UsersController.php +++ b/src/app/Http/Controllers/API/V4/UsersController.php @@ -1,835 +1,878 @@ <?php namespace App\Http\Controllers\API\V4; use App\Http\Controllers\Controller; use App\Domain; use App\Group; use App\Rules\UserEmailDomain; use App\Rules\UserEmailLocal; use App\Sku; use App\User; use Carbon\Carbon; use Illuminate\Http\Request; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Validator; use Illuminate\Support\Str; class UsersController extends Controller { /** @const array List of user setting keys available for modification in UI */ public const USER_SETTINGS = [ 'billing_address', 'country', 'currency', 'external_email', 'first_name', 'last_name', 'organization', 'phone', ]; /** * On user create it is filled with a user or group object to force-delete * before the creation of a new user record is possible. * * @var \App\User|\App\Group|null */ protected $deleteBeforeCreate; /** * Delete a user. * * @param int $id User identifier * * @return \Illuminate\Http\JsonResponse The response */ public function destroy($id) { $user = User::withEnvTenantContext()->find($id); if (empty($user)) { return $this->errorResponse(404); } // User can't remove himself until he's the controller if (!$this->guard()->user()->canDelete($user)) { return $this->errorResponse(403); } $user->delete(); return response()->json([ 'status' => 'success', 'message' => \trans('app.user-delete-success'), ]); } /** * Listing of users. * * The user-entitlements billed to the current user wallet(s) * * @return \Illuminate\Http\JsonResponse */ public function index() { $user = $this->guard()->user(); + $search = trim(request()->input('search')); + $page = intval(request()->input('page')) ?: 1; + $pageSize = 20; + $hasMore = false; + + $result = $user->users(); + + // Search by user email, alias or name + if (strlen($search) > 0) { + // thanks to cloning we skip some extra queries in $user->users() + $allUsers1 = clone $result; + $allUsers2 = clone $result; + + $result->whereLike('email', $search) + ->union( + $allUsers1->join('user_aliases', 'users.id', '=', 'user_aliases.user_id') + ->whereLike('alias', $search) + ) + ->union( + $allUsers2->join('user_settings', 'users.id', '=', 'user_settings.user_id') + ->whereLike('value', $search) + ->whereIn('key', ['first_name', 'last_name']) + ); + } + + $result = $result->orderBy('email') + ->limit($pageSize + 1) + ->offset($pageSize * ($page - 1)) + ->get(); + + if (count($result) > $pageSize) { + $result->pop(); + $hasMore = true; + } + + // Process the result + $result = $result->map( + function ($user) { + $data = $user->toArray(); + $data = array_merge($data, self::userStatuses($user)); + return $data; + } + ); - $result = $user->users()->orderBy('email')->get()->map(function ($user) { - $data = $user->toArray(); - $data = array_merge($data, self::userStatuses($user)); - return $data; - }); + $result = [ + 'list' => $result, + 'count' => count($result), + 'hasMore' => $hasMore, + ]; return response()->json($result); } /** * Set user config. * * @param int $id The user * * @return \Illuminate\Http\JsonResponse */ public function setConfig($id) { $user = User::find($id); if (empty($user)) { return $this->errorResponse(404); } if (!$this->guard()->user()->canRead($user)) { return $this->errorResponse(403); } $errors = $user->setConfig(request()->input()); if (!empty($errors)) { return response()->json(['status' => 'error', 'errors' => $errors], 422); } return response()->json([ 'status' => 'success', 'message' => \trans('app.user-setconfig-success'), ]); } /** * Display information on the user account specified by $id. * * @param int $id The account to show information for. * * @return \Illuminate\Http\JsonResponse */ public function show($id) { $user = User::withEnvTenantContext()->find($id); if (empty($user)) { return $this->errorResponse(404); } if (!$this->guard()->user()->canRead($user)) { return $this->errorResponse(403); } $response = $this->userResponse($user); $response['skus'] = \App\Entitlement::objectEntitlementsSummary($user); $response['config'] = $user->getConfig(); return response()->json($response); } /** * Fetch user status (and reload setup process) * * @param int $id User identifier * * @return \Illuminate\Http\JsonResponse */ public function status($id) { $user = User::withEnvTenantContext()->find($id); if (empty($user)) { return $this->errorResponse(404); } if (!$this->guard()->user()->canRead($user)) { return $this->errorResponse(403); } $response = self::statusInfo($user); if (!empty(request()->input('refresh'))) { $updated = false; $async = false; $last_step = 'none'; foreach ($response['process'] as $idx => $step) { $last_step = $step['label']; if (!$step['state']) { $exec = $this->execProcessStep($user, $step['label']); if (!$exec) { if ($exec === null) { $async = true; } break; } $updated = true; } } if ($updated) { $response = self::statusInfo($user); } $success = $response['isReady']; $suffix = $success ? 'success' : 'error-' . $last_step; $response['status'] = $success ? 'success' : 'error'; $response['message'] = \trans('app.process-' . $suffix); if ($async && !$success) { $response['processState'] = 'waiting'; $response['status'] = 'success'; $response['message'] = \trans('app.process-async'); } } $response = array_merge($response, self::userStatuses($user)); return response()->json($response); } /** * User status (extended) information * * @param \App\User $user User object * * @return array Status information */ public static function statusInfo(User $user): array { $process = []; $steps = [ 'user-new' => true, 'user-ldap-ready' => $user->isLdapReady(), 'user-imap-ready' => $user->isImapReady(), ]; // Create a process check list foreach ($steps as $step_name => $state) { $step = [ 'label' => $step_name, 'title' => \trans("app.process-{$step_name}"), 'state' => $state, ]; $process[] = $step; } list ($local, $domain) = explode('@', $user->email); $domain = Domain::where('namespace', $domain)->first(); // If that is not a public domain, add domain specific steps if ($domain && !$domain->isPublic()) { $domain_status = DomainsController::statusInfo($domain); $process = array_merge($process, $domain_status['process']); } $all = count($process); $checked = count(array_filter($process, function ($v) { return $v['state']; })); $state = $all === $checked ? 'done' : 'running'; // After 180 seconds assume the process is in failed state, // this should unlock the Refresh button in the UI if ($all !== $checked && $user->created_at->diffInSeconds(Carbon::now()) > 180) { $state = 'failed'; } // Check if the user is a controller of his wallet $isController = $user->canDelete($user); $hasCustomDomain = $user->wallet()->entitlements() ->where('entitleable_type', Domain::class) ->count() > 0; // Get user's entitlements titles $skus = $user->entitlements()->select('skus.title') ->join('skus', 'skus.id', '=', 'entitlements.sku_id') ->get() ->pluck('title') ->sort() ->unique() ->values() ->all(); return [ 'skus' => $skus, // TODO: This will change when we enable all users to create domains 'enableDomains' => $isController && $hasCustomDomain, // TODO: Make 'enableDistlists' working for wallet controllers that aren't account owners 'enableDistlists' => $isController && $hasCustomDomain && in_array('distlist', $skus), 'enableUsers' => $isController, 'enableWallets' => $isController, 'process' => $process, 'processState' => $state, 'isReady' => $all === $checked, ]; } /** * Create a new user record. * * @param \Illuminate\Http\Request $request The API request. * * @return \Illuminate\Http\JsonResponse The response */ public function store(Request $request) { $current_user = $this->guard()->user(); $owner = $current_user->wallet()->owner; if ($owner->id != $current_user->id) { return $this->errorResponse(403); } $this->deleteBeforeCreate = null; if ($error_response = $this->validateUserRequest($request, null, $settings)) { return $error_response; } if (empty($request->package) || !($package = \App\Package::withEnvTenantContext()->find($request->package))) { $errors = ['package' => \trans('validation.packagerequired')]; return response()->json(['status' => 'error', 'errors' => $errors], 422); } if ($package->isDomain()) { $errors = ['package' => \trans('validation.packageinvalid')]; return response()->json(['status' => 'error', 'errors' => $errors], 422); } DB::beginTransaction(); // @phpstan-ignore-next-line if ($this->deleteBeforeCreate) { $this->deleteBeforeCreate->forceDelete(); } // Create user record $user = User::create([ 'email' => $request->email, 'password' => $request->password, ]); $owner->assignPackage($package, $user); if (!empty($settings)) { $user->setSettings($settings); } if (!empty($request->aliases)) { $user->setAliases($request->aliases); } DB::commit(); return response()->json([ 'status' => 'success', 'message' => \trans('app.user-create-success'), ]); } /** * Update user data. * * @param \Illuminate\Http\Request $request The API request. * @param string $id User identifier * * @return \Illuminate\Http\JsonResponse The response */ public function update(Request $request, $id) { $user = User::withEnvTenantContext()->find($id); if (empty($user)) { return $this->errorResponse(404); } $current_user = $this->guard()->user(); // TODO: Decide what attributes a user can change on his own profile if (!$current_user->canUpdate($user)) { return $this->errorResponse(403); } if ($error_response = $this->validateUserRequest($request, $user, $settings)) { return $error_response; } // Entitlements, only controller can do that if ($request->skus !== null && !$current_user->canDelete($user)) { return $this->errorResponse(422, "You have no permission to change entitlements"); } DB::beginTransaction(); $this->updateEntitlements($user, $request->skus); if (!empty($settings)) { $user->setSettings($settings); } if (!empty($request->password)) { $user->password = $request->password; $user->save(); } if (isset($request->aliases)) { $user->setAliases($request->aliases); } // TODO: Make sure that UserUpdate job is created in case of entitlements update // and no password change. So, for example quota change is applied to LDAP // TODO: Review use of $user->save() in the above context DB::commit(); $response = [ 'status' => 'success', 'message' => \trans('app.user-update-success'), ]; // For self-update refresh the statusInfo in the UI if ($user->id == $current_user->id) { $response['statusInfo'] = self::statusInfo($user); } return response()->json($response); } /** * Update user entitlements. * * @param \App\User $user The user * @param array $rSkus List of SKU IDs requested for the user in the form [id=>qty] */ protected function updateEntitlements(User $user, $rSkus) { if (!is_array($rSkus)) { return; } // list of skus, [id=>obj] $skus = Sku::withEnvTenantContext()->get()->mapWithKeys( function ($sku) { return [$sku->id => $sku]; } ); // existing entitlement's SKUs $eSkus = []; $user->entitlements()->groupBy('sku_id') ->selectRaw('count(*) as total, sku_id')->each( function ($e) use (&$eSkus) { $eSkus[$e->sku_id] = $e->total; } ); foreach ($skus as $skuID => $sku) { $e = array_key_exists($skuID, $eSkus) ? $eSkus[$skuID] : 0; $r = array_key_exists($skuID, $rSkus) ? $rSkus[$skuID] : 0; if ($sku->handler_class == \App\Handlers\Mailbox::class) { if ($r != 1) { throw new \Exception("Invalid quantity of mailboxes"); } } if ($e > $r) { // remove those entitled more than existing $user->removeSku($sku, ($e - $r)); } elseif ($e < $r) { // add those requested more than entitled $user->assignSku($sku, ($r - $e)); } } } /** * Create a response data array for specified user. * * @param \App\User $user User object * * @return array Response data */ public static function userResponse(User $user): array { $response = $user->toArray(); // Settings $response['settings'] = []; foreach ($user->settings()->whereIn('key', self::USER_SETTINGS)->get() as $item) { $response['settings'][$item->key] = $item->value; } // Aliases $response['aliases'] = []; foreach ($user->aliases as $item) { $response['aliases'][] = $item->alias; } // Status info $response['statusInfo'] = self::statusInfo($user); $response = array_merge($response, self::userStatuses($user)); // Add more info to the wallet object output $map_func = function ($wallet) use ($user) { $result = $wallet->toArray(); if ($wallet->discount) { $result['discount'] = $wallet->discount->discount; $result['discount_description'] = $wallet->discount->description; } if ($wallet->user_id != $user->id) { $result['user_email'] = $wallet->owner->email; } $provider = \App\Providers\PaymentProvider::factory($wallet); $result['provider'] = $provider->name(); return $result; }; // Information about wallets and accounts for access checks $response['wallets'] = $user->wallets->map($map_func)->toArray(); $response['accounts'] = $user->accounts->map($map_func)->toArray(); $response['wallet'] = $map_func($user->wallet()); return $response; } /** * Prepare user statuses for the UI * * @param \App\User $user User object * * @return array Statuses array */ protected static function userStatuses(User $user): array { return [ 'isImapReady' => $user->isImapReady(), 'isLdapReady' => $user->isLdapReady(), 'isSuspended' => $user->isSuspended(), 'isActive' => $user->isActive(), 'isDeleted' => $user->isDeleted() || $user->trashed(), ]; } /** * Validate user input * * @param \Illuminate\Http\Request $request The API request. * @param \App\User|null $user User identifier * @param array $settings User settings (from the request) * * @return \Illuminate\Http\JsonResponse|null The error response on error */ protected function validateUserRequest(Request $request, $user, &$settings = []) { $rules = [ 'external_email' => 'nullable|email', 'phone' => 'string|nullable|max:64|regex:/^[0-9+() -]+$/', 'first_name' => 'string|nullable|max:128', 'last_name' => 'string|nullable|max:128', 'organization' => 'string|nullable|max:512', 'billing_address' => 'string|nullable|max:1024', 'country' => 'string|nullable|alpha|size:2', 'currency' => 'string|nullable|alpha|size:3', 'aliases' => 'array|nullable', ]; if (empty($user) || !empty($request->password) || !empty($request->password_confirmation)) { $rules['password'] = 'required|min:4|max:2048|confirmed'; } $errors = []; // Validate input $v = Validator::make($request->all(), $rules); if ($v->fails()) { $errors = $v->errors()->toArray(); } $controller = $user ? $user->wallet()->owner : $this->guard()->user(); // For new user validate email address if (empty($user)) { $email = $request->email; if (empty($email)) { $errors['email'] = \trans('validation.required', ['attribute' => 'email']); } elseif ($error = self::validateEmail($email, $controller, $this->deleteBeforeCreate)) { $errors['email'] = $error; } } // Validate aliases input if (isset($request->aliases)) { $aliases = []; $existing_aliases = $user ? $user->aliases()->get()->pluck('alias')->toArray() : []; foreach ($request->aliases as $idx => $alias) { if (is_string($alias) && !empty($alias)) { // Alias cannot be the same as the email address (new user) if (!empty($email) && Str::lower($alias) == Str::lower($email)) { continue; } // validate new aliases if ( !in_array($alias, $existing_aliases) && ($error = self::validateAlias($alias, $controller)) ) { if (!isset($errors['aliases'])) { $errors['aliases'] = []; } $errors['aliases'][$idx] = $error; continue; } $aliases[] = $alias; } } $request->aliases = $aliases; } if (!empty($errors)) { return response()->json(['status' => 'error', 'errors' => $errors], 422); } // Update user settings $settings = $request->only(array_keys($rules)); unset($settings['password'], $settings['aliases'], $settings['email']); return null; } /** * Execute (synchronously) specified step in a user setup process. * * @param \App\User $user User object * @param string $step Step identifier (as in self::statusInfo()) * * @return bool|null True if the execution succeeded, False if not, Null when * the job has been sent to the worker (result unknown) */ public static function execProcessStep(User $user, string $step): ?bool { try { if (strpos($step, 'domain-') === 0) { list ($local, $domain) = explode('@', $user->email); $domain = Domain::where('namespace', $domain)->first(); return DomainsController::execProcessStep($domain, $step); } switch ($step) { case 'user-ldap-ready': // User not in LDAP, create it $job = new \App\Jobs\User\CreateJob($user->id); $job->handle(); $user->refresh(); return $user->isLdapReady(); case 'user-imap-ready': // User not in IMAP? Verify again // Do it synchronously if the imap admin credentials are available // otherwise let the worker do the job if (!\config('imap.admin_password')) { \App\Jobs\User\VerifyJob::dispatch($user->id); return null; } $job = new \App\Jobs\User\VerifyJob($user->id); $job->handle(); $user->refresh(); return $user->isImapReady(); } } catch (\Exception $e) { \Log::error($e); } return false; } /** * Email address validation for use as a user mailbox (login). * * @param string $email Email address * @param \App\User $user The account owner * @param null|\App\User|\App\Group $deleted Filled with an instance of a deleted user or group * with the specified email address, if exists * * @return ?string Error message on validation error */ public static function validateEmail(string $email, \App\User $user, &$deleted = null): ?string { $deleted = null; if (strpos($email, '@') === false) { return \trans('validation.entryinvalid', ['attribute' => 'email']); } list($login, $domain) = explode('@', Str::lower($email)); if (strlen($login) === 0 || strlen($domain) === 0) { return \trans('validation.entryinvalid', ['attribute' => 'email']); } // Check if domain exists $domain = Domain::withEnvTenantContext()->where('namespace', $domain)->first(); if (empty($domain)) { return \trans('validation.domaininvalid'); } // Validate login part alone $v = Validator::make( ['email' => $login], ['email' => ['required', new UserEmailLocal(!$domain->isPublic())]] ); if ($v->fails()) { return $v->errors()->toArray()['email'][0]; } // Check if it is one of domains available to the user $domains = \collect($user->domains())->pluck('namespace')->all(); if (!in_array($domain->namespace, $domains)) { return \trans('validation.entryexists', ['attribute' => 'domain']); } // Check if a user with specified address already exists if ($existing_user = User::emailExists($email, true)) { // If this is a deleted user in the same custom domain // we'll force delete him before if (!$domain->isPublic() && $existing_user->trashed()) { $deleted = $existing_user; } else { return \trans('validation.entryexists', ['attribute' => 'email']); } } // Check if an alias with specified address already exists. if (User::aliasExists($email)) { return \trans('validation.entryexists', ['attribute' => 'email']); } // Check if a group with specified address already exists if ($existing_group = Group::emailExists($email, true)) { // If this is a deleted group in the same custom domain // we'll force delete it before if (!$domain->isPublic() && $existing_group->trashed()) { $deleted = $existing_group; } else { return \trans('validation.entryexists', ['attribute' => 'email']); } } return null; } /** * Email address validation for use as an alias. * * @param string $email Email address * @param \App\User $user The account owner * * @return ?string Error message on validation error */ public static function validateAlias(string $email, \App\User $user): ?string { if (strpos($email, '@') === false) { return \trans('validation.entryinvalid', ['attribute' => 'alias']); } list($login, $domain) = explode('@', Str::lower($email)); if (strlen($login) === 0 || strlen($domain) === 0) { return \trans('validation.entryinvalid', ['attribute' => 'alias']); } // Check if domain exists $domain = Domain::withEnvTenantContext()->where('namespace', $domain)->first(); if (empty($domain)) { return \trans('validation.domaininvalid'); } // Validate login part alone $v = Validator::make( ['alias' => $login], ['alias' => ['required', new UserEmailLocal(!$domain->isPublic())]] ); if ($v->fails()) { return $v->errors()->toArray()['alias'][0]; } // Check if it is one of domains available to the user $domains = \collect($user->domains())->pluck('namespace')->all(); if (!in_array($domain->namespace, $domains)) { return \trans('validation.entryexists', ['attribute' => 'domain']); } // Check if a user with specified address already exists if ($existing_user = User::emailExists($email, true)) { // Allow an alias in a custom domain to an address that was a user before if ($domain->isPublic() || !$existing_user->trashed()) { return \trans('validation.entryexists', ['attribute' => 'alias']); } } // Check if an alias with specified address already exists if (User::aliasExists($email)) { // Allow assigning the same alias to a user in the same group account, // but only for non-public domains if ($domain->isPublic()) { return \trans('validation.entryexists', ['attribute' => 'alias']); } } // Check if a group with specified address already exists if (Group::emailExists($email)) { return \trans('validation.entryexists', ['attribute' => 'alias']); } return null; } } diff --git a/src/resources/lang/en/ui.php b/src/resources/lang/en/ui.php index 8c13a1f2..41b54ff5 100644 --- a/src/resources/lang/en/ui.php +++ b/src/resources/lang/en/ui.php @@ -1,430 +1,431 @@ <?php /** * This file will be converted to a Vue-i18n compatible JSON format on build time * * Note: The Laravel localization features do not work here. Vue-i18n rules are different */ return [ 'app' => [ 'faq' => "FAQ", ], 'btn' => [ 'add' => "Add", 'accept' => "Accept", 'back' => "Back", 'cancel' => "Cancel", 'close' => "Close", 'continue' => "Continue", 'delete' => "Delete", 'deny' => "Deny", 'download' => "Download", 'edit' => "Edit", 'file' => "Choose file...", 'moreinfo' => "More information", 'refresh' => "Refresh", 'reset' => "Reset", 'resend' => "Resend", 'save' => "Save", 'search' => "Search", 'signup' => "Sign Up", 'submit' => "Submit", 'suspend' => "Suspend", 'unsuspend' => "Unsuspend", 'verify' => "Verify", ], 'dashboard' => [ 'beta' => "beta", 'distlists' => "Distribution lists", 'chat' => "Video chat", 'domains' => "Domains", 'invitations' => "Invitations", 'profile' => "Your profile", '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.", 'new' => "New distribution list", 'recipients' => "Recipients", ], '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:", '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: <var>.ess.barracuda.com</var>.", '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 <b>must have one of the following entries</b> 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", ], 'form' => [ 'amount' => "Amount", 'code' => "Confirmation Code", 'config' => "Configuration", 'date' => "Date", 'description' => "Description", 'details' => "Details", 'disabled' => "disabled", 'domain' => "Domain", 'email' => "Email Address", 'enabled' => "enabled", 'firstname' => "First Name", 'general' => "General", 'lastname' => "Last Name", 'none' => "none", 'or' => "or", 'password' => "Password", 'password-confirm' => "Confirm Password", 'phone' => "Phone", 'settings' => "Settings", 'status' => "Status", 'surname' => "Surname", '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.", 'empty-list' => "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", 'webmail' => "Webmail" ], 'meet' => [ 'title' => "Voice & Video Conferencing", 'welcome' => "Welcome to our beta program for Voice & Video Conferencing.", 'url' => "You have a room of your own at the URL below. This room is only open when you yourself are in attendance. Use this URL to invite people to join you.", 'notice' => "This is a work in progress and more features will be added over time. Current features include:", 'sharing' => "Screen Sharing", 'sharing-text' => "Share your screen for presentations or show-and-tell.", 'security' => "Room Security", 'security-text' => "Increase the room security by setting a password that attendees will need to know" . " before they can enter, or lock the door so attendees will have to knock, and a moderator can accept or deny those requests.", 'qa' => "Raise Hand (Q&A)", 'qa-text' => "Silent audience members can raise their hand to facilitate a Question & Answer session with the panel members.", 'moderation' => "Moderator Delegation", 'moderation-text' => "Delegate moderator authority for the session, so that a speaker is not needlessly" . " interrupted with attendees knocking and other moderator duties.", 'eject' => "Eject Attendees", 'eject-text' => "Eject attendees from the session in order to force them to reconnect, or address policy" . " violations. Click the user icon for effective dismissal.", 'silent' => "Silent Audience Members", 'silent-text' => "For a webinar-style session, configure the room to force all new attendees to be silent audience members.", 'interpreters' => "Language Specific Audio Channels", 'interpreters-text' => "Designate a participant to interpret the original audio to a target language, for sessions" . " with multi-lingual attendees. The interpreter is expected to be able to relay the original audio, and override it.", 'beta-notice' => "Keep in mind that this is still in beta and might come with some issues." . " Should you encounter any on your way, let us know by contacting support.", // 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", 'warning' => "Warning", 'success' => "Success", ], 'nav' => [ 'more' => "Load more", 'step' => "Step {i}/{n}", ], 'password' => [ '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.", ], '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 Kolab identity (you can choose additional addresses later).", '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-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-user' => "The user account is almost ready.", 'verify' => "Verify your domain to finish the setup process.", 'verify-domain' => "Verify domain", 'deleted' => "Deleted", '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 john@kolab.org", '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-email' => "Email 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 <b>{amount}</b> when under <b>{balance}</b> using {method}", 'country' => "Country", 'create' => "Create user", 'custno' => "Customer No.", '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", 'distlists-none' => "There are no distribution lists in this account.", 'domains' => "Domains", 'domains-none' => "There are no domains in this account.", 'ext-email' => "External Email", 'finances' => "Finances", '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.", 'list-title' => "User accounts", 'managed-by' => "Managed by", 'new' => "New user account", 'org' => "Organization", 'package' => "Package", '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 <a href=\"{href}\">{email}</a>.", '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", 'title' => "User account", + 'search' => "User email address or name", 'search-pl' => "User ID, email or domain", 'skureq' => "{sku} requires {list}.", 'subscription' => "Subscription", 'subscriptions' => "Subscriptions", 'subscriptions-none' => "This user has no subscriptions.", 'users' => "Users", 'users-none' => "There are no users in this account.", ], '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 <b>set</b> to fill up your account by <b>{amount}</b> every time your account balance gets under <b>{balance}</b>.", '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.", '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", 'month' => "month", 'noperm' => "Only account owners can access a wallet.", '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/vue/Reseller/Invitations.vue b/src/resources/vue/Reseller/Invitations.vue index 49be5ccd..4ec609fd 100644 --- a/src/resources/vue/Reseller/Invitations.vue +++ b/src/resources/vue/Reseller/Invitations.vue @@ -1,280 +1,223 @@ <template> <div class="container"> <div class="card" id="invitations"> <div class="card-body"> <div class="card-title"> {{ $t('invitation.title') }} </div> <div class="card-text"> <div class="mb-2 d-flex"> - <form @submit.prevent="searchInvitations" id="search-form" class="input-group" style="flex:1"> - <input class="form-control" type="text" :placeholder="$t('invitation.search')" v-model="search"> - <button type="submit" class="btn btn-primary"><svg-icon icon="search"></svg-icon> {{ $t('btn.search') }}</button> - </form> + <list-search :placeholder="$t('invitation.search')" :on-search="searchInvitations"></list-search> <div> <button class="btn btn-success create-invite ms-1" @click="inviteUserDialog"> <svg-icon icon="envelope-open-text"></svg-icon> {{ $t('invitation.create') }} </button> </div> </div> <table id="invitations-list" class="table table-sm table-hover"> <thead> <tr> <th scope="col">{{ $t('user.ext-email') }}</th> <th scope="col">{{ $t('form.created') }}</th> <th scope="col"></th> </tr> </thead> <tbody> <tr v-for="inv in invitations" :id="'i' + inv.id" :key="inv.id"> <td class="email"> <svg-icon icon="envelope-open-text" :class="statusClass(inv)" :title="$t('invitation.status-' + statusLabel(inv))"></svg-icon> <span>{{ inv.email }}</span> </td> <td class="datetime"> {{ inv.created }} </td> <td class="buttons"> <button class="btn text-danger button-delete p-0 ms-1" @click="deleteInvite(inv.id)"> <svg-icon icon="trash-alt"></svg-icon> <span class="btn-label">{{ $t('btn.delete') }}</span> </button> <button class="btn button-resend p-0 ms-1" :disabled="inv.isNew || inv.isCompleted" @click="resendInvite(inv.id)"> <svg-icon icon="redo"></svg-icon> <span class="btn-label">{{ $t('btn.resend') }}</span> </button> </td> </tr> </tbody> - <tfoot class="table-fake-body"> - <tr> - <td colspan="3">{{ $t('invitation.empty-list') }}</td> - </tr> - </tfoot> + <list-foot :text="$t('invitation.empty-list')" colspan="3"></list-foot> </table> - <div class="text-center p-3" id="more-loader" v-if="hasMore"> - <button class="btn btn-secondary" @click="loadInvitations(true)">{{ $t('nav.more') }}</button> - </div> + <list-more v-if="hasMore" :on-click="loadInvitations"></list-more> </div> </div> </div> <div id="invite-create" class="modal" tabindex="-1" role="dialog"> <div class="modal-dialog" role="document"> <div class="modal-content"> <div class="modal-header"> <h5 class="modal-title">{{ $t('invitation.create-title') }}</h5> <button type="button" class="btn-close" data-bs-dismiss="modal" :aria-label="$t('btn.close')"></button> </div> <div class="modal-body"> <form> <p>{{ $t('invitation.create-email') }}</p> <div> <input id="email" type="text" class="form-control" name="email"> </div> <div class="form-separator"><hr><span>{{ $t('form.or') }}</span></div> <p>{{ $t('invitation.create-csv') }}</p> <div> <input id="file" type="file" class="form-control" name="csv"> </div> </form> </div> <div class="modal-footer"> <button type="button" class="btn btn-secondary modal-cancel" data-bs-dismiss="modal">{{ $t('btn.cancel') }}</button> <button type="button" class="btn btn-primary modal-action" @click="inviteUser()"> <svg-icon icon="paper-plane"></svg-icon> {{ $t('invitation.send') }} </button> </div> </div> </div> </div> </div> </template> <script> import { Modal } from 'bootstrap' import { library } from '@fortawesome/fontawesome-svg-core' import { faEnvelopeOpenText, faPaperPlane, faRedo } from '@fortawesome/free-solid-svg-icons' + import ListTools from '../Widgets/ListTools' library.add(faEnvelopeOpenText, faPaperPlane, faRedo) export default { + mixins: [ ListTools ], data() { return { - invitations: [], - hasMore: false, - page: 1, - search: '' + invitations: [] } }, mounted() { - this.$root.startLoading() - this.loadInvitations(null, () => this.$root.stopLoading()) + this.loadInvitations({ init: true }) $('#invite-create')[0].addEventListener('shown.bs.modal', event => { $('input', event.target).first().focus() }) }, methods: { deleteInvite(id) { axios.delete('/api/v4/invitations/' + id) .then(response => { if (response.data.status == 'success') { this.$toast.success(response.data.message) // Remove the invitation record from the list const index = this.invitations.findIndex(item => item.id == id) this.invitations.splice(index, 1) } }) }, fileChange(e) { let label = this.$t('btn.file') let files = e.target.files if (files.length) { label = files[0].name if (files.length > 1) { label += ', ...' } } $(e.target).next().text(label) }, inviteUser() { let dialog = $('#invite-create') let post = new FormData() let params = { headers: { 'Content-Type': 'multipart/form-data' } } post.append('email', dialog.find('#email').val()) this.$root.clearFormValidation(dialog.find('form')) // Append the file to POST data let files = dialog.find('#file').get(0).files if (files.length) { post.append('file', files[0]) } axios.post('/api/v4/invitations', post, params) .then(response => { if (response.data.status == 'success') { this.dialog.hide() this.$toast.success(response.data.message) if (response.data.count) { this.loadInvitations({ reset: true }) } } }) }, inviteUserDialog() { const dialog = $('#invite-create')[0] const form = $('form', dialog) form.get(0).reset() this.fileChange({ target: form.find('#file')[0] }) // resets file input label this.$root.clearFormValidation(form) this.dialog = new Modal(dialog) this.dialog.show() }, - loadInvitations(params, callback) { - let loader - let get = {} - - if (params) { - if (params.reset) { - this.invitations = [] - this.page = 0 - } - - get.page = params.page || (this.page + 1) - - if (typeof params === 'object' && 'search' in params) { - get.search = params.search - this.currentSearch = params.search - } else { - get.search = this.currentSearch - } - - loader = $(get.page > 1 ? '#more-loader' : '#invitations-list tfoot td') - } else { - this.currentSearch = null - } - - this.$root.addLoader(loader) - - axios.get('/api/v4/invitations', { params: get }) - .then(response => { - this.$root.removeLoader(loader) - - // Note: In Vue we can't just use .concat() - for (let i in response.data.list) { - this.$set(this.invitations, this.invitations.length, response.data.list[i]) - } - this.hasMore = response.data.hasMore - this.page = response.data.page || 1 - - if (callback) { - callback() - } - }) - .catch(error => { - this.$root.removeLoader(loader) - - if (callback) { - callback() - } - }) + loadInvitations(params) { + this.listSearch('invitations', '/api/v4/invitations', params) }, resendInvite(id) { axios.post('/api/v4/invitations/' + id + '/resend') .then(response => { if (response.data.status == 'success') { this.$toast.success(response.data.message) // Update the invitation record const index = this.invitations.findIndex(item => item.id == id) if (index > -1) { this.$set(this.invitations, index, response.data.invitation) } } }) }, - searchInvitations() { - this.loadInvitations({ reset: true, search: this.search }) + searchInvitations(search) { + this.loadInvitations({ reset: true, search }) }, statusClass(invitation) { if (invitation.isCompleted) { return 'text-success' } if (invitation.isFailed) { return 'text-danger' } if (invitation.isSent) { return 'text-primary' } return '' }, statusLabel(invitation) { if (invitation.isCompleted) { return 'completed' } if (invitation.isFailed) { return 'failed' } if (invitation.isSent) { return 'sent' } return 'new' } } } </script> diff --git a/src/resources/vue/User/List.vue b/src/resources/vue/User/List.vue index 17b76660..a0f2f131 100644 --- a/src/resources/vue/User/List.vue +++ b/src/resources/vue/User/List.vue @@ -1,57 +1,62 @@ <template> <div class="container"> <div class="card" id="user-list"> <div class="card-body"> <div class="card-title"> {{ $t('user.list-title') }} - <router-link class="btn btn-success float-end create-user" :to="{ path: 'user/new' }" tag="button"> - <svg-icon icon="user"></svg-icon> {{ $t('user.create') }} - </router-link> </div> <div class="card-text"> - <table class="table table-sm table-hover"> + <div class="mb-2 d-flex"> + <list-search :placeholder="$t('user.search')" :on-search="searchUsers"></list-search> + <div> + <router-link class="btn btn-success ms-1 create-user" :to="{ path: 'user/new' }" tag="button"> + <svg-icon icon="user"></svg-icon> {{ $t('user.create') }} + </router-link> + </div> + </div> + <table id="users-list" class="table table-sm table-hover"> <thead> <tr> <th scope="col">{{ $t('form.primary-email') }}</th> </tr> </thead> <tbody> <tr v-for="user in users" :id="'user' + user.id" :key="user.id" @click="$root.clickRecord"> <td> <svg-icon icon="user" :class="$root.userStatusClass(user)" :title="$root.userStatusText(user)"></svg-icon> <router-link :to="{ path: 'user/' + user.id }">{{ user.email }}</router-link> </td> </tr> </tbody> - <tfoot class="table-fake-body"> - <tr> - <td>{{ $t('user.users-none') }}</td> - </tr> - </tfoot> + <list-foot :text="$t('user.users-none')"></list-foot> </table> + <list-more v-if="hasMore" :on-click="loadUsers"></list-more> </div> </div> </div> </div> </template> <script> + import ListTools from '../Widgets/ListTools' + export default { + mixins: [ ListTools ], data() { return { - users: [], - current_user: null + users: [] } }, - created() { - this.$root.startLoading() - - axios.get('/api/v4/users') - .then(response => { - this.$root.stopLoading() - this.users = response.data - }) - .catch(this.$root.errorHandler) + mounted() { + this.loadUsers({ init: true }) + }, + methods: { + loadUsers(params) { + this.listSearch('users', '/api/v4/users', params) + }, + searchUsers(search) { + this.loadUsers({ reset: true, search }) + } } } </script> diff --git a/src/resources/vue/Widgets/ListTools.vue b/src/resources/vue/Widgets/ListTools.vue new file mode 100644 index 00000000..f4f7f15c --- /dev/null +++ b/src/resources/vue/Widgets/ListTools.vue @@ -0,0 +1,116 @@ +<template> + <div></div> +</template> + +<script> + + const ListSearch = { + props: { + onSearch: { type: Function, default: () => {} }, + placeholder: { type: String, default: '' } + }, + data() { + return { + search: '' + } + }, + template: `<form @submit.prevent="onSearch(search)" id="search-form" class="input-group" style="flex:1"> + <input class="form-control" type="text" :placeholder="placeholder" v-model="search"> + <button type="submit" class="btn btn-primary"><svg-icon icon="search"></svg-icon> {{ $t('btn.search') }}</button> + </form>` + } + + const ListFoot = { + props: { + colspan: { type: Number, default: 1 }, + text: { type: String, default: '' } + }, + template: `<tfoot class="table-fake-body"><tr><td :colspan="colspan">{{ text }}</td></tr></tfoot>` + } + + const ListMore = { + props: { + onClick: { type: Function, default: () => {} } + }, + template: `<div class="text-center p-3 more-loader"> + <button class="btn btn-secondary" @click="onClick({})">{{ $t('nav.more') }}</button> + </div>` + } + + export default { + components: { + ListFoot, + ListMore, + ListSearch + }, + data() { + return { + currentSearch: '', + hasMore: false, + page: 1 + } + }, + methods: { + listSearch(name, url, params) { + let loader + let get = {} + + if (params) { + if (params.reset || params.init) { + this[name] = [] + this.page = 0 + } + + get.page = params.page || (this.page + 1) + + if ('search' in params) { + get.search = params.search + this.currentSearch = params.search + this.hasMore = false + } else { + get.search = this.currentSearch + } + + if (!params.init) { + loader = $(this.$el).find('.more-loader') + if (!loader.length || get.page == 1) { + loader = $(this.$el).find('tfoot td') + } + } + } else { + this.currentSearch = null + } + + if (params && params.init) { + this.$root.startLoading() + } else { + this.$root.addLoader(loader) + } + + const finish = () => { + if (params && params.init) { + this.$root.stopLoading() + } else { + this.$root.removeLoader(loader) + } + } + + axios.get(url, { params: get }) + .then(response => { + // Note: In Vue we can't just use .concat() + for (let i in response.data.list) { + this.$set(this[name], this[name].length, response.data.list[i]) + } + + this.hasMore = response.data.hasMore + this.page = response.data.page || 1 + + finish() + }) + .catch(error => { + finish() + }) + } + } + } +</script> diff --git a/src/resources/vue/Widgets/PaymentLog.vue b/src/resources/vue/Widgets/PaymentLog.vue index fe661904..0ea78d9b 100644 --- a/src/resources/vue/Widgets/PaymentLog.vue +++ b/src/resources/vue/Widgets/PaymentLog.vue @@ -1,76 +1,48 @@ <template> <div> <table class="table table-sm m-0 payments"> <thead> <tr> <th scope="col">{{ $t('form.date') }}</th> <th scope="col">{{ $t('form.description') }}</th> <th scope="col"></th> <th scope="col" class="price">{{ $t('form.amount') }}</th> </tr> </thead> <tbody> <tr v-for="payment in payments" :id="'log' + payment.id" :key="payment.id"> <td class="datetime">{{ payment.createdAt }}</td> <td class="description">{{ payment.description }}</td> <td><a v-if="payment.checkoutUrl" :href="payment.checkoutUrl">{{ $t('form.details') }}</a></td> <td class="price text-success">{{ amount(payment) }}</td> </tr> </tbody> - <tfoot class="table-fake-body"> - <tr> - <td colspan="4">{{ $t('wallet.pending-payments-none') }}</td> - </tr> - </tfoot> + <list-foot :text="$t('wallet.pending-payments-none')" colspan="4"></list-foot> </table> - <div class="text-center p-3" id="payments-loader" v-if="hasMore"> - <button class="btn btn-secondary" @click="loadLog(true)">{{ $t('nav.more') }}</button> - </div> + <list-more v-if="hasMore" :on-click="loadLog"></list-more> </div> </template> <script> + import ListTools from './ListTools' + export default { - props: { - }, + mixins: [ ListTools ], data() { return { - payments: [], - hasMore: false, - page: 1 + payments: [] } }, mounted() { - this.loadLog() + this.loadLog({ reset: true }) }, methods: { - loadLog(more) { - let loader = $(this.$el) - let param = '' - - if (more) { - param = '?page=' + (this.page + 1) - loader = $('#payments-loader') - } - - this.$root.addLoader(loader) - axios.get('/api/v4/payments/pending' + param) - .then(response => { - this.$root.removeLoader(loader) - // Note: In Vue we can't just use .concat() - for (let i in response.data.list) { - this.$set(this.payments, this.payments.length, response.data.list[i]) - } - this.hasMore = response.data.hasMore - this.page = response.data.page || 1 - }) - .catch(error => { - this.$root.removeLoader(loader) - }) + loadLog(params) { + this.listSearch('payments', '/api/v4/payments/pending', params) }, amount(payment) { return this.$root.price(payment.amount, payment.currency) } } } </script> diff --git a/src/resources/vue/Widgets/TransactionLog.vue b/src/resources/vue/Widgets/TransactionLog.vue index 0b974654..e62e4f0b 100644 --- a/src/resources/vue/Widgets/TransactionLog.vue +++ b/src/resources/vue/Widgets/TransactionLog.vue @@ -1,122 +1,94 @@ <template> <div> <table class="table table-sm m-0 transactions"> <thead> <tr> <th scope="col">{{ $t('form.date') }}</th> <th scope="col" v-if="isAdmin">{{ $t('form.user') }}</th> <th scope="col"></th> <th scope="col">{{ $t('form.description') }}</th> <th scope="col" class="price">{{ $t('form.amount') }}</th> </tr> </thead> <tbody> <tr v-for="transaction in transactions" :id="'log' + transaction.id" :key="transaction.id"> <td class="datetime">{{ transaction.createdAt }}</td> <td class="email" v-if="isAdmin">{{ transaction.user }}</td> <td class="selection"> <button class="btn btn-lg btn-link btn-action" title="Details" type="button" v-if="transaction.hasDetails" @click="loadTransaction(transaction.id)" > <svg-icon icon="info-circle"></svg-icon> </button> </td> <td class="description">{{ description(transaction) }}</td> <td :class="'price ' + className(transaction)">{{ amount(transaction) }}</td> </tr> </tbody> - <tfoot class="table-fake-body"> - <tr> - <td :colspan="isAdmin ? 5 : 4">{{ $t('wallet.transactions-none') }}</td> - </tr> - </tfoot> + <list-foot :text="$t('wallet.transactions-none')" :colspan="isAdmin ? 5 : 4"></list-foot> </table> - <div class="text-center p-3" id="transactions-loader" v-if="hasMore"> - <button class="btn btn-secondary" @click="loadLog(true)">{{ $t('nav.more') }}</button> - </div> + <list-more v-if="hasMore" :on-click="loadLog"></list-more> </div> </template> <script> + import ListTools from './ListTools' + export default { + mixins: [ ListTools ], props: { walletId: { type: String, default: null }, isAdmin: { type: Boolean, default: false }, }, data() { return { - transactions: [], - hasMore: false, - page: 1 + transactions: [] } }, mounted() { - this.loadLog() + this.loadLog({ reset: true }) }, methods: { - loadLog(more) { - if (!this.walletId) { - return - } - - let loader = $(this.$el) - let param = '' - - if (more) { - param = '?page=' + (this.page + 1) - loader = $('#transactions-loader') + loadLog(params) { + if (this.walletId) { + this.listSearch('transactions', '/api/v4/wallets/' + this.walletId + '/transactions', params) } - - this.$root.addLoader(loader) - axios.get('/api/v4/wallets/' + this.walletId + '/transactions' + param) - .then(response => { - this.$root.removeLoader(loader) - // Note: In Vue we can't just use .concat() - for (let i in response.data.list) { - this.$set(this.transactions, this.transactions.length, response.data.list[i]) - } - this.hasMore = response.data.hasMore - this.page = response.data.page || 1 - }) - .catch(error => { - this.$root.removeLoader(loader) - }) }, loadTransaction(id) { let record = $('#log' + id) let cell = record.find('td.description') let details = $('<div class="list-details"><ul></ul><div>').appendTo(cell) this.$root.addLoader(cell) axios.get('/api/v4/wallets/' + this.walletId + '/transactions' + '?transaction=' + id) .then(response => { this.$root.removeLoader(cell) record.find('button').remove() let list = details.find('ul') response.data.list.forEach(elem => { list.append($('<li>').text(this.description(elem))) }) }) .catch(error => { this.$root.removeLoader(cell) }) }, amount(transaction) { return this.$root.price(transaction.amount, transaction.currency) }, className(transaction) { return transaction.amount < 0 ? 'text-danger' : 'text-success'; }, description(transaction) { let desc = transaction.description if (/^(billed|created|deleted)$/.test(transaction.type)) { desc += ' (' + this.$root.price(transaction.amount) + ')' } return desc } } } </script> diff --git a/src/tests/Browser.php b/src/tests/Browser.php index 24174cbc..2d71ecde 100644 --- a/src/tests/Browser.php +++ b/src/tests/Browser.php @@ -1,267 +1,265 @@ <?php namespace Tests; use Facebook\WebDriver\WebDriverKeys; use PHPUnit\Framework\Assert; use Tests\Browser\Components\Error; use Tests\Browser\Components\Toast; /** * Laravel Dusk Browser extensions */ class Browser extends \Laravel\Dusk\Browser { /** * Assert that the given element attribute contains specified text. */ public function assertAttributeRegExp($selector, $attribute, $regexp) { $element = $this->resolver->findOrFail($selector); $value = (string) $element->getAttribute($attribute); $error = "No expected text in [$selector][$attribute]. Found: $value"; Assert::assertMatchesRegularExpression($regexp, $value, $error); return $this; } /** * Assert number of (visible) elements */ public function assertElementsCount($selector, $expected_count, $visible = true) { $elements = $this->elements($selector); $count = count($elements); if ($visible) { foreach ($elements as $element) { if (!$element->isDisplayed()) { $count--; } } } Assert::assertEquals($expected_count, $count, "Count of [$selector] elements is not $expected_count"); return $this; } /** * Assert Tip element content */ public function assertTip($selector, $content) { return $this->click($selector) ->withinBody(function ($browser) use ($content) { $browser->waitFor('div.tooltip .tooltip-inner') ->assertSeeIn('div.tooltip .tooltip-inner', $content); }) ->click($selector); } /** * Assert Toast element content (and close it) */ public function assertToast(string $type, string $message, $title = null) { return $this->withinBody(function ($browser) use ($type, $title, $message) { $browser->with(new Toast($type), function (Browser $browser) use ($title, $message) { $browser->assertToastTitle($title) ->assertToastMessage($message) ->closeToast(); }); }); } /** * Assert specified error page is displayed. */ public function assertErrorPage(int $error_code, string $hint = '') { $this->with(new Error($error_code, $hint), function ($browser) { // empty, assertions will be made by the Error component itself }); return $this; } /** * Assert that the given element has specified class assigned. */ public function assertHasClass($selector, $class_name) { $element = $this->resolver->findOrFail($selector); $classes = explode(' ', (string) $element->getAttribute('class')); Assert::assertContains($class_name, $classes, "[$selector] has no class '{$class_name}'"); return $this; } /** * Assert that the given element is readonly */ public function assertReadonly($selector) { $element = $this->resolver->findOrFail($selector); $value = $element->getAttribute('readonly'); Assert::assertTrue($value == 'true', "Element [$selector] is not readonly"); return $this; } /** * Assert that the given element is not readonly */ public function assertNotReadonly($selector) { $element = $this->resolver->findOrFail($selector); $value = $element->getAttribute('readonly'); Assert::assertTrue($value != 'true', "Element [$selector] is not readonly"); return $this; } /** * Assert that the given element contains specified text, * no matter it's displayed or not. */ public function assertText($selector, $text) { $element = $this->resolver->findOrFail($selector); if ($text === '') { Assert::assertTrue((string) $element->getText() === $text, "Element's text is not empty [$selector]"); } else { Assert::assertTrue(strpos($element->getText(), $text) !== false, "No expected text in [$selector]"); } return $this; } /** * Assert that the given element contains specified text, * no matter it's displayed or not - using a regular expression. */ public function assertTextRegExp($selector, $regexp) { $element = $this->resolver->findOrFail($selector); Assert::assertMatchesRegularExpression($regexp, $element->getText(), "No expected text in [$selector]"); return $this; } /** * Remove all toast messages */ public function clearToasts() { $this->script("\$('.toast-container > *').remove()"); return $this; } /** * Wait until a button becomes enabled and click it */ public function clickWhenEnabled($selector) { return $this->waitFor($selector . ':not([disabled])')->click($selector); } /** * Check if in Phone mode */ public static function isPhone() { return getenv('TESTS_MODE') == 'phone'; } /** * Check if in Tablet mode */ public static function isTablet() { return getenv('TESTS_MODE') == 'tablet'; } /** * Check if in Desktop mode */ public static function isDesktop() { return !self::isPhone() && !self::isTablet(); } /** * Returns content of a downloaded file */ public function readDownloadedFile($filename, $sleep = 5) { $filename = __DIR__ . "/Browser/downloads/$filename"; // Give the browser a chance to finish download if (!file_exists($filename) && $sleep) { sleep($sleep); } Assert::assertFileExists($filename); return file_get_contents($filename); } /** * Removes downloaded file */ public function removeDownloadedFile($filename) { @unlink(__DIR__ . "/Browser/downloads/$filename"); return $this; } /** * Clears the input field and related vue v-model data. */ public function vueClear($selector) { - if ($this->resolver->prefix != 'body') { - $selector = $this->resolver->prefix . ' ' . $selector; - } + $selector = $this->resolver->format($selector); // The existing clear(), and type() with empty string do not work. // We have to clear the field and dispatch 'input' event programatically. $this->script( "var element = document.querySelector('$selector');" . "element.value = '';" . "element.dispatchEvent(new Event('input'))" ); return $this; } /** * Execute code within body context. * Useful to execute code that selects elements outside of a component context */ public function withinBody($callback) { if ($this->resolver->prefix != 'body') { $orig_prefix = $this->resolver->prefix; $this->resolver->prefix = 'body'; } call_user_func($callback, $this); if (isset($orig_prefix)) { $this->resolver->prefix = $orig_prefix; } return $this; } } diff --git a/src/tests/Browser/Pages/UserList.php b/src/tests/Browser/Pages/UserList.php index 9a7c0702..d772d328 100644 --- a/src/tests/Browser/Pages/UserList.php +++ b/src/tests/Browser/Pages/UserList.php @@ -1,45 +1,46 @@ <?php namespace Tests\Browser\Pages; use Laravel\Dusk\Page; class UserList extends Page { /** * Get the URL for the page. * * @return string */ public function url(): string { return '/users'; } /** * Assert that the browser is on the page. * * @param \Laravel\Dusk\Browser $browser The browser object * * @return void */ public function assert($browser) { $browser->assertPathIs($this->url()) ->waitUntilMissing('@app .app-loader') ->assertSeeIn('#user-list .card-title', 'User accounts'); } /** * Get the element shortcuts for the page. * * @return array */ public function elements(): array { return [ '@app' => '#app', + '@search' => '#search-form', '@table' => '#user-list table', ]; } } diff --git a/src/tests/Browser/Reseller/InvitationsTest.php b/src/tests/Browser/Reseller/InvitationsTest.php index 5f96dc50..a940e951 100644 --- a/src/tests/Browser/Reseller/InvitationsTest.php +++ b/src/tests/Browser/Reseller/InvitationsTest.php @@ -1,225 +1,225 @@ <?php namespace Tests\Browser\Reseller; use App\SignupInvitation; use Illuminate\Support\Facades\Queue; use Tests\Browser; use Tests\Browser\Components\Dialog; use Tests\Browser\Components\Menu; use Tests\Browser\Components\Toast; use Tests\Browser\Pages\Dashboard; use Tests\Browser\Pages\Home; use Tests\Browser\Pages\Reseller\Invitations; use Tests\TestCaseDusk; class InvitationsTest extends TestCaseDusk { /** * {@inheritDoc} */ public function setUp(): void { parent::setUp(); self::useResellerUrl(); SignupInvitation::truncate(); } /** * Test invitations page (unauthenticated) */ public function testInvitationsUnauth(): void { // Test that the page requires authentication $this->browse(function (Browser $browser) { $browser->visit('/invitations')->on(new Home()); }); } /** * Test Invitations creation */ public function testInvitationCreate(): void { $this->browse(function (Browser $browser) { $date_regexp = '/^20[0-9]{2}-/'; $browser->visit(new Home()) ->submitLogon('reseller@' . \config('app.domain'), \App\Utils::generatePassphrase(), true) ->on(new Dashboard()) ->assertSeeIn('@links .link-invitations', 'Invitations') ->click('@links .link-invitations') ->on(new Invitations()) ->assertElementsCount('@table tbody tr', 0) - ->assertMissing('#more-loader') + ->assertMissing('.more-loader') ->assertSeeIn('@table tfoot td', "There are no invitations in the database.") ->assertSeeIn('@create-button', 'Create invite(s)'); // Create a single invite with email address input $browser->click('@create-button') ->with(new Dialog('#invite-create'), function (Browser $browser) { $browser->assertSeeIn('@title', 'Invite for a signup') ->assertFocused('@body input#email') ->assertValue('@body input#email', '') ->type('@body input#email', 'test') ->assertSeeIn('@button-action', 'Send invite(s)') ->click('@button-action') ->assertToast(Toast::TYPE_ERROR, "Form validation error") ->waitFor('@body input#email.is-invalid') ->assertSeeIn( '@body input#email.is-invalid + .invalid-feedback', "The email must be a valid email address." ) ->type('@body input#email', 'test@domain.tld') ->click('@button-action'); }) ->assertToast(Toast::TYPE_SUCCESS, "The invitation has been created.") ->waitUntilMissing('#invite-create') ->waitUntilMissing('@table .app-loader') ->assertElementsCount('@table tbody tr', 1) ->assertMissing('@table tfoot') ->assertSeeIn('@table tbody tr td.email', 'test@domain.tld') ->assertText('@table tbody tr td.email title', 'Not sent yet') ->assertTextRegExp('@table tbody tr td.datetime', $date_regexp) ->assertVisible('@table tbody tr td.buttons button.button-delete') ->assertVisible('@table tbody tr td.buttons button.button-resend:disabled'); sleep(1); // Create invites from a file $browser->click('@create-button') ->with(new Dialog('#invite-create'), function (Browser $browser) { $browser->assertFocused('@body input#email') ->assertValue('@body input#email', '') ->assertMissing('@body input#email.is-invalid') // Submit an empty file ->attach('@body input#file', __DIR__ . '/../../data/empty.csv') ->click('@button-action') ->assertToast(Toast::TYPE_ERROR, "Form validation error") // ->waitFor('input#file.is-invalid') ->assertSeeIn( '@body input#file.is-invalid + .invalid-feedback', "Failed to find any valid email addresses in the uploaded file." ) // Submit non-empty file ->attach('@body input#file', __DIR__ . '/../../data/email.csv') ->click('@button-action'); }) ->assertToast(Toast::TYPE_SUCCESS, "2 invitations has been created.") ->waitUntilMissing('#invite-create') ->waitUntilMissing('@table .app-loader') ->assertElementsCount('@table tbody tr', 3) ->assertTextRegExp('@table tbody tr:nth-child(1) td.email', '/email[12]@test\.com$/') ->assertTextRegExp('@table tbody tr:nth-child(2) td.email', '/email[12]@test\.com$/'); }); } /** * Test Invitations deletion and resending */ public function testInvitationDeleteAndResend(): void { $this->browse(function (Browser $browser) { Queue::fake(); $i1 = SignupInvitation::create(['email' => 'test1@domain.org']); $i2 = SignupInvitation::create(['email' => 'test2@domain.org']); SignupInvitation::where('id', $i1->id)->update(['status' => SignupInvitation::STATUS_FAILED]); SignupInvitation::where('id', $i2->id)->update(['created_at' => now()->subHours('2')]); $browser->visit(new Invitations()) ->assertElementsCount('@table tbody tr', 2); // Test resending $browser->assertSeeIn('@table tbody tr:first-child td.email', 'test1@domain.org') ->click('@table tbody tr:first-child button.button-resend') ->assertToast(Toast::TYPE_SUCCESS, "Invitation added to the sending queue successfully.") ->assertVisible('@table tbody tr:first-child button.button-resend:disabled') ->assertElementsCount('@table tbody tr', 2); // Test deleting $browser->assertSeeIn('@table tbody tr:last-child td.email', 'test2@domain.org') ->click('@table tbody tr:last-child button.button-delete') ->assertToast(Toast::TYPE_SUCCESS, "Invitation deleted successfully.") ->assertElementsCount('@table tbody tr', 1) ->assertSeeIn('@table tbody tr:first-child td.email', 'test1@domain.org'); }); } /** * Test Invitations list (paging and searching) */ public function testInvitationsList(): void { $this->browse(function (Browser $browser) { Queue::fake(); $i1 = SignupInvitation::create(['email' => 'email1@ext.com']); $i2 = SignupInvitation::create(['email' => 'email2@ext.com']); $i3 = SignupInvitation::create(['email' => 'email3@ext.com']); $i4 = SignupInvitation::create(['email' => 'email4@other.com']); $i5 = SignupInvitation::create(['email' => 'email5@other.com']); $i6 = SignupInvitation::create(['email' => 'email6@other.com']); $i7 = SignupInvitation::create(['email' => 'email7@other.com']); $i8 = SignupInvitation::create(['email' => 'email8@other.com']); $i9 = SignupInvitation::create(['email' => 'email9@other.com']); $i10 = SignupInvitation::create(['email' => 'email10@other.com']); $i11 = SignupInvitation::create(['email' => 'email11@other.com']); SignupInvitation::query()->update(['created_at' => now()->subDays('1')]); SignupInvitation::where('id', $i1->id) ->update(['created_at' => now()->subHours('2'), 'status' => SignupInvitation::STATUS_FAILED]); SignupInvitation::where('id', $i2->id) ->update(['created_at' => now()->subHours('3'), 'status' => SignupInvitation::STATUS_SENT]); SignupInvitation::where('id', $i3->id) ->update(['created_at' => now()->subHours('4'), 'status' => SignupInvitation::STATUS_COMPLETED]); SignupInvitation::where('id', $i11->id)->update(['created_at' => now()->subDays('3')]); // Test paging (load more) feature $browser->visit(new Invitations()) // ->submitLogon('reseller@' . \config('app.domain'), \App\Utils::generatePassphrase(), true) ->assertElementsCount('@table tbody tr', 10) - ->assertSeeIn('#more-loader button', 'Load more') + ->assertSeeIn('.more-loader button', 'Load more') ->with('@table tbody', function ($browser) use ($i1, $i2, $i3) { $browser->assertSeeIn('tr:nth-child(1) td.email', $i1->email) ->assertText('tr:nth-child(1) td.email svg.text-danger title', 'Sending failed') ->assertVisible('tr:nth-child(1) td.buttons button.button-delete') ->assertVisible('tr:nth-child(1) td.buttons button.button-resend:not(:disabled)') ->assertSeeIn('tr:nth-child(2) td.email', $i2->email) ->assertText('tr:nth-child(2) td.email svg.text-primary title', 'Sent') ->assertVisible('tr:nth-child(2) td.buttons button.button-delete') ->assertVisible('tr:nth-child(2) td.buttons button.button-resend:not(:disabled)') ->assertSeeIn('tr:nth-child(3) td.email', $i3->email) ->assertText('tr:nth-child(3) td.email svg.text-success title', 'User signed up') ->assertVisible('tr:nth-child(3) td.buttons button.button-delete') ->assertVisible('tr:nth-child(3) td.buttons button.button-resend:disabled') ->assertText('tr:nth-child(4) td.email svg title', 'Not sent yet') ->assertVisible('tr:nth-child(4) td.buttons button.button-delete') ->assertVisible('tr:nth-child(4) td.buttons button.button-resend:disabled'); }) - ->click('#more-loader button') + ->click('.more-loader button') ->whenAvailable('@table tbody tr:nth-child(11)', function ($browser) use ($i11) { $browser->assertSeeIn('td.email', $i11->email); }) - ->assertMissing('#more-loader button'); + ->assertMissing('.more-loader button'); // Test searching (by domain) $browser->type('@search-input', 'ext.com') ->click('@search-button') ->waitUntilMissing('@table .app-loader') ->assertElementsCount('@table tbody tr', 3) - ->assertMissing('#more-loader button') + ->assertMissing('.more-loader button') // search by full email ->type('@search-input', 'email7@other.com') ->keys('@search-input', '{enter}') ->waitUntilMissing('@table .app-loader') ->assertElementsCount('@table tbody tr', 1) ->assertSeeIn('@table tbody tr:nth-child(1) td.email', 'email7@other.com') - ->assertMissing('#more-loader button') + ->assertMissing('.more-loader button') // reset search ->vueClear('#search-form input') ->keys('@search-input', '{enter}') ->waitUntilMissing('@table .app-loader') ->assertElementsCount('@table tbody tr', 10) - ->assertVisible('#more-loader button'); + ->assertVisible('.more-loader button'); }); } } diff --git a/src/tests/Browser/UserListTest.php b/src/tests/Browser/UserListTest.php new file mode 100644 index 00000000..6cc03115 --- /dev/null +++ b/src/tests/Browser/UserListTest.php @@ -0,0 +1,107 @@ +<?php + +namespace Tests\Browser; + +use App\User; +use Tests\Browser; +use Tests\Browser\Components\Toast; +use Tests\Browser\Pages\Dashboard; +use Tests\Browser\Pages\Home; +use Tests\Browser\Pages\UserInfo; +use Tests\Browser\Pages\UserList; +use Tests\TestCaseDusk; + +class UserListTest extends TestCaseDusk +{ + /** + * {@inheritDoc} + */ + public function setUp(): void + { + parent::setUp(); + } + + /** + * {@inheritDoc} + */ + public function tearDown(): void + { + parent::tearDown(); + } + + /** + * Test users list page (unauthenticated) + */ + public function testListUnauth(): void + { + // Test that the page requires authentication + $this->browse(function (Browser $browser) { + $browser->visit('/users')->on(new Home()); + }); + } + + /** + * Test users list page + */ + public function testList(): void + { + $this->browse(function (Browser $browser) { + // Test that the page requires authentication + // Test the list + $browser->visit(new Home()) + ->submitLogon('john@kolab.org', 'simple123', true) + ->on(new Dashboard()) + ->assertSeeIn('@links .link-users', 'User accounts') + ->click('@links .link-users') + ->on(new UserList()) + ->whenAvailable('@table', function (Browser $browser) { + $browser->waitFor('tbody tr') + ->assertElementsCount('tbody tr', 4) + ->assertSeeIn('tbody tr:nth-child(1) a', 'jack@kolab.org') + ->assertSeeIn('tbody tr:nth-child(2) a', 'joe@kolab.org') + ->assertSeeIn('tbody tr:nth-child(3) a', 'john@kolab.org') + ->assertSeeIn('tbody tr:nth-child(4) a', 'ned@kolab.org') + ->assertMissing('tfoot'); + }); + + // Test searching + $browser->assertValue('@search input', '') + ->assertAttribute('@search input', 'placeholder', 'User email address or name') + ->assertSeeIn('@search button', 'Search') + ->type('@search input', 'jo') + ->click('@search button') + ->waitUntilMissing('@app .app-loader') + ->whenAvailable('@table', function (Browser $browser) { + $browser->waitFor('tbody tr') + ->assertElementsCount('tbody tr', 2) + ->assertSeeIn('tbody tr:nth-child(1) a', 'joe@kolab.org') + ->assertSeeIn('tbody tr:nth-child(2) a', 'john@kolab.org') + ->assertMissing('tfoot'); + }) + // test empty result + ->type('@search input', 'jojo') + ->click('@search button') + ->waitUntilMissing('@app .app-loader') + ->whenAvailable('@table', function (Browser $browser) { + $browser->waitFor('tfoot tr') + ->assertSeeIn('tfoot tr', "There are no users in this account."); + }) + // reset search + ->vueClear('@search input') + ->keys('@search input', '{enter}') + ->waitUntilMissing('@app .app-loader') + ->whenAvailable('@table', function (Browser $browser) { + $browser->waitFor('tbody tr')->assertElementsCount('tbody tr', 4); + }); + + // TODO: Test paging + + $browser->click('@table tr:nth-child(3)') + ->on(new UserInfo()) + ->assertSeeIn('#user-info .card-title', 'User account') + ->with('@general', function (Browser $browser) { + $browser->assertValue('#email', 'john@kolab.org'); + }); + }); + } +} diff --git a/src/tests/Browser/UsersTest.php b/src/tests/Browser/UsersTest.php index 89ce6760..7b898e12 100644 --- a/src/tests/Browser/UsersTest.php +++ b/src/tests/Browser/UsersTest.php @@ -1,835 +1,788 @@ <?php namespace Tests\Browser; use App\Discount; use App\Entitlement; use App\Sku; use App\User; use App\UserAlias; use Tests\Browser; use Tests\Browser\Components\Dialog; use Tests\Browser\Components\ListInput; use Tests\Browser\Components\QuotaInput; use Tests\Browser\Components\Toast; use Tests\Browser\Pages\Dashboard; use Tests\Browser\Pages\Home; use Tests\Browser\Pages\UserInfo; use Tests\Browser\Pages\UserList; use Tests\Browser\Pages\Wallet as WalletPage; use Tests\TestCaseDusk; use Illuminate\Foundation\Testing\DatabaseMigrations; class UsersTest extends TestCaseDusk { private $profile = [ 'first_name' => 'John', 'last_name' => 'Doe', 'organization' => 'Kolab Developers', ]; /** * {@inheritDoc} */ public function setUp(): void { parent::setUp(); $this->deleteTestUser('julia.roberts@kolab.org'); $john = User::where('email', 'john@kolab.org')->first(); $john->setSettings($this->profile); UserAlias::where('user_id', $john->id) ->where('alias', 'john.test@kolab.org')->delete(); $activesync_sku = Sku::withEnvTenantContext()->where('title', 'activesync')->first(); $storage_sku = Sku::withEnvTenantContext()->where('title', 'storage')->first(); Entitlement::where('entitleable_id', $john->id)->where('sku_id', $activesync_sku->id)->delete(); Entitlement::where('cost', '>=', 5000)->delete(); Entitlement::where('cost', '=', 25)->where('sku_id', $storage_sku->id)->delete(); $wallet = $john->wallets()->first(); $wallet->discount()->dissociate(); $wallet->currency = 'CHF'; $wallet->save(); $this->clearBetaEntitlements(); $this->clearMeetEntitlements(); } /** * {@inheritDoc} */ public function tearDown(): void { $this->deleteTestUser('julia.roberts@kolab.org'); $john = User::where('email', 'john@kolab.org')->first(); $john->setSettings($this->profile); UserAlias::where('user_id', $john->id) ->where('alias', 'john.test@kolab.org')->delete(); $activesync_sku = Sku::withEnvTenantContext()->where('title', 'activesync')->first(); $storage_sku = Sku::withEnvTenantContext()->where('title', 'storage')->first(); Entitlement::where('entitleable_id', $john->id)->where('sku_id', $activesync_sku->id)->delete(); Entitlement::where('cost', '>=', 5000)->delete(); Entitlement::where('cost', '=', 25)->where('sku_id', $storage_sku->id)->delete(); $wallet = $john->wallets()->first(); $wallet->discount()->dissociate(); $wallet->save(); $this->clearBetaEntitlements(); $this->clearMeetEntitlements(); parent::tearDown(); } - /** - * Test user info page (unauthenticated) - */ - public function testInfoUnauth(): void - { - // Test that the page requires authentication - $this->browse(function (Browser $browser) { - $user = User::where('email', 'john@kolab.org')->first(); - - $browser->visit('/user/' . $user->id)->on(new Home()); - }); - } - - /** - * Test users list page (unauthenticated) - */ - public function testListUnauth(): void - { - // Test that the page requires authentication - $this->browse(function (Browser $browser) { - $browser->visit('/users')->on(new Home()); - }); - } - - /** - * Test users list page - */ - public function testList(): void - { - // Test that the page requires authentication - $this->browse(function (Browser $browser) { - $browser->visit(new Home()) - ->submitLogon('john@kolab.org', 'simple123', true) - ->on(new Dashboard()) - ->assertSeeIn('@links .link-users', 'User accounts') - ->click('@links .link-users') - ->on(new UserList()) - ->whenAvailable('@table', function (Browser $browser) { - $browser->waitFor('tbody tr') - ->assertElementsCount('tbody tr', 4) - ->assertSeeIn('tbody tr:nth-child(1) a', 'jack@kolab.org') - ->assertSeeIn('tbody tr:nth-child(2) a', 'joe@kolab.org') - ->assertSeeIn('tbody tr:nth-child(3) a', 'john@kolab.org') - ->assertSeeIn('tbody tr:nth-child(4) a', 'ned@kolab.org') - ->assertMissing('tfoot'); - }); - }); - } - /** * Test user account editing page (not profile page) - * - * @depends testList */ public function testInfo(): void { $this->browse(function (Browser $browser) { - $browser->on(new UserList()) - ->click('@table tr:nth-child(3) a') + $user = User::where('email', 'john@kolab.org')->first(); + + // Test that the page requires authentication + $browser->visit('/user/' . $user->id) + ->on(new Home()) + ->submitLogon('john@kolab.org', 'simple123', false) ->on(new UserInfo()) ->assertSeeIn('#user-info .card-title', 'User account') ->with('@general', function (Browser $browser) { // Assert form content $browser->assertSeeIn('div.row:nth-child(1) label', 'Status') ->assertSeeIn('div.row:nth-child(1) #status', 'Active') ->assertFocused('div.row:nth-child(2) input') ->assertSeeIn('div.row:nth-child(2) label', 'First Name') ->assertValue('div.row:nth-child(2) input[type=text]', $this->profile['first_name']) ->assertSeeIn('div.row:nth-child(3) label', 'Last Name') ->assertValue('div.row:nth-child(3) input[type=text]', $this->profile['last_name']) ->assertSeeIn('div.row:nth-child(4) label', 'Organization') ->assertValue('div.row:nth-child(4) input[type=text]', $this->profile['organization']) ->assertSeeIn('div.row:nth-child(5) label', 'Email') ->assertValue('div.row:nth-child(5) input[type=text]', 'john@kolab.org') ->assertDisabled('div.row:nth-child(5) input[type=text]') ->assertSeeIn('div.row:nth-child(6) label', 'Email Aliases') ->assertVisible('div.row:nth-child(6) .list-input') ->with(new ListInput('#aliases'), function (Browser $browser) { $browser->assertListInputValue(['john.doe@kolab.org']) ->assertValue('@input', ''); }) ->assertSeeIn('div.row:nth-child(7) label', 'Password') ->assertValue('div.row:nth-child(7) input[type=password]', '') ->assertSeeIn('div.row:nth-child(8) label', 'Confirm Password') ->assertValue('div.row:nth-child(8) input[type=password]', '') ->assertSeeIn('button[type=submit]', 'Submit') // Clear some fields and submit ->vueClear('#first_name') ->vueClear('#last_name') ->click('button[type=submit]'); }) ->assertToast(Toast::TYPE_SUCCESS, 'User data updated successfully.') ->on(new UserList()) ->click('@table tr:nth-child(3) a') ->on(new UserInfo()) ->assertSeeIn('#user-info .card-title', 'User account') ->with('@general', function (Browser $browser) { // Test error handling (password) $browser->type('#password', 'aaaaaa') ->vueClear('#password_confirmation') ->click('button[type=submit]') ->waitFor('#password + .invalid-feedback') ->assertSeeIn('#password + .invalid-feedback', 'The password confirmation does not match.') ->assertFocused('#password') ->assertToast(Toast::TYPE_ERROR, 'Form validation error'); // TODO: Test password change // Test form error handling (aliases) $browser->vueClear('#password') ->vueClear('#password_confirmation') ->with(new ListInput('#aliases'), function (Browser $browser) { $browser->addListEntry('invalid address'); }) ->click('button[type=submit]') ->assertToast(Toast::TYPE_ERROR, 'Form validation error'); $browser->with(new ListInput('#aliases'), function (Browser $browser) { $browser->assertFormError(2, 'The specified alias is invalid.', false); }); // Test adding aliases $browser->with(new ListInput('#aliases'), function (Browser $browser) { $browser->removeListEntry(2) ->addListEntry('john.test@kolab.org'); }) ->click('button[type=submit]') ->assertToast(Toast::TYPE_SUCCESS, 'User data updated successfully.'); }) ->on(new UserList()) ->click('@table tr:nth-child(3) a') ->on(new UserInfo()); $john = User::where('email', 'john@kolab.org')->first(); $alias = UserAlias::where('user_id', $john->id)->where('alias', 'john.test@kolab.org')->first(); $this->assertTrue(!empty($alias)); // Test subscriptions $browser->with('@general', function (Browser $browser) { $browser->assertSeeIn('div.row:nth-child(9) label', 'Subscriptions') ->assertVisible('@skus.row:nth-child(9)') ->with('@skus', function ($browser) { $browser->assertElementsCount('tbody tr', 6) // Mailbox SKU ->assertSeeIn('tbody tr:nth-child(1) td.name', 'User Mailbox') ->assertSeeIn('tbody tr:nth-child(1) td.price', '5,00 CHF/month') ->assertChecked('tbody tr:nth-child(1) td.selection input') ->assertDisabled('tbody tr:nth-child(1) td.selection input') ->assertTip( 'tbody tr:nth-child(1) td.buttons button', 'Just a mailbox' ) // Storage SKU ->assertSeeIn('tbody tr:nth-child(2) td.name', 'Storage Quota') ->assertSeeIn('tr:nth-child(2) td.price', '0,00 CHF/month') ->assertChecked('tbody tr:nth-child(2) td.selection input') ->assertDisabled('tbody tr:nth-child(2) td.selection input') ->assertTip( 'tbody tr:nth-child(2) td.buttons button', 'Some wiggle room' ) ->with(new QuotaInput('tbody tr:nth-child(2) .range-input'), function ($browser) { $browser->assertQuotaValue(5)->setQuotaValue(6); }) ->assertSeeIn('tr:nth-child(2) td.price', '0,25 CHF/month') // groupware SKU ->assertSeeIn('tbody tr:nth-child(3) td.name', 'Groupware Features') ->assertSeeIn('tbody tr:nth-child(3) td.price', '4,90 CHF/month') ->assertChecked('tbody tr:nth-child(3) td.selection input') ->assertEnabled('tbody tr:nth-child(3) td.selection input') ->assertTip( 'tbody tr:nth-child(3) td.buttons button', 'Groupware functions like Calendar, Tasks, Notes, etc.' ) // ActiveSync SKU ->assertSeeIn('tbody tr:nth-child(4) td.name', 'Activesync') ->assertSeeIn('tbody tr:nth-child(4) td.price', '0,00 CHF/month') ->assertNotChecked('tbody tr:nth-child(4) td.selection input') ->assertEnabled('tbody tr:nth-child(4) td.selection input') ->assertTip( 'tbody tr:nth-child(4) td.buttons button', 'Mobile synchronization' ) // 2FA SKU ->assertSeeIn('tbody tr:nth-child(5) td.name', '2-Factor Authentication') ->assertSeeIn('tbody tr:nth-child(5) td.price', '0,00 CHF/month') ->assertNotChecked('tbody tr:nth-child(5) td.selection input') ->assertEnabled('tbody tr:nth-child(5) td.selection input') ->assertTip( 'tbody tr:nth-child(5) td.buttons button', 'Two factor authentication for webmail and administration panel' ) // Meet SKU ->assertSeeIn('tbody tr:nth-child(6) td.name', 'Voice & Video Conferencing (public beta)') ->assertSeeIn('tbody tr:nth-child(6) td.price', '0,00 CHF/month') ->assertNotChecked('tbody tr:nth-child(6) td.selection input') ->assertEnabled('tbody tr:nth-child(6) td.selection input') ->assertTip( 'tbody tr:nth-child(6) td.buttons button', 'Video conferencing tool' ) ->click('tbody tr:nth-child(4) td.selection input'); }) ->assertMissing('@skus table + .hint') ->click('button[type=submit]') ->assertToast(Toast::TYPE_SUCCESS, 'User data updated successfully.'); }) ->on(new UserList()) ->click('@table tr:nth-child(3) a') ->on(new UserInfo()); $expected = ['activesync', 'groupware', 'mailbox', 'storage', 'storage', 'storage', 'storage', 'storage', 'storage']; $this->assertEntitlements($john, $expected); // Test subscriptions interaction $browser->with('@general', function (Browser $browser) { $browser->with('@skus', function ($browser) { // Uncheck 'groupware', expect activesync unchecked $browser->click('#sku-input-groupware') ->assertNotChecked('#sku-input-groupware') ->assertNotChecked('#sku-input-activesync') ->assertEnabled('#sku-input-activesync') ->assertNotReadonly('#sku-input-activesync') // Check 'activesync', expect an alert ->click('#sku-input-activesync') ->assertDialogOpened('Activesync requires Groupware Features.') ->acceptDialog() ->assertNotChecked('#sku-input-activesync') // Check 'meet', expect an alert ->click('#sku-input-meet') ->assertDialogOpened('Voice & Video Conferencing (public beta) requires Groupware Features.') ->acceptDialog() ->assertNotChecked('#sku-input-meet') // Check '2FA', expect 'activesync' unchecked and readonly ->click('#sku-input-2fa') ->assertChecked('#sku-input-2fa') ->assertNotChecked('#sku-input-activesync') ->assertReadonly('#sku-input-activesync') // Uncheck '2FA' ->click('#sku-input-2fa') ->assertNotChecked('#sku-input-2fa') ->assertNotReadonly('#sku-input-activesync'); }); }); }); } /** * Test user settings tab * * @depends testInfo */ public function testUserSettings(): void { $john = $this->getTestUser('john@kolab.org'); $john->setSetting('greylist_enabled', null); $this->browse(function (Browser $browser) use ($john) { $browser->visit('/user/' . $john->id) ->on(new UserInfo()) ->assertElementsCount('@nav a', 2) ->assertSeeIn('@nav #tab-general', 'General') ->assertSeeIn('@nav #tab-settings', 'Settings') ->click('@nav #tab-settings') ->with('#settings form', function (Browser $browser) { $browser->assertSeeIn('div.row:nth-child(1) label', 'Greylisting') ->click('div.row:nth-child(1) input[type=checkbox]:checked') ->click('button[type=submit]') ->assertToast(Toast::TYPE_SUCCESS, 'User settings updated successfully.'); }); }); $this->assertSame('false', $john->fresh()->getSetting('greylist_enabled')); } /** * Test user adding page * - * @depends testList + * @depends testInfo */ public function testNewUser(): void { $this->browse(function (Browser $browser) { $browser->visit(new UserList()) ->assertSeeIn('button.create-user', 'Create user') ->click('button.create-user') ->on(new UserInfo()) ->assertSeeIn('#user-info .card-title', 'New user account') ->with('@general', function (Browser $browser) { // Assert form content $browser->assertFocused('div.row:nth-child(1) input') ->assertSeeIn('div.row:nth-child(1) label', 'First Name') ->assertValue('div.row:nth-child(1) input[type=text]', '') ->assertSeeIn('div.row:nth-child(2) label', 'Last Name') ->assertValue('div.row:nth-child(2) input[type=text]', '') ->assertSeeIn('div.row:nth-child(3) label', 'Organization') ->assertValue('div.row:nth-child(3) input[type=text]', '') ->assertSeeIn('div.row:nth-child(4) label', 'Email') ->assertValue('div.row:nth-child(4) input[type=text]', '') ->assertEnabled('div.row:nth-child(4) input[type=text]') ->assertSeeIn('div.row:nth-child(5) label', 'Email Aliases') ->assertVisible('div.row:nth-child(5) .list-input') ->with(new ListInput('#aliases'), function (Browser $browser) { $browser->assertListInputValue([]) ->assertValue('@input', ''); }) ->assertSeeIn('div.row:nth-child(6) label', 'Password') ->assertValue('div.row:nth-child(6) input[type=password]', '') ->assertSeeIn('div.row:nth-child(7) label', 'Confirm Password') ->assertValue('div.row:nth-child(7) input[type=password]', '') ->assertSeeIn('div.row:nth-child(8) label', 'Package') // assert packages list widget, select "Lite Account" ->with('@packages', function ($browser) { $browser->assertElementsCount('tbody tr', 2) ->assertSeeIn('tbody tr:nth-child(1)', 'Groupware Account') ->assertSeeIn('tbody tr:nth-child(2)', 'Lite Account') ->assertSeeIn('tbody tr:nth-child(1) .price', '9,90 CHF/month') ->assertSeeIn('tbody tr:nth-child(2) .price', '5,00 CHF/month') ->assertChecked('tbody tr:nth-child(1) input') ->click('tbody tr:nth-child(2) input') ->assertNotChecked('tbody tr:nth-child(1) input') ->assertChecked('tbody tr:nth-child(2) input'); }) ->assertMissing('@packages table + .hint') ->assertSeeIn('button[type=submit]', 'Submit'); // Test browser-side required fields and error handling $browser->click('button[type=submit]') ->assertFocused('#email') ->type('#email', 'invalid email') ->click('button[type=submit]') ->assertFocused('#password') ->type('#password', 'simple123') ->click('button[type=submit]') ->assertFocused('#password_confirmation') ->type('#password_confirmation', 'simple') ->click('button[type=submit]') ->assertToast(Toast::TYPE_ERROR, 'Form validation error') ->assertSeeIn('#email + .invalid-feedback', 'The specified email is invalid.') ->assertSeeIn('#password + .invalid-feedback', 'The password confirmation does not match.'); }); // Test form error handling (aliases) $browser->with('@general', function (Browser $browser) { $browser->type('#email', 'julia.roberts@kolab.org') ->type('#password_confirmation', 'simple123') ->with(new ListInput('#aliases'), function (Browser $browser) { $browser->addListEntry('invalid address'); }) ->click('button[type=submit]') ->assertToast(Toast::TYPE_ERROR, 'Form validation error') ->with(new ListInput('#aliases'), function (Browser $browser) { $browser->assertFormError(1, 'The specified alias is invalid.', false); }); }); // Successful account creation $browser->with('@general', function (Browser $browser) { $browser->type('#first_name', 'Julia') ->type('#last_name', 'Roberts') ->type('#organization', 'Test Org') ->with(new ListInput('#aliases'), function (Browser $browser) { $browser->removeListEntry(1) ->addListEntry('julia.roberts2@kolab.org'); }) ->click('button[type=submit]'); }) ->assertToast(Toast::TYPE_SUCCESS, 'User created successfully.') // check redirection to users list ->on(new UserList()) ->whenAvailable('@table', function (Browser $browser) { $browser->assertElementsCount('tbody tr', 5) ->assertSeeIn('tbody tr:nth-child(4) a', 'julia.roberts@kolab.org'); }); $julia = User::where('email', 'julia.roberts@kolab.org')->first(); $alias = UserAlias::where('user_id', $julia->id)->where('alias', 'julia.roberts2@kolab.org')->first(); $this->assertTrue(!empty($alias)); $this->assertEntitlements($julia, ['mailbox', 'storage', 'storage', 'storage', 'storage', 'storage']); $this->assertSame('Julia', $julia->getSetting('first_name')); $this->assertSame('Roberts', $julia->getSetting('last_name')); $this->assertSame('Test Org', $julia->getSetting('organization')); // Some additional tests for the list input widget $browser->click('@table tbody tr:nth-child(4) a') ->on(new UserInfo()) ->with(new ListInput('#aliases'), function (Browser $browser) { $browser->assertListInputValue(['julia.roberts2@kolab.org']) ->addListEntry('invalid address') ->type('.input-group:nth-child(2) input', '@kolab.org') ->keys('.input-group:nth-child(2) input', '{enter}'); }) // TODO: Investigate why this click does not work, for now we // submit the form with Enter key above //->click('@general button[type=submit]') ->assertToast(Toast::TYPE_ERROR, 'Form validation error') ->with(new ListInput('#aliases'), function (Browser $browser) { $browser->assertVisible('.input-group:nth-child(2) input.is-invalid') ->assertVisible('.input-group:nth-child(3) input.is-invalid') ->type('.input-group:nth-child(2) input', 'julia.roberts3@kolab.org') ->type('.input-group:nth-child(3) input', 'julia.roberts4@kolab.org') ->keys('.input-group:nth-child(3) input', '{enter}'); }) // TODO: Investigate why this click does not work, for now we // submit the form with Enter key above //->click('@general button[type=submit]') ->assertToast(Toast::TYPE_SUCCESS, 'User data updated successfully.'); $julia = User::where('email', 'julia.roberts@kolab.org')->first(); $aliases = $julia->aliases()->orderBy('alias')->get()->pluck('alias')->all(); $this->assertSame(['julia.roberts3@kolab.org', 'julia.roberts4@kolab.org'], $aliases); }); } /** * Test user delete * * @depends testNewUser */ public function testDeleteUser(): void { // First create a new user $john = $this->getTestUser('john@kolab.org'); $julia = $this->getTestUser('julia.roberts@kolab.org'); $package_kolab = \App\Package::where('title', 'kolab')->first(); $john->assignPackage($package_kolab, $julia); // Test deleting non-controller user $this->browse(function (Browser $browser) use ($julia) { $browser->visit('/user/' . $julia->id) ->on(new UserInfo()) ->assertSeeIn('button.button-delete', 'Delete user') ->click('button.button-delete') ->with(new Dialog('#delete-warning'), function (Browser $browser) { $browser->assertSeeIn('@title', 'Delete julia.roberts@kolab.org') ->assertFocused('@button-cancel') ->assertSeeIn('@button-cancel', 'Cancel') ->assertSeeIn('@button-action', 'Delete') ->click('@button-cancel'); }) ->waitUntilMissing('#delete-warning') ->click('button.button-delete') ->with(new Dialog('#delete-warning'), function (Browser $browser) { $browser->click('@button-action'); }) ->waitUntilMissing('#delete-warning') ->assertToast(Toast::TYPE_SUCCESS, 'User deleted successfully.') ->on(new UserList()) ->with('@table', function (Browser $browser) { $browser->assertElementsCount('tbody tr', 4) ->assertSeeIn('tbody tr:nth-child(1) a', 'jack@kolab.org') ->assertSeeIn('tbody tr:nth-child(2) a', 'joe@kolab.org') ->assertSeeIn('tbody tr:nth-child(3) a', 'john@kolab.org') ->assertSeeIn('tbody tr:nth-child(4) a', 'ned@kolab.org'); }); $julia = User::where('email', 'julia.roberts@kolab.org')->first(); $this->assertTrue(empty($julia)); }); // Test that non-controller user cannot see/delete himself on the users list $this->browse(function (Browser $browser) { $browser->visit('/logout') ->on(new Home()) ->submitLogon('jack@kolab.org', 'simple123', true) ->visit('/users') ->assertErrorPage(403); }); // Test that controller user (Ned) can see all the users $this->browse(function (Browser $browser) { $browser->visit('/logout') ->on(new Home()) ->submitLogon('ned@kolab.org', 'simple123', true) ->visit(new UserList()) ->whenAvailable('@table', function (Browser $browser) { $browser->assertElementsCount('tbody tr', 4); }); // TODO: Test the delete action in details }); // TODO: Test what happens with the logged in user session after he's been deleted by another user } /** * Test discounted sku/package prices in the UI */ public function testDiscountedPrices(): void { // Add 10% discount $discount = Discount::where('code', 'TEST')->first(); $john = User::where('email', 'john@kolab.org')->first(); $wallet = $john->wallet(); $wallet->discount()->associate($discount); $wallet->save(); // SKUs on user edit page $this->browse(function (Browser $browser) { $browser->visit('/logout') ->on(new Home()) ->submitLogon('john@kolab.org', 'simple123', true) ->visit(new UserList()) ->waitFor('@table tr:nth-child(2)') ->click('@table tr:nth-child(2) a') // joe@kolab.org ->on(new UserInfo()) ->with('@general', function (Browser $browser) { $browser->whenAvailable('@skus', function (Browser $browser) { $quota_input = new QuotaInput('tbody tr:nth-child(2) .range-input'); $browser->waitFor('tbody tr') ->assertElementsCount('tbody tr', 6) // Mailbox SKU ->assertSeeIn('tbody tr:nth-child(1) td.price', '4,50 CHF/month¹') // Storage SKU ->assertSeeIn('tr:nth-child(2) td.price', '0,00 CHF/month¹') ->with($quota_input, function (Browser $browser) { $browser->setQuotaValue(100); }) ->assertSeeIn('tr:nth-child(2) td.price', '21,37 CHF/month¹') // groupware SKU ->assertSeeIn('tbody tr:nth-child(3) td.price', '4,41 CHF/month¹') // ActiveSync SKU ->assertSeeIn('tbody tr:nth-child(4) td.price', '0,00 CHF/month¹') // 2FA SKU ->assertSeeIn('tbody tr:nth-child(5) td.price', '0,00 CHF/month¹'); }) ->assertSeeIn('@skus table + .hint', '¹ applied discount: 10% - Test voucher'); }); }); // Packages on new user page $this->browse(function (Browser $browser) { $browser->visit(new UserList()) ->click('button.create-user') ->on(new UserInfo()) ->with('@general', function (Browser $browser) { $browser->whenAvailable('@packages', function (Browser $browser) { $browser->assertElementsCount('tbody tr', 2) ->assertSeeIn('tbody tr:nth-child(1) .price', '8,91 CHF/month¹') // Groupware ->assertSeeIn('tbody tr:nth-child(2) .price', '4,50 CHF/month¹'); // Lite }) ->assertSeeIn('@packages table + .hint', '¹ applied discount: 10% - Test voucher'); }); }); // Test using entitlement cost instead of the SKU cost $this->browse(function (Browser $browser) use ($wallet) { $joe = User::where('email', 'joe@kolab.org')->first(); $beta_sku = Sku::withEnvTenantContext()->where('title', 'beta')->first(); $storage_sku = Sku::withEnvTenantContext()->where('title', 'storage')->first(); // Add an extra storage and beta entitlement with different prices Entitlement::create([ 'wallet_id' => $wallet->id, 'sku_id' => $beta_sku->id, 'cost' => 5010, 'entitleable_id' => $joe->id, 'entitleable_type' => User::class ]); Entitlement::create([ 'wallet_id' => $wallet->id, 'sku_id' => $storage_sku->id, 'cost' => 5000, 'entitleable_id' => $joe->id, 'entitleable_type' => User::class ]); $browser->visit('/user/' . $joe->id) ->on(new UserInfo()) ->with('@general', function (Browser $browser) { $browser->whenAvailable('@skus', function (Browser $browser) { $quota_input = new QuotaInput('tbody tr:nth-child(2) .range-input'); $browser->waitFor('tbody tr') // Beta SKU ->assertSeeIn('tbody tr:nth-child(7) td.price', '45,09 CHF/month¹') // Storage SKU ->assertSeeIn('tr:nth-child(2) td.price', '45,00 CHF/month¹') ->with($quota_input, function (Browser $browser) { $browser->setQuotaValue(7); }) ->assertSeeIn('tr:nth-child(2) td.price', '45,22 CHF/month¹') ->with($quota_input, function (Browser $browser) { $browser->setQuotaValue(5); }) ->assertSeeIn('tr:nth-child(2) td.price', '0,00 CHF/month¹'); }) ->assertSeeIn('@skus table + .hint', '¹ applied discount: 10% - Test voucher'); }); }); } /** * Test non-default currency in the UI */ public function testCurrency(): void { // Add 10% discount $john = User::where('email', 'john@kolab.org')->first(); $wallet = $john->wallet(); $wallet->balance = -1000; $wallet->currency = 'EUR'; $wallet->save(); // On Dashboard and the wallet page $this->browse(function (Browser $browser) { $browser->visit('/logout') ->on(new Home()) ->submitLogon('john@kolab.org', 'simple123', true) ->on(new Dashboard()) ->assertSeeIn('@links .link-wallet .badge', '-10,00 €') ->click('@links .link-wallet') ->on(new WalletPage()) ->assertSeeIn('#wallet .card-title', 'Account balance -10,00 €'); }); // SKUs on user edit page $this->browse(function (Browser $browser) { $browser->visit(new UserList()) ->waitFor('@table tr:nth-child(2)') ->click('@table tr:nth-child(2) a') // joe@kolab.org ->on(new UserInfo()) ->with('@general', function (Browser $browser) { $browser->whenAvailable('@skus', function (Browser $browser) { $quota_input = new QuotaInput('tbody tr:nth-child(2) .range-input'); $browser->waitFor('tbody tr') ->assertElementsCount('tbody tr', 6) // Mailbox SKU ->assertSeeIn('tbody tr:nth-child(1) td.price', '5,00 €/month') // Storage SKU ->assertSeeIn('tr:nth-child(2) td.price', '0,00 €/month') ->with($quota_input, function (Browser $browser) { $browser->setQuotaValue(100); }) ->assertSeeIn('tr:nth-child(2) td.price', '23,75 €/month'); }); }); }); // Packages on new user page $this->browse(function (Browser $browser) { $browser->visit(new UserList()) ->click('button.create-user') ->on(new UserInfo()) ->with('@general', function (Browser $browser) { $browser->whenAvailable('@packages', function (Browser $browser) { $browser->assertElementsCount('tbody tr', 2) ->assertSeeIn('tbody tr:nth-child(1) .price', '9,90 €/month') // Groupware ->assertSeeIn('tbody tr:nth-child(2) .price', '5,00 €/month'); // Lite }); }); }); } /** * Test beta entitlements * - * @depends testList + * @depends testInfo */ public function testBetaEntitlements(): void { $this->browse(function (Browser $browser) { $john = User::where('email', 'john@kolab.org')->first(); $sku = Sku::withEnvTenantContext()->where('title', 'beta')->first(); $john->assignSku($sku); $browser->visit('/user/' . $john->id) ->on(new UserInfo()) ->with('@skus', function ($browser) { $browser->assertElementsCount('tbody tr', 8) // Meet SKU ->assertSeeIn('tbody tr:nth-child(6) td.name', 'Voice & Video Conferencing (public beta)') ->assertSeeIn('tr:nth-child(6) td.price', '0,00 CHF/month') ->assertNotChecked('tbody tr:nth-child(6) td.selection input') ->assertEnabled('tbody tr:nth-child(6) td.selection input') ->assertTip( 'tbody tr:nth-child(6) td.buttons button', 'Video conferencing tool' ) // Beta SKU ->assertSeeIn('tbody tr:nth-child(7) td.name', 'Private Beta (invitation only)') ->assertSeeIn('tbody tr:nth-child(7) td.price', '0,00 CHF/month') ->assertChecked('tbody tr:nth-child(7) td.selection input') ->assertEnabled('tbody tr:nth-child(7) td.selection input') ->assertTip( 'tbody tr:nth-child(7) td.buttons button', 'Access to the private beta program subscriptions' ) // Distlist SKU ->assertSeeIn('tbody tr:nth-child(8) td.name', 'Distribution lists') ->assertSeeIn('tr:nth-child(8) td.price', '0,00 CHF/month') ->assertNotChecked('tbody tr:nth-child(8) td.selection input') ->assertEnabled('tbody tr:nth-child(8) td.selection input') ->assertTip( 'tbody tr:nth-child(8) td.buttons button', 'Access to mail distribution lists' ) // Check Distlist, Uncheck Beta, expect Distlist unchecked ->click('#sku-input-distlist') ->click('#sku-input-beta') ->assertNotChecked('#sku-input-beta') ->assertNotChecked('#sku-input-distlist') // Click Distlist expect an alert ->click('#sku-input-distlist') ->assertDialogOpened('Distribution lists requires Private Beta (invitation only).') ->acceptDialog() // Enable Beta and Distlist and submit ->click('#sku-input-beta') ->click('#sku-input-distlist'); }) ->click('@general button[type=submit]') ->assertToast(Toast::TYPE_SUCCESS, 'User data updated successfully.'); $expected = [ 'beta', 'distlist', 'groupware', 'mailbox', 'storage', 'storage', 'storage', 'storage', 'storage' ]; $this->assertEntitlements($john, $expected); $browser->visit('/user/' . $john->id) ->on(new UserInfo()) ->waitFor('#sku-input-beta') ->click('#sku-input-beta') ->click('@general button[type=submit]') ->assertToast(Toast::TYPE_SUCCESS, 'User data updated successfully.'); $expected = [ 'groupware', 'mailbox', 'storage', 'storage', 'storage', 'storage', 'storage' ]; $this->assertEntitlements($john, $expected); }); // TODO: Test that the Distlist SKU is not available for users that aren't a group account owners // TODO: Test that entitlements change has immediate effect on the available items in dashboard // i.e. does not require a page reload nor re-login. } } diff --git a/src/tests/Browser/WalletTest.php b/src/tests/Browser/WalletTest.php index 0ba73cc4..df2149ee 100644 --- a/src/tests/Browser/WalletTest.php +++ b/src/tests/Browser/WalletTest.php @@ -1,276 +1,275 @@ <?php namespace Tests\Browser; use App\Payment; use App\Providers\PaymentProvider; use App\Transaction; use App\Wallet; use Carbon\Carbon; use Tests\Browser; use Tests\Browser\Pages\Dashboard; use Tests\Browser\Pages\Home; use Tests\Browser\Pages\Wallet as WalletPage; use Tests\TestCaseDusk; class WalletTest extends TestCaseDusk { /** * {@inheritDoc} */ public function setUp(): void { parent::setUp(); $this->deleteTestUser('wallets-controller@kolabnow.com'); $john = $this->getTestUser('john@kolab.org'); Wallet::where('user_id', $john->id)->update(['balance' => -1234]); } /** * {@inheritDoc} */ public function tearDown(): void { $this->deleteTestUser('wallets-controller@kolabnow.com'); $john = $this->getTestUser('john@kolab.org'); Wallet::where('user_id', $john->id)->update(['balance' => 0]); - parent::tearDown(); } /** * Test wallet page (unauthenticated) */ public function testWalletUnauth(): void { // Test that the page requires authentication $this->browse(function (Browser $browser) { $browser->visit('/wallet')->on(new Home()); }); } /** * Test wallet "box" on Dashboard */ public function testDashboard(): void { // Test that the page requires authentication $this->browse(function (Browser $browser) { $browser->visit(new Home()) ->submitLogon('john@kolab.org', 'simple123', true) ->on(new Dashboard()) ->assertSeeIn('@links .link-wallet .name', 'Wallet') ->assertSeeIn('@links .link-wallet .badge', '-12,34 CHF'); }); } /** * Test wallet page * * @depends testDashboard */ public function testWallet(): void { $this->browse(function (Browser $browser) { $browser->click('@links .link-wallet') ->on(new WalletPage()) ->assertSeeIn('#wallet .card-title', 'Account balance -12,34 CHF') ->assertSeeIn('#wallet .card-title .text-danger', '-12,34 CHF') ->assertSeeIn('#wallet .card-text', 'You are out of credit'); }); } /** * Test Receipts tab */ public function testReceipts(): void { $user = $this->getTestUser('wallets-controller@kolabnow.com', ['password' => 'simple123']); $wallet = $user->wallets()->first(); $wallet->payments()->delete(); // Log out John and log in the test user $this->browse(function (Browser $browser) { $browser->visit('/logout') ->waitForLocation('/login') ->on(new Home()) ->submitLogon('wallets-controller@kolabnow.com', 'simple123', true); }); // Assert Receipts tab content when there's no receipts available $this->browse(function (Browser $browser) { $browser->on(new Dashboard()) ->click('@links .link-wallet') ->on(new WalletPage()) ->assertSeeIn('#wallet .card-title', 'Account balance 0,00 CHF') ->assertSeeIn('#wallet .card-title .text-success', '0,00 CHF') ->assertSeeIn('#wallet .card-text', 'You are in your free trial period.') ->assertSeeIn('@nav #tab-receipts', 'Receipts') ->with('@receipts-tab', function (Browser $browser) { $browser->waitUntilMissing('.app-loader') ->assertSeeIn('p', 'There are no receipts for payments') ->assertDontSeeIn('p', 'Here you can download') ->assertMissing('select') ->assertMissing('button'); }); }); // Create some sample payments $receipts = []; $date = Carbon::create(intval(date('Y')) - 1, 3, 30); $payment = Payment::create([ 'id' => 'AAA1', 'status' => PaymentProvider::STATUS_PAID, 'type' => PaymentProvider::TYPE_ONEOFF, 'description' => 'Paid in March', 'wallet_id' => $wallet->id, 'provider' => 'stripe', 'amount' => 1111, 'currency_amount' => 1111, 'currency' => 'CHF', ]); $payment->updated_at = $date; $payment->save(); $receipts[] = $date->format('Y-m'); $date = Carbon::create(intval(date('Y')) - 1, 4, 30); $payment = Payment::create([ 'id' => 'AAA2', 'status' => PaymentProvider::STATUS_PAID, 'type' => PaymentProvider::TYPE_ONEOFF, 'description' => 'Paid in April', 'wallet_id' => $wallet->id, 'provider' => 'stripe', 'amount' => 1111, 'currency_amount' => 1111, 'currency' => 'CHF', ]); $payment->updated_at = $date; $payment->save(); $receipts[] = $date->format('Y-m'); // Assert Receipts tab with receipts available $this->browse(function (Browser $browser) use ($receipts) { $browser->refresh() ->on(new WalletPage()) ->assertSeeIn('@nav #tab-receipts', 'Receipts') ->with('@receipts-tab', function (Browser $browser) use ($receipts) { $browser->waitUntilMissing('.app-loader') ->assertDontSeeIn('p', 'There are no receipts for payments') ->assertSeeIn('p', 'Here you can download') ->assertSeeIn('button', 'Download') ->assertElementsCount('select > option', 2) ->assertSeeIn('select > option:nth-child(1)', $receipts[1]) ->assertSeeIn('select > option:nth-child(2)', $receipts[0]); // Download a receipt file $browser->select('select', $receipts[0]) ->click('button') ->pause(2000); $files = glob(__DIR__ . '/downloads/*.pdf'); $filename = pathinfo($files[0], PATHINFO_BASENAME); $this->assertTrue(strpos($filename, $receipts[0]) !== false); $content = $browser->readDownloadedFile($filename, 0); $this->assertStringStartsWith("%PDF-1.", $content); $browser->removeDownloadedFile($filename); }); }); } /** * Test History tab */ public function testHistory(): void { $user = $this->getTestUser('wallets-controller@kolabnow.com', ['password' => 'simple123']); // Log out John and log in the test user $this->browse(function (Browser $browser) { $browser->visit('/logout') ->waitForLocation('/login') ->on(new Home()) ->submitLogon('wallets-controller@kolabnow.com', 'simple123', true); }); $package_kolab = \App\Package::where('title', 'kolab')->first(); $user->assignPackage($package_kolab); $wallet = $user->wallets()->first(); // Create some sample transactions $transactions = $this->createTestTransactions($wallet); $transactions = array_reverse($transactions); $pages = array_chunk($transactions, 10 /* page size*/); $this->browse(function (Browser $browser) use ($pages) { $browser->on(new Dashboard()) ->click('@links .link-wallet') ->on(new WalletPage()) ->assertSeeIn('@nav #tab-history', 'History') ->click('@nav #tab-history') ->with('@history-tab', function (Browser $browser) use ($pages) { $browser->waitUntilMissing('.app-loader') ->assertElementsCount('table tbody tr', 10) ->assertMissing('table td.email') - ->assertSeeIn('#transactions-loader button', 'Load more'); + ->assertSeeIn('.more-loader button', 'Load more'); foreach ($pages[0] as $idx => $transaction) { $selector = 'table tbody tr:nth-child(' . ($idx + 1) . ')'; $priceStyle = $transaction->type == Transaction::WALLET_AWARD ? 'text-success' : 'text-danger'; $browser->assertSeeIn("$selector td.description", $transaction->shortDescription()) ->assertMissing("$selector td.selection button") ->assertVisible("$selector td.price.{$priceStyle}"); // TODO: Test more transaction details } // Load the next page - $browser->click('#transactions-loader button') + $browser->click('.more-loader button') ->waitUntilMissing('.app-loader') ->assertElementsCount('table tbody tr', 12) - ->assertMissing('#transactions-loader button'); + ->assertMissing('.more-loader button'); $debitEntry = null; foreach ($pages[1] as $idx => $transaction) { $selector = 'table tbody tr:nth-child(' . ($idx + 1 + 10) . ')'; $priceStyle = $transaction->type == Transaction::WALLET_CREDIT ? 'text-success' : 'text-danger'; $browser->assertSeeIn("$selector td.description", $transaction->shortDescription()); if ($transaction->type == Transaction::WALLET_DEBIT) { $debitEntry = $selector; } else { $browser->assertMissing("$selector td.selection button"); } } // Load sub-transactions $browser->click("$debitEntry td.selection button") ->waitUntilMissing('.app-loader') ->assertElementsCount("$debitEntry td.description ul li", 2) ->assertMissing("$debitEntry td.selection button"); }); }); } /** * Test that non-controller user has no access to wallet */ public function testAccessDenied(): void { $this->browse(function (Browser $browser) { $browser->visit('/logout') ->on(new Home()) ->submitLogon('jack@kolab.org', 'simple123', true) ->on(new Dashboard()) ->assertMissing('@links .link-wallet') ->visit('/wallet') ->assertErrorPage(403, "Only account owners can access a wallet."); }); } } diff --git a/src/tests/Feature/Controller/UsersTest.php b/src/tests/Feature/Controller/UsersTest.php index e00019b6..459213f6 100644 --- a/src/tests/Feature/Controller/UsersTest.php +++ b/src/tests/Feature/Controller/UsersTest.php @@ -1,1357 +1,1398 @@ <?php namespace Tests\Feature\Controller; use App\Discount; use App\Domain; use App\Http\Controllers\API\V4\UsersController; use App\Package; use App\Sku; use App\User; use App\Wallet; use Carbon\Carbon; use Illuminate\Support\Facades\Queue; use Illuminate\Support\Str; use Tests\TestCase; class UsersTest extends TestCase { /** * {@inheritDoc} */ public function setUp(): void { parent::setUp(); $this->deleteTestUser('jane@kolabnow.com'); $this->deleteTestUser('UsersControllerTest1@userscontroller.com'); $this->deleteTestUser('UsersControllerTest2@userscontroller.com'); $this->deleteTestUser('UsersControllerTest3@userscontroller.com'); $this->deleteTestUser('UserEntitlement2A@UserEntitlement.com'); $this->deleteTestUser('john2.doe2@kolab.org'); $this->deleteTestUser('deleted@kolab.org'); $this->deleteTestUser('deleted@kolabnow.com'); $this->deleteTestDomain('userscontroller.com'); $this->deleteTestGroup('group-test@kolabnow.com'); $this->deleteTestGroup('group-test@kolab.org'); $user = $this->getTestUser('john@kolab.org'); $wallet = $user->wallets()->first(); $wallet->discount()->dissociate(); $wallet->settings()->whereIn('key', ['mollie_id', 'stripe_id'])->delete(); $wallet->save(); $user->status |= User::STATUS_IMAP_READY; $user->save(); } /** * {@inheritDoc} */ public function tearDown(): void { $this->deleteTestUser('jane@kolabnow.com'); $this->deleteTestUser('UsersControllerTest1@userscontroller.com'); $this->deleteTestUser('UsersControllerTest2@userscontroller.com'); $this->deleteTestUser('UsersControllerTest3@userscontroller.com'); $this->deleteTestUser('UserEntitlement2A@UserEntitlement.com'); $this->deleteTestUser('john2.doe2@kolab.org'); $this->deleteTestUser('deleted@kolab.org'); $this->deleteTestUser('deleted@kolabnow.com'); $this->deleteTestDomain('userscontroller.com'); $this->deleteTestGroup('group-test@kolabnow.com'); $this->deleteTestGroup('group-test@kolab.org'); $user = $this->getTestUser('john@kolab.org'); $wallet = $user->wallets()->first(); $wallet->discount()->dissociate(); $wallet->settings()->whereIn('key', ['mollie_id', 'stripe_id'])->delete(); $wallet->save(); $user->settings()->whereIn('key', ['greylist_enabled'])->delete(); $user->status |= User::STATUS_IMAP_READY; $user->save(); parent::tearDown(); } /** * Test user deleting (DELETE /api/v4/users/<id>) */ public function testDestroy(): void { // First create some users/accounts to delete $package_kolab = \App\Package::where('title', 'kolab')->first(); $package_domain = \App\Package::where('title', 'domain-hosting')->first(); $john = $this->getTestUser('john@kolab.org'); $user1 = $this->getTestUser('UsersControllerTest1@userscontroller.com'); $user2 = $this->getTestUser('UsersControllerTest2@userscontroller.com'); $user3 = $this->getTestUser('UsersControllerTest3@userscontroller.com'); $domain = $this->getTestDomain('userscontroller.com', [ 'status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_PUBLIC, ]); $user1->assignPackage($package_kolab); $domain->assignPackage($package_domain, $user1); $user1->assignPackage($package_kolab, $user2); $user1->assignPackage($package_kolab, $user3); // Test unauth access $response = $this->delete("api/v4/users/{$user2->id}"); $response->assertStatus(401); // Test access to other user/account $response = $this->actingAs($john)->delete("api/v4/users/{$user2->id}"); $response->assertStatus(403); $response = $this->actingAs($john)->delete("api/v4/users/{$user1->id}"); $response->assertStatus(403); $json = $response->json(); $this->assertSame('error', $json['status']); $this->assertSame("Access denied", $json['message']); $this->assertCount(2, $json); // Test that non-controller cannot remove himself $response = $this->actingAs($user3)->delete("api/v4/users/{$user3->id}"); $response->assertStatus(403); // Test removing a non-controller user $response = $this->actingAs($user1)->delete("api/v4/users/{$user3->id}"); $response->assertStatus(200); $json = $response->json(); $this->assertEquals('success', $json['status']); $this->assertEquals('User deleted successfully.', $json['message']); // Test removing self (an account with users) $response = $this->actingAs($user1)->delete("api/v4/users/{$user1->id}"); $response->assertStatus(200); $json = $response->json(); $this->assertEquals('success', $json['status']); $this->assertEquals('User deleted successfully.', $json['message']); } /** * Test user deleting (DELETE /api/v4/users/<id>) */ public function testDestroyByController(): void { // Create an account with additional controller - $user2 $package_kolab = \App\Package::where('title', 'kolab')->first(); $package_domain = \App\Package::where('title', 'domain-hosting')->first(); $user1 = $this->getTestUser('UsersControllerTest1@userscontroller.com'); $user2 = $this->getTestUser('UsersControllerTest2@userscontroller.com'); $user3 = $this->getTestUser('UsersControllerTest3@userscontroller.com'); $domain = $this->getTestDomain('userscontroller.com', [ 'status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_PUBLIC, ]); $user1->assignPackage($package_kolab); $domain->assignPackage($package_domain, $user1); $user1->assignPackage($package_kolab, $user2); $user1->assignPackage($package_kolab, $user3); $user1->wallets()->first()->addController($user2); // TODO/FIXME: // For now controller can delete himself, as well as // the whole account he has control to, including the owner // Probably he should not be able to do none of those // However, this is not 0-regression scenario as we // do not fully support additional controllers. //$response = $this->actingAs($user2)->delete("api/v4/users/{$user2->id}"); //$response->assertStatus(403); $response = $this->actingAs($user2)->delete("api/v4/users/{$user3->id}"); $response->assertStatus(200); $response = $this->actingAs($user2)->delete("api/v4/users/{$user1->id}"); $response->assertStatus(200); // Note: More detailed assertions in testDestroy() above $this->assertTrue($user1->fresh()->trashed()); $this->assertTrue($user2->fresh()->trashed()); $this->assertTrue($user3->fresh()->trashed()); } /** * Test user listing (GET /api/v4/users) */ public function testIndex(): void { // Test unauth access $response = $this->get("api/v4/users"); $response->assertStatus(401); $jack = $this->getTestUser('jack@kolab.org'); $joe = $this->getTestUser('joe@kolab.org'); $john = $this->getTestUser('john@kolab.org'); $ned = $this->getTestUser('ned@kolab.org'); $response = $this->actingAs($jack)->get("/api/v4/users"); $response->assertStatus(200); $json = $response->json(); - $this->assertCount(0, $json); + $this->assertSame(false, $json['hasMore']); + $this->assertSame(0, $json['count']); + $this->assertCount(0, $json['list']); $response = $this->actingAs($john)->get("/api/v4/users"); $response->assertStatus(200); $json = $response->json(); - $this->assertCount(4, $json); - $this->assertSame($jack->email, $json[0]['email']); - $this->assertSame($joe->email, $json[1]['email']); - $this->assertSame($john->email, $json[2]['email']); - $this->assertSame($ned->email, $json[3]['email']); + $this->assertSame(false, $json['hasMore']); + $this->assertSame(4, $json['count']); + $this->assertCount(4, $json['list']); + $this->assertSame($jack->email, $json['list'][0]['email']); + $this->assertSame($joe->email, $json['list'][1]['email']); + $this->assertSame($john->email, $json['list'][2]['email']); + $this->assertSame($ned->email, $json['list'][3]['email']); // Values below are tested by Unit tests - $this->assertArrayHasKey('isDeleted', $json[0]); - $this->assertArrayHasKey('isSuspended', $json[0]); - $this->assertArrayHasKey('isActive', $json[0]); - $this->assertArrayHasKey('isLdapReady', $json[0]); - $this->assertArrayHasKey('isImapReady', $json[0]); + $this->assertArrayHasKey('isDeleted', $json['list'][0]); + $this->assertArrayHasKey('isSuspended', $json['list'][0]); + $this->assertArrayHasKey('isActive', $json['list'][0]); + $this->assertArrayHasKey('isLdapReady', $json['list'][0]); + $this->assertArrayHasKey('isImapReady', $json['list'][0]); $response = $this->actingAs($ned)->get("/api/v4/users"); $response->assertStatus(200); $json = $response->json(); - $this->assertCount(4, $json); - $this->assertSame($jack->email, $json[0]['email']); - $this->assertSame($joe->email, $json[1]['email']); - $this->assertSame($john->email, $json[2]['email']); - $this->assertSame($ned->email, $json[3]['email']); + $this->assertSame(false, $json['hasMore']); + $this->assertSame(4, $json['count']); + $this->assertCount(4, $json['list']); + $this->assertSame($jack->email, $json['list'][0]['email']); + $this->assertSame($joe->email, $json['list'][1]['email']); + $this->assertSame($john->email, $json['list'][2]['email']); + $this->assertSame($ned->email, $json['list'][3]['email']); + + // Search by user email + $response = $this->actingAs($john)->get("/api/v4/users?search=jack@k"); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertSame(false, $json['hasMore']); + $this->assertSame(1, $json['count']); + $this->assertCount(1, $json['list']); + $this->assertSame($jack->email, $json['list'][0]['email']); + + // Search by alias + $response = $this->actingAs($john)->get("/api/v4/users?search=monster"); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertSame(false, $json['hasMore']); + $this->assertSame(1, $json['count']); + $this->assertCount(1, $json['list']); + $this->assertSame($joe->email, $json['list'][0]['email']); + + // Search by name + $response = $this->actingAs($john)->get("/api/v4/users?search=land"); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertSame(false, $json['hasMore']); + $this->assertSame(1, $json['count']); + $this->assertCount(1, $json['list']); + $this->assertSame($ned->email, $json['list'][0]['email']); + + // TODO: Test paging } /** * Test fetching user data/profile (GET /api/v4/users/<user-id>) */ public function testShow(): void { $userA = $this->getTestUser('UserEntitlement2A@UserEntitlement.com'); // Test getting profile of self $response = $this->actingAs($userA)->get("/api/v4/users/{$userA->id}"); $json = $response->json(); $response->assertStatus(200); $this->assertEquals($userA->id, $json['id']); $this->assertEquals($userA->email, $json['email']); $this->assertTrue(is_array($json['statusInfo'])); $this->assertTrue(is_array($json['settings'])); $this->assertTrue(is_array($json['aliases'])); $this->assertTrue($json['config']['greylist_enabled']); $this->assertSame([], $json['skus']); // Values below are tested by Unit tests $this->assertArrayHasKey('isDeleted', $json); $this->assertArrayHasKey('isSuspended', $json); $this->assertArrayHasKey('isActive', $json); $this->assertArrayHasKey('isLdapReady', $json); $this->assertArrayHasKey('isImapReady', $json); $john = $this->getTestUser('john@kolab.org'); $jack = $this->getTestUser('jack@kolab.org'); $ned = $this->getTestUser('ned@kolab.org'); // Test unauthorized access to a profile of other user $response = $this->actingAs($jack)->get("/api/v4/users/{$userA->id}"); $response->assertStatus(403); // Test authorized access to a profile of other user // Ned: Additional account controller $response = $this->actingAs($ned)->get("/api/v4/users/{$john->id}"); $response->assertStatus(200); $response = $this->actingAs($ned)->get("/api/v4/users/{$jack->id}"); $response->assertStatus(200); // John: Account owner $response = $this->actingAs($john)->get("/api/v4/users/{$jack->id}"); $response->assertStatus(200); $response = $this->actingAs($john)->get("/api/v4/users/{$ned->id}"); $response->assertStatus(200); $json = $response->json(); $storage_sku = Sku::withEnvTenantContext()->where('title', 'storage')->first(); $groupware_sku = Sku::withEnvTenantContext()->where('title', 'groupware')->first(); $mailbox_sku = Sku::withEnvTenantContext()->where('title', 'mailbox')->first(); $secondfactor_sku = Sku::withEnvTenantContext()->where('title', '2fa')->first(); $this->assertCount(5, $json['skus']); $this->assertSame(5, $json['skus'][$storage_sku->id]['count']); $this->assertSame([0,0,0,0,0], $json['skus'][$storage_sku->id]['costs']); $this->assertSame(1, $json['skus'][$groupware_sku->id]['count']); $this->assertSame([490], $json['skus'][$groupware_sku->id]['costs']); $this->assertSame(1, $json['skus'][$mailbox_sku->id]['count']); $this->assertSame([500], $json['skus'][$mailbox_sku->id]['costs']); $this->assertSame(1, $json['skus'][$secondfactor_sku->id]['count']); $this->assertSame([0], $json['skus'][$secondfactor_sku->id]['costs']); } /** * Test fetching user status (GET /api/v4/users/<user-id>/status) * and forcing setup process update (?refresh=1) * * @group imap * @group dns */ public function testStatus(): void { Queue::fake(); $john = $this->getTestUser('john@kolab.org'); $jack = $this->getTestUser('jack@kolab.org'); // Test unauthorized access $response = $this->actingAs($jack)->get("/api/v4/users/{$john->id}/status"); $response->assertStatus(403); if ($john->isImapReady()) { $john->status ^= User::STATUS_IMAP_READY; $john->save(); } // Get user status $response = $this->actingAs($john)->get("/api/v4/users/{$john->id}/status"); $response->assertStatus(200); $json = $response->json(); $this->assertFalse($json['isImapReady']); $this->assertFalse($json['isReady']); $this->assertCount(7, $json['process']); $this->assertSame('user-imap-ready', $json['process'][2]['label']); $this->assertSame(false, $json['process'][2]['state']); $this->assertTrue(empty($json['status'])); $this->assertTrue(empty($json['message'])); // Make sure the domain is confirmed (other test might unset that status) $domain = $this->getTestDomain('kolab.org'); $domain->status |= Domain::STATUS_CONFIRMED; $domain->save(); // Now "reboot" the process and verify the user in imap synchronously $response = $this->actingAs($john)->get("/api/v4/users/{$john->id}/status?refresh=1"); $response->assertStatus(200); $json = $response->json(); $this->assertTrue($json['isImapReady']); $this->assertTrue($json['isReady']); $this->assertCount(7, $json['process']); $this->assertSame('user-imap-ready', $json['process'][2]['label']); $this->assertSame(true, $json['process'][2]['state']); $this->assertSame('success', $json['status']); $this->assertSame('Setup process finished successfully.', $json['message']); Queue::size(1); // Test case for when the verify job is dispatched to the worker $john->refresh(); $john->status ^= User::STATUS_IMAP_READY; $john->save(); \config(['imap.admin_password' => null]); $response = $this->actingAs($john)->get("/api/v4/users/{$john->id}/status?refresh=1"); $response->assertStatus(200); $json = $response->json(); $this->assertFalse($json['isImapReady']); $this->assertFalse($json['isReady']); $this->assertSame('success', $json['status']); $this->assertSame('waiting', $json['processState']); $this->assertSame('Setup process has been pushed. Please wait.', $json['message']); Queue::assertPushed(\App\Jobs\User\VerifyJob::class, 1); } /** * Test UsersController::statusInfo() */ public function testStatusInfo(): void { $user = $this->getTestUser('UsersControllerTest1@userscontroller.com'); $domain = $this->getTestDomain('userscontroller.com', [ 'status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_PUBLIC, ]); $user->created_at = Carbon::now(); $user->status = User::STATUS_NEW; $user->save(); $result = UsersController::statusInfo($user); $this->assertFalse($result['isReady']); $this->assertSame([], $result['skus']); $this->assertCount(3, $result['process']); $this->assertSame('user-new', $result['process'][0]['label']); $this->assertSame(true, $result['process'][0]['state']); $this->assertSame('user-ldap-ready', $result['process'][1]['label']); $this->assertSame(false, $result['process'][1]['state']); $this->assertSame('user-imap-ready', $result['process'][2]['label']); $this->assertSame(false, $result['process'][2]['state']); $this->assertSame('running', $result['processState']); $user->created_at = Carbon::now()->subSeconds(181); $user->save(); $result = UsersController::statusInfo($user); $this->assertSame('failed', $result['processState']); $user->status |= User::STATUS_LDAP_READY | User::STATUS_IMAP_READY; $user->save(); $result = UsersController::statusInfo($user); $this->assertTrue($result['isReady']); $this->assertCount(3, $result['process']); $this->assertSame('user-new', $result['process'][0]['label']); $this->assertSame(true, $result['process'][0]['state']); $this->assertSame('user-ldap-ready', $result['process'][1]['label']); $this->assertSame(true, $result['process'][1]['state']); $this->assertSame('user-imap-ready', $result['process'][2]['label']); $this->assertSame(true, $result['process'][2]['state']); $this->assertSame('done', $result['processState']); $domain->status |= Domain::STATUS_VERIFIED; $domain->type = Domain::TYPE_EXTERNAL; $domain->save(); $result = UsersController::statusInfo($user); $this->assertFalse($result['isReady']); $this->assertSame([], $result['skus']); $this->assertCount(7, $result['process']); $this->assertSame('user-new', $result['process'][0]['label']); $this->assertSame(true, $result['process'][0]['state']); $this->assertSame('user-ldap-ready', $result['process'][1]['label']); $this->assertSame(true, $result['process'][1]['state']); $this->assertSame('user-imap-ready', $result['process'][2]['label']); $this->assertSame(true, $result['process'][2]['state']); $this->assertSame('domain-new', $result['process'][3]['label']); $this->assertSame(true, $result['process'][3]['state']); $this->assertSame('domain-ldap-ready', $result['process'][4]['label']); $this->assertSame(false, $result['process'][4]['state']); $this->assertSame('domain-verified', $result['process'][5]['label']); $this->assertSame(true, $result['process'][5]['state']); $this->assertSame('domain-confirmed', $result['process'][6]['label']); $this->assertSame(false, $result['process'][6]['state']); // Test 'skus' property $user->assignSku(Sku::withEnvTenantContext()->where('title', 'beta')->first()); $result = UsersController::statusInfo($user); $this->assertSame(['beta'], $result['skus']); $user->assignSku(Sku::withEnvTenantContext()->where('title', 'meet')->first()); $result = UsersController::statusInfo($user); $this->assertSame(['beta', 'meet'], $result['skus']); $user->assignSku(Sku::withEnvTenantContext()->where('title', 'meet')->first()); $result = UsersController::statusInfo($user); $this->assertSame(['beta', 'meet'], $result['skus']); } /** * Test user config update (POST /api/v4/users/<user>/config) */ public function testSetConfig(): void { $jack = $this->getTestUser('jack@kolab.org'); $john = $this->getTestUser('john@kolab.org'); $john->setSetting('greylist_enabled', null); // Test unknown user id $post = ['greylist_enabled' => 1]; $response = $this->actingAs($john)->post("/api/v4/users/123/config", $post); $json = $response->json(); $response->assertStatus(404); // Test access by user not being a wallet controller $post = ['greylist_enabled' => 1]; $response = $this->actingAs($jack)->post("/api/v4/users/{$john->id}/config", $post); $json = $response->json(); $response->assertStatus(403); $this->assertSame('error', $json['status']); $this->assertSame("Access denied", $json['message']); $this->assertCount(2, $json); // Test some invalid data $post = ['grey' => 1]; $response = $this->actingAs($john)->post("/api/v4/users/{$john->id}/config", $post); $response->assertStatus(422); $json = $response->json(); $this->assertSame('error', $json['status']); $this->assertCount(2, $json); $this->assertCount(1, $json['errors']); $this->assertSame('The requested configuration parameter is not supported.', $json['errors']['grey']); $this->assertNull($john->fresh()->getSetting('greylist_enabled')); // Test some valid data $post = ['greylist_enabled' => 1]; $response = $this->actingAs($john)->post("/api/v4/users/{$john->id}/config", $post); $response->assertStatus(200); $json = $response->json(); $this->assertCount(2, $json); $this->assertSame('success', $json['status']); $this->assertSame('User settings updated successfully.', $json['message']); $this->assertSame('true', $john->fresh()->getSetting('greylist_enabled')); // Test some valid data $post = ['greylist_enabled' => 0]; $response = $this->actingAs($john)->post("/api/v4/users/{$john->id}/config", $post); $response->assertStatus(200); $json = $response->json(); $this->assertCount(2, $json); $this->assertSame('success', $json['status']); $this->assertSame('User settings updated successfully.', $json['message']); $this->assertSame('false', $john->fresh()->getSetting('greylist_enabled')); } /** * Test user creation (POST /api/v4/users) */ public function testStore(): void { Queue::fake(); $jack = $this->getTestUser('jack@kolab.org'); $john = $this->getTestUser('john@kolab.org'); $deleted_priv = $this->getTestUser('deleted@kolab.org'); $deleted_priv->delete(); // Test empty request $response = $this->actingAs($john)->post("/api/v4/users", []); $response->assertStatus(422); $json = $response->json(); $this->assertSame('error', $json['status']); $this->assertSame("The email field is required.", $json['errors']['email']); $this->assertSame("The password field is required.", $json['errors']['password'][0]); $this->assertCount(2, $json); // Test access by user not being a wallet controller $post = ['first_name' => 'Test']; $response = $this->actingAs($jack)->post("/api/v4/users", $post); $json = $response->json(); $response->assertStatus(403); $this->assertSame('error', $json['status']); $this->assertSame("Access denied", $json['message']); $this->assertCount(2, $json); // Test some invalid data $post = ['password' => '12345678', 'email' => 'invalid']; $response = $this->actingAs($john)->post("/api/v4/users", $post); $response->assertStatus(422); $json = $response->json(); $this->assertSame('error', $json['status']); $this->assertCount(2, $json); $this->assertSame('The password confirmation does not match.', $json['errors']['password'][0]); $this->assertSame('The specified email is invalid.', $json['errors']['email']); // Test existing user email $post = [ 'password' => 'simple', 'password_confirmation' => 'simple', 'first_name' => 'John2', 'last_name' => 'Doe2', 'email' => 'jack.daniels@kolab.org', ]; $response = $this->actingAs($john)->post("/api/v4/users", $post); $response->assertStatus(422); $json = $response->json(); $this->assertSame('error', $json['status']); $this->assertCount(2, $json); $this->assertSame('The specified email is not available.', $json['errors']['email']); $package_kolab = \App\Package::withEnvTenantContext()->where('title', 'kolab')->first(); $package_domain = \App\Package::withEnvTenantContext()->where('title', 'domain-hosting')->first(); $post = [ 'password' => 'simple', 'password_confirmation' => 'simple', 'first_name' => 'John2', 'last_name' => 'Doe2', 'email' => 'john2.doe2@kolab.org', 'organization' => 'TestOrg', 'aliases' => ['useralias1@kolab.org', 'deleted@kolab.org'], ]; // Missing package $response = $this->actingAs($john)->post("/api/v4/users", $post); $json = $response->json(); $response->assertStatus(422); $this->assertSame('error', $json['status']); $this->assertSame("Package is required.", $json['errors']['package']); $this->assertCount(2, $json); // Invalid package $post['package'] = $package_domain->id; $response = $this->actingAs($john)->post("/api/v4/users", $post); $json = $response->json(); $response->assertStatus(422); $this->assertSame('error', $json['status']); $this->assertSame("Invalid package selected.", $json['errors']['package']); $this->assertCount(2, $json); // Test full and valid data $post['package'] = $package_kolab->id; $response = $this->actingAs($john)->post("/api/v4/users", $post); $json = $response->json(); $response->assertStatus(200); $this->assertSame('success', $json['status']); $this->assertSame("User created successfully.", $json['message']); $this->assertCount(2, $json); $user = User::where('email', 'john2.doe2@kolab.org')->first(); $this->assertInstanceOf(User::class, $user); $this->assertSame('John2', $user->getSetting('first_name')); $this->assertSame('Doe2', $user->getSetting('last_name')); $this->assertSame('TestOrg', $user->getSetting('organization')); $aliases = $user->aliases()->orderBy('alias')->get(); $this->assertCount(2, $aliases); $this->assertSame('deleted@kolab.org', $aliases[0]->alias); $this->assertSame('useralias1@kolab.org', $aliases[1]->alias); // Assert the new user entitlements $this->assertEntitlements($user, ['groupware', 'mailbox', 'storage', 'storage', 'storage', 'storage', 'storage']); // Assert the wallet to which the new user should be assigned to $wallet = $user->wallet(); $this->assertSame($john->wallets()->first()->id, $wallet->id); // Attempt to create a user previously deleted $user->delete(); $post['package'] = $package_kolab->id; $post['aliases'] = []; $response = $this->actingAs($john)->post("/api/v4/users", $post); $json = $response->json(); $response->assertStatus(200); $this->assertSame('success', $json['status']); $this->assertSame("User created successfully.", $json['message']); $this->assertCount(2, $json); $user = User::where('email', 'john2.doe2@kolab.org')->first(); $this->assertInstanceOf(User::class, $user); $this->assertSame('John2', $user->getSetting('first_name')); $this->assertSame('Doe2', $user->getSetting('last_name')); $this->assertSame('TestOrg', $user->getSetting('organization')); $this->assertCount(0, $user->aliases()->get()); $this->assertEntitlements($user, ['groupware', 'mailbox', 'storage', 'storage', 'storage', 'storage', 'storage']); // Test acting as account controller (not owner) $this->markTestIncomplete(); } /** * Test user update (PUT /api/v4/users/<user-id>) */ public function testUpdate(): void { $userA = $this->getTestUser('UsersControllerTest1@userscontroller.com'); $jack = $this->getTestUser('jack@kolab.org'); $john = $this->getTestUser('john@kolab.org'); $ned = $this->getTestUser('ned@kolab.org'); $domain = $this->getTestDomain( 'userscontroller.com', ['status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_EXTERNAL] ); // Test unauthorized update of other user profile $response = $this->actingAs($jack)->get("/api/v4/users/{$userA->id}", []); $response->assertStatus(403); // Test authorized update of account owner by account controller $response = $this->actingAs($ned)->get("/api/v4/users/{$john->id}", []); $response->assertStatus(200); // Test updating of self (empty request) $response = $this->actingAs($userA)->put("/api/v4/users/{$userA->id}", []); $response->assertStatus(200); $json = $response->json(); $this->assertSame('success', $json['status']); $this->assertSame("User data updated successfully.", $json['message']); $this->assertTrue(!empty($json['statusInfo'])); $this->assertCount(3, $json); // Test some invalid data $post = ['password' => '12345678', 'currency' => 'invalid']; $response = $this->actingAs($userA)->put("/api/v4/users/{$userA->id}", $post); $response->assertStatus(422); $json = $response->json(); $this->assertSame('error', $json['status']); $this->assertCount(2, $json); $this->assertSame('The password confirmation does not match.', $json['errors']['password'][0]); $this->assertSame('The currency must be 3 characters.', $json['errors']['currency'][0]); // Test full profile update including password $post = [ 'password' => 'simple', 'password_confirmation' => 'simple', 'first_name' => 'John2', 'last_name' => 'Doe2', 'organization' => 'TestOrg', 'phone' => '+123 123 123', 'external_email' => 'external@gmail.com', 'billing_address' => 'billing', 'country' => 'CH', 'currency' => 'CHF', 'aliases' => ['useralias1@' . \config('app.domain'), 'useralias2@' . \config('app.domain')] ]; $response = $this->actingAs($userA)->put("/api/v4/users/{$userA->id}", $post); $json = $response->json(); $response->assertStatus(200); $this->assertSame('success', $json['status']); $this->assertSame("User data updated successfully.", $json['message']); $this->assertTrue(!empty($json['statusInfo'])); $this->assertCount(3, $json); $this->assertTrue($userA->password != $userA->fresh()->password); unset($post['password'], $post['password_confirmation'], $post['aliases']); foreach ($post as $key => $value) { $this->assertSame($value, $userA->getSetting($key)); } $aliases = $userA->aliases()->orderBy('alias')->get(); $this->assertCount(2, $aliases); $this->assertSame('useralias1@' . \config('app.domain'), $aliases[0]->alias); $this->assertSame('useralias2@' . \config('app.domain'), $aliases[1]->alias); // Test unsetting values $post = [ 'first_name' => '', 'last_name' => '', 'organization' => '', 'phone' => '', 'external_email' => '', 'billing_address' => '', 'country' => '', 'currency' => '', 'aliases' => ['useralias2@' . \config('app.domain')] ]; $response = $this->actingAs($userA)->put("/api/v4/users/{$userA->id}", $post); $json = $response->json(); $response->assertStatus(200); $this->assertSame('success', $json['status']); $this->assertSame("User data updated successfully.", $json['message']); $this->assertTrue(!empty($json['statusInfo'])); $this->assertCount(3, $json); unset($post['aliases']); foreach ($post as $key => $value) { $this->assertNull($userA->getSetting($key)); } $aliases = $userA->aliases()->get(); $this->assertCount(1, $aliases); $this->assertSame('useralias2@' . \config('app.domain'), $aliases[0]->alias); // Test error on some invalid aliases missing password confirmation $post = [ 'password' => 'simple123', 'aliases' => [ 'useralias2@' . \config('app.domain'), 'useralias1@kolab.org', '@kolab.org', ] ]; $response = $this->actingAs($userA)->put("/api/v4/users/{$userA->id}", $post); $json = $response->json(); $response->assertStatus(422); $this->assertSame('error', $json['status']); $this->assertCount(2, $json['errors']); $this->assertCount(2, $json['errors']['aliases']); $this->assertSame("The specified domain is not available.", $json['errors']['aliases'][1]); $this->assertSame("The specified alias is invalid.", $json['errors']['aliases'][2]); $this->assertSame("The password confirmation does not match.", $json['errors']['password'][0]); // Test authorized update of other user $response = $this->actingAs($ned)->put("/api/v4/users/{$jack->id}", []); $response->assertStatus(200); $json = $response->json(); $this->assertTrue(empty($json['statusInfo'])); // TODO: Test error on aliases with invalid/non-existing/other-user's domain // Create entitlements and additional user for following tests $owner = $this->getTestUser('UsersControllerTest1@userscontroller.com'); $user = $this->getTestUser('UsersControllerTest2@userscontroller.com'); $package_domain = Package::withEnvTenantContext()->where('title', 'domain-hosting')->first(); $package_kolab = Package::withEnvTenantContext()->where('title', 'kolab')->first(); $package_lite = Package::withEnvTenantContext()->where('title', 'lite')->first(); $sku_mailbox = Sku::withEnvTenantContext()->where('title', 'mailbox')->first(); $sku_storage = Sku::withEnvTenantContext()->where('title', 'storage')->first(); $sku_groupware = Sku::withEnvTenantContext()->where('title', 'groupware')->first(); $domain = $this->getTestDomain( 'userscontroller.com', [ 'status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_EXTERNAL, ] ); $domain->assignPackage($package_domain, $owner); $owner->assignPackage($package_kolab); $owner->assignPackage($package_lite, $user); // Non-controller cannot update his own entitlements $post = ['skus' => []]; $response = $this->actingAs($user)->put("/api/v4/users/{$user->id}", $post); $response->assertStatus(422); // Test updating entitlements $post = [ 'skus' => [ $sku_mailbox->id => 1, $sku_storage->id => 6, $sku_groupware->id => 1, ], ]; $response = $this->actingAs($owner)->put("/api/v4/users/{$user->id}", $post); $response->assertStatus(200); $json = $response->json(); $storage_cost = $user->entitlements() ->where('sku_id', $sku_storage->id) ->orderBy('cost') ->pluck('cost')->all(); $this->assertEntitlements( $user, ['groupware', 'mailbox', 'storage', 'storage', 'storage', 'storage', 'storage', 'storage'] ); $this->assertSame([0, 0, 0, 0, 0, 25], $storage_cost); $this->assertTrue(empty($json['statusInfo'])); } /** * Test UsersController::updateEntitlements() */ public function testUpdateEntitlements(): void { $jane = $this->getTestUser('jane@kolabnow.com'); $kolab = Package::withEnvTenantContext()->where('title', 'kolab')->first(); $storage = Sku::withEnvTenantContext()->where('title', 'storage')->first(); $activesync = Sku::withEnvTenantContext()->where('title', 'activesync')->first(); $groupware = Sku::withEnvTenantContext()->where('title', 'groupware')->first(); $mailbox = Sku::withEnvTenantContext()->where('title', 'mailbox')->first(); // standard package, 1 mailbox, 1 groupware, 2 storage $jane->assignPackage($kolab); // add 2 storage, 1 activesync $post = [ 'skus' => [ $mailbox->id => 1, $groupware->id => 1, $storage->id => 7, $activesync->id => 1 ] ]; $response = $this->actingAs($jane)->put("/api/v4/users/{$jane->id}", $post); $response->assertStatus(200); $this->assertEntitlements( $jane, [ 'activesync', 'groupware', 'mailbox', 'storage', 'storage', 'storage', 'storage', 'storage', 'storage', 'storage' ] ); // add 2 storage, remove 1 activesync $post = [ 'skus' => [ $mailbox->id => 1, $groupware->id => 1, $storage->id => 9, $activesync->id => 0 ] ]; $response = $this->actingAs($jane)->put("/api/v4/users/{$jane->id}", $post); $response->assertStatus(200); $this->assertEntitlements( $jane, [ 'groupware', 'mailbox', 'storage', 'storage', 'storage', 'storage', 'storage', 'storage', 'storage', 'storage', 'storage' ] ); // add mailbox $post = [ 'skus' => [ $mailbox->id => 2, $groupware->id => 1, $storage->id => 9, $activesync->id => 0 ] ]; $response = $this->actingAs($jane)->put("/api/v4/users/{$jane->id}", $post); $response->assertStatus(500); $this->assertEntitlements( $jane, [ 'groupware', 'mailbox', 'storage', 'storage', 'storage', 'storage', 'storage', 'storage', 'storage', 'storage', 'storage' ] ); // remove mailbox $post = [ 'skus' => [ $mailbox->id => 0, $groupware->id => 1, $storage->id => 9, $activesync->id => 0 ] ]; $response = $this->actingAs($jane)->put("/api/v4/users/{$jane->id}", $post); $response->assertStatus(500); $this->assertEntitlements( $jane, [ 'groupware', 'mailbox', 'storage', 'storage', 'storage', 'storage', 'storage', 'storage', 'storage', 'storage', 'storage' ] ); // less than free storage $post = [ 'skus' => [ $mailbox->id => 1, $groupware->id => 1, $storage->id => 1, $activesync->id => 0 ] ]; $response = $this->actingAs($jane)->put("/api/v4/users/{$jane->id}", $post); $response->assertStatus(200); $this->assertEntitlements( $jane, [ 'groupware', 'mailbox', 'storage', 'storage', 'storage', 'storage', 'storage' ] ); } /** * Test user data response used in show and info actions */ public function testUserResponse(): void { $provider = \config('services.payment_provider') ?: 'mollie'; $user = $this->getTestUser('john@kolab.org'); $wallet = $user->wallets()->first(); $wallet->setSettings(['mollie_id' => null, 'stripe_id' => null]); $result = $this->invokeMethod(new UsersController(), 'userResponse', [$user]); $this->assertEquals($user->id, $result['id']); $this->assertEquals($user->email, $result['email']); $this->assertEquals($user->status, $result['status']); $this->assertTrue(is_array($result['statusInfo'])); $this->assertTrue(is_array($result['aliases'])); $this->assertCount(1, $result['aliases']); $this->assertSame('john.doe@kolab.org', $result['aliases'][0]); $this->assertTrue(is_array($result['settings'])); $this->assertSame('US', $result['settings']['country']); $this->assertSame('USD', $result['settings']['currency']); $this->assertTrue(is_array($result['accounts'])); $this->assertTrue(is_array($result['wallets'])); $this->assertCount(0, $result['accounts']); $this->assertCount(1, $result['wallets']); $this->assertSame($wallet->id, $result['wallet']['id']); $this->assertArrayNotHasKey('discount', $result['wallet']); $this->assertTrue($result['statusInfo']['enableDomains']); $this->assertTrue($result['statusInfo']['enableWallets']); $this->assertTrue($result['statusInfo']['enableUsers']); // Ned is John's wallet controller $ned = $this->getTestUser('ned@kolab.org'); $ned_wallet = $ned->wallets()->first(); $result = $this->invokeMethod(new UsersController(), 'userResponse', [$ned]); $this->assertEquals($ned->id, $result['id']); $this->assertEquals($ned->email, $result['email']); $this->assertTrue(is_array($result['accounts'])); $this->assertTrue(is_array($result['wallets'])); $this->assertCount(1, $result['accounts']); $this->assertCount(1, $result['wallets']); $this->assertSame($wallet->id, $result['wallet']['id']); $this->assertSame($wallet->id, $result['accounts'][0]['id']); $this->assertSame($ned_wallet->id, $result['wallets'][0]['id']); $this->assertSame($provider, $result['wallet']['provider']); $this->assertSame($provider, $result['wallets'][0]['provider']); $this->assertTrue($result['statusInfo']['enableDomains']); $this->assertTrue($result['statusInfo']['enableWallets']); $this->assertTrue($result['statusInfo']['enableUsers']); // Test discount in a response $discount = Discount::where('code', 'TEST')->first(); $wallet->discount()->associate($discount); $wallet->save(); $mod_provider = $provider == 'mollie' ? 'stripe' : 'mollie'; $wallet->setSetting($mod_provider . '_id', 123); $user->refresh(); $result = $this->invokeMethod(new UsersController(), 'userResponse', [$user]); $this->assertEquals($user->id, $result['id']); $this->assertSame($discount->id, $result['wallet']['discount_id']); $this->assertSame($discount->discount, $result['wallet']['discount']); $this->assertSame($discount->description, $result['wallet']['discount_description']); $this->assertSame($mod_provider, $result['wallet']['provider']); $this->assertSame($discount->id, $result['wallets'][0]['discount_id']); $this->assertSame($discount->discount, $result['wallets'][0]['discount']); $this->assertSame($discount->description, $result['wallets'][0]['discount_description']); $this->assertSame($mod_provider, $result['wallets'][0]['provider']); // Jack is not a John's wallet controller $jack = $this->getTestUser('jack@kolab.org'); $result = $this->invokeMethod(new UsersController(), 'userResponse', [$jack]); $this->assertFalse($result['statusInfo']['enableDomains']); $this->assertFalse($result['statusInfo']['enableWallets']); $this->assertFalse($result['statusInfo']['enableUsers']); } /** * List of email address validation cases for testValidateEmail() * * @return array Arguments for testValidateEmail() */ public function dataValidateEmail(): array { $this->refreshApplication(); $public_domains = Domain::getPublicDomains(); $domain = reset($public_domains); $john = $this->getTestUser('john@kolab.org'); $jack = $this->getTestUser('jack@kolab.org'); $user = $this->getTestUser('UsersControllerTest1@userscontroller.com'); return [ // Invalid format ["$domain", $john, 'The specified email is invalid.'], [".@$domain", $john, 'The specified email is invalid.'], ["test123456@localhost", $john, 'The specified domain is invalid.'], ["test123456@unknown-domain.org", $john, 'The specified domain is invalid.'], ["$domain", $john, 'The specified email is invalid.'], [".@$domain", $john, 'The specified email is invalid.'], // forbidden local part on public domains ["admin@$domain", $john, 'The specified email is not available.'], ["administrator@$domain", $john, 'The specified email is not available.'], // forbidden (other user's domain) ["testtest@kolab.org", $user, 'The specified domain is not available.'], // existing alias of other user, to be a user email ["jack.daniels@kolab.org", $john, 'The specified email is not available.'], // valid (user domain) ["admin@kolab.org", $john, null], // valid (public domain) ["test.test@$domain", $john, null], ]; } /** * User email address 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 dataValidateEmail */ public function testValidateEmail($email, $user, $expected_result): void { $result = UsersController::validateEmail($email, $user); $this->assertSame($expected_result, $result); } /** * User email validation - tests for $deleted argument * * Note: Technically these include unit tests, but let's keep it here for now. * FIXME: Shall we do a http request for each case? */ public function testValidateEmailDeleted(): void { Queue::fake(); $john = $this->getTestUser('john@kolab.org'); $deleted_priv = $this->getTestUser('deleted@kolab.org'); $deleted_priv->delete(); $deleted_pub = $this->getTestUser('deleted@kolabnow.com'); $deleted_pub->delete(); $result = UsersController::validateEmail('deleted@kolab.org', $john, $deleted); $this->assertSame(null, $result); $this->assertSame($deleted_priv->id, $deleted->id); $result = UsersController::validateEmail('deleted@kolabnow.com', $john, $deleted); $this->assertSame('The specified email is not available.', $result); $this->assertSame(null, $deleted); $result = UsersController::validateEmail('jack@kolab.org', $john, $deleted); $this->assertSame('The specified email is not available.', $result); $this->assertSame(null, $deleted); } /** * User email validation - tests for an address being a group email address * * Note: Technically these include unit tests, but let's keep it here for now. * FIXME: Shall we do a http request for each case? */ public function testValidateEmailGroup(): void { Queue::fake(); $john = $this->getTestUser('john@kolab.org'); $pub_group = $this->getTestGroup('group-test@kolabnow.com'); $priv_group = $this->getTestGroup('group-test@kolab.org'); // A group in a public domain, existing $result = UsersController::validateEmail($pub_group->email, $john, $deleted); $this->assertSame('The specified email is not available.', $result); $this->assertNull($deleted); $pub_group->delete(); // A group in a public domain, deleted $result = UsersController::validateEmail($pub_group->email, $john, $deleted); $this->assertSame('The specified email is not available.', $result); $this->assertNull($deleted); // A group in a private domain, existing $result = UsersController::validateEmail($priv_group->email, $john, $deleted); $this->assertSame('The specified email is not available.', $result); $this->assertNull($deleted); $priv_group->delete(); // A group in a private domain, deleted $result = UsersController::validateEmail($priv_group->email, $john, $deleted); $this->assertSame(null, $result); $this->assertSame($priv_group->id, $deleted->id); } /** * List of alias validation cases for testValidateAlias() * * @return array Arguments for testValidateAlias() */ public function dataValidateAlias(): array { $this->refreshApplication(); $public_domains = Domain::getPublicDomains(); $domain = reset($public_domains); $john = $this->getTestUser('john@kolab.org'); $user = $this->getTestUser('UsersControllerTest1@userscontroller.com'); return [ // Invalid format ["$domain", $john, 'The specified alias is invalid.'], [".@$domain", $john, 'The specified alias is invalid.'], ["test123456@localhost", $john, 'The specified domain is invalid.'], ["test123456@unknown-domain.org", $john, 'The specified domain is invalid.'], ["$domain", $john, 'The specified alias is invalid.'], [".@$domain", $john, 'The specified alias is invalid.'], // forbidden local part on public domains ["admin@$domain", $john, 'The specified alias is not available.'], ["administrator@$domain", $john, 'The specified alias is not available.'], // forbidden (other user's domain) ["testtest@kolab.org", $user, 'The specified domain is not available.'], // existing alias of other user, to be an alias, user in the same group account ["jack.daniels@kolab.org", $john, null], // existing user ["jack@kolab.org", $john, 'The specified alias is not available.'], // valid (user domain) ["admin@kolab.org", $john, null], // valid (public domain) ["test.test@$domain", $john, null], ]; } /** * User email alias 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 dataValidateAlias */ public function testValidateAlias($alias, $user, $expected_result): void { $result = UsersController::validateAlias($alias, $user); $this->assertSame($expected_result, $result); } /** * User alias validation - more cases. * * Note: Technically these include unit tests, but let's keep it here for now. * FIXME: Shall we do a http request for each case? */ public function testValidateAlias2(): void { Queue::fake(); $john = $this->getTestUser('john@kolab.org'); $jack = $this->getTestUser('jack@kolab.org'); $user = $this->getTestUser('UsersControllerTest1@userscontroller.com'); $deleted_priv = $this->getTestUser('deleted@kolab.org'); $deleted_priv->setAliases(['deleted-alias@kolab.org']); $deleted_priv->delete(); $deleted_pub = $this->getTestUser('deleted@kolabnow.com'); $deleted_pub->setAliases(['deleted-alias@kolabnow.com']); $deleted_pub->delete(); $group = $this->getTestGroup('group-test@kolabnow.com'); // An alias that was a user email before is allowed, but only for custom domains $result = UsersController::validateAlias('deleted@kolab.org', $john); $this->assertSame(null, $result); $result = UsersController::validateAlias('deleted-alias@kolab.org', $john); $this->assertSame(null, $result); $result = UsersController::validateAlias('deleted@kolabnow.com', $john); $this->assertSame('The specified alias is not available.', $result); $result = UsersController::validateAlias('deleted-alias@kolabnow.com', $john); $this->assertSame('The specified alias is not available.', $result); // A grpoup with the same email address exists $result = UsersController::validateAlias($group->email, $john); $this->assertSame('The specified alias is not available.', $result); } }