diff --git a/src/app/Http/Controllers/API/V4/Admin/UsersController.php b/src/app/Http/Controllers/API/V4/Admin/UsersController.php index 0eb1c97e..0c55cf66 100644 --- a/src/app/Http/Controllers/API/V4/Admin/UsersController.php +++ b/src/app/Http/Controllers/API/V4/Admin/UsersController.php @@ -1,201 +1,251 @@ errorResponse(404); + } + /** * Searching of user accounts. * * @return \Illuminate\Http\JsonResponse */ public function index() { $search = trim(request()->input('search')); $owner = trim(request()->input('owner')); $result = collect([]); if ($owner) { - if ($owner = User::find($owner)) { - $result = $owner->users(false)->orderBy('email')->get(); + $owner = User::where('id', $owner) + ->withEnvTenant() + ->whereNull('role') + ->first(); + + if ($owner) { + $result = $owner->users(false)->whereNull('role')->orderBy('email')->get(); } } elseif (strpos($search, '@')) { // Search by email $result = User::withTrashed()->where('email', $search) - ->orderBy('email')->get(); + ->withEnvTenant() + ->whereNull('role') + ->orderBy('email') + ->get(); if ($result->isEmpty()) { // Search by an alias $user_ids = UserAlias::where('alias', $search)->get()->pluck('user_id'); // Search by an external email $ext_user_ids = UserSetting::where('key', 'external_email') - ->where('value', $search)->get()->pluck('user_id'); + ->where('value', $search) + ->get() + ->pluck('user_id'); $user_ids = $user_ids->merge($ext_user_ids)->unique(); if (!$user_ids->isEmpty()) { $result = User::withTrashed()->whereIn('id', $user_ids) - ->orderBy('email')->get(); + ->withEnvTenant() + ->whereNull('role') + ->orderBy('email') + ->get(); } } } elseif (is_numeric($search)) { // Search by user ID - if ($user = User::withTrashed()->find($search)) { + $user = User::withTrashed()->where('id', $search) + ->withEnvTenant() + ->whereNull('role') + ->first(); + + if ($user) { $result->push($user); } } elseif (!empty($search)) { // Search by domain - if ($domain = Domain::withTrashed()->where('namespace', $search)->first()) { - if ($wallet = $domain->wallet()) { - $result->push($wallet->owner()->withTrashed()->first()); + $domain = Domain::withTrashed()->where('namespace', $search) + ->withEnvTenant() + ->first(); + + if ($domain) { + if ( + ($wallet = $domain->wallet()) + && ($owner = $wallet->owner()->withTrashed()->withEnvTenant()->first()) + && empty($owner->role) + ) { + $result->push($owner); } } } // Process the result $result = $result->map(function ($user) { $data = $user->toArray(); $data = array_merge($data, self::userStatuses($user)); return $data; }); $result = [ 'list' => $result, 'count' => count($result), 'message' => \trans('app.search-foundxusers', ['x' => count($result)]), ]; return response()->json($result); } /** * Reset 2-Factor Authentication for the user * * @param \Illuminate\Http\Request $request The API request. * @param string $id User identifier * * @return \Illuminate\Http\JsonResponse The response */ public function reset2FA(Request $request, $id) { - $user = User::find($id); + $user = User::withEnvTenant()->find($id); - if (empty($user)) { + if (empty($user) || !$this->guard()->user()->canUpdate($user)) { return $this->errorResponse(404); } $sku = Sku::where('title', '2fa')->first(); // Note: we do select first, so the observer can delete // 2FA preferences from Roundcube database, so don't // be tempted to replace first() with delete() below $entitlement = $user->entitlements()->where('sku_id', $sku->id)->first(); $entitlement->delete(); return response()->json([ 'status' => 'success', 'message' => __('app.user-reset-2fa-success'), ]); } + /** + * Create a new user record. + * + * @param \Illuminate\Http\Request $request The API request. + * + * @return \Illuminate\Http\JsonResponse The response + */ + public function store(Request $request) + { + return $this->errorResponse(404); + } + /** * Suspend the user * * @param \Illuminate\Http\Request $request The API request. * @param string $id User identifier * * @return \Illuminate\Http\JsonResponse The response */ public function suspend(Request $request, $id) { - $user = User::find($id); + $user = User::withEnvTenant()->find($id); - if (empty($user)) { + if (empty($user) || !$this->guard()->user()->canUpdate($user)) { return $this->errorResponse(404); } $user->suspend(); return response()->json([ 'status' => 'success', 'message' => __('app.user-suspend-success'), ]); } /** * Un-Suspend the user * * @param \Illuminate\Http\Request $request The API request. * @param string $id User identifier * * @return \Illuminate\Http\JsonResponse The response */ public function unsuspend(Request $request, $id) { - $user = User::find($id); + $user = User::withEnvTenant()->find($id); - if (empty($user)) { + if (empty($user) || !$this->guard()->user()->canUpdate($user)) { return $this->errorResponse(404); } $user->unsuspend(); return response()->json([ 'status' => 'success', 'message' => __('app.user-unsuspend-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::find($id); + $user = User::withEnvTenant()->find($id); - if (empty($user)) { + if (empty($user) || !$this->guard()->user()->canUpdate($user)) { return $this->errorResponse(404); } // For now admins can change only user external email address $rules = []; if (array_key_exists('external_email', $request->input())) { $rules['external_email'] = 'email'; } // Validate input $v = Validator::make($request->all(), $rules); if ($v->fails()) { return response()->json(['status' => 'error', 'errors' => $v->errors()], 422); } // Update user settings $settings = $request->only(array_keys($rules)); if (!empty($settings)) { $user->setSettings($settings); } return response()->json([ 'status' => 'success', 'message' => __('app.user-update-success'), ]); } } diff --git a/src/app/Http/Controllers/API/V4/Admin/WalletsController.php b/src/app/Http/Controllers/API/V4/Admin/WalletsController.php index f03e0563..70e3cfae 100644 --- a/src/app/Http/Controllers/API/V4/Admin/WalletsController.php +++ b/src/app/Http/Controllers/API/V4/Admin/WalletsController.php @@ -1,148 +1,149 @@ user()->canRead($wallet)) { return $this->errorResponse(404); } $result = $wallet->toArray(); $result['discount'] = 0; $result['discount_description'] = ''; if ($wallet->discount) { $result['discount'] = $wallet->discount->discount; $result['discount_description'] = $wallet->discount->description; } $result['mandate'] = PaymentsController::walletMandate($wallet); $provider = PaymentProvider::factory($wallet); $result['provider'] = $provider->name(); $result['providerLink'] = $provider->customerLink($wallet); return response()->json($result); } /** * Award/penalize a wallet. * * @param \Illuminate\Http\Request $request The API request. * @param string $id Wallet identifier * * @return \Illuminate\Http\JsonResponse The response */ public function oneOff(Request $request, $id) { $wallet = Wallet::find($id); - if (empty($wallet)) { + if (empty($wallet) || !Auth::guard()->user()->canRead($wallet)) { return $this->errorResponse(404); } // Check required fields $v = Validator::make( $request->all(), [ 'amount' => 'required|numeric', 'description' => 'required|string|max:1024', ] ); if ($v->fails()) { return response()->json(['status' => 'error', 'errors' => $v->errors()], 422); } $amount = (int) ($request->amount * 100); $type = $amount > 0 ? Transaction::WALLET_AWARD : Transaction::WALLET_PENALTY; DB::beginTransaction(); $wallet->balance += $amount; $wallet->save(); Transaction::create( [ 'user_email' => \App\Utils::userEmailOrNull(), 'object_id' => $wallet->id, 'object_type' => Wallet::class, 'type' => $type, 'amount' => $amount, 'description' => $request->description ] ); DB::commit(); $response = [ 'status' => 'success', 'message' => \trans("app.wallet-{$type}-success"), 'balance' => $wallet->balance ]; return response()->json($response); } /** * Update wallet data. * * @param \Illuminate\Http\Request $request The API request. * @param string $id Wallet identifier * * @return \Illuminate\Http\JsonResponse The response */ public function update(Request $request, $id) { $wallet = Wallet::find($id); - if (empty($wallet)) { + if (empty($wallet) || !Auth::guard()->user()->canRead($wallet)) { return $this->errorResponse(404); } if (array_key_exists('discount', $request->input())) { if (empty($request->discount)) { $wallet->discount()->dissociate(); $wallet->save(); - } elseif ($discount = Discount::find($request->discount)) { + } elseif ($discount = Discount::withEnvTenant()->find($request->discount)) { $wallet->discount()->associate($discount); $wallet->save(); } } $response = $wallet->toArray(); if ($wallet->discount) { $response['discount'] = $wallet->discount->discount; $response['discount_description'] = $wallet->discount->description; } $response['status'] = 'success'; $response['message'] = \trans('app.wallet-update-success'); return response()->json($response); } } diff --git a/src/app/Http/Controllers/API/V4/Reseller/UsersController.php b/src/app/Http/Controllers/API/V4/Reseller/UsersController.php index 819111d8..8c9921cf 100644 --- a/src/app/Http/Controllers/API/V4/Reseller/UsersController.php +++ b/src/app/Http/Controllers/API/V4/Reseller/UsersController.php @@ -1,148 +1,7 @@ input('search')); - $owner = trim(request()->input('owner')); - $result = collect([]); - - if ($owner) { - $owner = User::where('id', $owner) - ->withUserTenant() - ->whereNull('role') - ->first(); - - if ($owner) { - $result = $owner->users(false)->whereNull('role')->orderBy('email')->get(); - } - } elseif (strpos($search, '@')) { - // Search by email - $result = User::withTrashed()->where('email', $search) - ->withUserTenant() - ->whereNull('role') - ->orderBy('email') - ->get(); - - if ($result->isEmpty()) { - // Search by an alias - $user_ids = UserAlias::where('alias', $search)->get()->pluck('user_id'); - - // Search by an external email - $ext_user_ids = UserSetting::where('key', 'external_email') - ->where('value', $search) - ->get() - ->pluck('user_id'); - - $user_ids = $user_ids->merge($ext_user_ids)->unique(); - - if (!$user_ids->isEmpty()) { - $result = User::withTrashed()->whereIn('id', $user_ids) - ->withUserTenant() - ->whereNull('role') - ->orderBy('email') - ->get(); - } - } - } elseif (is_numeric($search)) { - // Search by user ID - $user = User::withTrashed()->where('id', $search) - ->withUserTenant() - ->whereNull('role') - ->first(); - - if ($user) { - $result->push($user); - } - } elseif (!empty($search)) { - // Search by domain - $domain = Domain::withTrashed()->where('namespace', $search) - ->withUserTenant() - ->first(); - - if ($domain) { - if ( - ($wallet = $domain->wallet()) - && ($owner = $wallet->owner()->withTrashed()->withUserTenant()->first()) - && empty($owner->role) - ) { - $result->push($owner); - } - } - } - - // Process the result - $result = $result->map(function ($user) { - $data = $user->toArray(); - $data = array_merge($data, self::userStatuses($user)); - return $data; - }); - - $result = [ - 'list' => $result, - 'count' => count($result), - 'message' => \trans('app.search-foundxusers', ['x' => count($result)]), - ]; - - return response()->json($result); - } - - /** - * Update user data. - * - * @param \Illuminate\Http\Request $request The API request. - * @params string $id User identifier - * - * @return \Illuminate\Http\JsonResponse The response - */ - public function update(Request $request, $id) - { - $user = User::where('id', $id)->withUserTenant()->first(); - - if (empty($user) || $user->role == 'admin') { - return $this->errorResponse(404); - } - - // For now admins can change only user external email address - - $rules = []; - - if (array_key_exists('external_email', $request->input())) { - $rules['external_email'] = 'email'; - } - - // Validate input - $v = Validator::make($request->all(), $rules); - - if ($v->fails()) { - return response()->json(['status' => 'error', 'errors' => $v->errors()], 422); - } - - // Update user settings - $settings = $request->only(array_keys($rules)); - - if (!empty($settings)) { - $user->setSettings($settings); - } - - return response()->json([ - 'status' => 'success', - 'message' => __('app.user-update-success'), - ]); - } } diff --git a/src/app/Http/Controllers/API/V4/Reseller/WalletsController.php b/src/app/Http/Controllers/API/V4/Reseller/WalletsController.php index d913e4ec..2edcde8b 100644 --- a/src/app/Http/Controllers/API/V4/Reseller/WalletsController.php +++ b/src/app/Http/Controllers/API/V4/Reseller/WalletsController.php @@ -1,49 +1,11 @@ errorResponse(404); - } - - if (array_key_exists('discount', $request->input())) { - if (empty($request->discount)) { - $wallet->discount()->dissociate(); - $wallet->save(); - } elseif ($discount = Discount::find($request->discount)) { - $wallet->discount()->associate($discount); - $wallet->save(); - } - } - - $response = $wallet->toArray(); - - if ($wallet->discount) { - $response['discount'] = $wallet->discount->discount; - $response['discount_description'] = $wallet->discount->description; - } - - $response['status'] = 'success'; - $response['message'] = \trans('app.wallet-update-success'); - - return response()->json($response); - } } diff --git a/src/app/Http/Controllers/API/V4/SkusController.php b/src/app/Http/Controllers/API/V4/SkusController.php index f18eb5bb..a4b2f54f 100644 --- a/src/app/Http/Controllers/API/V4/SkusController.php +++ b/src/app/Http/Controllers/API/V4/SkusController.php @@ -1,193 +1,193 @@ errorResponse(404); } /** * Remove the specified sku from storage. * * @param int $id SKU identifier * * @return \Illuminate\Http\JsonResponse */ public function destroy($id) { // TODO return $this->errorResponse(404); } /** * Show the form for editing the specified sku. * * @param int $id SKU identifier * * @return \Illuminate\Http\JsonResponse */ public function edit($id) { // TODO return $this->errorResponse(404); } /** * Get a list of active SKUs. * * @return \Illuminate\Http\JsonResponse */ public function index() { // Note: Order by title for consistent ordering in tests $skus = Sku::where('active', true)->orderBy('title')->get(); $response = []; foreach ($skus as $sku) { if ($data = $this->skuElement($sku)) { $response[] = $data; } } usort($response, function ($a, $b) { return ($b['prio'] <=> $a['prio']); }); return response()->json($response); } /** * Store a newly created sku in storage. * * @param \Illuminate\Http\Request $request * * @return \Illuminate\Http\JsonResponse */ public function store(Request $request) { // TODO return $this->errorResponse(404); } /** * Display the specified sku. * * @param int $id SKU identifier * * @return \Illuminate\Http\JsonResponse */ public function show($id) { // TODO return $this->errorResponse(404); } /** * Update the specified sku in storage. * * @param \Illuminate\Http\Request $request Request object * @param int $id SKU identifier * * @return \Illuminate\Http\JsonResponse */ public function update(Request $request, $id) { // TODO return $this->errorResponse(404); } /** * Get a list of SKUs available to the user. * * @param int $id User identifier * * @return \Illuminate\Http\JsonResponse */ public function userSkus($id) { - $user = \App\User::find($id); + $user = \App\User::withEnvTenant()->find($id); if (empty($user)) { return $this->errorResponse(404); } if (!Auth::guard()->user()->canRead($user)) { return $this->errorResponse(403); } $type = request()->input('type'); $response = []; // Note: Order by title for consistent ordering in tests $skus = Sku::orderBy('title')->get(); foreach ($skus as $sku) { if (!class_exists($sku->handler_class)) { continue; } if (!$sku->handler_class::isAvailable($sku, $user)) { continue; } if ($data = $this->skuElement($sku)) { if ($type && $type != $data['type']) { continue; } $response[] = $data; } } usort($response, function ($a, $b) { return ($b['prio'] <=> $a['prio']); }); return response()->json($response); } /** * Convert SKU information to metadata used by UI to * display the form control * * @param \App\Sku $sku SKU object * * @return array|null Metadata */ protected function skuElement($sku): ?array { if (!class_exists($sku->handler_class)) { return null; } $data = array_merge($sku->toArray(), $sku->handler_class::metadata($sku)); // ignore incomplete handlers if (empty($data['type'])) { return null; } // Use localized value, toArray() does not get them right $data['name'] = $sku->name; $data['description'] = $sku->description; unset($data['handler_class'], $data['created_at'], $data['updated_at']); return $data; } } diff --git a/src/app/Http/Middleware/AuthenticateAdmin.php b/src/app/Http/Middleware/AuthenticateAdmin.php index dfbff382..f7f6b85a 100644 --- a/src/app/Http/Middleware/AuthenticateAdmin.php +++ b/src/app/Http/Middleware/AuthenticateAdmin.php @@ -1,30 +1,30 @@ user(); if (!$user) { - abort(403, "Unauthorized"); + abort(401, "Unauthorized"); } if ($user->role !== "admin") { abort(403, "Unauthorized"); } return $next($request); } } diff --git a/src/app/Http/Middleware/AuthenticateReseller.php b/src/app/Http/Middleware/AuthenticateReseller.php index 829e0734..9d4308ea 100644 --- a/src/app/Http/Middleware/AuthenticateReseller.php +++ b/src/app/Http/Middleware/AuthenticateReseller.php @@ -1,34 +1,34 @@ user(); if (!$user) { - abort(403, "Unauthorized"); + abort(401, "Unauthorized"); } if ($user->role !== "reseller") { abort(403, "Unauthorized"); } if ($user->tenant_id != \config('app.tenant_id')) { abort(403, "Unauthorized"); } return $next($request); } } diff --git a/src/app/User.php b/src/app/User.php index bd41d6e0..1fffafde 100644 --- a/src/app/User.php +++ b/src/app/User.php @@ -1,742 +1,758 @@ belongsToMany( 'App\Wallet', // The foreign object definition 'user_accounts', // The table name 'user_id', // The local foreign key 'wallet_id' // The remote foreign key ); } /** * Email aliases of this user. * * @return \Illuminate\Database\Eloquent\Relations\HasMany */ public function aliases() { return $this->hasMany('App\UserAlias', 'user_id'); } /** * Assign a package to a user. The user should not have any existing entitlements. * * @param \App\Package $package The package to assign. * @param \App\User|null $user Assign the package to another user. * * @return \App\User */ public function assignPackage($package, $user = null) { if (!$user) { $user = $this; } $wallet_id = $this->wallets()->first()->id; foreach ($package->skus as $sku) { for ($i = $sku->pivot->qty; $i > 0; $i--) { \App\Entitlement::create( [ 'wallet_id' => $wallet_id, 'sku_id' => $sku->id, 'cost' => $sku->pivot->cost(), 'entitleable_id' => $user->id, 'entitleable_type' => User::class ] ); } } return $user; } /** * Assign a package plan to a user. * * @param \App\Plan $plan The plan to assign * @param \App\Domain $domain Optional domain object * * @return \App\User Self */ public function assignPlan($plan, $domain = null): User { $this->setSetting('plan_id', $plan->id); foreach ($plan->packages as $package) { if ($package->isDomain()) { $domain->assignPackage($package, $this); } else { $this->assignPackage($package); } } return $this; } /** * Assign a Sku to a user. * * @param \App\Sku $sku The sku to assign. * @param int $count Count of entitlements to add * * @return \App\User Self * @throws \Exception */ public function assignSku(Sku $sku, int $count = 1): User { // TODO: I guess wallet could be parametrized in future $wallet = $this->wallet(); $exists = $this->entitlements()->where('sku_id', $sku->id)->count(); while ($count > 0) { \App\Entitlement::create([ 'wallet_id' => $wallet->id, 'sku_id' => $sku->id, 'cost' => $exists >= $sku->units_free ? $sku->cost : 0, 'entitleable_id' => $this->id, 'entitleable_type' => User::class ]); $exists++; $count--; } return $this; } /** * Check if current user can delete another object. * * @param \App\User|\App\Domain $object A user|domain object * * @return bool True if he can, False otherwise */ public function canDelete($object): bool { if (!method_exists($object, 'wallet')) { return false; } $wallet = $object->wallet(); // TODO: For now controller can delete/update the account owner, // this may change in future, controllers are not 0-regression feature return $this->wallets->contains($wallet) || $this->accounts->contains($wallet); } /** * Check if current user can read data of another object. * * @param \App\User|\App\Domain|\App\Wallet $object A user|domain|wallet object * * @return bool True if he can, False otherwise */ public function canRead($object): bool { if ($this->role == 'admin') { return true; } if ($object instanceof User && $this->id == $object->id) { return true; } if ($this->role == 'reseller') { if ($object instanceof User && $object->role == 'admin') { return false; } if ($object instanceof Wallet && !empty($object->owner)) { $object = $object->owner; } return isset($object->tenant_id) && $object->tenant_id == $this->tenant_id; } if ($object instanceof Wallet) { return $object->user_id == $this->id || $object->controllers->contains($this); } if (!method_exists($object, 'wallet')) { return false; } $wallet = $object->wallet(); return $wallet && ($this->wallets->contains($wallet) || $this->accounts->contains($wallet)); } /** * Check if current user can update data of another object. * * @param \App\User|\App\Domain $object A user|domain object * * @return bool True if he can, False otherwise */ public function canUpdate($object): bool { if ($object instanceof User && $this->id == $object->id) { return true; } + if ($this->role == 'admin') { + return true; + } + + if ($this->role == 'reseller') { + if ($object instanceof User && $object->role == 'admin') { + return false; + } + + if ($object instanceof Wallet && !empty($object->owner)) { + $object = $object->owner; + } + + return isset($object->tenant_id) && $object->tenant_id == $this->tenant_id; + } + return $this->canDelete($object); } /** * Return the \App\Domain for this user. * * @return \App\Domain|null */ public function domain() { list($local, $domainName) = explode('@', $this->email); $domain = \App\Domain::withTrashed()->where('namespace', $domainName)->first(); return $domain; } /** * List the domains to which this user is entitled. * * @return Domain[] */ public function domains() { $dbdomains = Domain::whereRaw( sprintf( '(type & %s) AND (status & %s)', Domain::TYPE_PUBLIC, Domain::STATUS_ACTIVE ) )->get(); $domains = []; foreach ($dbdomains as $dbdomain) { $domains[] = $dbdomain; } foreach ($this->wallets as $wallet) { $entitlements = $wallet->entitlements()->where('entitleable_type', Domain::class)->get(); foreach ($entitlements as $entitlement) { $domain = $entitlement->entitleable; \Log::info("Found domain for {$this->email}: {$domain->namespace} (owned)"); $domains[] = $domain; } } foreach ($this->accounts as $wallet) { $entitlements = $wallet->entitlements()->where('entitleable_type', Domain::class)->get(); foreach ($entitlements as $entitlement) { $domain = $entitlement->entitleable; \Log::info("Found domain {$this->email}: {$domain->namespace} (charged)"); $domains[] = $domain; } } return $domains; } /** * The user entitlement. * * @return \Illuminate\Database\Eloquent\Relations\MorphOne */ public function entitlement() { return $this->morphOne('App\Entitlement', 'entitleable'); } /** * Entitlements for this user. * * Note that these are entitlements that apply to the user account, and not entitlements that * this user owns. * * @return \Illuminate\Database\Eloquent\Relations\HasMany */ public function entitlements() { return $this->hasMany('App\Entitlement', 'entitleable_id', 'id') ->where('entitleable_type', User::class); } /** * Find whether an email address exists as a user (including deleted users). * * @param string $email Email address * @param bool $return_user Return User instance instead of boolean * * @return \App\User|bool True or User model object if found, False otherwise */ public static function emailExists(string $email, bool $return_user = false) { if (strpos($email, '@') === false) { return false; } $email = \strtolower($email); $user = self::withTrashed()->where('email', $email)->first(); if ($user) { return $return_user ? $user : true; } return false; } /** * Helper to find user by email address, whether it is * main email address, alias or an external email. * * If there's more than one alias NULL will be returned. * * @param string $email Email address * @param bool $external Search also for an external email * * @return \App\User User model object if found */ public static function findByEmail(string $email, bool $external = false): ?User { if (strpos($email, '@') === false) { return null; } $email = \strtolower($email); $user = self::where('email', $email)->first(); if ($user) { return $user; } $aliases = UserAlias::where('alias', $email)->get(); if (count($aliases) == 1) { return $aliases->first()->user; } // TODO: External email return null; } public function getJWTIdentifier() { return $this->getKey(); } public function getJWTCustomClaims() { return []; } /** * Return groups controlled by the current user. * * @return \Illuminate\Database\Eloquent\Builder Query builder */ public function groups() { $wallets = $this->wallets()->pluck('id')->all(); $groupIds = \App\Entitlement::whereIn('entitlements.wallet_id', $wallets) ->where('entitlements.entitleable_type', Group::class) ->pluck('entitleable_id') ->all(); return Group::whereIn('id', $groupIds); } /** * Check if user has an entitlement for the specified SKU. * * @param string $title The SKU title * * @return bool True if specified SKU entitlement exists */ public function hasSku($title): bool { $sku = Sku::where('title', $title)->first(); if (!$sku) { return false; } return $this->entitlements()->where('sku_id', $sku->id)->count() > 0; } /** * Returns whether this domain is active. * * @return bool */ public function isActive(): bool { return ($this->status & self::STATUS_ACTIVE) > 0; } /** * Returns whether this domain is deleted. * * @return bool */ public function isDeleted(): bool { return ($this->status & self::STATUS_DELETED) > 0; } /** * Returns whether this (external) domain has been verified * to exist in DNS. * * @return bool */ public function isImapReady(): bool { return ($this->status & self::STATUS_IMAP_READY) > 0; } /** * Returns whether this user is registered in LDAP. * * @return bool */ public function isLdapReady(): bool { return ($this->status & self::STATUS_LDAP_READY) > 0; } /** * Returns whether this user is new. * * @return bool */ public function isNew(): bool { return ($this->status & self::STATUS_NEW) > 0; } /** * Returns whether this domain is suspended. * * @return bool */ public function isSuspended(): bool { return ($this->status & self::STATUS_SUSPENDED) > 0; } /** * A shortcut to get the user name. * * @param bool $fallback Return " User" if there's no name * * @return string Full user name */ public function name(bool $fallback = false): string { $firstname = $this->getSetting('first_name'); $lastname = $this->getSetting('last_name'); $name = trim($firstname . ' ' . $lastname); if (empty($name) && $fallback) { return \config('app.name') . ' User'; } return $name; } /** * Remove a number of entitlements for the SKU. * * @param \App\Sku $sku The SKU * @param int $count The number of entitlements to remove * * @return User Self */ public function removeSku(Sku $sku, int $count = 1): User { $entitlements = $this->entitlements() ->where('sku_id', $sku->id) ->orderBy('cost', 'desc') ->orderBy('created_at') ->get(); $entitlements_count = count($entitlements); foreach ($entitlements as $entitlement) { if ($entitlements_count <= $sku->units_free) { continue; } if ($count > 0) { $entitlement->delete(); $entitlements_count--; $count--; } } return $this; } /** * Any (additional) properties of this user. * * @return \Illuminate\Database\Eloquent\Relations\HasMany */ public function settings() { return $this->hasMany('App\UserSetting', 'user_id'); } /** * Suspend this domain. * * @return void */ public function suspend(): void { if ($this->isSuspended()) { return; } $this->status |= User::STATUS_SUSPENDED; $this->save(); } /** * The tenant for this user account. * * @return \Illuminate\Database\Eloquent\Relations\BelongsTo */ public function tenant() { return $this->belongsTo('App\Tenant', 'tenant_id', 'id'); } /** * Unsuspend this domain. * * @return void */ public function unsuspend(): void { if (!$this->isSuspended()) { return; } $this->status ^= User::STATUS_SUSPENDED; $this->save(); } /** * Return users controlled by the current user. * * @param bool $with_accounts Include users assigned to wallets * the current user controls but not owns. * * @return \Illuminate\Database\Eloquent\Builder Query builder */ public function users($with_accounts = true) { $wallets = $this->wallets()->pluck('id')->all(); if ($with_accounts) { $wallets = array_merge($wallets, $this->accounts()->pluck('wallet_id')->all()); } return $this->select(['users.*', 'entitlements.wallet_id']) ->distinct() ->leftJoin('entitlements', 'entitlements.entitleable_id', '=', 'users.id') ->whereIn('entitlements.wallet_id', $wallets) ->where('entitlements.entitleable_type', User::class); } /** * Verification codes for this user. * * @return \Illuminate\Database\Eloquent\Relations\HasMany */ public function verificationcodes() { return $this->hasMany('App\VerificationCode', 'user_id', 'id'); } /** * Returns the wallet by which the user is controlled * * @return ?\App\Wallet A wallet object */ public function wallet(): ?Wallet { $entitlement = $this->entitlement()->withTrashed()->first(); // TODO: No entitlement should not happen, but in tests we have // such cases, so we fallback to the user's wallet in this case return $entitlement ? $entitlement->wallet : $this->wallets()->first(); } /** * Wallets this user owns. * * @return \Illuminate\Database\Eloquent\Relations\HasMany */ public function wallets() { return $this->hasMany('App\Wallet'); } /** * User password mutator * * @param string $password The password in plain text. * * @return void */ public function setPasswordAttribute($password) { if (!empty($password)) { $this->attributes['password'] = bcrypt($password, [ "rounds" => 12 ]); $this->attributes['password_ldap'] = '{SSHA512}' . base64_encode( pack('H*', hash('sha512', $password)) ); } } /** * User LDAP password mutator * * @param string $password The password in plain text. * * @return void */ public function setPasswordLdapAttribute($password) { $this->setPasswordAttribute($password); } /** * User status mutator * * @throws \Exception */ public function setStatusAttribute($status) { $new_status = 0; $allowed_values = [ self::STATUS_NEW, self::STATUS_ACTIVE, self::STATUS_SUSPENDED, self::STATUS_DELETED, self::STATUS_LDAP_READY, self::STATUS_IMAP_READY, ]; foreach ($allowed_values as $value) { if ($status & $value) { $new_status |= $value; $status ^= $value; } } if ($status > 0) { throw new \Exception("Invalid user status: {$status}"); } $this->attributes['status'] = $new_status; } } diff --git a/src/phpstan.neon b/src/phpstan.neon index 519151bb..99cf9e6d 100644 --- a/src/phpstan.neon +++ b/src/phpstan.neon @@ -1,16 +1,15 @@ includes: - ./vendor/nunomaduro/larastan/extension.neon parameters: ignoreErrors: - '#Access to an undefined property Illuminate\\Contracts\\Auth\\Authenticatable#' - '#Access to an undefined property [a-zA-Z\\]+::\$pivot#' - - '#Access to an undefined property Illuminate\\Database\\Eloquent\\Model::\$id#' - - '#Access to an undefined property Illuminate\\Database\\Eloquent\\Model::\$created_at#' - - '#Call to an undefined method Illuminate\\Database\\Eloquent\\Model::toString\(\)#' + - '#Access to an undefined property Illuminate\\Database\\Eloquent\\Model::#' + - '#Call to an undefined method Illuminate\\Database\\Eloquent\\Model::#' - '#Call to an undefined method Illuminate\\Database\\Eloquent\\Builder[^:]*::withEnvTenant\(\)#' - '#Call to an undefined method Illuminate\\Database\\Eloquent\\Builder[^:]*::withUserTenant\(\)#' - '#Call to an undefined method Tests\\Browser::#' level: 4 paths: - app/ - tests/ diff --git a/src/routes/api.php b/src/routes/api.php index f74f7ff7..01f502c7 100644 --- a/src/routes/api.php +++ b/src/routes/api.php @@ -1,185 +1,187 @@ 'api', 'prefix' => $prefix . 'api/auth' ], function ($router) { Route::post('login', 'API\AuthController@login'); Route::group( ['middleware' => 'auth:api'], function ($router) { Route::get('info', 'API\AuthController@info'); Route::post('logout', 'API\AuthController@logout'); Route::post('refresh', 'API\AuthController@refresh'); } ); } ); Route::group( [ 'domain' => \config('app.domain'), 'middleware' => 'api', 'prefix' => $prefix . 'api/auth' ], function ($router) { Route::post('password-reset/init', 'API\PasswordResetController@init'); Route::post('password-reset/verify', 'API\PasswordResetController@verify'); Route::post('password-reset', 'API\PasswordResetController@reset'); Route::get('signup/plans', 'API\SignupController@plans'); Route::post('signup/init', 'API\SignupController@init'); Route::post('signup/verify', 'API\SignupController@verify'); Route::post('signup', 'API\SignupController@signup'); } ); Route::group( [ 'domain' => \config('app.domain'), 'middleware' => 'auth:api', 'prefix' => $prefix . 'api/v4' ], function () { Route::apiResource('domains', API\V4\DomainsController::class); Route::get('domains/{id}/confirm', 'API\V4\DomainsController@confirm'); Route::get('domains/{id}/status', 'API\V4\DomainsController@status'); Route::apiResource('entitlements', API\V4\EntitlementsController::class); Route::apiResource('packages', API\V4\PackagesController::class); Route::apiResource('skus', API\V4\SkusController::class); Route::apiResource('users', API\V4\UsersController::class); Route::get('users/{id}/skus', 'API\V4\SkusController@userSkus'); Route::get('users/{id}/status', 'API\V4\UsersController@status'); Route::apiResource('wallets', API\V4\WalletsController::class); Route::get('wallets/{id}/transactions', 'API\V4\WalletsController@transactions'); Route::get('wallets/{id}/receipts', 'API\V4\WalletsController@receipts'); Route::get('wallets/{id}/receipts/{receipt}', 'API\V4\WalletsController@receiptDownload'); Route::post('payments', 'API\V4\PaymentsController@store'); Route::get('payments/mandate', 'API\V4\PaymentsController@mandate'); Route::post('payments/mandate', 'API\V4\PaymentsController@mandateCreate'); Route::put('payments/mandate', 'API\V4\PaymentsController@mandateUpdate'); Route::delete('payments/mandate', 'API\V4\PaymentsController@mandateDelete'); Route::get('openvidu/rooms', 'API\V4\OpenViduController@index'); Route::post('openvidu/rooms/{id}/close', 'API\V4\OpenViduController@closeRoom'); Route::post('openvidu/rooms/{id}/config', 'API\V4\OpenViduController@setRoomConfig'); // FIXME: I'm not sure about this one, should we use DELETE request maybe? Route::post('openvidu/rooms/{id}/connections/{conn}/dismiss', 'API\V4\OpenViduController@dismissConnection'); Route::put('openvidu/rooms/{id}/connections/{conn}', 'API\V4\OpenViduController@updateConnection'); Route::post('openvidu/rooms/{id}/request/{reqid}/accept', 'API\V4\OpenViduController@acceptJoinRequest'); Route::post('openvidu/rooms/{id}/request/{reqid}/deny', 'API\V4\OpenViduController@denyJoinRequest'); } ); // Note: In Laravel 7.x we could just use withoutMiddleware() instead of a separate group Route::group( [ 'domain' => \config('app.domain'), 'prefix' => $prefix . 'api/v4' ], function () { Route::post('openvidu/rooms/{id}', 'API\V4\OpenViduController@joinRoom'); Route::post('openvidu/rooms/{id}/connections', 'API\V4\OpenViduController@createConnection'); // FIXME: I'm not sure about this one, should we use DELETE request maybe? Route::post('openvidu/rooms/{id}/connections/{conn}/dismiss', 'API\V4\OpenViduController@dismissConnection'); Route::put('openvidu/rooms/{id}/connections/{conn}', 'API\V4\OpenViduController@updateConnection'); Route::post('openvidu/rooms/{id}/request/{reqid}/accept', 'API\V4\OpenViduController@acceptJoinRequest'); Route::post('openvidu/rooms/{id}/request/{reqid}/deny', 'API\V4\OpenViduController@denyJoinRequest'); } ); Route::group( [ 'domain' => \config('app.domain'), 'middleware' => 'api', 'prefix' => $prefix . 'api/v4' ], function ($router) { Route::post('support/request', 'API\V4\SupportController@request'); } ); Route::group( [ 'domain' => \config('app.domain'), 'prefix' => $prefix . 'api/webhooks', ], function () { Route::post('payment/{provider}', 'API\V4\PaymentsController@webhook'); Route::post('meet/openvidu', 'API\V4\OpenViduController@webhook'); } ); Route::group( [ 'domain' => 'admin.' . \config('app.domain'), 'middleware' => ['auth:api', 'admin'], 'prefix' => $prefix . 'api/v4', ], function () { Route::apiResource('domains', API\V4\Admin\DomainsController::class); - // Route::get('domains/{id}/confirm', 'API\V4\Admin\DomainsController@confirm'); Route::post('domains/{id}/suspend', 'API\V4\Admin\DomainsController@suspend'); Route::post('domains/{id}/unsuspend', 'API\V4\Admin\DomainsController@unsuspend'); Route::apiResource('entitlements', API\V4\Admin\EntitlementsController::class); Route::apiResource('packages', API\V4\Admin\PackagesController::class); Route::apiResource('skus', API\V4\Admin\SkusController::class); Route::apiResource('users', API\V4\Admin\UsersController::class); Route::post('users/{id}/reset2FA', 'API\V4\Admin\UsersController@reset2FA'); Route::get('users/{id}/skus', 'API\V4\Admin\SkusController@userSkus'); Route::post('users/{id}/suspend', 'API\V4\Admin\UsersController@suspend'); Route::post('users/{id}/unsuspend', 'API\V4\Admin\UsersController@unsuspend'); Route::apiResource('wallets', API\V4\Admin\WalletsController::class); Route::post('wallets/{id}/one-off', 'API\V4\Admin\WalletsController@oneOff'); Route::get('wallets/{id}/transactions', 'API\V4\Admin\WalletsController@transactions'); Route::apiResource('discounts', API\V4\Admin\DiscountsController::class); Route::get('stats/chart/{chart}', 'API\V4\Admin\StatsController@chart'); } ); Route::group( [ 'domain' => 'reseller.' . \config('app.domain'), 'middleware' => ['auth:api', 'reseller'], 'prefix' => $prefix . 'api/v4', ], function () { Route::apiResource('domains', API\V4\Reseller\DomainsController::class); - // Route::get('domains/{id}/confirm', 'API\V4\Reseller\DomainsController@confirm'); - Route::post('domains/{id}/suspend', 'API\V4\Admin\DomainsController@suspend'); - Route::post('domains/{id}/unsuspend', 'API\V4\Admin\DomainsController@unsuspend'); + Route::post('domains/{id}/suspend', 'API\V4\Reseller\DomainsController@suspend'); + Route::post('domains/{id}/unsuspend', 'API\V4\Reseller\DomainsController@unsuspend'); Route::apiResource('entitlements', API\V4\Reseller\EntitlementsController::class); Route::apiResource('packages', API\V4\Reseller\PackagesController::class); Route::apiResource('skus', API\V4\Reseller\SkusController::class); Route::apiResource('users', API\V4\Reseller\UsersController::class); + Route::post('users/{id}/reset2FA', 'API\V4\Reseller\UsersController@reset2FA'); Route::get('users/{id}/skus', 'API\V4\Reseller\SkusController@userSkus'); + Route::post('users/{id}/suspend', 'API\V4\Reseller\UsersController@suspend'); + Route::post('users/{id}/unsuspend', 'API\V4\Reseller\UsersController@unsuspend'); Route::apiResource('wallets', API\V4\Reseller\WalletsController::class); + Route::post('wallets/{id}/one-off', 'API\V4\Reseller\WalletsController@oneOff'); Route::get('wallets/{id}/transactions', 'API\V4\Reseller\WalletsController@transactions'); Route::apiResource('discounts', API\V4\Reseller\DiscountsController::class); } ); diff --git a/src/tests/Browser/Reseller/UserFinancesTest.php b/src/tests/Browser/Reseller/UserFinancesTest.php new file mode 100644 index 00000000..81b3c2d7 --- /dev/null +++ b/src/tests/Browser/Reseller/UserFinancesTest.php @@ -0,0 +1,325 @@ +getTestUser('john@kolab.org'); + $wallet = $john->wallets()->first(); + $wallet->discount()->dissociate(); + $wallet->balance = 0; + $wallet->save(); + $wallet->setSettings(['mollie_id' => null, 'stripe_id' => null]); + } + + /** + * Test Finances tab (and transactions) + */ + public function testFinances(): void + { + // Assert Jack's Finances tab + $this->browse(function (Browser $browser) { + $jack = $this->getTestUser('jack@kolab.org'); + $wallet = $jack->wallets()->first(); + $wallet->transactions()->delete(); + $wallet->setSetting('stripe_id', 'abc'); + $page = new UserPage($jack->id); + + $browser->visit(new Home()) + ->submitLogon('reseller@kolabnow.com', 'reseller', true) + ->on(new Dashboard()) + ->visit($page) + ->on($page) + ->assertSeeIn('@nav #tab-finances', 'Finances') + ->with('@user-finances', function (Browser $browser) { + $browser->waitUntilMissing('.app-loader') + ->assertSeeIn('.card-title:first-child', 'Account balance') + ->assertSeeIn('.card-title:first-child .text-success', '0,00 CHF') + ->with('form', function (Browser $browser) { + $browser->assertElementsCount('.row', 2) + ->assertSeeIn('.row:nth-child(1) label', 'Discount') + ->assertSeeIn('.row:nth-child(1) #discount span', 'none') + ->assertSeeIn('.row:nth-child(2) label', 'Stripe ID') + ->assertSeeIn('.row:nth-child(2) a', 'abc'); + }) + ->assertSeeIn('h2:nth-of-type(2)', 'Transactions') + ->with('table', function (Browser $browser) { + $browser->assertMissing('tbody') + ->assertSeeIn('tfoot td', "There are no transactions for this account."); + }) + ->assertMissing('table + button'); + }); + }); + + // Assert John's Finances tab (with discount, and debit) + $this->browse(function (Browser $browser) { + $john = $this->getTestUser('john@kolab.org'); + $page = new UserPage($john->id); + $discount = Discount::where('code', 'TEST')->first(); + $wallet = $john->wallet(); + $wallet->transactions()->delete(); + $wallet->discount()->associate($discount); + $wallet->debit(2010); + $wallet->save(); + + // Create test transactions + $transaction = Transaction::create([ + 'user_email' => 'jeroen@jeroen.jeroen', + 'object_id' => $wallet->id, + 'object_type' => Wallet::class, + 'type' => Transaction::WALLET_CREDIT, + 'amount' => 100, + 'description' => 'Payment', + ]); + $transaction->created_at = Carbon::now()->subMonth(); + $transaction->save(); + + // Click the managed-by link on Jack's page + $browser->click('@user-info #manager a') + ->on($page) + ->with('@user-finances', function (Browser $browser) { + $browser->waitUntilMissing('.app-loader') + ->assertSeeIn('.card-title:first-child', 'Account balance') + ->assertSeeIn('.card-title:first-child .text-danger', '-20,10 CHF') + ->with('form', function (Browser $browser) { + $browser->assertElementsCount('.row', 1) + ->assertSeeIn('.row:nth-child(1) label', 'Discount') + ->assertSeeIn('.row:nth-child(1) #discount span', '10% - Test voucher'); + }) + ->assertSeeIn('h2:nth-of-type(2)', 'Transactions') + ->with('table', function (Browser $browser) { + $browser->assertElementsCount('tbody tr', 2) + ->assertMissing('tfoot'); + + if (!$browser->isPhone()) { + $browser->assertSeeIn('tbody tr:last-child td.email', 'jeroen@jeroen.jeroen'); + } + }); + }); + }); + + // Now we go to Ned's info page, he's a controller on John's wallet + $this->browse(function (Browser $browser) { + $ned = $this->getTestUser('ned@kolab.org'); + $wallet = $ned->wallets()->first(); + $wallet->balance = 0; + $wallet->save(); + $page = new UserPage($ned->id); + + $browser->click('@nav #tab-users') + ->click('@user-users tbody tr:nth-child(4) td:first-child a') + ->on($page) + ->with('@user-finances', function (Browser $browser) { + $browser->waitUntilMissing('.app-loader') + ->assertSeeIn('.card-title:first-child', 'Account balance') + ->assertSeeIn('.card-title:first-child .text-success', '0,00 CHF') + ->with('form', function (Browser $browser) { + $browser->assertElementsCount('.row', 1) + ->assertSeeIn('.row:nth-child(1) label', 'Discount') + ->assertSeeIn('.row:nth-child(1) #discount span', 'none'); + }) + ->assertSeeIn('h2:nth-of-type(2)', 'Transactions') + ->with('table', function (Browser $browser) { + $browser->assertMissing('tbody') + ->assertSeeIn('tfoot td', "There are no transactions for this account."); + }) + ->assertMissing('table + button'); + }); + }); + } + + /** + * Test editing wallet discount + * + * @depends testFinances + */ + public function testWalletDiscount(): void + { + $this->browse(function (Browser $browser) { + $john = $this->getTestUser('john@kolab.org'); + + $browser->visit(new UserPage($john->id)) + ->pause(100) + ->waitUntilMissing('@user-finances .app-loader') + ->click('@user-finances #discount button') + // Test dialog content, and closing it with Cancel button + ->with(new Dialog('#discount-dialog'), function (Browser $browser) { + $browser->assertSeeIn('@title', 'Account discount') + ->assertFocused('@body select') + ->assertSelected('@body select', '') + ->assertSeeIn('@button-cancel', 'Cancel') + ->assertSeeIn('@button-action', 'Submit') + ->click('@button-cancel'); + }) + ->assertMissing('#discount-dialog') + ->click('@user-finances #discount button') + // Change the discount + ->with(new Dialog('#discount-dialog'), function (Browser $browser) { + $browser->click('@body select') + ->click('@body select option:nth-child(2)') + ->click('@button-action'); + }) + ->assertToast(Toast::TYPE_SUCCESS, 'User wallet updated successfully.') + ->assertSeeIn('#discount span', '10% - Test voucher') + ->click('@nav #tab-subscriptions') + ->with('@user-subscriptions', function (Browser $browser) { + $browser->assertSeeIn('table tbody tr:nth-child(1) td:last-child', '3,99 CHF/month¹') + ->assertSeeIn('table tbody tr:nth-child(2) td:last-child', '0,00 CHF/month¹') + ->assertSeeIn('table tbody tr:nth-child(3) td:last-child', '4,99 CHF/month¹') + ->assertSeeIn('table + .hint', '¹ applied discount: 10% - Test voucher'); + }) + // Change back to 'none' + ->click('@nav #tab-finances') + ->click('@user-finances #discount button') + ->with(new Dialog('#discount-dialog'), function (Browser $browser) { + $browser->click('@body select') + ->click('@body select option:nth-child(1)') + ->click('@button-action'); + }) + ->assertToast(Toast::TYPE_SUCCESS, 'User wallet updated successfully.') + ->assertSeeIn('#discount span', 'none') + ->click('@nav #tab-subscriptions') + ->with('@user-subscriptions', function (Browser $browser) { + $browser->assertSeeIn('table tbody tr:nth-child(1) td:last-child', '4,44 CHF/month') + ->assertSeeIn('table tbody tr:nth-child(2) td:last-child', '0,00 CHF/month') + ->assertSeeIn('table tbody tr:nth-child(3) td:last-child', '5,55 CHF/month') + ->assertMissing('table + .hint'); + }); + }); + } + + /** + * Test awarding/penalizing a wallet + * + * @depends testFinances + */ + public function testBonusPenalty(): void + { + $this->browse(function (Browser $browser) { + $john = $this->getTestUser('john@kolab.org'); + + $browser->visit(new UserPage($john->id)) + ->waitFor('@user-finances #button-award') + ->click('@user-finances #button-award') + // Test dialog content, and closing it with Cancel button + ->with(new Dialog('#oneoff-dialog'), function (Browser $browser) { + $browser->assertSeeIn('@title', 'Add a bonus to the wallet') + ->assertFocused('@body input#oneoff_amount') + ->assertSeeIn('@body label[for="oneoff_amount"]', 'Amount') + ->assertvalue('@body input#oneoff_amount', '') + ->assertSeeIn('@body label[for="oneoff_description"]', 'Description') + ->assertvalue('@body input#oneoff_description', '') + ->assertSeeIn('@button-cancel', 'Cancel') + ->assertSeeIn('@button-action', 'Submit') + ->click('@button-cancel'); + }) + ->assertMissing('#oneoff-dialog'); + + // Test bonus + $browser->click('@user-finances #button-award') + ->with(new Dialog('#oneoff-dialog'), function (Browser $browser) { + // Test input validation for a bonus + $browser->type('@body #oneoff_amount', 'aaa') + ->type('@body #oneoff_description', '') + ->click('@button-action') + ->assertToast(Toast::TYPE_ERROR, 'Form validation error') + ->assertVisible('@body #oneoff_amount.is-invalid') + ->assertVisible('@body #oneoff_description.is-invalid') + ->assertSeeIn( + '@body #oneoff_amount + span + .invalid-feedback', + 'The amount must be a number.' + ) + ->assertSeeIn( + '@body #oneoff_description + .invalid-feedback', + 'The description field is required.' + ); + + // Test adding a bonus + $browser->type('@body #oneoff_amount', '12.34') + ->type('@body #oneoff_description', 'Test bonus') + ->click('@button-action') + ->assertToast(Toast::TYPE_SUCCESS, 'The bonus has been added to the wallet successfully.'); + }) + ->assertMissing('#oneoff-dialog') + ->assertSeeIn('@user-finances .card-title span.text-success', '12,34 CHF') + ->waitUntilMissing('.app-loader') + ->with('table', function (Browser $browser) { + $browser->assertElementsCount('tbody tr', 3) + ->assertMissing('tfoot') + ->assertSeeIn('tbody tr:first-child td.description', 'Bonus: Test bonus') + ->assertSeeIn('tbody tr:first-child td.price', '12,34 CHF'); + + if (!$browser->isPhone()) { + $browser->assertSeeIn('tbody tr:first-child td.email', 'reseller@kolabnow.com'); + } + }); + + $this->assertSame(1234, $john->wallets()->first()->balance); + + // Test penalty + $browser->click('@user-finances #button-penalty') + // Test dialog content, and closing it with Cancel button + ->with(new Dialog('#oneoff-dialog'), function (Browser $browser) { + $browser->assertSeeIn('@title', 'Add a penalty to the wallet') + ->assertFocused('@body input#oneoff_amount') + ->assertSeeIn('@body label[for="oneoff_amount"]', 'Amount') + ->assertvalue('@body input#oneoff_amount', '') + ->assertSeeIn('@body label[for="oneoff_description"]', 'Description') + ->assertvalue('@body input#oneoff_description', '') + ->assertSeeIn('@button-cancel', 'Cancel') + ->assertSeeIn('@button-action', 'Submit') + ->click('@button-cancel'); + }) + ->assertMissing('#oneoff-dialog') + ->click('@user-finances #button-penalty') + ->with(new Dialog('#oneoff-dialog'), function (Browser $browser) { + // Test input validation for a penalty + $browser->type('@body #oneoff_amount', '') + ->type('@body #oneoff_description', '') + ->click('@button-action') + ->assertToast(Toast::TYPE_ERROR, 'Form validation error') + ->assertVisible('@body #oneoff_amount.is-invalid') + ->assertVisible('@body #oneoff_description.is-invalid') + ->assertSeeIn( + '@body #oneoff_amount + span + .invalid-feedback', + 'The amount field is required.' + ) + ->assertSeeIn( + '@body #oneoff_description + .invalid-feedback', + 'The description field is required.' + ); + + // Test adding a penalty + $browser->type('@body #oneoff_amount', '12.35') + ->type('@body #oneoff_description', 'Test penalty') + ->click('@button-action') + ->assertToast(Toast::TYPE_SUCCESS, 'The penalty has been added to the wallet successfully.'); + }) + ->assertMissing('#oneoff-dialog') + ->assertSeeIn('@user-finances .card-title span.text-danger', '-0,01 CHF'); + + $this->assertSame(-1, $john->wallets()->first()->balance); + }); + } +} diff --git a/src/tests/Browser/Reseller/UserTest.php b/src/tests/Browser/Reseller/UserTest.php new file mode 100644 index 00000000..fcee3987 --- /dev/null +++ b/src/tests/Browser/Reseller/UserTest.php @@ -0,0 +1,443 @@ +getTestUser('john@kolab.org'); + $john->setSettings([ + 'phone' => '+48123123123', + 'external_email' => 'john.doe.external@gmail.com', + ]); + if ($john->isSuspended()) { + User::where('email', $john->email)->update(['status' => $john->status - User::STATUS_SUSPENDED]); + } + $wallet = $john->wallets()->first(); + $wallet->discount()->dissociate(); + $wallet->save(); + + $this->clearMeetEntitlements(); + } + + /** + * {@inheritDoc} + */ + public function tearDown(): void + { + $john = $this->getTestUser('john@kolab.org'); + $john->setSettings([ + 'phone' => null, + 'external_email' => 'john.doe.external@gmail.com', + ]); + if ($john->isSuspended()) { + User::where('email', $john->email)->update(['status' => $john->status - User::STATUS_SUSPENDED]); + } + $wallet = $john->wallets()->first(); + $wallet->discount()->dissociate(); + $wallet->save(); + + $this->clearMeetEntitlements(); + + parent::tearDown(); + } + + /** + * Test user info page (unauthenticated) + */ + public function testUserUnauth(): void + { + // Test that the page requires authentication + $this->browse(function (Browser $browser) { + $jack = $this->getTestUser('jack@kolab.org'); + $browser->visit('/user/' . $jack->id)->on(new Home()); + }); + } + + /** + * Test user info page + */ + public function testUserInfo(): void + { + $this->browse(function (Browser $browser) { + $jack = $this->getTestUser('jack@kolab.org'); + $page = new UserPage($jack->id); + + $browser->visit(new Home()) + ->submitLogon('reseller@kolabnow.com', 'reseller', true) + ->on(new Dashboard()) + ->visit($page) + ->on($page); + + // Assert main info box content + $browser->assertSeeIn('@user-info .card-title', $jack->email) + ->with('@user-info form', function (Browser $browser) use ($jack) { + $browser->assertElementsCount('.row', 7) + ->assertSeeIn('.row:nth-child(1) label', 'Managed by') + ->assertSeeIn('.row:nth-child(1) #manager a', 'john@kolab.org') + ->assertSeeIn('.row:nth-child(2) label', 'ID (Created at)') + ->assertSeeIn('.row:nth-child(2) #userid', "{$jack->id} ({$jack->created_at})") + ->assertSeeIn('.row:nth-child(3) label', 'Status') + ->assertSeeIn('.row:nth-child(3) #status span.text-success', 'Active') + ->assertSeeIn('.row:nth-child(4) label', 'First name') + ->assertSeeIn('.row:nth-child(4) #first_name', 'Jack') + ->assertSeeIn('.row:nth-child(5) label', 'Last name') + ->assertSeeIn('.row:nth-child(5) #last_name', 'Daniels') + ->assertSeeIn('.row:nth-child(6) label', 'External email') + ->assertMissing('.row:nth-child(6) #external_email a') + ->assertSeeIn('.row:nth-child(7) label', 'Country') + ->assertSeeIn('.row:nth-child(7) #country', 'United States'); + }); + + // Some tabs are loaded in background, wait a second + $browser->pause(500) + ->assertElementsCount('@nav a', 5); + + // Note: Finances tab is tested in UserFinancesTest.php + $browser->assertSeeIn('@nav #tab-finances', 'Finances'); + + // Assert Aliases tab + $browser->assertSeeIn('@nav #tab-aliases', 'Aliases (1)') + ->click('@nav #tab-aliases') + ->whenAvailable('@user-aliases', function (Browser $browser) { + $browser->assertElementsCount('table tbody tr', 1) + ->assertSeeIn('table tbody tr:first-child td:first-child', 'jack.daniels@kolab.org') + ->assertMissing('table tfoot'); + }); + + // Assert Subscriptions tab + $browser->assertSeeIn('@nav #tab-subscriptions', 'Subscriptions (3)') + ->click('@nav #tab-subscriptions') + ->with('@user-subscriptions', function (Browser $browser) { + $browser->assertElementsCount('table tbody tr', 3) + ->assertSeeIn('table tbody tr:nth-child(1) td:first-child', 'User Mailbox') + ->assertSeeIn('table tbody tr:nth-child(1) td:last-child', '4,44 CHF') + ->assertSeeIn('table tbody tr:nth-child(2) td:first-child', 'Storage Quota 2 GB') + ->assertSeeIn('table tbody tr:nth-child(2) td:last-child', '0,00 CHF') + ->assertSeeIn('table tbody tr:nth-child(3) td:first-child', 'Groupware Features') + ->assertSeeIn('table tbody tr:nth-child(3) td:last-child', '5,55 CHF') + ->assertMissing('table tfoot') + ->assertMissing('#reset2fa'); + }); + + // Assert Domains tab + $browser->assertSeeIn('@nav #tab-domains', 'Domains (0)') + ->click('@nav #tab-domains') + ->with('@user-domains', function (Browser $browser) { + $browser->assertElementsCount('table tbody tr', 0) + ->assertSeeIn('table tfoot tr td', 'There are no domains in this account.'); + }); + + // Assert Users tab + $browser->assertSeeIn('@nav #tab-users', 'Users (0)') + ->click('@nav #tab-users') + ->with('@user-users', function (Browser $browser) { + $browser->assertElementsCount('table tbody tr', 0) + ->assertSeeIn('table tfoot tr td', 'There are no users in this account.'); + }); + }); + } + + /** + * Test user info page (continue) + * + * @depends testUserInfo + */ + public function testUserInfo2(): void + { + $this->browse(function (Browser $browser) { + $john = $this->getTestUser('john@kolab.org'); + $page = new UserPage($john->id); + $discount = Discount::where('code', 'TEST')->first(); + $wallet = $john->wallet(); + $wallet->discount()->associate($discount); + $wallet->debit(2010); + $wallet->save(); + + // Click the managed-by link on Jack's page + $browser->click('@user-info #manager a') + ->on($page); + + // Assert main info box content + $browser->assertSeeIn('@user-info .card-title', $john->email) + ->with('@user-info form', function (Browser $browser) use ($john) { + $ext_email = $john->getSetting('external_email'); + + $browser->assertElementsCount('.row', 9) + ->assertSeeIn('.row:nth-child(1) label', 'ID (Created at)') + ->assertSeeIn('.row:nth-child(1) #userid', "{$john->id} ({$john->created_at})") + ->assertSeeIn('.row:nth-child(2) label', 'Status') + ->assertSeeIn('.row:nth-child(2) #status span.text-success', 'Active') + ->assertSeeIn('.row:nth-child(3) label', 'First name') + ->assertSeeIn('.row:nth-child(3) #first_name', 'John') + ->assertSeeIn('.row:nth-child(4) label', 'Last name') + ->assertSeeIn('.row:nth-child(4) #last_name', 'Doe') + ->assertSeeIn('.row:nth-child(5) label', 'Organization') + ->assertSeeIn('.row:nth-child(5) #organization', 'Kolab Developers') + ->assertSeeIn('.row:nth-child(6) label', 'Phone') + ->assertSeeIn('.row:nth-child(6) #phone', $john->getSetting('phone')) + ->assertSeeIn('.row:nth-child(7) label', 'External email') + ->assertSeeIn('.row:nth-child(7) #external_email a', $ext_email) + ->assertAttribute('.row:nth-child(7) #external_email a', 'href', "mailto:$ext_email") + ->assertSeeIn('.row:nth-child(8) label', 'Address') + ->assertSeeIn('.row:nth-child(8) #billing_address', $john->getSetting('billing_address')) + ->assertSeeIn('.row:nth-child(9) label', 'Country') + ->assertSeeIn('.row:nth-child(9) #country', 'United States'); + }); + + // Some tabs are loaded in background, wait a second + $browser->pause(500) + ->assertElementsCount('@nav a', 5); + + // Note: Finances tab is tested in UserFinancesTest.php + $browser->assertSeeIn('@nav #tab-finances', 'Finances'); + + // Assert Aliases tab + $browser->assertSeeIn('@nav #tab-aliases', 'Aliases (1)') + ->click('@nav #tab-aliases') + ->whenAvailable('@user-aliases', function (Browser $browser) { + $browser->assertElementsCount('table tbody tr', 1) + ->assertSeeIn('table tbody tr:first-child td:first-child', 'john.doe@kolab.org') + ->assertMissing('table tfoot'); + }); + + // Assert Subscriptions tab + $browser->assertSeeIn('@nav #tab-subscriptions', 'Subscriptions (3)') + ->click('@nav #tab-subscriptions') + ->with('@user-subscriptions', function (Browser $browser) { + $browser->assertElementsCount('table tbody tr', 3) + ->assertSeeIn('table tbody tr:nth-child(1) td:first-child', 'User Mailbox') + ->assertSeeIn('table tbody tr:nth-child(1) td:last-child', '3,99 CHF/month¹') + ->assertSeeIn('table tbody tr:nth-child(2) td:first-child', 'Storage Quota 2 GB') + ->assertSeeIn('table tbody tr:nth-child(2) td:last-child', '0,00 CHF/month¹') + ->assertSeeIn('table tbody tr:nth-child(3) td:first-child', 'Groupware Features') + ->assertSeeIn('table tbody tr:nth-child(3) td:last-child', '4,99 CHF/month¹') + ->assertMissing('table tfoot') + ->assertSeeIn('table + .hint', '¹ applied discount: 10% - Test voucher'); + }); + + // Assert Domains tab + $browser->assertSeeIn('@nav #tab-domains', 'Domains (1)') + ->click('@nav #tab-domains') + ->with('@user-domains table', function (Browser $browser) { + $browser->assertElementsCount('tbody tr', 1) + ->assertSeeIn('tbody tr:nth-child(1) td:first-child a', 'kolab.org') + ->assertVisible('tbody tr:nth-child(1) td:first-child svg.text-success') + ->assertMissing('tfoot'); + }); + + // Assert Users tab + $browser->assertSeeIn('@nav #tab-users', 'Users (4)') + ->click('@nav #tab-users') + ->with('@user-users table', function (Browser $browser) { + $browser->assertElementsCount('tbody tr', 4) + ->assertSeeIn('tbody tr:nth-child(1) td:first-child a', 'jack@kolab.org') + ->assertVisible('tbody tr:nth-child(1) td:first-child svg.text-success') + ->assertSeeIn('tbody tr:nth-child(2) td:first-child a', 'joe@kolab.org') + ->assertVisible('tbody tr:nth-child(2) td:first-child svg.text-success') + ->assertSeeIn('tbody tr:nth-child(3) td:first-child span', 'john@kolab.org') + ->assertVisible('tbody tr:nth-child(3) td:first-child svg.text-success') + ->assertSeeIn('tbody tr:nth-child(4) td:first-child a', 'ned@kolab.org') + ->assertVisible('tbody tr:nth-child(4) td:first-child svg.text-success') + ->assertMissing('tfoot'); + }); + }); + + // Now we go to Ned's info page, he's a controller on John's wallet + $this->browse(function (Browser $browser) { + $ned = $this->getTestUser('ned@kolab.org'); + $page = new UserPage($ned->id); + + $browser->click('@user-users tbody tr:nth-child(4) td:first-child a') + ->on($page); + + // Assert main info box content + $browser->assertSeeIn('@user-info .card-title', $ned->email) + ->with('@user-info form', function (Browser $browser) use ($ned) { + $browser->assertSeeIn('.row:nth-child(2) label', 'ID (Created at)') + ->assertSeeIn('.row:nth-child(2) #userid', "{$ned->id} ({$ned->created_at})"); + }); + + // Some tabs are loaded in background, wait a second + $browser->pause(500) + ->assertElementsCount('@nav a', 5); + + // Note: Finances tab is tested in UserFinancesTest.php + $browser->assertSeeIn('@nav #tab-finances', 'Finances'); + + // Assert Aliases tab + $browser->assertSeeIn('@nav #tab-aliases', 'Aliases (0)') + ->click('@nav #tab-aliases') + ->whenAvailable('@user-aliases', function (Browser $browser) { + $browser->assertElementsCount('table tbody tr', 0) + ->assertSeeIn('table tfoot tr td', 'This user has no email aliases.'); + }); + + // Assert Subscriptions tab, we expect John's discount here + $browser->assertSeeIn('@nav #tab-subscriptions', 'Subscriptions (5)') + ->click('@nav #tab-subscriptions') + ->with('@user-subscriptions', function (Browser $browser) { + $browser->assertElementsCount('table tbody tr', 5) + ->assertSeeIn('table tbody tr:nth-child(1) td:first-child', 'User Mailbox') + ->assertSeeIn('table tbody tr:nth-child(1) td:last-child', '3,99 CHF/month¹') + ->assertSeeIn('table tbody tr:nth-child(2) td:first-child', 'Storage Quota 2 GB') + ->assertSeeIn('table tbody tr:nth-child(2) td:last-child', '0,00 CHF/month¹') + ->assertSeeIn('table tbody tr:nth-child(3) td:first-child', 'Groupware Features') + ->assertSeeIn('table tbody tr:nth-child(3) td:last-child', '4,99 CHF/month¹') + ->assertSeeIn('table tbody tr:nth-child(4) td:first-child', 'Activesync') + ->assertSeeIn('table tbody tr:nth-child(4) td:last-child', '0,90 CHF/month¹') + ->assertSeeIn('table tbody tr:nth-child(5) td:first-child', '2-Factor Authentication') + ->assertSeeIn('table tbody tr:nth-child(5) td:last-child', '0,00 CHF/month¹') + ->assertMissing('table tfoot') + ->assertSeeIn('table + .hint', '¹ applied discount: 10% - Test voucher') + ->assertSeeIn('#reset2fa', 'Reset 2-Factor Auth'); + }); + + // We don't expect John's domains here + $browser->assertSeeIn('@nav #tab-domains', 'Domains (0)') + ->click('@nav #tab-domains') + ->with('@user-domains', function (Browser $browser) { + $browser->assertElementsCount('table tbody tr', 0) + ->assertSeeIn('table tfoot tr td', 'There are no domains in this account.'); + }); + + // We don't expect John's users here + $browser->assertSeeIn('@nav #tab-users', 'Users (0)') + ->click('@nav #tab-users') + ->with('@user-users', function (Browser $browser) { + $browser->assertElementsCount('table tbody tr', 0) + ->assertSeeIn('table tfoot tr td', 'There are no users in this account.'); + }); + }); + } + + /** + * Test editing an external email + * + * @depends testUserInfo2 + */ + public function testExternalEmail(): void + { + $this->browse(function (Browser $browser) { + $john = $this->getTestUser('john@kolab.org'); + + $browser->visit(new UserPage($john->id)) + ->waitFor('@user-info #external_email button') + ->click('@user-info #external_email button') + // Test dialog content, and closing it with Cancel button + ->with(new Dialog('#email-dialog'), function (Browser $browser) { + $browser->assertSeeIn('@title', 'External email') + ->assertFocused('@body input') + ->assertValue('@body input', 'john.doe.external@gmail.com') + ->assertSeeIn('@button-cancel', 'Cancel') + ->assertSeeIn('@button-action', 'Submit') + ->click('@button-cancel'); + }) + ->assertMissing('#email-dialog') + ->click('@user-info #external_email button') + // Test email validation error handling, and email update + ->with(new Dialog('#email-dialog'), function (Browser $browser) { + $browser->type('@body input', 'test') + ->click('@button-action') + ->waitFor('@body input.is-invalid') + ->assertSeeIn( + '@body input + .invalid-feedback', + 'The external email must be a valid email address.' + ) + ->assertToast(Toast::TYPE_ERROR, 'Form validation error') + ->type('@body input', 'test@test.com') + ->click('@button-action'); + }) + ->assertToast(Toast::TYPE_SUCCESS, 'User data updated successfully.') + ->assertSeeIn('@user-info #external_email a', 'test@test.com') + ->click('@user-info #external_email button') + ->with(new Dialog('#email-dialog'), function (Browser $browser) { + $browser->assertValue('@body input', 'test@test.com') + ->assertMissing('@body input.is-invalid') + ->assertMissing('@body input + .invalid-feedback') + ->click('@button-cancel'); + }) + ->assertSeeIn('@user-info #external_email a', 'test@test.com'); + + // $john->getSetting() may not work here as it uses internal cache + // read the value form database + $current_ext_email = $john->settings()->where('key', 'external_email')->first()->value; + $this->assertSame('test@test.com', $current_ext_email); + }); + } + + /** + * Test suspending/unsuspending the user + */ + public function testSuspendAndUnsuspend(): void + { + $this->browse(function (Browser $browser) { + $john = $this->getTestUser('john@kolab.org'); + + $browser->visit(new UserPage($john->id)) + ->assertVisible('@user-info #button-suspend') + ->assertMissing('@user-info #button-unsuspend') + ->click('@user-info #button-suspend') + ->assertToast(Toast::TYPE_SUCCESS, 'User suspended successfully.') + ->assertSeeIn('@user-info #status span.text-warning', 'Suspended') + ->assertMissing('@user-info #button-suspend') + ->click('@user-info #button-unsuspend') + ->assertToast(Toast::TYPE_SUCCESS, 'User unsuspended successfully.') + ->assertSeeIn('@user-info #status span.text-success', 'Active') + ->assertVisible('@user-info #button-suspend') + ->assertMissing('@user-info #button-unsuspend'); + }); + } + + /** + * Test resetting 2FA for the user + */ + public function testReset2FA(): void + { + $this->browse(function (Browser $browser) { + $this->deleteTestUser('userstest1@kolabnow.com'); + $user = $this->getTestUser('userstest1@kolabnow.com'); + $sku2fa = Sku::firstOrCreate(['title' => '2fa']); + $user->assignSku($sku2fa); + SecondFactor::seed('userstest1@kolabnow.com'); + + $browser->visit(new UserPage($user->id)) + ->click('@nav #tab-subscriptions') + ->with('@user-subscriptions', function (Browser $browser) use ($sku2fa) { + $browser->waitFor('#reset2fa') + ->assertVisible('#sku' . $sku2fa->id); + }) + ->assertSeeIn('@nav #tab-subscriptions', 'Subscriptions (1)') + ->click('#reset2fa') + ->with(new Dialog('#reset-2fa-dialog'), function (Browser $browser) { + $browser->assertSeeIn('@title', '2-Factor Authentication Reset') + ->assertSeeIn('@button-cancel', 'Cancel') + ->assertSeeIn('@button-action', 'Reset') + ->click('@button-action'); + }) + ->assertToast(Toast::TYPE_SUCCESS, '2-Factor authentication reset successfully.') + ->assertMissing('#sku' . $sku2fa->id) + ->assertSeeIn('@nav #tab-subscriptions', 'Subscriptions (0)'); + }); + } +} diff --git a/src/tests/Feature/Controller/Admin/SkusTest.php b/src/tests/Feature/Controller/Admin/SkusTest.php new file mode 100644 index 00000000..1cb2b6cb --- /dev/null +++ b/src/tests/Feature/Controller/Admin/SkusTest.php @@ -0,0 +1,94 @@ +clearBetaEntitlements(); + $this->clearMeetEntitlements(); + } + + /** + * {@inheritDoc} + */ + public function tearDown(): void + { + $this->clearBetaEntitlements(); + $this->clearMeetEntitlements(); + + parent::tearDown(); + } + + /** + * Test fetching SKUs list + */ + public function testIndex(): void + { + $admin = $this->getTestUser('jeroen@jeroen.jeroen'); + $user = $this->getTestUser('john@kolab.org'); + $sku = Sku::where('title', 'mailbox')->first(); + + // Unauth access not allowed + $response = $this->get("api/v4/skus"); + $response->assertStatus(401); + + // User access not allowed on admin API + $response = $this->actingAs($user)->get("api/v4/skus"); + $response->assertStatus(403); + + $response = $this->actingAs($admin)->get("api/v4/skus"); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertCount(8, $json); + + $this->assertSame(100, $json[0]['prio']); + $this->assertSame($sku->id, $json[0]['id']); + $this->assertSame($sku->title, $json[0]['title']); + $this->assertSame($sku->name, $json[0]['name']); + $this->assertSame($sku->description, $json[0]['description']); + $this->assertSame($sku->cost, $json[0]['cost']); + $this->assertSame($sku->units_free, $json[0]['units_free']); + $this->assertSame($sku->period, $json[0]['period']); + $this->assertSame($sku->active, $json[0]['active']); + $this->assertSame('user', $json[0]['type']); + $this->assertSame('mailbox', $json[0]['handler']); + } + + /** + * Test fetching SKUs list for a user (GET /users//skus) + */ + public function testUserSkus(): void + { + $admin = $this->getTestUser('jeroen@jeroen.jeroen'); + $user = $this->getTestUser('john@kolab.org'); + + // Unauth access not allowed + $response = $this->get("api/v4/users/{$user->id}/skus"); + $response->assertStatus(401); + + // Non-admin access not allowed + $response = $this->actingAs($user)->get("api/v4/users/{$user->id}/skus"); + $response->assertStatus(403); + + $response = $this->actingAs($admin)->get("api/v4/users/{$user->id}/skus"); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertCount(8, $json); + // Note: Details are tested where we test API\V4\SkusController + } +} diff --git a/src/tests/Feature/Controller/Admin/UsersTest.php b/src/tests/Feature/Controller/Admin/UsersTest.php index 9070c0a0..0000a592 100644 --- a/src/tests/Feature/Controller/Admin/UsersTest.php +++ b/src/tests/Feature/Controller/Admin/UsersTest.php @@ -1,341 +1,371 @@ deleteTestUser('UsersControllerTest1@userscontroller.com'); $this->deleteTestUser('test@testsearch.com'); $this->deleteTestDomain('testsearch.com'); $jack = $this->getTestUser('jack@kolab.org'); $jack->setSetting('external_email', null); } /** * {@inheritDoc} */ public function tearDown(): void { $this->deleteTestUser('UsersControllerTest1@userscontroller.com'); $this->deleteTestUser('test@testsearch.com'); $this->deleteTestDomain('testsearch.com'); $jack = $this->getTestUser('jack@kolab.org'); $jack->setSetting('external_email', null); parent::tearDown(); } + /** + * Test user deleting (DELETE /api/v4/users/) + */ + public function testDestroy(): void + { + $john = $this->getTestUser('john@kolab.org'); + $user = $this->getTestUser('UsersControllerTest1@userscontroller.com'); + $admin = $this->getTestUser('jeroen@jeroen.jeroen'); + + // Test unauth access + $response = $this->delete("api/v4/users/{$user->id}"); + $response->assertStatus(401); + + // The end-point does not exist + $response = $this->actingAs($admin)->delete("api/v4/users/{$user->id}"); + $response->assertStatus(404); + } + /** * Test users searching (/api/v4/users) */ public function testIndex(): void { $user = $this->getTestUser('john@kolab.org'); $admin = $this->getTestUser('jeroen@jeroen.jeroen'); // Non-admin user $response = $this->actingAs($user)->get("api/v4/users"); $response->assertStatus(403); // Search with no search criteria $response = $this->actingAs($admin)->get("api/v4/users"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(0, $json['count']); $this->assertSame([], $json['list']); // Search with no matches expected $response = $this->actingAs($admin)->get("api/v4/users?search=abcd1234efgh5678"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(0, $json['count']); $this->assertSame([], $json['list']); // Search by domain $response = $this->actingAs($admin)->get("api/v4/users?search=kolab.org"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(1, $json['count']); $this->assertCount(1, $json['list']); $this->assertSame($user->id, $json['list'][0]['id']); $this->assertSame($user->email, $json['list'][0]['email']); // Search by user ID $response = $this->actingAs($admin)->get("api/v4/users?search={$user->id}"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(1, $json['count']); $this->assertCount(1, $json['list']); $this->assertSame($user->id, $json['list'][0]['id']); $this->assertSame($user->email, $json['list'][0]['email']); // Search by email (primary) $response = $this->actingAs($admin)->get("api/v4/users?search=john@kolab.org"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(1, $json['count']); $this->assertCount(1, $json['list']); $this->assertSame($user->id, $json['list'][0]['id']); $this->assertSame($user->email, $json['list'][0]['email']); // Search by email (alias) $response = $this->actingAs($admin)->get("api/v4/users?search=john.doe@kolab.org"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(1, $json['count']); $this->assertCount(1, $json['list']); $this->assertSame($user->id, $json['list'][0]['id']); $this->assertSame($user->email, $json['list'][0]['email']); // Search by email (external), expect two users in a result $jack = $this->getTestUser('jack@kolab.org'); $jack->setSetting('external_email', 'john.doe.external@gmail.com'); $response = $this->actingAs($admin)->get("api/v4/users?search=john.doe.external@gmail.com"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(2, $json['count']); $this->assertCount(2, $json['list']); $emails = array_column($json['list'], 'email'); $this->assertContains($user->email, $emails); $this->assertContains($jack->email, $emails); // Search by owner $response = $this->actingAs($admin)->get("api/v4/users?owner={$user->id}"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(4, $json['count']); $this->assertCount(4, $json['list']); // Search by owner (Ned is a controller on John's wallets, // here we expect only users assigned to Ned's wallet(s)) $ned = $this->getTestUser('ned@kolab.org'); $response = $this->actingAs($admin)->get("api/v4/users?owner={$ned->id}"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(0, $json['count']); $this->assertCount(0, $json['list']); // Deleted users/domains $domain = $this->getTestDomain('testsearch.com', ['type' => \App\Domain::TYPE_EXTERNAL]); $user = $this->getTestUser('test@testsearch.com'); $plan = \App\Plan::where('title', 'group')->first(); $user->assignPlan($plan, $domain); $user->setAliases(['alias@testsearch.com']); Queue::fake(); $user->delete(); $response = $this->actingAs($admin)->get("api/v4/users?search=test@testsearch.com"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(1, $json['count']); $this->assertCount(1, $json['list']); $this->assertSame($user->id, $json['list'][0]['id']); $this->assertSame($user->email, $json['list'][0]['email']); $this->assertTrue($json['list'][0]['isDeleted']); $response = $this->actingAs($admin)->get("api/v4/users?search=alias@testsearch.com"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(1, $json['count']); $this->assertCount(1, $json['list']); $this->assertSame($user->id, $json['list'][0]['id']); $this->assertSame($user->email, $json['list'][0]['email']); $this->assertTrue($json['list'][0]['isDeleted']); $response = $this->actingAs($admin)->get("api/v4/users?search=testsearch.com"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(1, $json['count']); $this->assertCount(1, $json['list']); $this->assertSame($user->id, $json['list'][0]['id']); $this->assertSame($user->email, $json['list'][0]['email']); $this->assertTrue($json['list'][0]['isDeleted']); } /** * Test reseting 2FA (POST /api/v4/users//reset2FA) */ public function testReset2FA(): void { $user = $this->getTestUser('UsersControllerTest1@userscontroller.com'); $admin = $this->getTestUser('jeroen@jeroen.jeroen'); $sku2fa = Sku::firstOrCreate(['title' => '2fa']); $user->assignSku($sku2fa); SecondFactor::seed('userscontrollertest1@userscontroller.com'); // Test unauthorized access to admin API $response = $this->actingAs($user)->post("/api/v4/users/{$user->id}/reset2FA", []); $response->assertStatus(403); $entitlements = $user->fresh()->entitlements()->where('sku_id', $sku2fa->id)->get(); $this->assertCount(1, $entitlements); $sf = new SecondFactor($user); $this->assertCount(1, $sf->factors()); // Test reseting 2FA $response = $this->actingAs($admin)->post("/api/v4/users/{$user->id}/reset2FA", []); $response->assertStatus(200); $json = $response->json(); $this->assertSame('success', $json['status']); $this->assertSame("2-Factor authentication reset successfully.", $json['message']); $this->assertCount(2, $json); $entitlements = $user->fresh()->entitlements()->where('sku_id', $sku2fa->id)->get(); $this->assertCount(0, $entitlements); $sf = new SecondFactor($user); $this->assertCount(0, $sf->factors()); } + /** + * Test user creation (POST /api/v4/users) + */ + public function testStore(): void + { + $admin = $this->getTestUser('jeroen@jeroen.jeroen'); + + // The end-point does not exist + $response = $this->actingAs($admin)->post("/api/v4/users", []); + $response->assertStatus(404); + } + /** * Test user suspending (POST /api/v4/users//suspend) */ public function testSuspend(): void { Queue::fake(); // disable jobs $user = $this->getTestUser('UsersControllerTest1@userscontroller.com'); $admin = $this->getTestUser('jeroen@jeroen.jeroen'); // Test unauthorized access to admin API $response = $this->actingAs($user)->post("/api/v4/users/{$user->id}/suspend", []); $response->assertStatus(403); $this->assertFalse($user->isSuspended()); // Test suspending the user $response = $this->actingAs($admin)->post("/api/v4/users/{$user->id}/suspend", []); $response->assertStatus(200); $json = $response->json(); $this->assertSame('success', $json['status']); $this->assertSame("User suspended successfully.", $json['message']); $this->assertCount(2, $json); $this->assertTrue($user->fresh()->isSuspended()); } /** * Test user un-suspending (POST /api/v4/users//unsuspend) */ public function testUnsuspend(): void { Queue::fake(); // disable jobs $user = $this->getTestUser('UsersControllerTest1@userscontroller.com'); $admin = $this->getTestUser('jeroen@jeroen.jeroen'); // Test unauthorized access to admin API $response = $this->actingAs($user)->post("/api/v4/users/{$user->id}/unsuspend", []); $response->assertStatus(403); $this->assertFalse($user->isSuspended()); $user->suspend(); $this->assertTrue($user->isSuspended()); // Test suspending the user $response = $this->actingAs($admin)->post("/api/v4/users/{$user->id}/unsuspend", []); $response->assertStatus(200); $json = $response->json(); $this->assertSame('success', $json['status']); $this->assertSame("User unsuspended successfully.", $json['message']); $this->assertCount(2, $json); $this->assertFalse($user->fresh()->isSuspended()); } /** * Test user update (PUT /api/v4/users/) */ public function testUpdate(): void { $user = $this->getTestUser('UsersControllerTest1@userscontroller.com'); $admin = $this->getTestUser('jeroen@jeroen.jeroen'); // Test unauthorized access to admin API $response = $this->actingAs($user)->put("/api/v4/users/{$user->id}", []); $response->assertStatus(403); // Test updatig the user data (empty data) $response = $this->actingAs($admin)->put("/api/v4/users/{$user->id}", []); $response->assertStatus(200); $json = $response->json(); $this->assertSame('success', $json['status']); $this->assertSame("User data updated successfully.", $json['message']); $this->assertCount(2, $json); // Test error handling $post = ['external_email' => 'aaa']; $response = $this->actingAs($admin)->put("/api/v4/users/{$user->id}", $post); $response->assertStatus(422); $json = $response->json(); $this->assertSame('error', $json['status']); $this->assertSame("The external email must be a valid email address.", $json['errors']['external_email'][0]); $this->assertCount(2, $json); // Test real update $post = ['external_email' => 'modified@test.com']; $response = $this->actingAs($admin)->put("/api/v4/users/{$user->id}", $post); $response->assertStatus(200); $json = $response->json(); $this->assertSame('success', $json['status']); $this->assertSame("User data updated successfully.", $json['message']); $this->assertCount(2, $json); $this->assertSame('modified@test.com', $user->getSetting('external_email')); } } diff --git a/src/tests/Feature/Controller/Reseller/SkusTest.php b/src/tests/Feature/Controller/Reseller/SkusTest.php new file mode 100644 index 00000000..d948c7c7 --- /dev/null +++ b/src/tests/Feature/Controller/Reseller/SkusTest.php @@ -0,0 +1,118 @@ +clearBetaEntitlements(); + $this->clearMeetEntitlements(); + } + + /** + * {@inheritDoc} + */ + public function tearDown(): void + { + $this->clearBetaEntitlements(); + $this->clearMeetEntitlements(); + + parent::tearDown(); + } + + /** + * Test fetching SKUs list + */ + public function testIndex(): void + { + $reseller1 = $this->getTestUser('reseller@kolabnow.com'); + $reseller2 = $this->getTestUser('reseller@reseller.com'); + $admin = $this->getTestUser('jeroen@jeroen.jeroen'); + $user = $this->getTestUser('john@kolab.org'); + $sku = Sku::where('title', 'mailbox')->first(); + + // Unauth access not allowed + $response = $this->get("api/v4/skus"); + $response->assertStatus(401); + + // User access not allowed on admin API + $response = $this->actingAs($user)->get("api/v4/skus"); + $response->assertStatus(403); + + // Admin access not allowed + $response = $this->actingAs($admin)->get("api/v4/skus"); + $response->assertStatus(403); + + $response = $this->actingAs($reseller1)->get("api/v4/skus"); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertCount(8, $json); + + $this->assertSame(100, $json[0]['prio']); + $this->assertSame($sku->id, $json[0]['id']); + $this->assertSame($sku->title, $json[0]['title']); + $this->assertSame($sku->name, $json[0]['name']); + $this->assertSame($sku->description, $json[0]['description']); + $this->assertSame($sku->cost, $json[0]['cost']); + $this->assertSame($sku->units_free, $json[0]['units_free']); + $this->assertSame($sku->period, $json[0]['period']); + $this->assertSame($sku->active, $json[0]['active']); + $this->assertSame('user', $json[0]['type']); + $this->assertSame('mailbox', $json[0]['handler']); + + // TODO: Test limiting SKUs to the tenant's SKUs + } + + /** + * Test fetching SKUs list for a user (GET /users//skus) + */ + public function testUserSkus(): void + { + $reseller1 = $this->getTestUser('reseller@kolabnow.com'); + $reseller2 = $this->getTestUser('reseller@reseller.com'); + $admin = $this->getTestUser('jeroen@jeroen.jeroen'); + $user = $this->getTestUser('john@kolab.org'); + + // Unauth access not allowed + $response = $this->get("api/v4/users/{$user->id}/skus"); + $response->assertStatus(401); + + // User access not allowed + $response = $this->actingAs($user)->get("api/v4/users/{$user->id}/skus"); + $response->assertStatus(403); + + // Admin access not allowed + $response = $this->actingAs($admin)->get("api/v4/users/{$user->id}/skus"); + $response->assertStatus(403); + + // Reseller from another tenant not allowed + $response = $this->actingAs($reseller2)->get("api/v4/users/{$user->id}/skus"); + $response->assertStatus(403); + + // Reseller access + $response = $this->actingAs($reseller1)->get("api/v4/users/{$user->id}/skus"); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertCount(8, $json); + // Note: Details are tested where we test API\V4\SkusController + + // Reseller from another tenant not allowed + \config(['app.tenant_id' => 2]); + $response = $this->actingAs($reseller2)->get("api/v4/users/{$user->id}/skus"); + $response->assertStatus(404); + } +} diff --git a/src/tests/Feature/Controller/Reseller/UsersTest.php b/src/tests/Feature/Controller/Reseller/UsersTest.php index 735345fa..c7e7665f 100644 --- a/src/tests/Feature/Controller/Reseller/UsersTest.php +++ b/src/tests/Feature/Controller/Reseller/UsersTest.php @@ -1,285 +1,479 @@ 1]); - // $this->deleteTestUser('UsersControllerTest1@userscontroller.com'); + $this->deleteTestUser('UsersControllerTest1@userscontroller.com'); $this->deleteTestUser('test@testsearch.com'); $this->deleteTestDomain('testsearch.com'); } /** * {@inheritDoc} */ public function tearDown(): void { - // $this->deleteTestUser('UsersControllerTest1@userscontroller.com'); + $this->deleteTestUser('UsersControllerTest1@userscontroller.com'); $this->deleteTestUser('test@testsearch.com'); $this->deleteTestDomain('testsearch.com'); \config(['app.tenant_id' => 1]); parent::tearDown(); } + /** + * Test user deleting (DELETE /api/v4/users/) + */ + public function testDestroy(): void + { + $reseller1 = $this->getTestUser('reseller@kolabnow.com'); + $user = $this->getTestUser('UsersControllerTest1@userscontroller.com'); + + // Test unauth access + $response = $this->delete("api/v4/users/{$user->id}"); + $response->assertStatus(401); + + // The end-point does not exist + $response = $this->actingAs($reseller1)->delete("api/v4/users/{$user->id}"); + $response->assertStatus(404); + } + /** * Test users searching (/api/v4/users) */ public function testIndex(): void { Queue::fake(); $user = $this->getTestUser('john@kolab.org'); $admin = $this->getTestUser('jeroen@jeroen.jeroen'); - $reseller = $this->getTestUser('reseller@reseller.com'); - $reseller2 = $this->getTestUser('reseller@kolabnow.com'); - $tenant = Tenant::where('title', 'Sample Tenant')->first(); + $reseller1 = $this->getTestUser('reseller@kolabnow.com'); + $reseller2 = $this->getTestUser('reseller@reseller.com'); + + \config(['app.tenant_id' => 2]); - \config(['app.tenant_id' => $tenant->id]); + // Guess access + $response = $this->get("api/v4/users"); + $response->assertStatus(401); // Normal user $response = $this->actingAs($user)->get("api/v4/users"); $response->assertStatus(403); // Admin user $response = $this->actingAs($admin)->get("api/v4/users"); $response->assertStatus(403); // Reseller from another tenant - $response = $this->actingAs($reseller2)->get("api/v4/users"); + $response = $this->actingAs($reseller1)->get("api/v4/users"); $response->assertStatus(403); // Search with no search criteria - $response = $this->actingAs($reseller)->get("api/v4/users"); + $response = $this->actingAs($reseller2)->get("api/v4/users"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(0, $json['count']); $this->assertSame([], $json['list']); // Search with no matches expected - $response = $this->actingAs($reseller)->get("api/v4/users?search=abcd1234efgh5678"); + $response = $this->actingAs($reseller2)->get("api/v4/users?search=abcd1234efgh5678"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(0, $json['count']); $this->assertSame([], $json['list']); // Search by domain in another tenant - $response = $this->actingAs($reseller)->get("api/v4/users?search=kolab.org"); + $response = $this->actingAs($reseller2)->get("api/v4/users?search=kolab.org"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(0, $json['count']); $this->assertSame([], $json['list']); // Search by user ID in another tenant - $response = $this->actingAs($reseller)->get("api/v4/users?search={$user->id}"); + $response = $this->actingAs($reseller2)->get("api/v4/users?search={$user->id}"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(0, $json['count']); $this->assertSame([], $json['list']); // Search by email (primary) - existing user in another tenant - $response = $this->actingAs($reseller)->get("api/v4/users?search=john@kolab.org"); + $response = $this->actingAs($reseller2)->get("api/v4/users?search=john@kolab.org"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(0, $json['count']); $this->assertSame([], $json['list']); // Search by owner - existing user in another tenant - $response = $this->actingAs($reseller)->get("api/v4/users?owner={$user->id}"); + $response = $this->actingAs($reseller2)->get("api/v4/users?owner={$user->id}"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(0, $json['count']); $this->assertSame([], $json['list']); // Create a domain with some users in the Sample Tenant so we have anything to search for $domain = $this->getTestDomain('testsearch.com', ['type' => \App\Domain::TYPE_EXTERNAL]); - $domain->tenant_id = $tenant->id; + $domain->tenant_id = 2; $domain->save(); $user = $this->getTestUser('test@testsearch.com'); - $user->tenant_id = $tenant->id; + $user->tenant_id = 2; $user->save(); $plan = \App\Plan::where('title', 'group')->first(); $user->assignPlan($plan, $domain); $user->setAliases(['alias@testsearch.com']); $user->setSetting('external_email', 'john.doe.external@gmail.com'); // Search by domain - $response = $this->actingAs($reseller)->get("api/v4/users?search=testsearch.com"); + $response = $this->actingAs($reseller2)->get("api/v4/users?search=testsearch.com"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(1, $json['count']); $this->assertCount(1, $json['list']); $this->assertSame($user->id, $json['list'][0]['id']); $this->assertSame($user->email, $json['list'][0]['email']); // Search by user ID - $response = $this->actingAs($reseller)->get("api/v4/users?search={$user->id}"); + $response = $this->actingAs($reseller2)->get("api/v4/users?search={$user->id}"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(1, $json['count']); $this->assertCount(1, $json['list']); $this->assertSame($user->id, $json['list'][0]['id']); $this->assertSame($user->email, $json['list'][0]['email']); // Search by email (primary) - existing user in reseller's tenant - $response = $this->actingAs($reseller)->get("api/v4/users?search=test@testsearch.com"); + $response = $this->actingAs($reseller2)->get("api/v4/users?search=test@testsearch.com"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(1, $json['count']); $this->assertCount(1, $json['list']); $this->assertSame($user->id, $json['list'][0]['id']); $this->assertSame($user->email, $json['list'][0]['email']); // Search by email (alias) - $response = $this->actingAs($reseller)->get("api/v4/users?search=alias@testsearch.com"); + $response = $this->actingAs($reseller2)->get("api/v4/users?search=alias@testsearch.com"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(1, $json['count']); $this->assertCount(1, $json['list']); $this->assertSame($user->id, $json['list'][0]['id']); $this->assertSame($user->email, $json['list'][0]['email']); // Search by email (external), there are two users with this email, but only one // in the reseller's tenant - $response = $this->actingAs($reseller)->get("api/v4/users?search=john.doe.external@gmail.com"); + $response = $this->actingAs($reseller2)->get("api/v4/users?search=john.doe.external@gmail.com"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(1, $json['count']); $this->assertCount(1, $json['list']); $this->assertSame($user->id, $json['list'][0]['id']); $this->assertSame($user->email, $json['list'][0]['email']); // Search by owner - $response = $this->actingAs($reseller)->get("api/v4/users?owner={$user->id}"); + $response = $this->actingAs($reseller2)->get("api/v4/users?owner={$user->id}"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(1, $json['count']); $this->assertCount(1, $json['list']); $this->assertSame($user->id, $json['list'][0]['id']); $this->assertSame($user->email, $json['list'][0]['email']); // Deleted users/domains $user->delete(); - $response = $this->actingAs($reseller)->get("api/v4/users?search=test@testsearch.com"); + $response = $this->actingAs($reseller2)->get("api/v4/users?search=test@testsearch.com"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(1, $json['count']); $this->assertCount(1, $json['list']); $this->assertSame($user->id, $json['list'][0]['id']); $this->assertSame($user->email, $json['list'][0]['email']); $this->assertTrue($json['list'][0]['isDeleted']); - $response = $this->actingAs($reseller)->get("api/v4/users?search=alias@testsearch.com"); + $response = $this->actingAs($reseller2)->get("api/v4/users?search=alias@testsearch.com"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(1, $json['count']); $this->assertCount(1, $json['list']); $this->assertSame($user->id, $json['list'][0]['id']); $this->assertSame($user->email, $json['list'][0]['email']); $this->assertTrue($json['list'][0]['isDeleted']); - $response = $this->actingAs($reseller)->get("api/v4/users?search=testsearch.com"); + $response = $this->actingAs($reseller2)->get("api/v4/users?search=testsearch.com"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(1, $json['count']); $this->assertCount(1, $json['list']); $this->assertSame($user->id, $json['list'][0]['id']); $this->assertSame($user->email, $json['list'][0]['email']); $this->assertTrue($json['list'][0]['isDeleted']); } + /** + * Test reseting 2FA (POST /api/v4/users//reset2FA) + */ + public function testReset2FA(): void + { + $user = $this->getTestUser('UsersControllerTest1@userscontroller.com'); + $admin = $this->getTestUser('jeroen@jeroen.jeroen'); + $reseller1 = $this->getTestUser('reseller@kolabnow.com'); + $reseller2 = $this->getTestUser('reseller@reseller.com'); + + $sku2fa = \App\Sku::firstOrCreate(['title' => '2fa']); + $user->assignSku($sku2fa); + \App\Auth\SecondFactor::seed('userscontrollertest1@userscontroller.com'); + + // Test unauthorized access + $response = $this->actingAs($user)->post("/api/v4/users/{$user->id}/reset2FA", []); + $response->assertStatus(403); + + $response = $this->actingAs($admin)->post("/api/v4/users/{$user->id}/reset2FA", []); + $response->assertStatus(403); + + $response = $this->actingAs($reseller2)->post("/api/v4/users/{$user->id}/reset2FA", []); + $response->assertStatus(403); + + // Touching admins is forbidden + $response = $this->actingAs($reseller1)->post("/api/v4/users/{$admin->id}/reset2FA", []); + $response->assertStatus(404); + + $entitlements = $user->fresh()->entitlements()->where('sku_id', $sku2fa->id)->get(); + $this->assertCount(1, $entitlements); + + $sf = new \App\Auth\SecondFactor($user); + $this->assertCount(1, $sf->factors()); + + // Test reseting 2FA + $response = $this->actingAs($reseller1)->post("/api/v4/users/{$user->id}/reset2FA", []); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertSame('success', $json['status']); + $this->assertSame("2-Factor authentication reset successfully.", $json['message']); + $this->assertCount(2, $json); + + $entitlements = $user->fresh()->entitlements()->where('sku_id', $sku2fa->id)->get(); + $this->assertCount(0, $entitlements); + + $sf = new \App\Auth\SecondFactor($user); + $this->assertCount(0, $sf->factors()); + + // Other tenant's user + \config(['app.tenant_id' => 2]); + $response = $this->actingAs($reseller2)->post("/api/v4/users/{$user->id}/reset2FA", []); + $response->assertStatus(404); + } + + /** + * Test user creation (POST /api/v4/users) + */ + public function testStore(): void + { + $reseller1 = $this->getTestUser('reseller@kolabnow.com'); + + // The end-point does not exist + $response = $this->actingAs($reseller1)->post("/api/v4/users", []); + $response->assertStatus(404); + } + + /** + * Test user suspending (POST /api/v4/users//suspend) + */ + public function testSuspend(): void + { + Queue::fake(); // disable jobs + + $user = $this->getTestUser('UsersControllerTest1@userscontroller.com'); + $admin = $this->getTestUser('jeroen@jeroen.jeroen'); + $reseller1 = $this->getTestUser('reseller@kolabnow.com'); + $reseller2 = $this->getTestUser('reseller@reseller.com'); + + // Test unauthorized access + $response = $this->actingAs($user)->post("/api/v4/users/{$user->id}/suspend", []); + $response->assertStatus(403); + + $response = $this->actingAs($admin)->post("/api/v4/users/{$user->id}/suspend", []); + $response->assertStatus(403); + + $response = $this->actingAs($reseller2)->post("/api/v4/users/{$user->id}/suspend", []); + $response->assertStatus(403); + + $response = $this->actingAs($reseller1)->post("/api/v4/users/{$admin->id}/suspend", []); + $response->assertStatus(404); + + $this->assertFalse($user->isSuspended()); + + // Test suspending the user + $response = $this->actingAs($reseller1)->post("/api/v4/users/{$user->id}/suspend", []); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertSame('success', $json['status']); + $this->assertSame("User suspended successfully.", $json['message']); + $this->assertCount(2, $json); + + $this->assertTrue($user->fresh()->isSuspended()); + + // Access to other tenant's users + \config(['app.tenant_id' => 2]); + $response = $this->actingAs($reseller2)->post("/api/v4/users/{$user->id}/suspend", []); + $response->assertStatus(404); + } + + /** + * Test user un-suspending (POST /api/v4/users//unsuspend) + */ + public function testUnsuspend(): void + { + Queue::fake(); // disable jobs + + $user = $this->getTestUser('UsersControllerTest1@userscontroller.com'); + $admin = $this->getTestUser('jeroen@jeroen.jeroen'); + $reseller1 = $this->getTestUser('reseller@kolabnow.com'); + $reseller2 = $this->getTestUser('reseller@reseller.com'); + + // Test unauthorized access to admin API + $response = $this->actingAs($user)->post("/api/v4/users/{$user->id}/unsuspend", []); + $response->assertStatus(403); + + $response = $this->actingAs($admin)->post("/api/v4/users/{$user->id}/unsuspend", []); + $response->assertStatus(403); + + $response = $this->actingAs($reseller2)->post("/api/v4/users/{$user->id}/unsuspend", []); + $response->assertStatus(403); + + $response = $this->actingAs($reseller1)->post("/api/v4/users/{$admin->id}/unsuspend", []); + $response->assertStatus(404); + + $this->assertFalse($user->isSuspended()); + $user->suspend(); + $this->assertTrue($user->isSuspended()); + + // Test suspending the user + $response = $this->actingAs($reseller1)->post("/api/v4/users/{$user->id}/unsuspend", []); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertSame('success', $json['status']); + $this->assertSame("User unsuspended successfully.", $json['message']); + $this->assertCount(2, $json); + + $this->assertFalse($user->fresh()->isSuspended()); + + // Access to other tenant's users + \config(['app.tenant_id' => 2]); + $response = $this->actingAs($reseller2)->post("/api/v4/users/{$user->id}/unsuspend", []); + $response->assertStatus(404); + } + /** * Test user update (PUT /api/v4/users/) */ public function testUpdate(): void { - $this->markTestIncomplete(); -/* $user = $this->getTestUser('UsersControllerTest1@userscontroller.com'); $admin = $this->getTestUser('jeroen@jeroen.jeroen'); + $reseller1 = $this->getTestUser('reseller@kolabnow.com'); + $reseller2 = $this->getTestUser('reseller@reseller.com'); - // Test unauthorized access to admin API + // Test unauthorized access $response = $this->actingAs($user)->put("/api/v4/users/{$user->id}", []); $response->assertStatus(403); - // Test updatig the user data (empty data) $response = $this->actingAs($admin)->put("/api/v4/users/{$user->id}", []); + $response->assertStatus(403); + + $response = $this->actingAs($reseller2)->put("/api/v4/users/{$user->id}", []); + $response->assertStatus(403); + + $response = $this->actingAs($reseller1)->put("/api/v4/users/{$admin->id}", []); + $response->assertStatus(404); + + // Test updatig the user data (empty data) + $response = $this->actingAs($reseller1)->put("/api/v4/users/{$user->id}", []); $response->assertStatus(200); $json = $response->json(); $this->assertSame('success', $json['status']); $this->assertSame("User data updated successfully.", $json['message']); $this->assertCount(2, $json); // Test error handling $post = ['external_email' => 'aaa']; - $response = $this->actingAs($admin)->put("/api/v4/users/{$user->id}", $post); + $response = $this->actingAs($reseller1)->put("/api/v4/users/{$user->id}", $post); $response->assertStatus(422); $json = $response->json(); $this->assertSame('error', $json['status']); $this->assertSame("The external email must be a valid email address.", $json['errors']['external_email'][0]); $this->assertCount(2, $json); // Test real update $post = ['external_email' => 'modified@test.com']; - $response = $this->actingAs($admin)->put("/api/v4/users/{$user->id}", $post); + $response = $this->actingAs($reseller1)->put("/api/v4/users/{$user->id}", $post); $response->assertStatus(200); $json = $response->json(); $this->assertSame('success', $json['status']); $this->assertSame("User data updated successfully.", $json['message']); $this->assertCount(2, $json); $this->assertSame('modified@test.com', $user->getSetting('external_email')); -*/ + + // Access to other tenant's users + \config(['app.tenant_id' => 2]); + $response = $this->actingAs($reseller2)->put("/api/v4/users/{$user->id}", $post); + $response->assertStatus(404); } } diff --git a/src/tests/Feature/Controller/Reseller/WalletsTest.php b/src/tests/Feature/Controller/Reseller/WalletsTest.php new file mode 100644 index 00000000..58bdf7e2 --- /dev/null +++ b/src/tests/Feature/Controller/Reseller/WalletsTest.php @@ -0,0 +1,290 @@ + 'stripe']); + + $user = $this->getTestUser('john@kolab.org'); + $admin = $this->getTestUser('jeroen@jeroen.jeroen'); + $reseller1 = $this->getTestUser('reseller@kolabnow.com'); + $reseller2 = $this->getTestUser('reseller@reseller.com'); + $wallet = $user->wallets()->first(); + $wallet->discount_id = null; + $wallet->save(); + + // Make sure there's no stripe/mollie identifiers + $wallet->setSetting('stripe_id', null); + $wallet->setSetting('stripe_mandate_id', null); + $wallet->setSetting('mollie_id', null); + $wallet->setSetting('mollie_mandate_id', null); + + // Non-admin user + $response = $this->actingAs($user)->get("api/v4/wallets/{$wallet->id}"); + $response->assertStatus(403); + + // Admin user + $response = $this->actingAs($admin)->get("api/v4/wallets/{$wallet->id}"); + $response->assertStatus(403); + + // Reseller from a different tenant + $response = $this->actingAs($reseller2)->get("api/v4/wallets/{$wallet->id}"); + $response->assertStatus(403); + + // Reseller + $response = $this->actingAs($reseller1)->get("api/v4/wallets/{$wallet->id}"); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertSame($wallet->id, $json['id']); + $this->assertSame('CHF', $json['currency']); + $this->assertSame($wallet->balance, $json['balance']); + $this->assertSame(0, $json['discount']); + $this->assertTrue(empty($json['description'])); + $this->assertTrue(empty($json['discount_description'])); + $this->assertTrue(!empty($json['provider'])); + $this->assertTrue(empty($json['providerLink'])); + $this->assertTrue(!empty($json['mandate'])); + + // Reseller from a different tenant + \config(['app.tenant_id' => 2]); + $response = $this->actingAs($reseller2)->get("api/v4/wallets/{$wallet->id}"); + $response->assertStatus(404); + } + + /** + * Test awarding/penalizing a wallet (POST /api/v4/wallets/:id/one-off) + */ + public function testOneOff(): void + { + $user = $this->getTestUser('john@kolab.org'); + $admin = $this->getTestUser('jeroen@jeroen.jeroen'); + $reseller1 = $this->getTestUser('reseller@kolabnow.com'); + $reseller2 = $this->getTestUser('reseller@reseller.com'); + $wallet = $user->wallets()->first(); + $balance = $wallet->balance; + + Transaction::where('object_id', $wallet->id) + ->whereIn('type', [Transaction::WALLET_AWARD, Transaction::WALLET_PENALTY]) + ->delete(); + + // Non-admin user + $response = $this->actingAs($user)->post("api/v4/wallets/{$wallet->id}/one-off", []); + $response->assertStatus(403); + + // Admin user + $response = $this->actingAs($admin)->post("api/v4/wallets/{$wallet->id}/one-off", []); + $response->assertStatus(403); + + // Reseller from a different tenant + $response = $this->actingAs($reseller2)->post("api/v4/wallets/{$wallet->id}/one-off", []); + $response->assertStatus(403); + + // Admin user - invalid input + $post = ['amount' => 'aaaa']; + $response = $this->actingAs($reseller1)->post("api/v4/wallets/{$wallet->id}/one-off", $post); + $response->assertStatus(422); + + $json = $response->json(); + + $this->assertSame('error', $json['status']); + $this->assertSame('The amount must be a number.', $json['errors']['amount'][0]); + $this->assertSame('The description field is required.', $json['errors']['description'][0]); + $this->assertCount(2, $json); + $this->assertCount(2, $json['errors']); + + // Admin user - a valid bonus + $post = ['amount' => '50', 'description' => 'A bonus']; + $response = $this->actingAs($reseller1)->post("api/v4/wallets/{$wallet->id}/one-off", $post); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertSame('success', $json['status']); + $this->assertSame('The bonus has been added to the wallet successfully.', $json['message']); + $this->assertSame($balance += 5000, $json['balance']); + $this->assertSame($balance, $wallet->fresh()->balance); + + $transaction = Transaction::where('object_id', $wallet->id) + ->where('type', Transaction::WALLET_AWARD)->first(); + + $this->assertSame($post['description'], $transaction->description); + $this->assertSame(5000, $transaction->amount); + $this->assertSame($reseller1->email, $transaction->user_email); + + // Admin user - a valid penalty + $post = ['amount' => '-40', 'description' => 'A penalty']; + $response = $this->actingAs($reseller1)->post("api/v4/wallets/{$wallet->id}/one-off", $post); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertSame('success', $json['status']); + $this->assertSame('The penalty has been added to the wallet successfully.', $json['message']); + $this->assertSame($balance -= 4000, $json['balance']); + $this->assertSame($balance, $wallet->fresh()->balance); + + $transaction = Transaction::where('object_id', $wallet->id) + ->where('type', Transaction::WALLET_PENALTY)->first(); + + $this->assertSame($post['description'], $transaction->description); + $this->assertSame(-4000, $transaction->amount); + $this->assertSame($reseller1->email, $transaction->user_email); + + // Reseller from a different tenant + \config(['app.tenant_id' => 2]); + $response = $this->actingAs($reseller2)->post("api/v4/wallets/{$wallet->id}/one-off", []); + $response->assertStatus(404); + } + + /** + * Test fetching wallet transactions (GET /api/v4/wallets/:id/transactions) + */ + public function testTransactions(): void + { + // Note: Here we're testing only that the end-point works, + // and admin can get the transaction log, response details + // are tested in Feature/Controller/WalletsTest.php + + $this->deleteTestUser('wallets-controller@kolabnow.com'); + $user = $this->getTestUser('wallets-controller@kolabnow.com'); + $wallet = $user->wallets()->first(); + $admin = $this->getTestUser('jeroen@jeroen.jeroen'); + $reseller1 = $this->getTestUser('reseller@kolabnow.com'); + $reseller2 = $this->getTestUser('reseller@reseller.com'); + + // Non-admin + $response = $this->actingAs($user)->get("api/v4/wallets/{$wallet->id}/transactions"); + $response->assertStatus(403); + + // Admin + $response = $this->actingAs($admin)->get("api/v4/wallets/{$wallet->id}/transactions"); + $response->assertStatus(403); + + // Reseller from a different tenant + $response = $this->actingAs($reseller2)->get("api/v4/wallets/{$wallet->id}/transactions"); + $response->assertStatus(403); + + // Create some sample transactions + $transactions = $this->createTestTransactions($wallet); + $transactions = array_reverse($transactions); + $pages = array_chunk($transactions, 10 /* page size*/); + + // Get the 2nd page + $response = $this->actingAs($reseller1)->get("api/v4/wallets/{$wallet->id}/transactions?page=2"); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertCount(5, $json); + $this->assertSame('success', $json['status']); + $this->assertSame(2, $json['page']); + $this->assertSame(2, $json['count']); + $this->assertSame(false, $json['hasMore']); + $this->assertCount(2, $json['list']); + foreach ($pages[1] as $idx => $transaction) { + $this->assertSame($transaction->id, $json['list'][$idx]['id']); + $this->assertSame($transaction->type, $json['list'][$idx]['type']); + $this->assertSame($transaction->shortDescription(), $json['list'][$idx]['description']); + $this->assertFalse($json['list'][$idx]['hasDetails']); + } + + // The 'user' key is set only on the admin/reseller end-point + // FIXME: Should we hide this for resellers? + $this->assertSame('jeroen@jeroen.jeroen', $json['list'][1]['user']); + + // Reseller from a different tenant + \config(['app.tenant_id' => 2]); + $response = $this->actingAs($reseller2)->get("api/v4/wallets/{$wallet->id}/transactions"); + $response->assertStatus(403); + } + + /** + * Test updating a wallet (PUT /api/v4/wallets/:id) + */ + public function testUpdate(): void + { + $admin = $this->getTestUser('jeroen@jeroen.jeroen'); + $reseller1 = $this->getTestUser('reseller@kolabnow.com'); + $reseller2 = $this->getTestUser('reseller@reseller.com'); + $user = $this->getTestUser('john@kolab.org'); + $wallet = $user->wallets()->first(); + $discount = Discount::where('code', 'TEST')->first(); + + // Non-admin user + $response = $this->actingAs($user)->put("api/v4/wallets/{$wallet->id}", []); + $response->assertStatus(403); + + // Admin + $response = $this->actingAs($admin)->put("api/v4/wallets/{$wallet->id}", []); + $response->assertStatus(403); + + // Reseller from another tenant + $response = $this->actingAs($reseller2)->put("api/v4/wallets/{$wallet->id}", []); + $response->assertStatus(403); + + // Admin user - setting a discount + $post = ['discount' => $discount->id]; + $response = $this->actingAs($reseller1)->put("api/v4/wallets/{$wallet->id}", $post); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertSame('success', $json['status']); + $this->assertSame('User wallet updated successfully.', $json['message']); + $this->assertSame($wallet->id, $json['id']); + $this->assertSame($discount->discount, $json['discount']); + $this->assertSame($discount->id, $json['discount_id']); + $this->assertSame($discount->description, $json['discount_description']); + $this->assertSame($discount->id, $wallet->fresh()->discount->id); + + // Admin user - removing a discount + $post = ['discount' => null]; + $response = $this->actingAs($reseller1)->put("api/v4/wallets/{$wallet->id}", $post); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertSame('success', $json['status']); + $this->assertSame('User wallet updated successfully.', $json['message']); + $this->assertSame($wallet->id, $json['id']); + $this->assertSame(null, $json['discount_id']); + $this->assertTrue(empty($json['discount_description'])); + $this->assertSame(null, $wallet->fresh()->discount); + + // Reseller from a different tenant + \config(['app.tenant_id' => 2]); + $response = $this->actingAs($reseller2)->put("api/v4/wallets/{$wallet->id}", []); + $response->assertStatus(404); + } +} diff --git a/src/tests/Feature/UserTest.php b/src/tests/Feature/UserTest.php index 3330e921..7e400ea1 100644 --- a/src/tests/Feature/UserTest.php +++ b/src/tests/Feature/UserTest.php @@ -1,766 +1,831 @@ deleteTestUser('user-test@' . \config('app.domain')); $this->deleteTestUser('UserAccountA@UserAccount.com'); $this->deleteTestUser('UserAccountB@UserAccount.com'); $this->deleteTestUser('UserAccountC@UserAccount.com'); $this->deleteTestGroup('test-group@UserAccount.com'); $this->deleteTestDomain('UserAccount.com'); $this->deleteTestDomain('UserAccountAdd.com'); } public function tearDown(): void { $this->deleteTestUser('user-test@' . \config('app.domain')); $this->deleteTestUser('UserAccountA@UserAccount.com'); $this->deleteTestUser('UserAccountB@UserAccount.com'); $this->deleteTestUser('UserAccountC@UserAccount.com'); $this->deleteTestGroup('test-group@UserAccount.com'); $this->deleteTestDomain('UserAccount.com'); $this->deleteTestDomain('UserAccountAdd.com'); parent::tearDown(); } /** * Tests for User::assignPackage() */ public function testAssignPackage(): void { $this->markTestIncomplete(); } /** * Tests for User::assignPlan() */ public function testAssignPlan(): void { $this->markTestIncomplete(); } /** * Tests for User::assignSku() */ public function testAssignSku(): void { $this->markTestIncomplete(); } /** * Verify a wallet assigned a controller is among the accounts of the assignee. */ public function testAccounts(): void { $userA = $this->getTestUser('UserAccountA@UserAccount.com'); $userB = $this->getTestUser('UserAccountB@UserAccount.com'); $this->assertTrue($userA->wallets()->count() == 1); $userA->wallets()->each( function ($wallet) use ($userB) { $wallet->addController($userB); } ); $this->assertTrue($userB->accounts()->get()[0]->id === $userA->wallets()->get()[0]->id); } public function testCanDelete(): void { $this->markTestIncomplete(); } /** * Test User::canRead() method */ public function testCanRead(): void { $john = $this->getTestUser('john@kolab.org'); $ned = $this->getTestUser('ned@kolab.org'); $jack = $this->getTestUser('jack@kolab.org'); $reseller1 = $this->getTestUser('reseller@kolabnow.com'); $reseller2 = $this->getTestUser('reseller@reseller.com'); $admin = $this->getTestUser('jeroen@jeroen.jeroen'); $domain = $this->getTestDomain('kolab.org'); // Admin $this->assertTrue($admin->canRead($admin)); $this->assertTrue($admin->canRead($john)); $this->assertTrue($admin->canRead($jack)); $this->assertTrue($admin->canRead($reseller1)); $this->assertTrue($admin->canRead($reseller2)); $this->assertTrue($admin->canRead($domain)); $this->assertTrue($admin->canRead($domain->wallet())); // Reseller - kolabnow $this->assertTrue($reseller1->canRead($john)); $this->assertTrue($reseller1->canRead($jack)); $this->assertTrue($reseller1->canRead($reseller1)); $this->assertTrue($reseller1->canRead($domain)); $this->assertTrue($reseller1->canRead($domain->wallet())); $this->assertFalse($reseller1->canRead($reseller2)); $this->assertFalse($reseller1->canRead($admin)); // Reseller - different tenant $this->assertTrue($reseller2->canRead($reseller2)); $this->assertFalse($reseller2->canRead($john)); $this->assertFalse($reseller2->canRead($jack)); $this->assertFalse($reseller2->canRead($reseller1)); $this->assertFalse($reseller2->canRead($domain)); $this->assertFalse($reseller2->canRead($domain->wallet())); $this->assertFalse($reseller2->canRead($admin)); // Normal user - account owner $this->assertTrue($john->canRead($john)); $this->assertTrue($john->canRead($ned)); $this->assertTrue($john->canRead($jack)); $this->assertTrue($john->canRead($domain)); $this->assertTrue($john->canRead($domain->wallet())); $this->assertFalse($john->canRead($reseller1)); $this->assertFalse($john->canRead($reseller2)); $this->assertFalse($john->canRead($admin)); // Normal user - a non-owner and non-controller $this->assertTrue($jack->canRead($jack)); $this->assertFalse($jack->canRead($john)); $this->assertFalse($jack->canRead($domain)); $this->assertFalse($jack->canRead($domain->wallet())); $this->assertFalse($jack->canRead($reseller1)); $this->assertFalse($jack->canRead($reseller2)); $this->assertFalse($jack->canRead($admin)); // Normal user - John's wallet controller $this->assertTrue($ned->canRead($ned)); $this->assertTrue($ned->canRead($john)); $this->assertTrue($ned->canRead($jack)); $this->assertTrue($ned->canRead($domain)); $this->assertTrue($ned->canRead($domain->wallet())); $this->assertFalse($ned->canRead($reseller1)); $this->assertFalse($ned->canRead($reseller2)); $this->assertFalse($ned->canRead($admin)); } + /** + * Test User::canUpdate() method + */ public function testCanUpdate(): void { - $this->markTestIncomplete(); + $john = $this->getTestUser('john@kolab.org'); + $ned = $this->getTestUser('ned@kolab.org'); + $jack = $this->getTestUser('jack@kolab.org'); + $reseller1 = $this->getTestUser('reseller@kolabnow.com'); + $reseller2 = $this->getTestUser('reseller@reseller.com'); + $admin = $this->getTestUser('jeroen@jeroen.jeroen'); + $domain = $this->getTestDomain('kolab.org'); + + // Admin + $this->assertTrue($admin->canUpdate($admin)); + $this->assertTrue($admin->canUpdate($john)); + $this->assertTrue($admin->canUpdate($jack)); + $this->assertTrue($admin->canUpdate($reseller1)); + $this->assertTrue($admin->canUpdate($reseller2)); + $this->assertTrue($admin->canUpdate($domain)); + $this->assertTrue($admin->canUpdate($domain->wallet())); + + // Reseller - kolabnow + $this->assertTrue($reseller1->canUpdate($john)); + $this->assertTrue($reseller1->canUpdate($jack)); + $this->assertTrue($reseller1->canUpdate($reseller1)); + $this->assertTrue($reseller1->canUpdate($domain)); + $this->assertTrue($reseller1->canUpdate($domain->wallet())); + $this->assertFalse($reseller1->canUpdate($reseller2)); + $this->assertFalse($reseller1->canUpdate($admin)); + + // Reseller - different tenant + $this->assertTrue($reseller2->canUpdate($reseller2)); + $this->assertFalse($reseller2->canUpdate($john)); + $this->assertFalse($reseller2->canUpdate($jack)); + $this->assertFalse($reseller2->canUpdate($reseller1)); + $this->assertFalse($reseller2->canUpdate($domain)); + $this->assertFalse($reseller2->canUpdate($domain->wallet())); + $this->assertFalse($reseller2->canUpdate($admin)); + + // Normal user - account owner + $this->assertTrue($john->canUpdate($john)); + $this->assertTrue($john->canUpdate($ned)); + $this->assertTrue($john->canUpdate($jack)); + $this->assertTrue($john->canUpdate($domain)); + $this->assertFalse($john->canUpdate($domain->wallet())); + $this->assertFalse($john->canUpdate($reseller1)); + $this->assertFalse($john->canUpdate($reseller2)); + $this->assertFalse($john->canUpdate($admin)); + + // Normal user - a non-owner and non-controller + $this->assertTrue($jack->canUpdate($jack)); + $this->assertFalse($jack->canUpdate($john)); + $this->assertFalse($jack->canUpdate($domain)); + $this->assertFalse($jack->canUpdate($domain->wallet())); + $this->assertFalse($jack->canUpdate($reseller1)); + $this->assertFalse($jack->canUpdate($reseller2)); + $this->assertFalse($jack->canUpdate($admin)); + + // Normal user - John's wallet controller + $this->assertTrue($ned->canUpdate($ned)); + $this->assertTrue($ned->canUpdate($john)); + $this->assertTrue($ned->canUpdate($jack)); + $this->assertTrue($ned->canUpdate($domain)); + $this->assertFalse($ned->canUpdate($domain->wallet())); + $this->assertFalse($ned->canUpdate($reseller1)); + $this->assertFalse($ned->canUpdate($reseller2)); + $this->assertFalse($ned->canUpdate($admin)); } /** * Test user create/creating observer */ public function testCreate(): void { Queue::fake(); $domain = \config('app.domain'); $user = User::create(['email' => 'USER-test@' . \strtoupper($domain)]); $result = User::where('email', 'user-test@' . $domain)->first(); $this->assertSame('user-test@' . $domain, $result->email); $this->assertSame($user->id, $result->id); $this->assertSame(User::STATUS_NEW | User::STATUS_ACTIVE, $result->status); } /** * Verify user creation process */ public function testCreateJobs(): void { // Fake the queue, assert that no jobs were pushed... Queue::fake(); Queue::assertNothingPushed(); $user = User::create([ 'email' => 'user-test@' . \config('app.domain') ]); Queue::assertPushed(\App\Jobs\User\CreateJob::class, 1); Queue::assertPushed( \App\Jobs\User\CreateJob::class, function ($job) use ($user) { $userEmail = TestCase::getObjectProperty($job, 'userEmail'); $userId = TestCase::getObjectProperty($job, 'userId'); return $userEmail === $user->email && $userId === $user->id; } ); Queue::assertPushedWithChain( \App\Jobs\User\CreateJob::class, [ \App\Jobs\User\VerifyJob::class, ] ); /* FIXME: Looks like we can't really do detailed assertions on chained jobs Another thing to consider is if we maybe should run these jobs independently (not chained) and make sure there's no race-condition in status update Queue::assertPushed(\App\Jobs\User\VerifyJob::class, 1); Queue::assertPushed(\App\Jobs\User\VerifyJob::class, function ($job) use ($user) { $userEmail = TestCase::getObjectProperty($job, 'userEmail'); $userId = TestCase::getObjectProperty($job, 'userId'); return $userEmail === $user->email && $userId === $user->id; }); */ } /** * Tests for User::domains() */ public function testDomains(): void { $user = $this->getTestUser('john@kolab.org'); $domains = []; foreach ($user->domains() as $domain) { $domains[] = $domain->namespace; } $this->assertContains(\config('app.domain'), $domains); $this->assertContains('kolab.org', $domains); // Jack is not the wallet controller, so for him the list should not // include John's domains, kolab.org specifically $user = $this->getTestUser('jack@kolab.org'); $domains = []; foreach ($user->domains() as $domain) { $domains[] = $domain->namespace; } $this->assertContains(\config('app.domain'), $domains); $this->assertNotContains('kolab.org', $domains); } public function testUserQuota(): void { // TODO: This test does not test much, probably could be removed // or moved to somewhere else, or extended with // other entitlements() related cases. $user = $this->getTestUser('john@kolab.org'); $storage_sku = \App\Sku::where('title', 'storage')->first(); $count = 0; foreach ($user->entitlements()->get() as $entitlement) { if ($entitlement->sku_id == $storage_sku->id) { $count += 1; } } $this->assertTrue($count == 2); } /** * Test user deletion */ public function testDelete(): void { Queue::fake(); $user = $this->getTestUser('user-test@' . \config('app.domain')); $package = \App\Package::where('title', 'kolab')->first(); $user->assignPackage($package); $id = $user->id; $this->assertCount(4, $user->entitlements()->get()); $user->delete(); $this->assertCount(0, $user->entitlements()->get()); $this->assertTrue($user->fresh()->trashed()); $this->assertFalse($user->fresh()->isDeleted()); // Delete the user for real $job = new \App\Jobs\User\DeleteJob($id); $job->handle(); $this->assertTrue(User::withTrashed()->where('id', $id)->first()->isDeleted()); $user->forceDelete(); $this->assertCount(0, User::withTrashed()->where('id', $id)->get()); // Test an account with users, domain, and group $userA = $this->getTestUser('UserAccountA@UserAccount.com'); $userB = $this->getTestUser('UserAccountB@UserAccount.com'); $userC = $this->getTestUser('UserAccountC@UserAccount.com'); $package_kolab = \App\Package::where('title', 'kolab')->first(); $package_domain = \App\Package::where('title', 'domain-hosting')->first(); $domain = $this->getTestDomain('UserAccount.com', [ 'status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_HOSTED, ]); $userA->assignPackage($package_kolab); $domain->assignPackage($package_domain, $userA); $userA->assignPackage($package_kolab, $userB); $userA->assignPackage($package_kolab, $userC); $group = $this->getTestGroup('test-group@UserAccount.com'); $group->assignToWallet($userA->wallets->first()); $entitlementsA = \App\Entitlement::where('entitleable_id', $userA->id); $entitlementsB = \App\Entitlement::where('entitleable_id', $userB->id); $entitlementsC = \App\Entitlement::where('entitleable_id', $userC->id); $entitlementsDomain = \App\Entitlement::where('entitleable_id', $domain->id); $entitlementsGroup = \App\Entitlement::where('entitleable_id', $group->id); $this->assertSame(4, $entitlementsA->count()); $this->assertSame(4, $entitlementsB->count()); $this->assertSame(4, $entitlementsC->count()); $this->assertSame(1, $entitlementsDomain->count()); $this->assertSame(1, $entitlementsGroup->count()); // Delete non-controller user $userC->delete(); $this->assertTrue($userC->fresh()->trashed()); $this->assertFalse($userC->fresh()->isDeleted()); $this->assertSame(0, $entitlementsC->count()); // Delete the controller (and expect "sub"-users to be deleted too) $userA->delete(); $this->assertSame(0, $entitlementsA->count()); $this->assertSame(0, $entitlementsB->count()); $this->assertSame(0, $entitlementsDomain->count()); $this->assertSame(0, $entitlementsGroup->count()); $this->assertTrue($userA->fresh()->trashed()); $this->assertTrue($userB->fresh()->trashed()); $this->assertTrue($domain->fresh()->trashed()); $this->assertTrue($group->fresh()->trashed()); $this->assertFalse($userA->isDeleted()); $this->assertFalse($userB->isDeleted()); $this->assertFalse($domain->isDeleted()); $this->assertFalse($group->isDeleted()); $userA->forceDelete(); $all_entitlements = \App\Entitlement::where('wallet_id', $userA->wallets->first()->id); $this->assertSame(0, $all_entitlements->withTrashed()->count()); $this->assertCount(0, User::withTrashed()->where('id', $userA->id)->get()); $this->assertCount(0, User::withTrashed()->where('id', $userB->id)->get()); $this->assertCount(0, User::withTrashed()->where('id', $userC->id)->get()); $this->assertCount(0, Domain::withTrashed()->where('id', $domain->id)->get()); $this->assertCount(0, Group::withTrashed()->where('id', $group->id)->get()); } /** * Test user deletion vs. group membership */ public function testDeleteAndGroups(): void { Queue::fake(); $package_kolab = \App\Package::where('title', 'kolab')->first(); $userA = $this->getTestUser('UserAccountA@UserAccount.com'); $userB = $this->getTestUser('UserAccountB@UserAccount.com'); $userA->assignPackage($package_kolab, $userB); $group = $this->getTestGroup('test-group@UserAccount.com'); $group->members = ['test@gmail.com', $userB->email]; $group->assignToWallet($userA->wallets->first()); $group->save(); $userGroups = $userA->groups()->get(); $this->assertSame(1, $userGroups->count()); $this->assertSame($group->id, $userGroups->first()->id); $userB->delete(); $this->assertSame(['test@gmail.com'], $group->fresh()->members); // Twice, one for save() and one for delete() above Queue::assertPushed(\App\Jobs\Group\UpdateJob::class, 2); } /** * Tests for User::aliasExists() */ public function testAliasExists(): void { $this->assertTrue(User::aliasExists('jack.daniels@kolab.org')); $this->assertFalse(User::aliasExists('j.daniels@kolab.org')); $this->assertFalse(User::aliasExists('john@kolab.org')); } /** * Tests for User::emailExists() */ public function testEmailExists(): void { $this->assertFalse(User::emailExists('jack.daniels@kolab.org')); $this->assertFalse(User::emailExists('j.daniels@kolab.org')); $this->assertTrue(User::emailExists('john@kolab.org')); $user = User::emailExists('john@kolab.org', true); $this->assertSame('john@kolab.org', $user->email); } /** * Tests for User::findByEmail() */ public function testFindByEmail(): void { $user = $this->getTestUser('john@kolab.org'); $result = User::findByEmail('john'); $this->assertNull($result); $result = User::findByEmail('non-existing@email.com'); $this->assertNull($result); $result = User::findByEmail('john@kolab.org'); $this->assertInstanceOf(User::class, $result); $this->assertSame($user->id, $result->id); // Use an alias $result = User::findByEmail('john.doe@kolab.org'); $this->assertInstanceOf(User::class, $result); $this->assertSame($user->id, $result->id); // A case where two users have the same alias $ned = $this->getTestUser('ned@kolab.org'); $ned->setAliases(['joe.monster@kolab.org']); $result = User::findByEmail('joe.monster@kolab.org'); $this->assertNull($result); $ned->setAliases([]); // TODO: searching by external email (setting) $this->markTestIncomplete(); } /** * Test User::name() */ public function testName(): void { Queue::fake(); $user = $this->getTestUser('user-test@' . \config('app.domain')); $this->assertSame('', $user->name()); $this->assertSame(\config('app.name') . ' User', $user->name(true)); $user->setSetting('first_name', 'First'); $this->assertSame('First', $user->name()); $this->assertSame('First', $user->name(true)); $user->setSetting('last_name', 'Last'); $this->assertSame('First Last', $user->name()); $this->assertSame('First Last', $user->name(true)); } /** * Test user restoring */ public function testRestore(): void { Queue::fake(); // Test an account with users and domain $userA = $this->getTestUser('UserAccountA@UserAccount.com', [ 'status' => User::STATUS_LDAP_READY | User::STATUS_IMAP_READY | User::STATUS_SUSPENDED, ]); $userB = $this->getTestUser('UserAccountB@UserAccount.com'); $package_kolab = \App\Package::where('title', 'kolab')->first(); $package_domain = \App\Package::where('title', 'domain-hosting')->first(); $domainA = $this->getTestDomain('UserAccount.com', [ 'status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_HOSTED, ]); $domainB = $this->getTestDomain('UserAccountAdd.com', [ 'status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_HOSTED, ]); $userA->assignPackage($package_kolab); $domainA->assignPackage($package_domain, $userA); $domainB->assignPackage($package_domain, $userA); $userA->assignPackage($package_kolab, $userB); $storage_sku = \App\Sku::where('title', 'storage')->first(); $now = \Carbon\Carbon::now(); $wallet_id = $userA->wallets->first()->id; // add an extra storage entitlement $ent1 = \App\Entitlement::create([ 'wallet_id' => $wallet_id, 'sku_id' => $storage_sku->id, 'cost' => 0, 'entitleable_id' => $userA->id, 'entitleable_type' => User::class, ]); $entitlementsA = \App\Entitlement::where('entitleable_id', $userA->id); $entitlementsB = \App\Entitlement::where('entitleable_id', $userB->id); $entitlementsDomain = \App\Entitlement::where('entitleable_id', $domainA->id); // First delete the user $userA->delete(); $this->assertSame(0, $entitlementsA->count()); $this->assertSame(0, $entitlementsB->count()); $this->assertSame(0, $entitlementsDomain->count()); $this->assertTrue($userA->fresh()->trashed()); $this->assertTrue($userB->fresh()->trashed()); $this->assertTrue($domainA->fresh()->trashed()); $this->assertTrue($domainB->fresh()->trashed()); $this->assertFalse($userA->isDeleted()); $this->assertFalse($userB->isDeleted()); $this->assertFalse($domainA->isDeleted()); // Backdate one storage entitlement (it's not expected to be restored) \App\Entitlement::withTrashed()->where('id', $ent1->id) ->update(['deleted_at' => $now->copy()->subMinutes(2)]); // Backdate entitlements to assert that they were restored with proper updated_at timestamp \App\Entitlement::withTrashed()->where('wallet_id', $wallet_id) ->update(['updated_at' => $now->subMinutes(10)]); Queue::fake(); // Then restore it $userA->restore(); $userA->refresh(); $this->assertFalse($userA->trashed()); $this->assertFalse($userA->isDeleted()); $this->assertFalse($userA->isSuspended()); $this->assertFalse($userA->isLdapReady()); $this->assertFalse($userA->isImapReady()); $this->assertTrue($userA->isActive()); $this->assertTrue($userB->fresh()->trashed()); $this->assertTrue($domainB->fresh()->trashed()); $this->assertFalse($domainA->fresh()->trashed()); // Assert entitlements $this->assertSame(4, $entitlementsA->count()); // mailbox + groupware + 2 x storage $this->assertTrue($ent1->fresh()->trashed()); $entitlementsA->get()->each(function ($ent) { $this->assertTrue($ent->updated_at->greaterThan(\Carbon\Carbon::now()->subSeconds(5))); }); // We expect only CreateJob + UpdateJob pair for both user and domain. // Because how Illuminate/Database/Eloquent/SoftDeletes::restore() method // is implemented we cannot skip the UpdateJob in any way. // I don't want to overwrite this method, the extra job shouldn't do any harm. $this->assertCount(4, Queue::pushedJobs()); // @phpstan-ignore-line Queue::assertPushed(\App\Jobs\Domain\UpdateJob::class, 1); Queue::assertPushed(\App\Jobs\Domain\CreateJob::class, 1); Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 1); Queue::assertPushed(\App\Jobs\User\CreateJob::class, 1); Queue::assertPushed( \App\Jobs\User\CreateJob::class, function ($job) use ($userA) { return $userA->id === TestCase::getObjectProperty($job, 'userId'); } ); Queue::assertPushedWithChain( \App\Jobs\User\CreateJob::class, [ \App\Jobs\User\VerifyJob::class, ] ); } /** * Tests for UserAliasesTrait::setAliases() */ public function testSetAliases(): void { Queue::fake(); Queue::assertNothingPushed(); $user = $this->getTestUser('UserAccountA@UserAccount.com'); $domain = $this->getTestDomain('UserAccount.com', [ 'status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_HOSTED, ]); $this->assertCount(0, $user->aliases->all()); // Add an alias $user->setAliases(['UserAlias1@UserAccount.com']); Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 1); $aliases = $user->aliases()->get(); $this->assertCount(1, $aliases); $this->assertSame('useralias1@useraccount.com', $aliases[0]['alias']); // Add another alias $user->setAliases(['UserAlias1@UserAccount.com', 'UserAlias2@UserAccount.com']); Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 2); $aliases = $user->aliases()->orderBy('alias')->get(); $this->assertCount(2, $aliases); $this->assertSame('useralias1@useraccount.com', $aliases[0]->alias); $this->assertSame('useralias2@useraccount.com', $aliases[1]->alias); // Remove an alias $user->setAliases(['UserAlias1@UserAccount.com']); Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 3); $aliases = $user->aliases()->get(); $this->assertCount(1, $aliases); $this->assertSame('useralias1@useraccount.com', $aliases[0]['alias']); // Remove all aliases $user->setAliases([]); Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 4); $this->assertCount(0, $user->aliases()->get()); } /** * Tests for UserSettingsTrait::setSettings() and getSetting() */ public function testUserSettings(): void { Queue::fake(); Queue::assertNothingPushed(); $user = $this->getTestUser('UserAccountA@UserAccount.com'); Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 0); // Test default settings // Note: Technicly this tests UserObserver::created() behavior $all_settings = $user->settings()->orderBy('key')->get(); $this->assertCount(2, $all_settings); $this->assertSame('country', $all_settings[0]->key); $this->assertSame('CH', $all_settings[0]->value); $this->assertSame('currency', $all_settings[1]->key); $this->assertSame('CHF', $all_settings[1]->value); // Add a setting $user->setSetting('first_name', 'Firstname'); Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 1); // Note: We test both current user as well as fresh user object // to make sure cache works as expected $this->assertSame('Firstname', $user->getSetting('first_name')); $this->assertSame('Firstname', $user->fresh()->getSetting('first_name')); // Update a setting $user->setSetting('first_name', 'Firstname1'); Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 2); // Note: We test both current user as well as fresh user object // to make sure cache works as expected $this->assertSame('Firstname1', $user->getSetting('first_name')); $this->assertSame('Firstname1', $user->fresh()->getSetting('first_name')); // Delete a setting (null) $user->setSetting('first_name', null); Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 3); // Note: We test both current user as well as fresh user object // to make sure cache works as expected $this->assertSame(null, $user->getSetting('first_name')); $this->assertSame(null, $user->fresh()->getSetting('first_name')); // Delete a setting (empty string) $user->setSetting('first_name', 'Firstname1'); $user->setSetting('first_name', ''); Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 5); // Note: We test both current user as well as fresh user object // to make sure cache works as expected $this->assertSame(null, $user->getSetting('first_name')); $this->assertSame(null, $user->fresh()->getSetting('first_name')); // Set multiple settings at once $user->setSettings([ 'first_name' => 'Firstname2', 'last_name' => 'Lastname2', 'country' => null, ]); // TODO: This really should create a single UserUpdate job, not 3 Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 7); // Note: We test both current user as well as fresh user object // to make sure cache works as expected $this->assertSame('Firstname2', $user->getSetting('first_name')); $this->assertSame('Firstname2', $user->fresh()->getSetting('first_name')); $this->assertSame('Lastname2', $user->getSetting('last_name')); $this->assertSame('Lastname2', $user->fresh()->getSetting('last_name')); $this->assertSame(null, $user->getSetting('country')); $this->assertSame(null, $user->fresh()->getSetting('country')); $all_settings = $user->settings()->orderBy('key')->get(); $this->assertCount(3, $all_settings); } /** * Tests for User::users() */ public function testUsers(): void { $jack = $this->getTestUser('jack@kolab.org'); $joe = $this->getTestUser('joe@kolab.org'); $john = $this->getTestUser('john@kolab.org'); $ned = $this->getTestUser('ned@kolab.org'); $wallet = $john->wallets()->first(); $users = $john->users()->orderBy('email')->get(); $this->assertCount(4, $users); $this->assertEquals($jack->id, $users[0]->id); $this->assertEquals($joe->id, $users[1]->id); $this->assertEquals($john->id, $users[2]->id); $this->assertEquals($ned->id, $users[3]->id); $this->assertSame($wallet->id, $users[0]->wallet_id); $this->assertSame($wallet->id, $users[1]->wallet_id); $this->assertSame($wallet->id, $users[2]->wallet_id); $this->assertSame($wallet->id, $users[3]->wallet_id); $users = $jack->users()->orderBy('email')->get(); $this->assertCount(0, $users); $users = $ned->users()->orderBy('email')->get(); $this->assertCount(4, $users); } public function testWallets(): void { $this->markTestIncomplete(); } }