diff --git a/src/app/Console/Commands/Sku/ListUsers.php b/src/app/Console/Commands/Sku/ListUsers.php new file mode 100644 index 00000000..631bfed8 --- /dev/null +++ b/src/app/Console/Commands/Sku/ListUsers.php @@ -0,0 +1,64 @@ +argument('sku')); + + if (!$sku) { + $sku = \App\Sku::where('title', $this->argument('sku'))->first(); + } + + if (!$sku) { + $this->error("Unable to find the SKU."); + return 1; + } + + $fn = function ($entitlement) { + $user_id = $entitlement->user_id; + if ($entitlement->entitleable_type == \App\User::class) { + $user_id = $entitlement->entitleable_id; + } + + return $user_id; + }; + + $users = \App\Entitlement::select('user_id', 'entitleable_id', 'entitleable_type') + ->join('wallets', 'wallets.id', '=', 'wallet_id') + ->where('sku_id', $sku->id) + ->get() + ->map($fn) + ->unique(); + + // TODO: This wereIn() might not scale + \App\User::whereIn('id', $users)->orderBy('email')->get() + ->pluck('email') + ->each(function ($email, $key) { + $this->info($email); + }); + } +} diff --git a/src/app/Console/Commands/UserAssignSku.php b/src/app/Console/Commands/UserAssignSku.php new file mode 100644 index 00000000..bdc1dd60 --- /dev/null +++ b/src/app/Console/Commands/UserAssignSku.php @@ -0,0 +1,60 @@ +argument('user'))->first(); + + if (!$user) { + $this->error("Unable to find the user {$this->argument('user')}."); + return 1; + } + + $sku = \App\Sku::find($this->argument('sku')); + + if (!$sku) { + $sku = \App\Sku::where('title', $this->argument('sku'))->first(); + } + + if (!$sku) { + $this->error("Unable to find the SKU {$this->argument('sku')}."); + return 1; + } + + $quantity = (int) $this->option('qty'); + + // Check if the entitlement already exists + if (empty($quantity)) { + if ($user->entitlements()->where('sku_id', $sku->id)->first()) { + $this->error("The entitlement already exists. Maybe try with --qty=X?"); + return 1; + } + } + + $user->assignSku($sku, $quantity ?: 1); + } +} diff --git a/src/app/Handlers/Activesync.php b/src/app/Handlers/Activesync.php index ad54abd1..98e3fc0a 100644 --- a/src/app/Handlers/Activesync.php +++ b/src/app/Handlers/Activesync.php @@ -1,26 +1,43 @@ sku->active) { - \Log::error("Sku not active"); - return false; - } + $data = parent::metadata($sku); - return true; + $data['required'] = ['groupware']; + + return $data; } + /** + * The priority that specifies the order of SKUs in UI. + * Higher number means higher on the list. + * + * @return int + */ public static function priority(): int { return 70; } } diff --git a/src/app/Handlers/Auth2F.php b/src/app/Handlers/Auth2F.php index f408c9f4..a7651e83 100644 --- a/src/app/Handlers/Auth2F.php +++ b/src/app/Handlers/Auth2F.php @@ -1,26 +1,43 @@ sku->active) { - \Log::error("Sku not active"); - return false; - } + $data = parent::metadata($sku); - return true; + $data['forbidden'] = ['activesync']; + + return $data; } + /** + * The priority that specifies the order of SKUs in UI. + * Higher number means higher on the list. + * + * @return int + */ public static function priority(): int { return 60; } } diff --git a/src/app/Handlers/Base.php b/src/app/Handlers/Base.php index 4574b4de..6c010781 100644 --- a/src/app/Handlers/Base.php +++ b/src/app/Handlers/Base.php @@ -1,40 +1,102 @@ active) { + if (!$user->entitlements()->where('sku_id', $sku->id)->first()) { + return false; + } + } + + return true; + } + + /** + * Metadata of this SKU handler. + * + * @param \App\Sku $sku The SKU object + * + * @return array + */ + public static function metadata(\App\Sku $sku): array + { + $handler = explode('\\', static::class); + $handler = strtolower(end($handler)); + + $type = explode('\\', static::entitleableClass()); + $type = strtolower(end($type)); + + return [ + 'type' => $type, + 'handler' => $handler, + 'readonly' => false, // cannot be changed + 'enabled' => false, // enabled by default + 'prio' => static::priority(), // priority + ]; + } + /** * Prerequisites for the Entitlement to be applied to the object. * * @param \App\Entitlement $entitlement * @param mixed $object * * @return bool */ public static function preReq($entitlement, $object): bool { + $type = static::entitleableClass(); + + if (empty($type) || empty($entitlement->entitleable_type)) { + \Log::error("Entitleable class/type not specified"); + return false; + } + + if ($type !== $entitlement->entitleable_type) { + \Log::error("Entitleable class mismatch"); + return false; + } + + if (!$entitlement->sku->active) { + \Log::error("Sku not active"); + return false; + } + return true; } /** * The priority that specifies the order of SKUs in UI. * Higher number means higher on the list. * * @return int */ public static function priority(): int { return 0; } } diff --git a/src/app/Handlers/Base.php b/src/app/Handlers/Beta.php similarity index 65% copy from src/app/Handlers/Base.php copy to src/app/Handlers/Beta.php index 4574b4de..f54d3302 100644 --- a/src/app/Handlers/Base.php +++ b/src/app/Handlers/Beta.php @@ -1,40 +1,48 @@ entitleable_type) { + \Log::error("Entitleable class mismatch"); + return false; + } + return true; } /** * The priority that specifies the order of SKUs in UI. * Higher number means higher on the list. * * @return int */ public static function priority(): int { - return 0; + // Just above all other beta SKUs, please + return 10; } } diff --git a/src/app/Handlers/Beta/Base.php b/src/app/Handlers/Beta/Base.php new file mode 100644 index 00000000..59130120 --- /dev/null +++ b/src/app/Handlers/Beta/Base.php @@ -0,0 +1,73 @@ +active) { + $beta = \App\Sku::where('title', 'beta')->first(); + if (!$beta) { + return false; + } + + if ($user->entitlements()->where('sku_id', $beta->id)->first()) { + return true; + } + } else { + if ($user->entitlements()->where('sku_id', $sku->id)->first()) { + return true; + } + } + + return false; + } + + /** + * SKU handler metadata. + * + * @param \App\Sku $sku The SKU object + * + * @return array + */ + public static function metadata(\App\Sku $sku): array + { + $data = parent::metadata($sku); + + $data['required'] = ['beta']; + + return $data; + } + + /** + * Prerequisites for the Entitlement to be applied to the object. + * + * @param \App\Entitlement $entitlement + * @param mixed $object + * + * @return bool + */ + public static function preReq($entitlement, $object): bool + { + if (!parent::preReq($entitlement, $object)) { + return false; + } + + // TODO: User has to have the "beta" entitlement + + return true; + } +} diff --git a/src/app/Handlers/Beta/Meet.php b/src/app/Handlers/Beta/Meet.php new file mode 100644 index 00000000..33757764 --- /dev/null +++ b/src/app/Handlers/Beta/Meet.php @@ -0,0 +1,18 @@ +sku->active) { - \Log::error("Sku not active"); - return false; - } - - return true; - } } diff --git a/src/app/Handlers/DomainHosting.php b/src/app/Handlers/DomainHosting.php index f599dec2..4350b077 100644 --- a/src/app/Handlers/DomainHosting.php +++ b/src/app/Handlers/DomainHosting.php @@ -1,21 +1,16 @@ sku->active) { - \Log::error("Sku not active"); - return false; - } - - return true; - } } diff --git a/src/app/Handlers/DomainRegistration.php b/src/app/Handlers/DomainRegistration.php index 78116392..77ad1fe5 100644 --- a/src/app/Handlers/DomainRegistration.php +++ b/src/app/Handlers/DomainRegistration.php @@ -1,21 +1,16 @@ sku->active) { - \Log::error("Sku not active"); - return false; - } - - return true; - } } diff --git a/src/app/Handlers/DomainRelay.php b/src/app/Handlers/DomainRelay.php index 69e298aa..d96fe7c8 100644 --- a/src/app/Handlers/DomainRelay.php +++ b/src/app/Handlers/DomainRelay.php @@ -1,21 +1,16 @@ sku->active) { - \Log::error("Sku not active"); - return false; - } - - return true; - } } diff --git a/src/app/Handlers/Groupware.php b/src/app/Handlers/Groupware.php index 9bc574dc..26bf73c3 100644 --- a/src/app/Handlers/Groupware.php +++ b/src/app/Handlers/Groupware.php @@ -1,32 +1,27 @@ sku->active) { - \Log::error("Sku not active"); - return false; - } - - return true; - } - /** * The priority that specifies the order of SKUs in UI. * Higher number means higher on the list. * * @return int */ public static function priority(): int { return 80; } } diff --git a/src/app/Handlers/Mailbox.php b/src/app/Handlers/Mailbox.php index f255c390..b28a1d88 100644 --- a/src/app/Handlers/Mailbox.php +++ b/src/app/Handlers/Mailbox.php @@ -1,50 +1,45 @@ sku->active) { - \Log::error("Sku not active"); - return false; - } -/* - FIXME: This code prevents from creating initial mailbox SKU - on signup of group account, because User::domains() - does not return the new domain. - Either we make sure to create domain entitlement before mailbox - entitlement or make the method here aware of that case or? - - list($local, $domain) = explode('@', $user->email); - - $domains = $user->domains(); + $data = parent::metadata($sku); - foreach ($domains as $_domain) { - if ($domain == $_domain->namespace) { - return true; - } - } + // Mailbox is always enabled and cannot be unset + $data['readonly'] = true; + $data['enabled'] = true; - \Log::info("Domain not for user"); -*/ - return true; + return $data; } /** * The priority that specifies the order of SKUs in UI. * Higher number means higher on the list. * * @return int */ public static function priority(): int { return 100; } } diff --git a/src/app/Handlers/Resource.php b/src/app/Handlers/Resource.php index 66cf6b03..4e6e3bd9 100644 --- a/src/app/Handlers/Resource.php +++ b/src/app/Handlers/Resource.php @@ -1,22 +1,17 @@ sku->active) { - \Log::error("Sku not active"); - return false; - } - - return true; - } } diff --git a/src/app/Handlers/SharedFolder.php b/src/app/Handlers/SharedFolder.php index 96b13ccb..52a8963e 100644 --- a/src/app/Handlers/SharedFolder.php +++ b/src/app/Handlers/SharedFolder.php @@ -1,22 +1,17 @@ sku->active) { - \Log::error("Sku not active"); - return false; - } - - return true; - } } diff --git a/src/app/Handlers/Storage.php b/src/app/Handlers/Storage.php index 340a4b4e..85a17125 100644 --- a/src/app/Handlers/Storage.php +++ b/src/app/Handlers/Storage.php @@ -1,37 +1,52 @@ sku->active) { - \Log::error("Sku not active"); - return false; - } + $data = parent::metadata($sku); - // TODO: The storage can not be modified to below what is already consumed. + $data['readonly'] = true; // only the checkbox will be disabled, not range + $data['enabled'] = true; + $data['range'] = [ + 'min' => $sku->units_free, + 'max' => self::MAX_ITEMS, + 'unit' => self::ITEM_UNIT, + ]; - return true; + return $data; } /** * The priority that specifies the order of SKUs in UI. * Higher number means higher on the list. * * @return int */ public static function priority(): int { return 90; } } diff --git a/src/app/Http/Controllers/API/V4/SkusController.php b/src/app/Http/Controllers/API/V4/SkusController.php index 6de81c59..bfc551a0 100644 --- a/src/app/Http/Controllers/API/V4/SkusController.php +++ b/src/app/Http/Controllers/API/V4/SkusController.php @@ -1,183 +1,185 @@ 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); } /** - * Display a listing of the sku. + * Get a list of active SKUs. * * @return \Illuminate\Http\JsonResponse */ public function index() { // Note: Order by title for consistent ordering in tests - $skus = Sku::select()->orderBy('title')->get(); - - // Note: we do not limit the result to active SKUs only. - // It's because we might need users assigned to old SKUs, - // we need to display these old SKUs on the entitlements list + $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); + + 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 (!$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 { - $type = $sku->handler_class::entitleableClass(); + $data = array_merge($sku->toArray(), $sku->handler_class::metadata($sku)); // ignore incomplete handlers - if (!$type) { + if (empty($data['type'])) { return null; } - $type = explode('\\', $type); - $type = strtolower(end($type)); - - $handler = explode('\\', $sku->handler_class); - $handler = strtolower(end($handler)); - - $data = $sku->toArray(); - - $data['type'] = $type; - $data['handler'] = $handler; - $data['readonly'] = false; - $data['enabled'] = false; - $data['prio'] = $sku->handler_class::priority(); - // Use localized value, toArray() does not get them right $data['name'] = $sku->name; $data['description'] = $sku->description; - unset($data['handler_class']); - - switch ($handler) { - case 'activesync': - $data['required'] = ['groupware']; - break; - - case 'auth2f': - $data['forbidden'] = ['activesync']; - break; - - case 'storage': - // Quota range input - $data['readonly'] = true; // only the checkbox will be disabled, not range - $data['enabled'] = true; - $data['range'] = [ - 'min' => $data['units_free'], - 'max' => $sku->handler_class::MAX_ITEMS, - 'unit' => $sku->handler_class::ITEM_UNIT, - ]; - break; - - case 'mailbox': - // Mailbox is always enabled and cannot be unset - $data['readonly'] = true; - $data['enabled'] = true; - break; - } + unset($data['handler_class'], $data['created_at'], $data['updated_at']); return $data; } } diff --git a/src/app/Observers/EntitlementObserver.php b/src/app/Observers/EntitlementObserver.php index 61a9ac12..7156c614 100644 --- a/src/app/Observers/EntitlementObserver.php +++ b/src/app/Observers/EntitlementObserver.php @@ -1,165 +1,165 @@ {$entitlement->getKeyName()} = $allegedly_unique; - break; - } - } - // can't dispatch job here because it'll fail serialization // Make sure the owner is at least a controller on the wallet $wallet = \App\Wallet::find($entitlement->wallet_id); if (!$wallet || !$wallet->owner) { return false; } $sku = \App\Sku::find($entitlement->sku_id); if (!$sku) { return false; } $result = $sku->handler_class::preReq($entitlement, $wallet->owner); if (!$result) { return false; } + while (true) { + $allegedly_unique = \App\Utils::uuidStr(); + if (!Entitlement::find($allegedly_unique)) { + $entitlement->{$entitlement->getKeyName()} = $allegedly_unique; + break; + } + } + return true; } /** * Handle the entitlement "created" event. * * @param \App\Entitlement $entitlement The entitlement. * * @return void */ public function created(Entitlement $entitlement) { $entitlement->entitleable->updated_at = Carbon::now(); $entitlement->entitleable->save(); $entitlement->createTransaction(\App\Transaction::ENTITLEMENT_CREATED); } /** * Handle the entitlement "deleted" event. * * @param \App\Entitlement $entitlement The entitlement. * * @return void */ public function deleted(Entitlement $entitlement) { // Remove all configured 2FA methods from Roundcube database if ($entitlement->sku->title == '2fa') { // FIXME: Should that be an async job? $sf = new \App\Auth\SecondFactor($entitlement->entitleable); $sf->removeFactors(); } $entitlement->entitleable->updated_at = Carbon::now(); $entitlement->entitleable->save(); $entitlement->createTransaction(\App\Transaction::ENTITLEMENT_DELETED); } /** * Handle the entitlement "deleting" event. * * @param \App\Entitlement $entitlement The entitlement. * * @return void */ public function deleting(Entitlement $entitlement) { if ($entitlement->trashed()) { return; } // Start calculating the costs for the consumption of this entitlement if the // existing consumption spans >= 14 days. // // Effect is that anything's free for the first 14 days if ($entitlement->created_at >= Carbon::now()->subDays(14)) { return; } $owner = $entitlement->wallet->owner; // Determine if we're still within the free first month $freeMonthEnds = $owner->created_at->copy()->addMonthsWithoutOverflow(1); if ($freeMonthEnds >= Carbon::now()) { return; } $cost = 0; $now = Carbon::now(); // get the discount rate applied to the wallet. $discount = $entitlement->wallet->getDiscountRate(); // just in case this had not been billed yet, ever $diffInMonths = $entitlement->updated_at->diffInMonths($now); $cost += (int) ($entitlement->cost * $discount * $diffInMonths); // this moves the hypothetical updated at forward to however many months past the original $updatedAt = $entitlement->updated_at->copy()->addMonthsWithoutOverflow($diffInMonths); // now we have the diff in days since the last "billed" period end. // This may be an entitlement paid up until February 28th, 2020, with today being March // 12th 2020. Calculating the costs for the entitlement is based on the daily price // the price per day is based on the number of days in the last month // or the current month if the period does not overlap with the previous month // FIXME: This really should be simplified to $daysInMonth=30 $diffInDays = $updatedAt->diffInDays($now); if ($now->day >= $diffInDays) { $daysInMonth = $now->daysInMonth; } else { $daysInMonth = \App\Utils::daysInLastMonth(); } $pricePerDay = $entitlement->cost / $daysInMonth; $cost += (int) (round($pricePerDay * $discount * $diffInDays, 0)); if ($cost == 0) { return; } $entitlement->wallet->debit($cost); } } diff --git a/src/app/User.php b/src/app/User.php index da9c21f3..5fe941d4 100644 --- a/src/app/User.php +++ b/src/app/User.php @@ -1,734 +1,728 @@ 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(); - // TODO: Sanity check, this probably should be in preReq() on handlers - // or in EntitlementObserver - if ($sku->handler_class::entitleableClass() != User::class) { - throw new \Exception("Cannot assign non-user SKU ({$sku->title}) to a user"); - } - 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 ($object instanceof Wallet) { return $object->user_id == $this->id || $object->controllers->contains($this); } if (!method_exists($object, 'wallet')) { return false; } $wallet = $object->wallet(); return $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 (!method_exists($object, 'wallet')) { return false; } if ($object instanceof User && $this->id == $object->id) { return true; } 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; } 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'); } public function addEntitlement($entitlement) { if (!$this->entitlements->contains($entitlement)) { return $this->entitlements()->save($entitlement); } } /** * Find whether an email address exists (user or alias). * Note: This will also find deleted users. * * @param string $email Email address * @param bool $return_user Return User instance instead of boolean * @param bool $is_alias Set to True if the existing email is an alias * @param bool $existing Ignore deleted users * * @return \App\User|bool True or User model object if found, False otherwise */ public static function emailExists(string $email, bool $return_user = false, &$is_alias = false, $existing = false) { if (strpos($email, '@') === false) { return false; } $email = \strtolower($email); if ($existing) { $user = self::where('email', $email)->first(); } else { $user = self::withTrashed()->where('email', $email)->first(); } if ($user) { return $return_user ? $user : true; } $aliases = UserAlias::where('alias', $email); if ($existing) { $aliases = $aliases->join('users', 'user_id', '=', 'users.id') ->whereNull('users.deleted_at'); } $alias = $aliases->first(); if ($alias) { $is_alias = true; return $return_user ? self::withTrashed()->find($alias->user_id) : 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 []; } /** * 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(); } /** * 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', 'App\User'); } /** * 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()->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/database/seeds/local/SkuSeeder.php b/src/database/seeds/local/SkuSeeder.php index e3b49179..f9908242 100644 --- a/src/database/seeds/local/SkuSeeder.php +++ b/src/database/seeds/local/SkuSeeder.php @@ -1,155 +1,181 @@ 'mailbox', 'name' => 'User Mailbox', 'description' => 'Just a mailbox', 'cost' => 444, 'units_free' => 0, 'period' => 'monthly', 'handler_class' => 'App\Handlers\Mailbox', 'active' => true, ] ); Sku::create( [ 'title' => 'domain', 'name' => 'Hosted Domain', 'description' => 'Somewhere to place a mailbox', 'cost' => 100, 'period' => 'monthly', 'handler_class' => 'App\Handlers\Domain', 'active' => false, ] ); Sku::create( [ 'title' => 'domain-registration', 'name' => 'Domain Registration', 'description' => 'Register a domain with us', 'cost' => 101, 'period' => 'yearly', 'handler_class' => 'App\Handlers\DomainRegistration', 'active' => false, ] ); Sku::create( [ 'title' => 'domain-hosting', 'name' => 'External Domain', 'description' => 'Host a domain that is externally registered', 'cost' => 100, 'units_free' => 1, 'period' => 'monthly', 'handler_class' => 'App\Handlers\DomainHosting', 'active' => true, ] ); Sku::create( [ 'title' => 'domain-relay', 'name' => 'Domain Relay', 'description' => 'A domain you host at home, for which we relay email', 'cost' => 103, 'period' => 'monthly', 'handler_class' => 'App\Handlers\DomainRelay', 'active' => false, ] ); Sku::create( [ 'title' => 'storage', 'name' => 'Storage Quota', 'description' => 'Some wiggle room', 'cost' => 25, 'units_free' => 2, 'period' => 'monthly', 'handler_class' => 'App\Handlers\Storage', 'active' => true, ] ); Sku::create( [ 'title' => 'groupware', 'name' => 'Groupware Features', 'description' => 'Groupware functions like Calendar, Tasks, Notes, etc.', 'cost' => 555, 'units_free' => 0, 'period' => 'monthly', 'handler_class' => 'App\Handlers\Groupware', 'active' => true, ] ); Sku::create( [ 'title' => 'resource', 'name' => 'Resource', 'description' => 'Reservation taker', 'cost' => 101, 'period' => 'monthly', 'handler_class' => 'App\Handlers\Resource', 'active' => false, ] ); Sku::create( [ 'title' => 'shared_folder', 'name' => 'Shared Folder', 'description' => 'A shared folder', 'cost' => 89, 'period' => 'monthly', 'handler_class' => 'App\Handlers\SharedFolder', 'active' => false, ] ); Sku::create( [ 'title' => '2fa', 'name' => '2-Factor Authentication', 'description' => 'Two factor authentication for webmail and administration panel', 'cost' => 0, 'units_free' => 0, 'period' => 'monthly', 'handler_class' => 'App\Handlers\Auth2F', 'active' => true, ] ); Sku::create( [ 'title' => 'activesync', 'name' => 'Activesync', 'description' => 'Mobile synchronization', 'cost' => 100, 'units_free' => 0, 'period' => 'monthly', 'handler_class' => 'App\Handlers\Activesync', 'active' => true, ] ); + + Sku::create( + [ + 'title' => 'beta', + 'name' => 'Beta program', + 'description' => 'Access to beta program subscriptions', + 'cost' => 0, + 'units_free' => 0, + 'period' => 'monthly', + 'handler_class' => 'App\Handlers\Beta', + 'active' => false, + ] + ); + + Sku::create( + [ + 'title' => 'meet', + 'name' => 'Video chat', + 'description' => 'Video conferencing tool', + 'cost' => 0, + 'units_free' => 0, + 'period' => 'monthly', + 'handler_class' => 'App\Handlers\Beta\Meet', + 'active' => true, + ] + ); } } diff --git a/src/database/seeds/production/SkuSeeder.php b/src/database/seeds/production/SkuSeeder.php index c5142773..3cea8847 100644 --- a/src/database/seeds/production/SkuSeeder.php +++ b/src/database/seeds/production/SkuSeeder.php @@ -1,155 +1,181 @@ 'mailbox', 'name' => 'User Mailbox', 'description' => 'Just a mailbox', 'cost' => 444, 'units_free' => 0, 'period' => 'monthly', 'handler_class' => 'App\Handlers\Mailbox', 'active' => true, ] ); Sku::create( [ 'title' => 'domain', 'name' => 'Hosted Domain', 'description' => 'Somewhere to place a mailbox', 'cost' => 100, 'period' => 'monthly', 'handler_class' => 'App\Handlers\Domain', 'active' => false, ] ); Sku::create( [ 'title' => 'domain-registration', 'name' => 'Domain Registration', 'description' => 'Register a domain with us', 'cost' => 101, 'period' => 'yearly', 'handler_class' => 'App\Handlers\DomainRegistration', 'active' => false, ] ); Sku::create( [ 'title' => 'domain-hosting', 'name' => 'External Domain', 'description' => 'Host a domain that is externally registered', 'cost' => 100, 'units_free' => 1, 'period' => 'monthly', 'handler_class' => 'App\Handlers\DomainHosting', 'active' => true, ] ); Sku::create( [ 'title' => 'domain-relay', 'name' => 'Domain Relay', 'description' => 'A domain you host at home, for which we relay email', 'cost' => 103, 'period' => 'monthly', 'handler_class' => 'App\Handlers\DomainRelay', 'active' => false, ] ); Sku::create( [ 'title' => 'storage', 'name' => 'Storage Quota', 'description' => 'Some wiggle room', 'cost' => 50, 'units_free' => 2, 'period' => 'monthly', 'handler_class' => 'App\Handlers\Storage', 'active' => true, ] ); Sku::create( [ 'title' => 'groupware', 'name' => 'Groupware Features', 'description' => 'Groupware functions like Calendar, Tasks, Notes, etc.', 'cost' => 555, 'units_free' => 0, 'period' => 'monthly', 'handler_class' => 'App\Handlers\Groupware', 'active' => true, ] ); Sku::create( [ 'title' => 'resource', 'name' => 'Resource', 'description' => 'Reservation taker', 'cost' => 101, 'period' => 'monthly', 'handler_class' => 'App\Handlers\Resource', 'active' => false, ] ); Sku::create( [ 'title' => 'shared_folder', 'name' => 'Shared Folder', 'description' => 'A shared folder', 'cost' => 89, 'period' => 'monthly', 'handler_class' => 'App\Handlers\SharedFolder', 'active' => false, ] ); Sku::create( [ 'title' => '2fa', 'name' => '2-Factor Authentication', 'description' => 'Two factor authentication for webmail and administration panel', 'cost' => 0, 'units_free' => 0, 'period' => 'monthly', 'handler_class' => 'App\Handlers\Auth2F', 'active' => true, ] ); Sku::create( [ 'title' => 'activesync', 'name' => 'Activesync', 'description' => 'Mobile synchronization', 'cost' => 100, 'units_free' => 0, 'period' => 'monthly', 'handler_class' => 'App\Handlers\Activesync', 'active' => true, ] ); + + Sku::create( + [ + 'title' => 'beta', + 'name' => 'Beta program', + 'description' => 'Access to beta program subscriptions', + 'cost' => 0, + 'units_free' => 0, + 'period' => 'monthly', + 'handler_class' => 'App\Handlers\Beta', + 'active' => false, + ] + ); + + Sku::create( + [ + 'title' => 'meet', + 'name' => 'Video chat', + 'description' => 'Video conferencing tool', + 'cost' => 0, + 'units_free' => 0, + 'period' => 'monthly', + 'handler_class' => 'App\Handlers\Beta\Meet', + 'active' => true, + ] + ); } } diff --git a/src/resources/vue/Admin/User.vue b/src/resources/vue/Admin/User.vue index 1cff1957..72867877 100644 --- a/src/resources/vue/Admin/User.vue +++ b/src/resources/vue/Admin/User.vue @@ -1,644 +1,644 @@ diff --git a/src/resources/vue/User/Info.vue b/src/resources/vue/User/Info.vue index d4d6ee53..ece335a1 100644 --- a/src/resources/vue/User/Info.vue +++ b/src/resources/vue/User/Info.vue @@ -1,370 +1,369 @@ diff --git a/src/routes/api.php b/src/routes/api.php index 9f3f8cb6..2f88ac49 100644 --- a/src/routes/api.php +++ b/src/routes/api.php @@ -1,119 +1,121 @@ '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::group( [ 'domain' => \config('app.domain'), 'prefix' => $prefix . 'api/webhooks', ], function () { Route::post('payment/{provider}', 'API\V4\PaymentsController@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); } ); diff --git a/src/tests/Browser/UsersTest.php b/src/tests/Browser/UsersTest.php index 419b777f..dc152883 100644 --- a/src/tests/Browser/UsersTest.php +++ b/src/tests/Browser/UsersTest.php @@ -1,586 +1,656 @@ 'John', 'last_name' => 'Doe', 'organization' => 'Kolab Developers', ]; /** * {@inheritDoc} */ public function setUp(): void { parent::setUp(); $this->deleteTestUser('julia.roberts@kolab.org'); $john = User::where('email', 'john@kolab.org')->first(); $john->setSettings($this->profile); UserAlias::where('user_id', $john->id) ->where('alias', 'john.test@kolab.org')->delete(); Entitlement::where('entitleable_id', $john->id)->whereIn('cost', [25, 100])->delete(); $wallet = $john->wallets()->first(); $wallet->discount()->dissociate(); $wallet->save(); + + $betas = Sku::where('handler_class', 'like', '%\\Beta%')->pluck('id')->all(); + Entitlement::whereIn('sku_id', $betas)->delete(); } /** * {@inheritDoc} */ public function tearDown(): void { $this->deleteTestUser('julia.roberts@kolab.org'); $john = User::where('email', 'john@kolab.org')->first(); $john->setSettings($this->profile); UserAlias::where('user_id', $john->id) ->where('alias', 'john.test@kolab.org')->delete(); Entitlement::where('entitleable_id', $john->id)->whereIn('cost', [25, 100])->delete(); $wallet = $john->wallets()->first(); $wallet->discount()->dissociate(); $wallet->save(); + $betas = Sku::where('handler_class', 'like', '%\\Beta%')->pluck('id')->all(); + Entitlement::whereIn('sku_id', $betas)->delete(); + parent::tearDown(); } /** * Test user info page (unauthenticated) */ public function testInfoUnauth(): void { // Test that the page requires authentication $this->browse(function (Browser $browser) { $user = User::where('email', 'john@kolab.org')->first(); $browser->visit('/user/' . $user->id)->on(new Home()); }); } /** * Test users list page (unauthenticated) */ public function testListUnauth(): void { // Test that the page requires authentication $this->browse(function (Browser $browser) { $browser->visit('/users')->on(new Home()); }); } /** * Test users list page */ public function testList(): void { // Test that the page requires authentication $this->browse(function (Browser $browser) { $browser->visit(new Home()) ->submitLogon('john@kolab.org', 'simple123', true) ->on(new Dashboard()) ->assertSeeIn('@links .link-users', 'User accounts') ->click('@links .link-users') ->on(new UserList()) ->whenAvailable('@table', function (Browser $browser) { $browser->waitFor('tbody tr') ->assertElementsCount('tbody tr', 4) ->assertSeeIn('tbody tr:nth-child(1) a', 'jack@kolab.org') ->assertSeeIn('tbody tr:nth-child(2) a', 'joe@kolab.org') ->assertSeeIn('tbody tr:nth-child(3) a', 'john@kolab.org') ->assertSeeIn('tbody tr:nth-child(4) a', 'ned@kolab.org') ->assertVisible('tbody tr:nth-child(1) button.button-delete') ->assertVisible('tbody tr:nth-child(2) button.button-delete') ->assertVisible('tbody tr:nth-child(3) button.button-delete') ->assertVisible('tbody tr:nth-child(4) button.button-delete') ->assertMissing('tfoot'); }); }); } /** * Test user account editing page (not profile page) * * @depends testList */ public function testInfo(): void { $this->browse(function (Browser $browser) { $browser->on(new UserList()) ->click('@table tr:nth-child(3) a') ->on(new UserInfo()) ->assertSeeIn('#user-info .card-title', 'User account') ->with('@form', function (Browser $browser) { // Assert form content $browser->assertSeeIn('div.row:nth-child(1) label', 'Status') ->assertSeeIn('div.row:nth-child(1) #status', 'Active') ->assertFocused('div.row:nth-child(2) input') ->assertSeeIn('div.row:nth-child(2) label', 'First name') ->assertValue('div.row:nth-child(2) input[type=text]', $this->profile['first_name']) ->assertSeeIn('div.row:nth-child(3) label', 'Last name') ->assertValue('div.row:nth-child(3) input[type=text]', $this->profile['last_name']) ->assertSeeIn('div.row:nth-child(4) label', 'Organization') ->assertValue('div.row:nth-child(4) input[type=text]', $this->profile['organization']) ->assertSeeIn('div.row:nth-child(5) label', 'Email') ->assertValue('div.row:nth-child(5) input[type=text]', 'john@kolab.org') ->assertDisabled('div.row:nth-child(5) input[type=text]') ->assertSeeIn('div.row:nth-child(6) label', 'Email aliases') ->assertVisible('div.row:nth-child(6) .list-input') ->with(new ListInput('#aliases'), function (Browser $browser) { $browser->assertListInputValue(['john.doe@kolab.org']) ->assertValue('@input', ''); }) ->assertSeeIn('div.row:nth-child(7) label', 'Password') ->assertValue('div.row:nth-child(7) input[type=password]', '') ->assertSeeIn('div.row:nth-child(8) label', 'Confirm password') ->assertValue('div.row:nth-child(8) input[type=password]', '') ->assertSeeIn('button[type=submit]', 'Submit') // Clear some fields and submit ->vueClear('#first_name') ->vueClear('#last_name') ->click('button[type=submit]'); }) ->assertToast(Toast::TYPE_SUCCESS, 'User data updated successfully.') ->on(new UserList()) ->click('@table tr:nth-child(3) a') ->on(new UserInfo()) ->assertSeeIn('#user-info .card-title', 'User account') ->with('@form', function (Browser $browser) { // Test error handling (password) $browser->type('#password', 'aaaaaa') ->vueClear('#password_confirmation') ->click('button[type=submit]') ->waitFor('#password + .invalid-feedback') ->assertSeeIn('#password + .invalid-feedback', 'The password confirmation does not match.') ->assertFocused('#password') ->assertToast(Toast::TYPE_ERROR, 'Form validation error'); // TODO: Test password change // Test form error handling (aliases) $browser->vueClear('#password') ->vueClear('#password_confirmation') ->with(new ListInput('#aliases'), function (Browser $browser) { $browser->addListEntry('invalid address'); }) ->click('button[type=submit]') ->assertToast(Toast::TYPE_ERROR, 'Form validation error'); $browser->with(new ListInput('#aliases'), function (Browser $browser) { $browser->assertFormError(2, 'The specified alias is invalid.', false); }); // Test adding aliases $browser->with(new ListInput('#aliases'), function (Browser $browser) { $browser->removeListEntry(2) ->addListEntry('john.test@kolab.org'); }) ->click('button[type=submit]') ->assertToast(Toast::TYPE_SUCCESS, 'User data updated successfully.'); }) ->on(new UserList()) ->click('@table tr:nth-child(3) a') ->on(new UserInfo()); $john = User::where('email', 'john@kolab.org')->first(); $alias = UserAlias::where('user_id', $john->id)->where('alias', 'john.test@kolab.org')->first(); $this->assertTrue(!empty($alias)); // Test subscriptions $browser->with('@form', function (Browser $browser) { $browser->assertSeeIn('div.row:nth-child(9) label', 'Subscriptions') ->assertVisible('@skus.row:nth-child(9)') ->with('@skus', function ($browser) { $browser->assertElementsCount('tbody tr', 5) // Mailbox SKU ->assertSeeIn('tbody tr:nth-child(1) td.name', 'User Mailbox') ->assertSeeIn('tbody tr:nth-child(1) td.price', '4,44 CHF/month') ->assertChecked('tbody tr:nth-child(1) td.selection input') ->assertDisabled('tbody tr:nth-child(1) td.selection input') ->assertTip( 'tbody tr:nth-child(1) td.buttons button', 'Just a mailbox' ) // Storage SKU ->assertSeeIn('tbody tr:nth-child(2) td.name', 'Storage Quota') ->assertSeeIn('tr:nth-child(2) td.price', '0,00 CHF/month') ->assertChecked('tbody tr:nth-child(2) td.selection input') ->assertDisabled('tbody tr:nth-child(2) td.selection input') ->assertTip( 'tbody tr:nth-child(2) td.buttons button', 'Some wiggle room' ) ->with(new QuotaInput('tbody tr:nth-child(2) .range-input'), function ($browser) { $browser->assertQuotaValue(2)->setQuotaValue(3); }) ->assertSeeIn('tr:nth-child(2) td.price', '0,25 CHF/month') // groupware SKU ->assertSeeIn('tbody tr:nth-child(3) td.name', 'Groupware Features') ->assertSeeIn('tbody tr:nth-child(3) td.price', '5,55 CHF/month') ->assertChecked('tbody tr:nth-child(3) td.selection input') ->assertEnabled('tbody tr:nth-child(3) td.selection input') ->assertTip( 'tbody tr:nth-child(3) td.buttons button', 'Groupware functions like Calendar, Tasks, Notes, etc.' ) // ActiveSync SKU ->assertSeeIn('tbody tr:nth-child(4) td.name', 'Activesync') ->assertSeeIn('tbody tr:nth-child(4) td.price', '1,00 CHF/month') ->assertNotChecked('tbody tr:nth-child(4) td.selection input') ->assertEnabled('tbody tr:nth-child(4) td.selection input') ->assertTip( 'tbody tr:nth-child(4) td.buttons button', 'Mobile synchronization' ) // 2FA SKU ->assertSeeIn('tbody tr:nth-child(5) td.name', '2-Factor Authentication') ->assertSeeIn('tbody tr:nth-child(5) td.price', '0,00 CHF/month') ->assertNotChecked('tbody tr:nth-child(5) td.selection input') ->assertEnabled('tbody tr:nth-child(5) td.selection input') ->assertTip( 'tbody tr:nth-child(5) td.buttons button', 'Two factor authentication for webmail and administration panel' ) ->click('tbody tr:nth-child(4) td.selection input'); }) ->assertMissing('@skus table + .hint') ->click('button[type=submit]') ->assertToast(Toast::TYPE_SUCCESS, 'User data updated successfully.'); }) ->on(new UserList()) ->click('@table tr:nth-child(3) a') ->on(new UserInfo()); $expected = ['activesync', 'groupware', 'mailbox', 'storage', 'storage', 'storage']; $this->assertUserEntitlements($john, $expected); // Test subscriptions interaction $browser->with('@form', function (Browser $browser) { $browser->with('@skus', function ($browser) { // Uncheck 'groupware', expect activesync unchecked $browser->click('#sku-input-groupware') ->assertNotChecked('#sku-input-groupware') ->assertNotChecked('#sku-input-activesync') ->assertEnabled('#sku-input-activesync') ->assertNotReadonly('#sku-input-activesync') // Check 'activesync', expect an alert ->click('#sku-input-activesync') ->assertDialogOpened('Activesync requires Groupware Features.') ->acceptDialog() ->assertNotChecked('#sku-input-activesync') // Check '2FA', expect 'activesync' unchecked and readonly ->click('#sku-input-2fa') ->assertChecked('#sku-input-2fa') ->assertNotChecked('#sku-input-activesync') ->assertReadonly('#sku-input-activesync') // Uncheck '2FA' ->click('#sku-input-2fa') ->assertNotChecked('#sku-input-2fa') ->assertNotReadonly('#sku-input-activesync'); }); }); }); } /** * Test user adding page * * @depends testList */ public function testNewUser(): void { $this->browse(function (Browser $browser) { $browser->visit(new UserList()) ->assertSeeIn('button.create-user', 'Create user') ->click('button.create-user') ->on(new UserInfo()) ->assertSeeIn('#user-info .card-title', 'New user account') ->with('@form', function (Browser $browser) { // Assert form content $browser->assertFocused('div.row:nth-child(1) input') ->assertSeeIn('div.row:nth-child(1) label', 'First name') ->assertValue('div.row:nth-child(1) input[type=text]', '') ->assertSeeIn('div.row:nth-child(2) label', 'Last name') ->assertValue('div.row:nth-child(2) input[type=text]', '') ->assertSeeIn('div.row:nth-child(3) label', 'Organization') ->assertValue('div.row:nth-child(3) input[type=text]', '') ->assertSeeIn('div.row:nth-child(4) label', 'Email') ->assertValue('div.row:nth-child(4) input[type=text]', '') ->assertEnabled('div.row:nth-child(4) input[type=text]') ->assertSeeIn('div.row:nth-child(5) label', 'Email aliases') ->assertVisible('div.row:nth-child(5) .list-input') ->with(new ListInput('#aliases'), function (Browser $browser) { $browser->assertListInputValue([]) ->assertValue('@input', ''); }) ->assertSeeIn('div.row:nth-child(6) label', 'Password') ->assertValue('div.row:nth-child(6) input[type=password]', '') ->assertSeeIn('div.row:nth-child(7) label', 'Confirm password') ->assertValue('div.row:nth-child(7) input[type=password]', '') ->assertSeeIn('div.row:nth-child(8) label', 'Package') // assert packages list widget, select "Lite Account" ->with('@packages', function ($browser) { $browser->assertElementsCount('tbody tr', 2) ->assertSeeIn('tbody tr:nth-child(1)', 'Groupware Account') ->assertSeeIn('tbody tr:nth-child(2)', 'Lite Account') ->assertSeeIn('tbody tr:nth-child(1) .price', '9,99 CHF/month') ->assertSeeIn('tbody tr:nth-child(2) .price', '4,44 CHF/month') ->assertChecked('tbody tr:nth-child(1) input') ->click('tbody tr:nth-child(2) input') ->assertNotChecked('tbody tr:nth-child(1) input') ->assertChecked('tbody tr:nth-child(2) input'); }) ->assertMissing('@packages table + .hint') ->assertSeeIn('button[type=submit]', 'Submit'); // Test browser-side required fields and error handling $browser->click('button[type=submit]') ->assertFocused('#email') ->type('#email', 'invalid email') ->click('button[type=submit]') ->assertFocused('#password') ->type('#password', 'simple123') ->click('button[type=submit]') ->assertFocused('#password_confirmation') ->type('#password_confirmation', 'simple') ->click('button[type=submit]') ->assertToast(Toast::TYPE_ERROR, 'Form validation error') ->assertSeeIn('#email + .invalid-feedback', 'The specified email is invalid.') ->assertSeeIn('#password + .invalid-feedback', 'The password confirmation does not match.'); }); // Test form error handling (aliases) $browser->with('@form', function (Browser $browser) { $browser->type('#email', 'julia.roberts@kolab.org') ->type('#password_confirmation', 'simple123') ->with(new ListInput('#aliases'), function (Browser $browser) { $browser->addListEntry('invalid address'); }) ->click('button[type=submit]') ->assertToast(Toast::TYPE_ERROR, 'Form validation error') ->with(new ListInput('#aliases'), function (Browser $browser) { $browser->assertFormError(1, 'The specified alias is invalid.', false); }); }); // Successful account creation $browser->with('@form', function (Browser $browser) { $browser->type('#first_name', 'Julia') ->type('#last_name', 'Roberts') ->type('#organization', 'Test Org') ->with(new ListInput('#aliases'), function (Browser $browser) { $browser->removeListEntry(1) ->addListEntry('julia.roberts2@kolab.org'); }) ->click('button[type=submit]'); }) ->assertToast(Toast::TYPE_SUCCESS, 'User created successfully.') // check redirection to users list ->on(new UserList()) ->whenAvailable('@table', function (Browser $browser) { $browser->assertElementsCount('tbody tr', 5) ->assertSeeIn('tbody tr:nth-child(4) a', 'julia.roberts@kolab.org'); }); $julia = User::where('email', 'julia.roberts@kolab.org')->first(); $alias = UserAlias::where('user_id', $julia->id)->where('alias', 'julia.roberts2@kolab.org')->first(); $this->assertTrue(!empty($alias)); $this->assertUserEntitlements($julia, ['mailbox', 'storage', 'storage']); $this->assertSame('Julia', $julia->getSetting('first_name')); $this->assertSame('Roberts', $julia->getSetting('last_name')); $this->assertSame('Test Org', $julia->getSetting('organization')); // Some additional tests for the list input widget $browser->click('tbody tr:nth-child(4) a') ->on(new UserInfo()) ->with(new ListInput('#aliases'), function (Browser $browser) { $browser->assertListInputValue(['julia.roberts2@kolab.org']) ->addListEntry('invalid address') ->type('.input-group:nth-child(2) input', '@kolab.org'); }) ->click('button[type=submit]') ->assertToast(Toast::TYPE_ERROR, 'Form validation error') ->with(new ListInput('#aliases'), function (Browser $browser) { $browser->assertVisible('.input-group:nth-child(2) input.is-invalid') ->assertVisible('.input-group:nth-child(3) input.is-invalid') ->type('.input-group:nth-child(2) input', 'julia.roberts3@kolab.org') ->type('.input-group:nth-child(3) input', 'julia.roberts4@kolab.org'); }) ->click('button[type=submit]') ->assertToast(Toast::TYPE_SUCCESS, 'User data updated successfully.'); $julia = User::where('email', 'julia.roberts@kolab.org')->first(); $aliases = $julia->aliases()->orderBy('alias')->get()->pluck('alias')->all(); $this->assertSame(['julia.roberts3@kolab.org', 'julia.roberts4@kolab.org'], $aliases); }); } /** * Test user delete * * @depends testNewUser */ public function testDeleteUser(): void { // First create a new user $john = $this->getTestUser('john@kolab.org'); $julia = $this->getTestUser('julia.roberts@kolab.org'); $package_kolab = \App\Package::where('title', 'kolab')->first(); $john->assignPackage($package_kolab, $julia); // Test deleting non-controller user $this->browse(function (Browser $browser) { $browser->visit(new UserList()) ->whenAvailable('@table', function (Browser $browser) { $browser->assertElementsCount('tbody tr', 5) ->assertSeeIn('tbody tr:nth-child(4) a', 'julia.roberts@kolab.org') ->click('tbody tr:nth-child(4) button.button-delete'); }) ->with(new Dialog('#delete-warning'), function (Browser $browser) { $browser->assertSeeIn('@title', 'Delete julia.roberts@kolab.org') ->assertFocused('@button-cancel') ->assertSeeIn('@button-cancel', 'Cancel') ->assertSeeIn('@button-action', 'Delete') ->click('@button-cancel'); }) ->whenAvailable('@table', function (Browser $browser) { $browser->click('tbody tr:nth-child(4) button.button-delete'); }) ->with(new Dialog('#delete-warning'), function (Browser $browser) { $browser->click('@button-action'); }) ->assertToast(Toast::TYPE_SUCCESS, 'User deleted successfully.') ->with('@table', function (Browser $browser) { $browser->assertElementsCount('tbody tr', 4) ->assertSeeIn('tbody tr:nth-child(1) a', 'jack@kolab.org') ->assertSeeIn('tbody tr:nth-child(2) a', 'joe@kolab.org') ->assertSeeIn('tbody tr:nth-child(3) a', 'john@kolab.org') ->assertSeeIn('tbody tr:nth-child(4) a', 'ned@kolab.org'); }); $julia = User::where('email', 'julia.roberts@kolab.org')->first(); $this->assertTrue(empty($julia)); // Test clicking Delete on the controller record redirects to /profile/delete $browser ->with('@table', function (Browser $browser) { $browser->click('tbody tr:nth-child(3) button.button-delete'); }) ->waitForLocation('/profile/delete'); }); // Test that non-controller user cannot see/delete himself on the users list // Note: Access to /profile/delete page is tested in UserProfileTest.php $this->browse(function (Browser $browser) { $browser->visit('/logout') ->on(new Home()) ->submitLogon('jack@kolab.org', 'simple123', true) ->visit(new UserList()) ->whenAvailable('@table', function (Browser $browser) { $browser->assertElementsCount('tbody tr', 0) ->assertSeeIn('tfoot td', 'There are no users in this account.'); }); }); // Test that controller user (Ned) can see/delete all the users ??? $this->browse(function (Browser $browser) { $browser->visit('/logout') ->on(new Home()) ->submitLogon('ned@kolab.org', 'simple123', true) ->visit(new UserList()) ->whenAvailable('@table', function (Browser $browser) { $browser->assertElementsCount('tbody tr', 4) ->assertElementsCount('tbody button.button-delete', 4); }); // TODO: Test the delete action in details }); // TODO: Test what happens with the logged in user session after he's been deleted by another user } /** * Test discounted sku/package prices in the UI */ public function testDiscountedPrices(): void { // Add 10% discount $discount = Discount::where('code', 'TEST')->first(); $john = User::where('email', 'john@kolab.org')->first(); $wallet = $john->wallet(); $wallet->discount()->associate($discount); $wallet->save(); // SKUs on user edit page $this->browse(function (Browser $browser) { $browser->visit('/logout') ->on(new Home()) ->submitLogon('john@kolab.org', 'simple123', true) ->visit(new UserList()) ->waitFor('@table tr:nth-child(2)') ->click('@table tr:nth-child(2) a') ->on(new UserInfo()) ->with('@form', function (Browser $browser) { $browser->whenAvailable('@skus', function (Browser $browser) { $quota_input = new QuotaInput('tbody tr:nth-child(2) .range-input'); $browser->waitFor('tbody tr') ->assertElementsCount('tbody tr', 5) // Mailbox SKU ->assertSeeIn('tbody tr:nth-child(1) td.price', '3,99 CHF/month¹') // Storage SKU ->assertSeeIn('tr:nth-child(2) td.price', '0,00 CHF/month¹') ->with($quota_input, function (Browser $browser) { $browser->setQuotaValue(100); }) ->assertSeeIn('tr:nth-child(2) td.price', '21,56 CHF/month¹') // groupware SKU ->assertSeeIn('tbody tr:nth-child(3) td.price', '4,99 CHF/month¹') // ActiveSync SKU ->assertSeeIn('tbody tr:nth-child(4) td.price', '0,90 CHF/month¹') // 2FA SKU ->assertSeeIn('tbody tr:nth-child(5) td.price', '0,00 CHF/month¹'); }) ->assertSeeIn('@skus table + .hint', '¹ applied discount: 10% - Test voucher'); }); }); // Packages on new user page $this->browse(function (Browser $browser) { $browser->visit(new UserList()) ->click('button.create-user') ->on(new UserInfo()) ->with('@form', function (Browser $browser) { $browser->whenAvailable('@packages', function (Browser $browser) { $browser->assertElementsCount('tbody tr', 2) ->assertSeeIn('tbody tr:nth-child(1) .price', '8,99 CHF/month¹') // Groupware ->assertSeeIn('tbody tr:nth-child(2) .price', '3,99 CHF/month¹'); // Lite }) ->assertSeeIn('@packages table + .hint', '¹ applied discount: 10% - Test voucher'); }); }); } + + /** + * Test beta entitlements + * + * @depends testList + */ + public function testBetaEntitlements(): void + { + $this->browse(function (Browser $browser) { + $john = User::where('email', 'john@kolab.org')->first(); + $sku = Sku::where('title', 'beta')->first(); + $john->assignSku($sku); + + $browser->visit('/user/' . $john->id) + ->on(new UserInfo()) + ->with('@skus', function ($browser) { + $browser->assertElementsCount('tbody tr', 7) + // Beta SKU + ->assertSeeIn('tbody tr:nth-child(6) td.name', 'Beta program') + ->assertSeeIn('tbody tr:nth-child(6) td.price', '0,00 CHF/month') + ->assertChecked('tbody tr:nth-child(6) td.selection input') + ->assertEnabled('tbody tr:nth-child(6) td.selection input') + ->assertTip( + 'tbody tr:nth-child(6) td.buttons button', + 'Access to beta program subscriptions' + ) + // Beta/Meet SKU + ->assertSeeIn('tbody tr:nth-child(7) td.name', 'Video chat') + ->assertSeeIn('tr:nth-child(7) td.price', '0,00 CHF/month') + ->assertNotChecked('tbody tr:nth-child(7) td.selection input') + ->assertEnabled('tbody tr:nth-child(7) td.selection input') + ->assertTip( + 'tbody tr:nth-child(7) td.buttons button', + 'Video conferencing tool' + ) + // Check Meet, Uncheck Beta, expect Meet unchecked + ->click('#sku-input-meet') + ->click('#sku-input-beta') + ->assertNotChecked('#sku-input-beta') + ->assertNotChecked('#sku-input-meet') + // Click Meet expect an alert + ->click('#sku-input-meet') + ->assertDialogOpened('Video chat requires Beta program.') + ->acceptDialog() + // Enable Meet and Beta and submit + ->click('#sku-input-beta') + ->click('#sku-input-meet'); + }) + ->click('button[type=submit]') + ->assertToast(Toast::TYPE_SUCCESS, 'User data updated successfully.'); + + $expected = ['beta', 'groupware', 'mailbox', 'meet', 'storage', 'storage']; + $this->assertUserEntitlements($john, $expected); + + $browser->visit('/user/' . $john->id) + ->on(new UserInfo()) + ->click('#sku-input-beta') + ->click('button[type=submit]') + ->assertToast(Toast::TYPE_SUCCESS, 'User data updated successfully.'); + + $expected = ['groupware', 'mailbox', 'storage', 'storage']; + $this->assertUserEntitlements($john, $expected); + }); + } } diff --git a/src/tests/Feature/Console/Sku/ListUsersTest.php b/src/tests/Feature/Console/Sku/ListUsersTest.php new file mode 100644 index 00000000..abf54505 --- /dev/null +++ b/src/tests/Feature/Console/Sku/ListUsersTest.php @@ -0,0 +1,78 @@ +deleteTestUser('sku-list-users@kolabnow.com'); + } + + /** + * {@inheritDoc} + */ + public function tearDown(): void + { + $this->deleteTestUser('sku-list-users@kolabnow.com'); + + parent::tearDown(); + } + + /** + * Test command runs + */ + public function testHandle(): void + { + // Warning: We're not using artisan() here, as this will not + // allow us to test "empty output" cases + $code = \Artisan::call('sku:list-users meet'); + $output = trim(\Artisan::output()); + $this->assertSame(0, $code); + $this->assertSame('', $output); + + $code = \Artisan::call('sku:list-users unknown'); + $output = trim(\Artisan::output()); + $this->assertSame(1, $code); + $this->assertSame("Unable to find the SKU.", $output); + + $code = \Artisan::call('sku:list-users 2fa'); + $output = trim(\Artisan::output()); + $this->assertSame(0, $code); + $this->assertSame("ned@kolab.org", $output); + + $code = \Artisan::call('sku:list-users mailbox'); + $output = trim(\Artisan::output()); + $this->assertSame(0, $code); + $this->assertSame("jack@kolab.org\njoe@kolab.org\njohn@kolab.org\nned@kolab.org", $output); + + $code = \Artisan::call('sku:list-users domain-hosting'); + $output = trim(\Artisan::output()); + $this->assertSame(0, $code); + $this->assertSame("john@kolab.org", $output); + + $sku = \App\Sku::where('title', 'meet')->first(); + $user = $this->getTestUser('sku-list-users@kolabnow.com'); + $user->assignSku($sku); + + $code = \Artisan::call('sku:list-users meet'); + $output = trim(\Artisan::output()); + $this->assertSame(0, $code); + $this->assertSame($user->email, $output); + + $user->assignSku($sku); + + $code = \Artisan::call('sku:list-users meet'); + $output = trim(\Artisan::output()); + $this->assertSame(0, $code); + $this->assertSame($user->email, $output); + } +} diff --git a/src/tests/Feature/Console/UserAssignSkuTest.php b/src/tests/Feature/Console/UserAssignSkuTest.php new file mode 100644 index 00000000..9905fe72 --- /dev/null +++ b/src/tests/Feature/Console/UserAssignSkuTest.php @@ -0,0 +1,63 @@ +deleteTestUser('add-entitlement@kolabnow.com'); + } + + /** + * {@inheritDoc} + */ + public function tearDown(): void + { + $this->deleteTestUser('add-entitlement@kolabnow.com'); + + parent::tearDown(); + } + + /** + * Test command runs + */ + public function testHandle(): void + { + $sku = \App\Sku::where('title', 'meet')->first(); + $user = $this->getTestUser('add-entitlement@kolabnow.com'); + + $this->artisan('user:assign-sku unknown@unknown.org ' . $sku->id) + ->assertExitCode(1) + ->expectsOutput("Unable to find the user unknown@unknown.org."); + + $this->artisan('user:assign-sku ' . $user->email . ' unknownsku') + ->assertExitCode(1) + ->expectsOutput("Unable to find the SKU unknownsku."); + + $this->artisan('user:assign-sku ' . $user->email . ' ' . $sku->id) + ->assertExitCode(0); + + $this->assertCount(1, $user->entitlements()->where('sku_id', $sku->id)->get()); + + // Try again (also test sku by title) + $this->artisan('user:assign-sku ' . $user->email . ' ' . $sku->title) + ->assertExitCode(1) + ->expectsOutput("The entitlement already exists. Maybe try with --qty=X?"); + + $this->assertCount(1, $user->entitlements()->where('sku_id', $sku->id)->get()); + + // Try again with --qty option, to force the assignment + $this->artisan('user:assign-sku ' . $user->email . ' ' . $sku->title . ' --qty=1') + ->assertExitCode(0); + + $this->assertCount(2, $user->entitlements()->where('sku_id', $sku->id)->get()); + } +} diff --git a/src/tests/Feature/Controller/SkusTest.php b/src/tests/Feature/Controller/SkusTest.php index b1245308..29787b53 100644 --- a/src/tests/Feature/Controller/SkusTest.php +++ b/src/tests/Feature/Controller/SkusTest.php @@ -1,70 +1,203 @@ pluck('id')->all(); + Entitlement::whereIn('sku_id', $betas)->delete(); + } + + /** + * {@inheritDoc} + */ + public function tearDown(): void + { + $betas = Sku::where('handler_class', 'like', '%\\Beta%')->pluck('id')->all(); + Entitlement::whereIn('sku_id', $betas)->delete(); + + parent::tearDown(); + } + + /** * Test fetching SKUs list */ public function testIndex(): void { // Unauth access not allowed $response = $this->get("api/v4/skus"); $response->assertStatus(401); $user = $this->getTestUser('john@kolab.org'); $sku = Sku::where('title', 'mailbox')->first(); $response = $this->actingAs($user)->get("api/v4/skus"); $response->assertStatus(200); $json = $response->json(); - $this->assertCount(9, $json); + $this->assertCount(7, $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 for SkusController::skuElement() + * Test fetching SKUs list for a user (GET /users//skus) */ - public function testSkuElement(): void + public function testUserSkus(): void { - $sku = Sku::where('title', 'storage')->first(); - $result = $this->invokeMethod(new SkusController(), 'skuElement', [$sku]); + $user = $this->getTestUser('john@kolab.org'); + + // Unauth access not allowed + $response = $this->get("api/v4/users/{$user->id}/skus"); + $response->assertStatus(401); + + $response = $this->actingAs($user)->get("api/v4/users/{$user->id}/skus"); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertCount(6, $json); + + $this->assertSkuElement('mailbox', $json[0], [ + 'prio' => 100, + 'type' => 'user', + 'handler' => 'mailbox', + 'enabled' => true, + 'readonly' => true, + ]); + + $this->assertSkuElement('storage', $json[1], [ + 'prio' => 90, + 'type' => 'user', + 'handler' => 'storage', + 'enabled' => true, + 'readonly' => true, + 'range' => [ + 'min' => 2, + 'max' => 100, + 'unit' => 'GB', + ] + ]); + + $this->assertSkuElement('groupware', $json[2], [ + 'prio' => 80, + 'type' => 'user', + 'handler' => 'groupware', + 'enabled' => false, + 'readonly' => false, + ]); + + $this->assertSkuElement('activesync', $json[3], [ + 'prio' => 70, + 'type' => 'user', + 'handler' => 'activesync', + 'enabled' => false, + 'readonly' => false, + 'required' => ['groupware'], + ]); + + $this->assertSkuElement('2fa', $json[4], [ + 'prio' => 60, + 'type' => 'user', + 'handler' => 'auth2f', + 'enabled' => false, + 'readonly' => false, + 'forbidden' => ['activesync'], + ]); + + $this->assertSkuElement('domain-hosting', $json[5], [ + 'prio' => 0, + 'type' => 'domain', + 'handler' => 'domainhosting', + 'enabled' => false, + 'readonly' => false, + ]); + + // Test filter by type + $response = $this->actingAs($user)->get("api/v4/users/{$user->id}/skus?type=domain"); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertCount(1, $json); + $this->assertSame('domain', $json[0]['type']); + + // Test inclusion of beta SKUs + $sku = Sku::where('title', 'beta')->first(); + $user->assignSku($sku); + $response = $this->actingAs($user)->get("api/v4/users/{$user->id}/skus?type=user"); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertCount(7, $json); + + $this->assertSkuElement('beta', $json[5], [ + 'prio' => 10, + 'type' => 'user', + 'handler' => 'beta', + 'enabled' => false, + 'readonly' => false, + ]); + + $this->assertSkuElement('meet', $json[6], [ + 'prio' => 0, + 'type' => 'user', + 'handler' => 'meet', + 'enabled' => false, + 'readonly' => false, + 'required' => ['beta'], + ]); + } + + /** + * Assert content of the SKU element in an API response + * + * @param string $sku_title The SKU title + * @param array $result The result to assert + * @param array $other Other items the SKU itself does not include + */ + protected function assertSkuElement($sku_title, $result, $other = []): void + { + $sku = Sku::where('title', $sku_title)->first(); $this->assertSame($sku->id, $result['id']); $this->assertSame($sku->title, $result['title']); $this->assertSame($sku->name, $result['name']); $this->assertSame($sku->description, $result['description']); $this->assertSame($sku->cost, $result['cost']); $this->assertSame($sku->units_free, $result['units_free']); $this->assertSame($sku->period, $result['period']); $this->assertSame($sku->active, $result['active']); - $this->assertSame('user', $result['type']); - $this->assertSame('storage', $result['handler']); - $this->assertSame($sku->units_free, $result['range']['min']); - $this->assertSame($sku->handler_class::MAX_ITEMS, $result['range']['max']); - $this->assertSame($sku->handler_class::ITEM_UNIT, $result['range']['unit']); - $this->assertTrue($result['readonly']); - $this->assertTrue($result['enabled']); - - // Test all SKU types - $this->markTestIncomplete(); + + foreach ($other as $key => $value) { + $this->assertSame($value, $result[$key]); + } + + $this->assertCount(8 + count($other), $result); } } diff --git a/src/tests/Feature/DomainTest.php b/src/tests/Feature/DomainTest.php index 63f47c59..39f75005 100644 --- a/src/tests/Feature/DomainTest.php +++ b/src/tests/Feature/DomainTest.php @@ -1,221 +1,219 @@ domains as $domain) { $this->deleteTestDomain($domain); } } /** * {@inheritDoc} */ public function tearDown(): void { foreach ($this->domains as $domain) { $this->deleteTestDomain($domain); } parent::tearDown(); } /** * Test domain create/creating observer */ public function testCreate(): void { Queue::fake(); $domain = Domain::create([ 'namespace' => 'GMAIL.COM', 'status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_EXTERNAL, ]); $result = Domain::where('namespace', 'gmail.com')->first(); $this->assertSame('gmail.com', $result->namespace); $this->assertSame($domain->id, $result->id); $this->assertSame($domain->type, $result->type); $this->assertSame(Domain::STATUS_NEW, $result->status); } /** * Test domain creating jobs */ public function testCreateJobs(): void { // Fake the queue, assert that no jobs were pushed... Queue::fake(); Queue::assertNothingPushed(); $domain = Domain::create([ 'namespace' => 'gmail.com', 'status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_EXTERNAL, ]); Queue::assertPushed(\App\Jobs\Domain\CreateJob::class, 1); Queue::assertPushed( \App\Jobs\Domain\CreateJob::class, function ($job) use ($domain) { $domainId = TestCase::getObjectProperty($job, 'domainId'); $domainNamespace = TestCase::getObjectProperty($job, 'domainNamespace'); return $domainId === $domain->id && $domainNamespace === $domain->namespace; } ); $job = new \App\Jobs\Domain\CreateJob($domain->id); $job->handle(); } /** * Tests getPublicDomains() method */ public function testGetPublicDomains(): void { $public_domains = Domain::getPublicDomains(); $this->assertNotContains('public-active.com', $public_domains); $queue = Queue::fake(); $domain = Domain::create([ 'namespace' => 'public-active.com', 'status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_EXTERNAL, ]); // External domains should not be returned $public_domains = Domain::getPublicDomains(); $this->assertNotContains('public-active.com', $public_domains); $domain = Domain::where('namespace', 'public-active.com')->first(); $domain->type = Domain::TYPE_PUBLIC; $domain->save(); $public_domains = Domain::getPublicDomains(); $this->assertContains('public-active.com', $public_domains); } /** * Test domain (ownership) confirmation * * @group dns */ public function testConfirm(): void { /* DNS records for positive and negative tests - kolab.org: ci-success-cname A 212.103.80.148 ci-success-cname MX 10 mx01.kolabnow.com. ci-success-cname TXT "v=spf1 mx -all" kolab-verify.ci-success-cname CNAME 2b719cfa4e1033b1e1e132977ed4fe3e.ci-success-cname ci-failure-cname A 212.103.80.148 ci-failure-cname MX 10 mx01.kolabnow.com. kolab-verify.ci-failure-cname CNAME 2b719cfa4e1033b1e1e132977ed4fe3e.ci-failure-cname ci-success-txt A 212.103.80.148 ci-success-txt MX 10 mx01.kolabnow.com. ci-success-txt TXT "v=spf1 mx -all" ci-success-txt TXT "kolab-verify=de5d04ababb52d52e2519a2f16d11422" ci-failure-txt A 212.103.80.148 ci-failure-txt MX 10 mx01.kolabnow.com. kolab-verify.ci-failure-txt TXT "kolab-verify=de5d04ababb52d52e2519a2f16d11422" ci-failure-none A 212.103.80.148 ci-failure-none MX 10 mx01.kolabnow.com. */ $queue = Queue::fake(); $domain_props = ['status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_EXTERNAL]; $domain = $this->getTestDomain('ci-failure-none.kolab.org', $domain_props); $this->assertTrue($domain->confirm() === false); $this->assertFalse($domain->isConfirmed()); $domain = $this->getTestDomain('ci-failure-txt.kolab.org', $domain_props); $this->assertTrue($domain->confirm() === false); $this->assertFalse($domain->isConfirmed()); $domain = $this->getTestDomain('ci-failure-cname.kolab.org', $domain_props); $this->assertTrue($domain->confirm() === false); $this->assertFalse($domain->isConfirmed()); $domain = $this->getTestDomain('ci-success-txt.kolab.org', $domain_props); $this->assertTrue($domain->confirm()); $this->assertTrue($domain->isConfirmed()); $domain = $this->getTestDomain('ci-success-cname.kolab.org', $domain_props); $this->assertTrue($domain->confirm()); $this->assertTrue($domain->isConfirmed()); } /** * Test domain deletion */ public function testDelete(): void { Queue::fake(); $domain = $this->getTestDomain('gmail.com', [ 'status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_PUBLIC, ]); $domain->delete(); $this->assertTrue($domain->fresh()->trashed()); $this->assertFalse($domain->fresh()->isDeleted()); // Delete the domain for real $job = new \App\Jobs\Domain\DeleteJob($domain->id); $job->handle(); $this->assertTrue(Domain::withTrashed()->where('id', $domain->id)->first()->isDeleted()); $domain->forceDelete(); $this->assertCount(0, Domain::withTrashed()->where('id', $domain->id)->get()); } } diff --git a/src/tests/Feature/SignupCodeTest.php b/src/tests/Feature/SignupCodeTest.php index d6576559..170a7205 100644 --- a/src/tests/Feature/SignupCodeTest.php +++ b/src/tests/Feature/SignupCodeTest.php @@ -1,45 +1,43 @@ [ 'email' => 'User@email.org', ] ]; $now = Carbon::now(); $code = SignupCode::create($data); $code_length = env('VERIFICATION_CODE_LENGTH', SignupCode::SHORTCODE_LENGTH); $exp = Carbon::now()->addHours(env('SIGNUP_CODE_EXPIRY', SignupCode::CODE_EXP_HOURS)); $this->assertFalse($code->isExpired()); $this->assertTrue(strlen($code->code) === SignupCode::CODE_LENGTH); $this->assertTrue(strlen($code->short_code) === $code_length); $this->assertSame($data['data'], $code->data); $this->assertInstanceOf(Carbon::class, $code->expires_at); $this->assertSame($code->expires_at->toDateTimeString(), $exp->toDateTimeString()); $inst = SignupCode::find($code->code); $this->assertInstanceOf(SignupCode::class, $inst); $this->assertSame($inst->code, $code->code); } } diff --git a/src/tests/Feature/VerificationCodeTest.php b/src/tests/Feature/VerificationCodeTest.php index 4742ff5e..3abe155b 100644 --- a/src/tests/Feature/VerificationCodeTest.php +++ b/src/tests/Feature/VerificationCodeTest.php @@ -1,60 +1,58 @@ deleteTestUser('UserAccountA@UserAccount.com'); } public function tearDown(): void { $this->deleteTestUser('UserAccountA@UserAccount.com'); parent::tearDown(); } /** * Test VerificationCode creation */ public function testVerificationCodeCreate(): void { $user = $this->getTestUser('UserAccountA@UserAccount.com'); $data = [ 'user_id' => $user->id, 'mode' => 'password-reset', ]; $now = new \DateTime('now'); $code = VerificationCode::create($data); $code_length = env('VERIFICATION_CODE_LENGTH', VerificationCode::SHORTCODE_LENGTH); $code_exp_hrs = env('VERIFICATION_CODE_EXPIRY', VerificationCode::CODE_EXP_HOURS); $exp = Carbon::now()->addHours($code_exp_hrs); $this->assertFalse($code->isExpired()); $this->assertTrue(strlen($code->code) === VerificationCode::CODE_LENGTH); $this->assertTrue(strlen($code->short_code) === $code_length); $this->assertSame($data['mode'], $code->mode); $this->assertEquals($user->id, $code->user->id); $this->assertInstanceOf(\DateTime::class, $code->expires_at); $this->assertSame($code->expires_at->toDateTimeString(), $exp->toDateTimeString()); $inst = VerificationCode::find($code->code); $this->assertInstanceOf(VerificationCode::class, $inst); $this->assertSame($inst->code, $code->code); } } diff --git a/src/tests/Feature/WalletTest.php b/src/tests/Feature/WalletTest.php index 7dc96664..36d13983 100644 --- a/src/tests/Feature/WalletTest.php +++ b/src/tests/Feature/WalletTest.php @@ -1,284 +1,282 @@ users as $user) { $this->deleteTestUser($user); } } public function tearDown(): void { foreach ($this->users as $user) { $this->deleteTestUser($user); } parent::tearDown(); } /** * Test that turning wallet balance from negative to positive * unsuspends the account */ public function testBalancePositiveUnsuspend(): void { $user = $this->getTestUser('UserWallet1@UserWallet.com'); $user->suspend(); $wallet = $user->wallets()->first(); $wallet->balance = -100; $wallet->save(); $this->assertTrue($user->isSuspended()); $this->assertNotNull($wallet->getSetting('balance_negative_since')); $wallet->balance = 100; $wallet->save(); $this->assertFalse($user->fresh()->isSuspended()); $this->assertNull($wallet->getSetting('balance_negative_since')); // TODO: Test group account and unsuspending domain/members } /** * Test for Wallet::balanceLastsUntil() */ public function testBalanceLastsUntil(): void { // Monthly cost of all entitlements: 999 // 28 days: 35.68 per day // 31 days: 32.22 per day $user = $this->getTestUser('jane@kolabnow.com'); $package = Package::where('title', 'kolab')->first(); $user->assignPackage($package); $wallet = $user->wallets()->first(); // User/entitlements created today, balance=0 $until = $wallet->balanceLastsUntil(); $this->assertSame( Carbon::now()->addMonthsWithoutOverflow(1)->toDateString(), $until->toDateString() ); // User/entitlements created today, balance=-10 CHF $wallet->balance = -1000; $until = $wallet->balanceLastsUntil(); $this->assertSame(null, $until); // User/entitlements created today, balance=-9,99 CHF (monthly cost) $wallet->balance = 999; $until = $wallet->balanceLastsUntil(); $daysInLastMonth = \App\Utils::daysInLastMonth(); $this->assertSame( Carbon::now()->addMonthsWithoutOverflow(1)->addDays($daysInLastMonth)->toDateString(), $until->toDateString() ); // Old entitlements, 100% discount $this->backdateEntitlements($wallet->entitlements, Carbon::now()->subDays(40)); $discount = \App\Discount::where('discount', 100)->first(); $wallet->discount()->associate($discount); $until = $wallet->refresh()->balanceLastsUntil(); $this->assertSame(null, $until); // User with no entitlements $wallet->discount()->dissociate($discount); $wallet->entitlements()->delete(); $until = $wallet->refresh()->balanceLastsUntil(); $this->assertSame(null, $until); } /** * Test for Wallet::costsPerDay() */ public function testCostsPerDay(): void { // 999 // 28 days: 35.68 // 31 days: 32.22 $user = $this->getTestUser('jane@kolabnow.com'); $package = Package::where('title', 'kolab')->first(); $mailbox = Sku::where('title', 'mailbox')->first(); $user->assignPackage($package); $wallet = $user->wallets()->first(); $costsPerDay = $wallet->costsPerDay(); $this->assertTrue($costsPerDay < 35.68); $this->assertTrue($costsPerDay > 32.22); } /** * Verify a wallet is created, when a user is created. */ public function testCreateUserCreatesWallet(): void { $user = $this->getTestUser('UserWallet1@UserWallet.com'); $this->assertCount(1, $user->wallets); } /** * Verify a user can haz more wallets. */ public function testAddWallet(): void { $user = $this->getTestUser('UserWallet2@UserWallet.com'); $user->wallets()->save( new Wallet(['currency' => 'USD']) ); $this->assertCount(2, $user->wallets); $user->wallets()->each( function ($wallet) { $this->assertEquals(0, $wallet->balance); } ); } /** * Verify we can not delete a user wallet that holds balance. */ public function testDeleteWalletWithCredit(): void { $user = $this->getTestUser('UserWallet3@UserWallet.com'); $user->wallets()->each( function ($wallet) { $wallet->credit(100)->save(); } ); $user->wallets()->each( function ($wallet) { $this->assertFalse($wallet->delete()); } ); } /** * Verify we can not delete a wallet that is the last wallet. */ public function testDeleteLastWallet(): void { $user = $this->getTestUser('UserWallet4@UserWallet.com'); $this->assertCount(1, $user->wallets); $user->wallets()->each( function ($wallet) { $this->assertFalse($wallet->delete()); } ); } /** * Verify we can remove a wallet that is an additional wallet. */ public function testDeleteAddtWallet(): void { $user = $this->getTestUser('UserWallet5@UserWallet.com'); $user->wallets()->save( new Wallet(['currency' => 'USD']) ); $user->wallets()->each( function ($wallet) { if ($wallet->currency == 'USD') { $this->assertNotFalse($wallet->delete()); } } ); } /** * Verify a wallet can be assigned a controller. */ public function testAddWalletController(): void { $userA = $this->getTestUser('WalletControllerA@WalletController.com'); $userB = $this->getTestUser('WalletControllerB@WalletController.com'); $userA->wallets()->each( function ($wallet) use ($userB) { $wallet->addController($userB); } ); $this->assertCount(1, $userB->accounts); $aWallet = $userA->wallets()->first(); $bAccount = $userB->accounts()->first(); $this->assertTrue($bAccount->id === $aWallet->id); } /** * Verify controllers can also be removed from wallets. */ public function testRemoveWalletController(): void { $userA = $this->getTestUser('WalletController2A@WalletController.com'); $userB = $this->getTestUser('WalletController2B@WalletController.com'); $userA->wallets()->each( function ($wallet) use ($userB) { $wallet->addController($userB); } ); $userB->refresh(); $userB->accounts()->each( function ($wallet) use ($userB) { $wallet->removeController($userB); } ); $this->assertCount(0, $userB->accounts); } }