diff --git a/src/app/Entitlement.php b/src/app/Entitlement.php index 5b9d5bfd..57273151 100644 --- a/src/app/Entitlement.php +++ b/src/app/Entitlement.php @@ -1,136 +1,136 @@ 'integer', ]; /** * Return the costs per day for this entitlement. * * @return float */ public function costsPerDay() { if ($this->cost == 0) { return (float) 0; } $discount = $this->wallet->getDiscountRate(); $daysInLastMonth = \App\Utils::daysInLastMonth(); $costsPerDay = (float) ($this->cost * $discount) / $daysInLastMonth; return $costsPerDay; } /** * Create a transaction record for this entitlement. * * @param string $type The type of transaction ('created', 'billed', 'deleted'), but use the * \App\Transaction constants. * @param int $amount The amount involved in cents * * @return string The transaction ID */ public function createTransaction($type, $amount = null) { $transaction = \App\Transaction::create( [ 'user_email' => \App\Utils::userEmailOrNull(), 'object_id' => $this->id, 'object_type' => \App\Entitlement::class, 'type' => $type, 'amount' => $amount ] ); return $transaction->id; } /** * Principally entitleable objects such as 'Domain' or 'User'. * * @return mixed */ public function entitleable() { - return $this->morphTo()->withTrashed(); + return $this->morphTo(); } /** * The SKU concerned. * * @return \Illuminate\Database\Eloquent\Relations\BelongsTo */ public function sku() { return $this->belongsTo('App\Sku'); } /** * The wallet this entitlement is being billed to * * @return \Illuminate\Database\Eloquent\Relations\BelongsTo */ public function wallet() { return $this->belongsTo('App\Wallet'); } /** * Cost mutator. Make sure cost is integer. */ public function setCostAttribute($cost): void { $this->attributes['cost'] = round($cost); } } diff --git a/src/app/Exceptions/Handler.php b/src/app/Exceptions/Handler.php index 0c9a4db9..2554ff4a 100644 --- a/src/app/Exceptions/Handler.php +++ b/src/app/Exceptions/Handler.php @@ -1,61 +1,67 @@ 0) { + DB::rollBack(); + } + return parent::render($request, $exception); } protected function unauthenticated($request, AuthenticationException $exception) { return $request->expectsJson() ? response()->json(['message' => $exception->getMessage()], 401) : abort(401); } } diff --git a/src/app/Http/Controllers/API/V4/UsersController.php b/src/app/Http/Controllers/API/V4/UsersController.php index a7c72dfe..608a9c02 100644 --- a/src/app/Http/Controllers/API/V4/UsersController.php +++ b/src/app/Http/Controllers/API/V4/UsersController.php @@ -1,602 +1,601 @@ errorResponse(404); } // User can't remove himself until he's the controller if (!$this->guard()->user()->canDelete($user)) { return $this->errorResponse(403); } $user->delete(); return response()->json([ 'status' => 'success', 'message' => __('app.user-delete-success'), ]); } /** * Listing of users. * * The user-entitlements billed to the current user wallet(s) * * @return \Illuminate\Http\JsonResponse */ public function index() { - \Log::debug("Regular API"); $user = $this->guard()->user(); $result = $user->users()->orderBy('email')->get()->map(function ($user) { $data = $user->toArray(); $data = array_merge($data, self::userStatuses($user)); return $data; }); return response()->json($result); } /** * Display information on the user account specified by $id. * * @param int $id The account to show information for. * * @return \Illuminate\Http\JsonResponse */ public function show($id) { $user = User::find($id); if (empty($user)) { return $this->errorResponse(404); } if (!$this->guard()->user()->canRead($user)) { return $this->errorResponse(403); } $response = $this->userResponse($user); // Simplified Entitlement/SKU information, // TODO: I agree this format may need to be extended in future $response['skus'] = []; foreach ($user->entitlements as $ent) { $sku = $ent->sku; $response['skus'][$sku->id] = [ // 'cost' => $ent->cost, 'count' => isset($response['skus'][$sku->id]) ? $response['skus'][$sku->id]['count'] + 1 : 1, ]; } return response()->json($response); } /** * Fetch user status (and reload setup process) * * @param int $id User identifier * * @return \Illuminate\Http\JsonResponse */ public function status($id) { $user = User::find($id); if (empty($user)) { return $this->errorResponse(404); } if (!$this->guard()->user()->canRead($user)) { return $this->errorResponse(403); } $response = self::statusInfo($user); if (!empty(request()->input('refresh'))) { $updated = false; $last_step = 'none'; foreach ($response['process'] as $idx => $step) { $last_step = $step['label']; if (!$step['state']) { if (!$this->execProcessStep($user, $step['label'])) { break; } $updated = true; } } if ($updated) { $response = self::statusInfo($user); } $success = $response['isReady']; $suffix = $success ? 'success' : 'error-' . $last_step; $response['status'] = $success ? 'success' : 'error'; $response['message'] = \trans('app.process-' . $suffix); } $response = array_merge($response, self::userStatuses($user)); return response()->json($response); } /** * User status (extended) information * * @param \App\User $user User object * * @return array Status information */ public static function statusInfo(User $user): array { $process = []; $steps = [ 'user-new' => true, 'user-ldap-ready' => $user->isLdapReady(), 'user-imap-ready' => $user->isImapReady(), ]; // Create a process check list foreach ($steps as $step_name => $state) { $step = [ 'label' => $step_name, 'title' => \trans("app.process-{$step_name}"), 'state' => $state, ]; $process[] = $step; } list ($local, $domain) = explode('@', $user->email); $domain = Domain::where('namespace', $domain)->first(); // If that is not a public domain, add domain specific steps if ($domain && !$domain->isPublic()) { $domain_status = DomainsController::statusInfo($domain); $process = array_merge($process, $domain_status['process']); } $all = count($process); $checked = count(array_filter($process, function ($v) { return $v['state']; })); $state = $all === $checked ? 'done' : 'running'; // After 180 seconds assume the process is in failed state, // this should unlock the Refresh button in the UI if ($all !== $checked && $user->created_at->diffInSeconds(Carbon::now()) > 180) { $state = 'failed'; } return [ 'process' => $process, 'processState' => $state, 'isReady' => $all === $checked, ]; } /** * Create a new user record. * * @param \Illuminate\Http\Request $request The API request. * * @return \Illuminate\Http\JsonResponse The response */ public function store(Request $request) { $current_user = $this->guard()->user(); $owner = $current_user->wallet()->owner; if ($owner->id != $current_user->id) { return $this->errorResponse(403); } if ($error_response = $this->validateUserRequest($request, null, $settings)) { return $error_response; } if (empty($request->package) || !($package = \App\Package::find($request->package))) { $errors = ['package' => \trans('validation.packagerequired')]; return response()->json(['status' => 'error', 'errors' => $errors], 422); } if ($package->isDomain()) { $errors = ['package' => \trans('validation.packageinvalid')]; return response()->json(['status' => 'error', 'errors' => $errors], 422); } DB::beginTransaction(); // Create user record $user = User::create([ 'email' => $request->email, 'password' => $request->password, ]); $owner->assignPackage($package, $user); if (!empty($settings)) { $user->setSettings($settings); } if (!empty($request->aliases)) { $user->setAliases($request->aliases); } DB::commit(); return response()->json([ 'status' => 'success', 'message' => __('app.user-create-success'), ]); } /** * 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::find($id); if (empty($user)) { return $this->errorResponse(404); } $current_user = $this->guard()->user(); // TODO: Decide what attributes a user can change on his own profile if (!$current_user->canUpdate($user)) { return $this->errorResponse(403); } if ($error_response = $this->validateUserRequest($request, $user, $settings)) { return $error_response; } // Entitlements, only controller can do that if ($request->skus !== null && !$current_user->canDelete($user)) { return $this->errorResponse(422, "You have no permission to change entitlements"); } DB::beginTransaction(); $this->updateEntitlements($user, $request->skus); if (!empty($settings)) { $user->setSettings($settings); } if (!empty($request->password)) { $user->password = $request->password; $user->save(); } if (isset($request->aliases)) { $user->setAliases($request->aliases); } // TODO: Make sure that UserUpdate job is created in case of entitlements update // and no password change. So, for example quota change is applied to LDAP // TODO: Review use of $user->save() in the above context DB::commit(); return response()->json([ 'status' => 'success', 'message' => __('app.user-update-success'), ]); } /** * Get the guard to be used during authentication. * * @return \Illuminate\Contracts\Auth\Guard */ public function guard() { return Auth::guard(); } /** * Update user entitlements. * - * @param \App\User $user The user - * @param array|null $skus Set of SKUs for the user + * @param \App\User $user The user + * @param array $rSkus List of SKU IDs requested for the user in the form [id=>qty] */ - protected function updateEntitlements(User $user, $skus) + protected function updateEntitlements(User $user, $rSkus) { - if (!is_array($skus)) { + if (!is_array($rSkus)) { return; } - // Existing SKUs - // FIXME: Is there really no query builder method to get result indexed - // by some column or primary key? - $all_skus = Sku::all()->mapWithKeys(function ($sku) { - return [$sku->id => $sku]; - }); + // list of skus, [id=>obj] + $skus = Sku::all()->mapWithKeys( + function ($sku) { + return [$sku->id => $sku]; + } + ); - // Existing user entitlements - // Note: We sort them by cost, so e.g. for storage we get these free first - $entitlements = $user->entitlements()->orderBy('cost')->get(); + // existing entitlement's SKUs + $eSkus = []; - // Go through existing entitlements and remove those no longer needed - foreach ($entitlements as $ent) { - $sku_id = $ent->sku_id; + $user->entitlements()->groupBy('sku_id') + ->selectRaw('count(*) as total, sku_id')->each( + function ($e) use (&$eSkus) { + $eSkus[$e->sku_id] = $e->total; + } + ); - if (array_key_exists($sku_id, $skus)) { - // An existing entitlement exists on the requested list - $skus[$sku_id] -= 1; + foreach ($skus as $skuID => $sku) { + $e = array_key_exists($skuID, $eSkus) ? $eSkus[$skuID] : 0; + $r = array_key_exists($skuID, $rSkus) ? $rSkus[$skuID] : 0; - if ($skus[$sku_id] < 0) { - $ent->delete(); + if ($sku->handler_class == \App\Handlers\Mailbox::class) { + if ($r != 1) { + throw new \Exception("Invalid quantity of mailboxes"); } - } elseif ($all_skus->get($sku_id)->handler_class != \App\Handlers\Mailbox::class) { - // An existing entitlement does not exists on the requested list - // Never delete 'mailbox' SKU - $ent->delete(); } - } - // Add missing entitlements - foreach ($skus as $sku_id => $count) { - if ($count > 0 && $all_skus->has($sku_id)) { - $user->assignSku($all_skus[$sku_id], $count); + if ($e > $r) { + // remove those entitled more than existing + $user->removeSku($sku, ($e - $r)); + } elseif ($e < $r) { + // add those requested more than entitled + $user->assignSku($sku, ($r - $e)); } } } /** * Create a response data array for specified user. * * @param \App\User $user User object * * @return array Response data */ public static function userResponse(User $user): array { $response = $user->toArray(); // Settings $response['settings'] = []; foreach ($user->settings()->whereIn('key', self::USER_SETTINGS)->get() as $item) { $response['settings'][$item->key] = $item->value; } // Aliases $response['aliases'] = []; foreach ($user->aliases as $item) { $response['aliases'][] = $item->alias; } // Status info $response['statusInfo'] = self::statusInfo($user); $response = array_merge($response, self::userStatuses($user)); // Add more info to the wallet object output $map_func = function ($wallet) use ($user) { $result = $wallet->toArray(); if ($wallet->discount) { $result['discount'] = $wallet->discount->discount; $result['discount_description'] = $wallet->discount->description; } if ($wallet->user_id != $user->id) { $result['user_email'] = $wallet->owner->email; } $provider = \App\Providers\PaymentProvider::factory($wallet); $result['provider'] = $provider->name(); return $result; }; // Information about wallets and accounts for access checks $response['wallets'] = $user->wallets->map($map_func)->toArray(); $response['accounts'] = $user->accounts->map($map_func)->toArray(); $response['wallet'] = $map_func($user->wallet()); return $response; } /** * Prepare user statuses for the UI * * @param \App\User $user User object * * @return array Statuses array */ protected static function userStatuses(User $user): array { return [ 'isImapReady' => $user->isImapReady(), 'isLdapReady' => $user->isLdapReady(), 'isSuspended' => $user->isSuspended(), 'isActive' => $user->isActive(), 'isDeleted' => $user->isDeleted() || $user->trashed(), ]; } /** * Validate user input * * @param \Illuminate\Http\Request $request The API request. * @param \App\User|null $user User identifier * @param array $settings User settings (from the request) * * @return \Illuminate\Http\JsonResponse|null The error response on error */ protected function validateUserRequest(Request $request, $user, &$settings = []) { $rules = [ 'external_email' => 'nullable|email', 'phone' => 'string|nullable|max:64|regex:/^[0-9+() -]+$/', 'first_name' => 'string|nullable|max:128', 'last_name' => 'string|nullable|max:128', 'organization' => 'string|nullable|max:512', 'billing_address' => 'string|nullable|max:1024', 'country' => 'string|nullable|alpha|size:2', 'currency' => 'string|nullable|alpha|size:3', 'aliases' => 'array|nullable', ]; if (empty($user) || !empty($request->password) || !empty($request->password_confirmation)) { $rules['password'] = 'required|min:4|max:2048|confirmed'; } $errors = []; // Validate input $v = Validator::make($request->all(), $rules); if ($v->fails()) { $errors = $v->errors()->toArray(); } $controller = $user ? $user->wallet()->owner : $this->guard()->user(); // For new user validate email address if (empty($user)) { $email = $request->email; if (empty($email)) { $errors['email'] = \trans('validation.required', ['attribute' => 'email']); } elseif ($error = \App\Utils::validateEmail($email, $controller, false)) { $errors['email'] = $error; } } // Validate aliases input if (isset($request->aliases)) { $aliases = []; $existing_aliases = $user ? $user->aliases()->get()->pluck('alias')->toArray() : []; foreach ($request->aliases as $idx => $alias) { if (is_string($alias) && !empty($alias)) { // Alias cannot be the same as the email address (new user) if (!empty($email) && Str::lower($alias) == Str::lower($email)) { continue; } // validate new aliases if ( !in_array($alias, $existing_aliases) && ($error = \App\Utils::validateEmail($alias, $controller, true)) ) { if (!isset($errors['aliases'])) { $errors['aliases'] = []; } $errors['aliases'][$idx] = $error; continue; } $aliases[] = $alias; } } $request->aliases = $aliases; } if (!empty($errors)) { return response()->json(['status' => 'error', 'errors' => $errors], 422); } // Update user settings $settings = $request->only(array_keys($rules)); unset($settings['password'], $settings['aliases'], $settings['email']); return null; } /** * Execute (synchronously) specified step in a user setup process. * * @param \App\User $user User object * @param string $step Step identifier (as in self::statusInfo()) * * @return bool True if the execution succeeded, False otherwise */ public static function execProcessStep(User $user, string $step): bool { try { if (strpos($step, 'domain-') === 0) { list ($local, $domain) = explode('@', $user->email); $domain = Domain::where('namespace', $domain)->first(); return DomainsController::execProcessStep($domain, $step); } switch ($step) { case 'user-ldap-ready': // User not in LDAP, create it $job = new \App\Jobs\UserCreate($user); $job->handle(); return $user->isLdapReady(); case 'user-imap-ready': // User not in IMAP? Verify again $job = new \App\Jobs\UserVerify($user); $job->handle(); return $user->isImapReady(); } } catch (\Exception $e) { \Log::error($e); } return false; } } diff --git a/src/app/Observers/EntitlementObserver.php b/src/app/Observers/EntitlementObserver.php index 53c06a09..7e388de0 100644 --- a/src/app/Observers/EntitlementObserver.php +++ b/src/app/Observers/EntitlementObserver.php @@ -1,95 +1,146 @@ {$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; } 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); } + + public function deleting(Entitlement $entitlement) + { + // Start calculating the costs for the consumption of this entitlement if the + // existing consumption spans >= 14 days. + // anything's free for 14 days + if ($entitlement->created_at >= Carbon::now()->subDays(14)) { + return; + } + + $cost = 0; + + // 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(Carbon::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 for the + // past month -- i.e. $price/29 in the case at hand -- times the number of (full) days in + // between the period end and now. + // + // a) The number of days left in the past month, 1 + // b) The cost divided by the number of days in the past month, for example, 555/29, + // c) a) + Todays day-of-month, 12, so 13. + // + + $diffInDays = $updatedAt->diffInDays(Carbon::now()); + + $dayOfThisMonth = Carbon::now()->day; + + // days in the month for the month prior to this one. + // the price per day is based on the number of days left in the last month + $daysInLastMonth = \App\Utils::daysInLastMonth(); + + $pricePerDay = (float)$entitlement->cost / $daysInLastMonth; + + $cost += (int) (round($pricePerDay * $diffInDays, 0)); + + if ($cost == 0) { + return; + } + + $entitlement->wallet->debit($cost); + } } diff --git a/src/app/Transaction.php b/src/app/Transaction.php index a97c78dd..4ebf1820 100644 --- a/src/app/Transaction.php +++ b/src/app/Transaction.php @@ -1,226 +1,226 @@ 'integer', ]; /** @var boolean This model uses an automatically incrementing integer primary key? */ public $incrementing = false; /** @var string The type of the primary key */ protected $keyType = 'string'; public const ENTITLEMENT_BILLED = 'billed'; public const ENTITLEMENT_CREATED = 'created'; public const ENTITLEMENT_DELETED = 'deleted'; public const WALLET_AWARD = 'award'; public const WALLET_CREDIT = 'credit'; public const WALLET_DEBIT = 'debit'; public const WALLET_PENALTY = 'penalty'; public function entitlement() { if ($this->object_type !== \App\Entitlement::class) { return null; } return \App\Entitlement::withTrashed()->where('id', $this->object_id)->first(); } public function setTypeAttribute($value) { switch ($value) { case self::ENTITLEMENT_BILLED: case self::ENTITLEMENT_CREATED: case self::ENTITLEMENT_DELETED: // TODO: Must be an entitlement. $this->attributes['type'] = $value; break; case self::WALLET_AWARD: case self::WALLET_CREDIT: case self::WALLET_DEBIT: case self::WALLET_PENALTY: // TODO: This must be a wallet. $this->attributes['type'] = $value; break; default: throw new \Exception("Invalid type value"); } } public function toArray() { $result = [ 'user_email' => $this->user_email, 'entitlement_cost' => $this->getEntitlementCost(), 'object_email' => $this->getEntitlementObjectEmail(), 'sku_title' => $this->getEntitlementSkuTitle(), 'wallet_description' => $this->getWalletDescription(), 'description' => $this->{'description'}, 'amount' => $this->amount ]; return $result; } public function toString() { $label = $this->objectTypeToLabelString() . '-' . $this->{'type'}; return \trans("transactions.{$label}", $this->toArray()); } public function shortDescription() { $label = $this->objectTypeToLabelString() . '-' . $this->{'type'} . '-short'; return \trans("transactions.{$label}", $this->toArray()); } public function wallet() { if ($this->object_type !== \App\Wallet::class) { return null; } return \App\Wallet::where('id', $this->object_id)->first(); } /** * Return the costs for this entitlement. * * @return int|null */ private function getEntitlementCost(): ?int { if (!$this->entitlement()) { return null; } // FIXME: without wallet discount // FIXME: in cents // FIXME: without wallet currency $cost = $this->entitlement()->cost; $discount = $this->entitlement()->wallet->getDiscountRate(); return $cost * $discount; } /** * Return the object email if any. This is the email for the target user entitlement. * * @return string|null */ private function getEntitlementObjectEmail(): ?string { $entitlement = $this->entitlement(); if (!$entitlement) { return null; } - $entitleable = $entitlement->entitleable; + $user = \App\User::withTrashed()->where('id', $entitlement->object_id)->first(); - if (!$entitleable) { + if (!$user) { \Log::debug("No entitleable for {$entitlement->id} ?"); return null; } - return $entitleable->email; + return $user->email; } /** * Return the title for the SKU this entitlement is for. * * @return string|null */ private function getEntitlementSkuTitle(): ?string { if (!$this->entitlement()) { return null; } return $this->entitlement()->sku->{'title'}; } /** * Return the description for the wallet, if any, or 'default wallet'. * * @return string */ public function getWalletDescription() { $description = null; if ($entitlement = $this->entitlement()) { $description = $entitlement->wallet->{'description'}; } if ($wallet = $this->wallet()) { $description = $wallet->{'description'}; } return $description ?: 'Default wallet'; } /** * Get a string for use in translation tables derived from the object type. * * @return string|null */ private function objectTypeToLabelString(): ?string { if ($this->object_type == \App\Entitlement::class) { return 'entitlement'; } if ($this->object_type == \App\Wallet::class) { return 'wallet'; } return null; } } diff --git a/src/app/User.php b/src/app/User.php index d7ce616a..de65365b 100644 --- a/src/app/User.php +++ b/src/app/User.php @@ -1,625 +1,659 @@ 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, int $count = 1): User + 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' => $sku->units_free >= $exists ? $sku->cost : 0, + '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); } /** * 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); } } /** * Helper to find user by email address, whether it is * main email address, alias or external email * * @param string $email Email address * @param bool $external Search also by 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; } $alias = UserAlias::where('alias', $email)->first(); if ($alias) { return $alias->user; } // TODO: External email return null; } public function getJWTIdentifier() { return $this->getKey(); } public function getJWTCustomClaims() { return []; } /** * 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) { if (!empty($password)) { $this->attributes['password'] = bcrypt($password, [ "rounds" => 12 ]); $this->attributes['password_ldap'] = '{SSHA512}' . base64_encode( pack('H*', hash('sha512', $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/tests/Feature/Controller/UsersTest.php b/src/tests/Feature/Controller/UsersTest.php index a87f84de..e9a2816b 100644 --- a/src/tests/Feature/Controller/UsersTest.php +++ b/src/tests/Feature/Controller/UsersTest.php @@ -1,857 +1,1001 @@ deleteTestUser('jane@kolabnow.com'); $this->deleteTestUser('UsersControllerTest1@userscontroller.com'); $this->deleteTestUser('UsersControllerTest2@userscontroller.com'); $this->deleteTestUser('UsersControllerTest3@userscontroller.com'); $this->deleteTestUser('UserEntitlement2A@UserEntitlement.com'); $this->deleteTestUser('john2.doe2@kolab.org'); $this->deleteTestDomain('userscontroller.com'); $user = $this->getTestUser('john@kolab.org'); $wallet = $user->wallets()->first(); $wallet->discount()->dissociate(); $wallet->settings()->whereIn('key', ['mollie_id', 'stripe_id'])->delete(); $wallet->save(); $user->status |= User::STATUS_IMAP_READY; $user->save(); } /** * {@inheritDoc} */ public function tearDown(): void { + $this->deleteTestUser('jane@kolabnow.com'); $this->deleteTestUser('UsersControllerTest1@userscontroller.com'); $this->deleteTestUser('UsersControllerTest2@userscontroller.com'); $this->deleteTestUser('UsersControllerTest3@userscontroller.com'); $this->deleteTestUser('UserEntitlement2A@UserEntitlement.com'); $this->deleteTestUser('john2.doe2@kolab.org'); $this->deleteTestDomain('userscontroller.com'); $user = $this->getTestUser('john@kolab.org'); $wallet = $user->wallets()->first(); $wallet->discount()->dissociate(); $wallet->settings()->whereIn('key', ['mollie_id', 'stripe_id'])->delete(); $wallet->save(); $user->status |= User::STATUS_IMAP_READY; $user->save(); parent::tearDown(); } /** * Test user deleting (DELETE /api/v4/users/) */ public function testDestroy(): void { // First create some users/accounts to delete $package_kolab = \App\Package::where('title', 'kolab')->first(); $package_domain = \App\Package::where('title', 'domain-hosting')->first(); $john = $this->getTestUser('john@kolab.org'); $user1 = $this->getTestUser('UsersControllerTest1@userscontroller.com'); $user2 = $this->getTestUser('UsersControllerTest2@userscontroller.com'); $user3 = $this->getTestUser('UsersControllerTest3@userscontroller.com'); $domain = $this->getTestDomain('userscontroller.com', [ 'status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_PUBLIC, ]); $user1->assignPackage($package_kolab); $domain->assignPackage($package_domain, $user1); $user1->assignPackage($package_kolab, $user2); $user1->assignPackage($package_kolab, $user3); // Test unauth access $response = $this->delete("api/v4/users/{$user2->id}"); $response->assertStatus(401); // Test access to other user/account $response = $this->actingAs($john)->delete("api/v4/users/{$user2->id}"); $response->assertStatus(403); $response = $this->actingAs($john)->delete("api/v4/users/{$user1->id}"); $response->assertStatus(403); $json = $response->json(); $this->assertSame('error', $json['status']); $this->assertSame("Access denied", $json['message']); $this->assertCount(2, $json); // Test that non-controller cannot remove himself $response = $this->actingAs($user3)->delete("api/v4/users/{$user3->id}"); $response->assertStatus(403); // Test removing a non-controller user $response = $this->actingAs($user1)->delete("api/v4/users/{$user3->id}"); $response->assertStatus(200); $json = $response->json(); $this->assertEquals('success', $json['status']); $this->assertEquals('User deleted successfully.', $json['message']); // Test removing self (an account with users) $response = $this->actingAs($user1)->delete("api/v4/users/{$user1->id}"); $response->assertStatus(200); $json = $response->json(); $this->assertEquals('success', $json['status']); $this->assertEquals('User deleted successfully.', $json['message']); } /** * Test user deleting (DELETE /api/v4/users/) */ public function testDestroyByController(): void { // Create an account with additional controller - $user2 $package_kolab = \App\Package::where('title', 'kolab')->first(); $package_domain = \App\Package::where('title', 'domain-hosting')->first(); $user1 = $this->getTestUser('UsersControllerTest1@userscontroller.com'); $user2 = $this->getTestUser('UsersControllerTest2@userscontroller.com'); $user3 = $this->getTestUser('UsersControllerTest3@userscontroller.com'); $domain = $this->getTestDomain('userscontroller.com', [ 'status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_PUBLIC, ]); $user1->assignPackage($package_kolab); $domain->assignPackage($package_domain, $user1); $user1->assignPackage($package_kolab, $user2); $user1->assignPackage($package_kolab, $user3); $user1->wallets()->first()->addController($user2); // TODO/FIXME: // For now controller can delete himself, as well as // the whole account he has control to, including the owner // Probably he should not be able to do none of those // However, this is not 0-regression scenario as we // do not fully support additional controllers. //$response = $this->actingAs($user2)->delete("api/v4/users/{$user2->id}"); //$response->assertStatus(403); $response = $this->actingAs($user2)->delete("api/v4/users/{$user3->id}"); $response->assertStatus(200); $response = $this->actingAs($user2)->delete("api/v4/users/{$user1->id}"); $response->assertStatus(200); // Note: More detailed assertions in testDestroy() above $this->assertTrue($user1->fresh()->trashed()); $this->assertTrue($user2->fresh()->trashed()); $this->assertTrue($user3->fresh()->trashed()); } /** * Test user listing (GET /api/v4/users) */ public function testIndex(): void { // Test unauth access $response = $this->get("api/v4/users"); $response->assertStatus(401); $jack = $this->getTestUser('jack@kolab.org'); $joe = $this->getTestUser('joe@kolab.org'); $john = $this->getTestUser('john@kolab.org'); $ned = $this->getTestUser('ned@kolab.org'); $response = $this->actingAs($jack)->get("/api/v4/users"); $response->assertStatus(200); $json = $response->json(); $this->assertCount(0, $json); $response = $this->actingAs($john)->get("/api/v4/users"); $response->assertStatus(200); $json = $response->json(); $this->assertCount(4, $json); $this->assertSame($jack->email, $json[0]['email']); $this->assertSame($joe->email, $json[1]['email']); $this->assertSame($john->email, $json[2]['email']); $this->assertSame($ned->email, $json[3]['email']); // Values below are tested by Unit tests $this->assertArrayHasKey('isDeleted', $json[0]); $this->assertArrayHasKey('isSuspended', $json[0]); $this->assertArrayHasKey('isActive', $json[0]); $this->assertArrayHasKey('isLdapReady', $json[0]); $this->assertArrayHasKey('isImapReady', $json[0]); $response = $this->actingAs($ned)->get("/api/v4/users"); $response->assertStatus(200); $json = $response->json(); $this->assertCount(4, $json); $this->assertSame($jack->email, $json[0]['email']); $this->assertSame($joe->email, $json[1]['email']); $this->assertSame($john->email, $json[2]['email']); $this->assertSame($ned->email, $json[3]['email']); } /** * Test fetching user data/profile (GET /api/v4/users/) */ public function testShow(): void { $userA = $this->getTestUser('UserEntitlement2A@UserEntitlement.com'); // Test getting profile of self $response = $this->actingAs($userA)->get("/api/v4/users/{$userA->id}"); $json = $response->json(); $response->assertStatus(200); $this->assertEquals($userA->id, $json['id']); $this->assertEquals($userA->email, $json['email']); $this->assertTrue(is_array($json['statusInfo'])); $this->assertTrue(is_array($json['settings'])); $this->assertTrue(is_array($json['aliases'])); $this->assertSame([], $json['skus']); // Values below are tested by Unit tests $this->assertArrayHasKey('isDeleted', $json); $this->assertArrayHasKey('isSuspended', $json); $this->assertArrayHasKey('isActive', $json); $this->assertArrayHasKey('isLdapReady', $json); $this->assertArrayHasKey('isImapReady', $json); $john = $this->getTestUser('john@kolab.org'); $jack = $this->getTestUser('jack@kolab.org'); $ned = $this->getTestUser('ned@kolab.org'); // Test unauthorized access to a profile of other user $response = $this->actingAs($jack)->get("/api/v4/users/{$userA->id}"); $response->assertStatus(403); // Test authorized access to a profile of other user // Ned: Additional account controller $response = $this->actingAs($ned)->get("/api/v4/users/{$john->id}"); $response->assertStatus(200); $response = $this->actingAs($ned)->get("/api/v4/users/{$jack->id}"); $response->assertStatus(200); // John: Account owner $response = $this->actingAs($john)->get("/api/v4/users/{$jack->id}"); $response->assertStatus(200); $response = $this->actingAs($john)->get("/api/v4/users/{$ned->id}"); $response->assertStatus(200); $json = $response->json(); $storage_sku = Sku::where('title', 'storage')->first(); $groupware_sku = Sku::where('title', 'groupware')->first(); $mailbox_sku = Sku::where('title', 'mailbox')->first(); $secondfactor_sku = Sku::where('title', '2fa')->first(); $this->assertCount(5, $json['skus']); $this->assertSame(2, $json['skus'][$storage_sku->id]['count']); $this->assertSame(1, $json['skus'][$groupware_sku->id]['count']); $this->assertSame(1, $json['skus'][$mailbox_sku->id]['count']); $this->assertSame(1, $json['skus'][$secondfactor_sku->id]['count']); } /** * Test fetching user status (GET /api/v4/users//status) * and forcing setup process update (?refresh=1) * * @group imap * @group dns */ public function testStatus(): void { $john = $this->getTestUser('john@kolab.org'); $jack = $this->getTestUser('jack@kolab.org'); // Test unauthorized access $response = $this->actingAs($jack)->get("/api/v4/users/{$john->id}/status"); $response->assertStatus(403); $john->status ^= User::STATUS_IMAP_READY; $john->save(); // Get user status $response = $this->actingAs($john)->get("/api/v4/users/{$john->id}/status"); $response->assertStatus(200); $json = $response->json(); $this->assertFalse($json['isImapReady']); $this->assertFalse($json['isReady']); $this->assertCount(7, $json['process']); $this->assertSame('user-imap-ready', $json['process'][2]['label']); $this->assertSame(false, $json['process'][2]['state']); $this->assertTrue(empty($json['status'])); $this->assertTrue(empty($json['message'])); // Make sure the domain is confirmed (other test might unset that status) $domain = $this->getTestDomain('kolab.org'); $domain->status |= Domain::STATUS_CONFIRMED; $domain->save(); // Now "reboot" the process and verify the user in imap synchronously $response = $this->actingAs($john)->get("/api/v4/users/{$john->id}/status?refresh=1"); $response->assertStatus(200); $json = $response->json(); $this->assertTrue($json['isImapReady']); $this->assertTrue($json['isReady']); $this->assertCount(7, $json['process']); $this->assertSame('user-imap-ready', $json['process'][2]['label']); $this->assertSame(true, $json['process'][2]['state']); $this->assertSame('success', $json['status']); $this->assertSame('Setup process finished successfully.', $json['message']); } /** * Test UsersController::statusInfo() */ public function testStatusInfo(): void { $user = $this->getTestUser('UsersControllerTest1@userscontroller.com'); $domain = $this->getTestDomain('userscontroller.com', [ 'status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_PUBLIC, ]); $user->created_at = Carbon::now(); $user->status = User::STATUS_NEW; $user->save(); $result = UsersController::statusInfo($user); $this->assertFalse($result['isReady']); $this->assertCount(3, $result['process']); $this->assertSame('user-new', $result['process'][0]['label']); $this->assertSame(true, $result['process'][0]['state']); $this->assertSame('user-ldap-ready', $result['process'][1]['label']); $this->assertSame(false, $result['process'][1]['state']); $this->assertSame('user-imap-ready', $result['process'][2]['label']); $this->assertSame(false, $result['process'][2]['state']); $this->assertSame('running', $result['processState']); $user->created_at = Carbon::now()->subSeconds(181); $user->save(); $result = UsersController::statusInfo($user); $this->assertSame('failed', $result['processState']); $user->status |= User::STATUS_LDAP_READY | User::STATUS_IMAP_READY; $user->save(); $result = UsersController::statusInfo($user); $this->assertTrue($result['isReady']); $this->assertCount(3, $result['process']); $this->assertSame('user-new', $result['process'][0]['label']); $this->assertSame(true, $result['process'][0]['state']); $this->assertSame('user-ldap-ready', $result['process'][1]['label']); $this->assertSame(true, $result['process'][1]['state']); $this->assertSame('user-imap-ready', $result['process'][2]['label']); $this->assertSame(true, $result['process'][2]['state']); $this->assertSame('done', $result['processState']); $domain->status |= Domain::STATUS_VERIFIED; $domain->type = Domain::TYPE_EXTERNAL; $domain->save(); $result = UsersController::statusInfo($user); $this->assertFalse($result['isReady']); $this->assertCount(7, $result['process']); $this->assertSame('user-new', $result['process'][0]['label']); $this->assertSame(true, $result['process'][0]['state']); $this->assertSame('user-ldap-ready', $result['process'][1]['label']); $this->assertSame(true, $result['process'][1]['state']); $this->assertSame('user-imap-ready', $result['process'][2]['label']); $this->assertSame(true, $result['process'][2]['state']); $this->assertSame('domain-new', $result['process'][3]['label']); $this->assertSame(true, $result['process'][3]['state']); $this->assertSame('domain-ldap-ready', $result['process'][4]['label']); $this->assertSame(false, $result['process'][4]['state']); $this->assertSame('domain-verified', $result['process'][5]['label']); $this->assertSame(true, $result['process'][5]['state']); $this->assertSame('domain-confirmed', $result['process'][6]['label']); $this->assertSame(false, $result['process'][6]['state']); } /** * Test user creation (POST /api/v4/users) */ public function testStore(): void { $jack = $this->getTestUser('jack@kolab.org'); $john = $this->getTestUser('john@kolab.org'); // Test empty request $response = $this->actingAs($john)->post("/api/v4/users", []); $response->assertStatus(422); $json = $response->json(); $this->assertSame('error', $json['status']); $this->assertSame("The email field is required.", $json['errors']['email']); $this->assertSame("The password field is required.", $json['errors']['password'][0]); $this->assertCount(2, $json); // Test access by user not being a wallet controller $post = ['first_name' => 'Test']; $response = $this->actingAs($jack)->post("/api/v4/users", $post); $json = $response->json(); $response->assertStatus(403); $this->assertSame('error', $json['status']); $this->assertSame("Access denied", $json['message']); $this->assertCount(2, $json); // Test some invalid data $post = ['password' => '12345678', 'email' => 'invalid']; $response = $this->actingAs($john)->post("/api/v4/users", $post); $response->assertStatus(422); $json = $response->json(); $this->assertSame('error', $json['status']); $this->assertCount(2, $json); $this->assertSame('The password confirmation does not match.', $json['errors']['password'][0]); $this->assertSame('The specified email is invalid.', $json['errors']['email']); // Test existing user email $post = [ 'password' => 'simple', 'password_confirmation' => 'simple', 'first_name' => 'John2', 'last_name' => 'Doe2', 'email' => 'jack.daniels@kolab.org', ]; $response = $this->actingAs($john)->post("/api/v4/users", $post); $response->assertStatus(422); $json = $response->json(); $this->assertSame('error', $json['status']); $this->assertCount(2, $json); $this->assertSame('The specified email is not available.', $json['errors']['email']); $package_kolab = \App\Package::where('title', 'kolab')->first(); $package_domain = \App\Package::where('title', 'domain-hosting')->first(); $post = [ 'password' => 'simple', 'password_confirmation' => 'simple', 'first_name' => 'John2', 'last_name' => 'Doe2', 'email' => 'john2.doe2@kolab.org', 'organization' => 'TestOrg', 'aliases' => ['useralias1@kolab.org', 'useralias2@kolab.org'], ]; // Missing package $response = $this->actingAs($john)->post("/api/v4/users", $post); $json = $response->json(); $response->assertStatus(422); $this->assertSame('error', $json['status']); $this->assertSame("Package is required.", $json['errors']['package']); $this->assertCount(2, $json); // Invalid package $post['package'] = $package_domain->id; $response = $this->actingAs($john)->post("/api/v4/users", $post); $json = $response->json(); $response->assertStatus(422); $this->assertSame('error', $json['status']); $this->assertSame("Invalid package selected.", $json['errors']['package']); $this->assertCount(2, $json); // Test full and valid data $post['package'] = $package_kolab->id; $response = $this->actingAs($john)->post("/api/v4/users", $post); $json = $response->json(); $response->assertStatus(200); $this->assertSame('success', $json['status']); $this->assertSame("User created successfully.", $json['message']); $this->assertCount(2, $json); $user = User::where('email', 'john2.doe2@kolab.org')->first(); $this->assertInstanceOf(User::class, $user); $this->assertSame('John2', $user->getSetting('first_name')); $this->assertSame('Doe2', $user->getSetting('last_name')); $this->assertSame('TestOrg', $user->getSetting('organization')); $aliases = $user->aliases()->orderBy('alias')->get(); $this->assertCount(2, $aliases); $this->assertSame('useralias1@kolab.org', $aliases[0]->alias); $this->assertSame('useralias2@kolab.org', $aliases[1]->alias); // Assert the new user entitlements $this->assertUserEntitlements($user, ['groupware', 'mailbox', 'storage', 'storage']); // Assert the wallet to which the new user should be assigned to $wallet = $user->wallet(); $this->assertSame($john->wallets()->first()->id, $wallet->id); // Test acting as account controller (not owner) /* // FIXME: How do we know to which wallet the new user should be assigned to? $this->deleteTestUser('john2.doe2@kolab.org'); $response = $this->actingAs($ned)->post("/api/v4/users", $post); $json = $response->json(); $response->assertStatus(200); $this->assertSame('success', $json['status']); */ $this->markTestIncomplete(); } /** * Test user update (PUT /api/v4/users/) */ public function testUpdate(): void { $userA = $this->getTestUser('UsersControllerTest1@userscontroller.com'); $jack = $this->getTestUser('jack@kolab.org'); $john = $this->getTestUser('john@kolab.org'); $ned = $this->getTestUser('ned@kolab.org'); $domain = $this->getTestDomain( 'userscontroller.com', ['status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_EXTERNAL] ); // Test unauthorized update of other user profile $response = $this->actingAs($jack)->get("/api/v4/users/{$userA->id}", []); $response->assertStatus(403); // Test authorized update of account owner by account controller $response = $this->actingAs($ned)->get("/api/v4/users/{$john->id}", []); $response->assertStatus(200); // Test updating of self (empty request) $response = $this->actingAs($userA)->put("/api/v4/users/{$userA->id}", []); $response->assertStatus(200); $json = $response->json(); $this->assertSame('success', $json['status']); $this->assertSame("User data updated successfully.", $json['message']); $this->assertCount(2, $json); // Test some invalid data $post = ['password' => '12345678', 'currency' => 'invalid']; $response = $this->actingAs($userA)->put("/api/v4/users/{$userA->id}", $post); $response->assertStatus(422); $json = $response->json(); $this->assertSame('error', $json['status']); $this->assertCount(2, $json); $this->assertSame('The password confirmation does not match.', $json['errors']['password'][0]); $this->assertSame('The currency must be 3 characters.', $json['errors']['currency'][0]); // Test full profile update including password $post = [ 'password' => 'simple', 'password_confirmation' => 'simple', 'first_name' => 'John2', 'last_name' => 'Doe2', 'organization' => 'TestOrg', 'phone' => '+123 123 123', 'external_email' => 'external@gmail.com', 'billing_address' => 'billing', 'country' => 'CH', 'currency' => 'CHF', 'aliases' => ['useralias1@' . \config('app.domain'), 'useralias2@' . \config('app.domain')] ]; $response = $this->actingAs($userA)->put("/api/v4/users/{$userA->id}", $post); $json = $response->json(); $response->assertStatus(200); $this->assertSame('success', $json['status']); $this->assertSame("User data updated successfully.", $json['message']); $this->assertCount(2, $json); $this->assertTrue($userA->password != $userA->fresh()->password); unset($post['password'], $post['password_confirmation'], $post['aliases']); foreach ($post as $key => $value) { $this->assertSame($value, $userA->getSetting($key)); } $aliases = $userA->aliases()->orderBy('alias')->get(); $this->assertCount(2, $aliases); $this->assertSame('useralias1@' . \config('app.domain'), $aliases[0]->alias); $this->assertSame('useralias2@' . \config('app.domain'), $aliases[1]->alias); // Test unsetting values $post = [ 'first_name' => '', 'last_name' => '', 'organization' => '', 'phone' => '', 'external_email' => '', 'billing_address' => '', 'country' => '', 'currency' => '', 'aliases' => ['useralias2@' . \config('app.domain')] ]; $response = $this->actingAs($userA)->put("/api/v4/users/{$userA->id}", $post); $json = $response->json(); $response->assertStatus(200); $this->assertSame('success', $json['status']); $this->assertSame("User data updated successfully.", $json['message']); $this->assertCount(2, $json); unset($post['aliases']); foreach ($post as $key => $value) { $this->assertNull($userA->getSetting($key)); } $aliases = $userA->aliases()->get(); $this->assertCount(1, $aliases); $this->assertSame('useralias2@' . \config('app.domain'), $aliases[0]->alias); // Test error on setting an alias to other user's domain // and missing password confirmation $post = [ 'password' => 'simple123', 'aliases' => ['useralias2@' . \config('app.domain'), 'useralias1@kolab.org'] ]; $response = $this->actingAs($userA)->put("/api/v4/users/{$userA->id}", $post); $json = $response->json(); $response->assertStatus(422); $this->assertSame('error', $json['status']); $this->assertCount(2, $json['errors']); $this->assertCount(1, $json['errors']['aliases']); $this->assertSame("The specified domain is not available.", $json['errors']['aliases'][1]); $this->assertSame("The password confirmation does not match.", $json['errors']['password'][0]); // Test authorized update of other user $response = $this->actingAs($ned)->get("/api/v4/users/{$jack->id}", []); $response->assertStatus(200); // TODO: Test error on aliases with invalid/non-existing/other-user's domain // Create entitlements and additional user for following tests $owner = $this->getTestUser('UsersControllerTest1@userscontroller.com'); $user = $this->getTestUser('UsersControllerTest2@userscontroller.com'); $package_domain = Package::where('title', 'domain-hosting')->first(); $package_kolab = Package::where('title', 'kolab')->first(); $package_lite = Package::where('title', 'lite')->first(); $sku_mailbox = Sku::where('title', 'mailbox')->first(); $sku_storage = Sku::where('title', 'storage')->first(); $sku_groupware = Sku::where('title', 'groupware')->first(); $domain = $this->getTestDomain( 'userscontroller.com', [ 'status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_EXTERNAL, ] ); $domain->assignPackage($package_domain, $owner); $owner->assignPackage($package_kolab); $owner->assignPackage($package_lite, $user); // Non-controller cannot update his own entitlements $post = ['skus' => []]; $response = $this->actingAs($user)->put("/api/v4/users/{$user->id}", $post); $response->assertStatus(422); // Test updating entitlements $post = [ 'skus' => [ $sku_mailbox->id => 1, $sku_storage->id => 3, $sku_groupware->id => 1, ], ]; $response = $this->actingAs($owner)->put("/api/v4/users/{$user->id}", $post); $response->assertStatus(200); $storage_cost = $user->entitlements() ->where('sku_id', $sku_storage->id) ->orderBy('cost') ->pluck('cost')->all(); - $this->assertUserEntitlements($user, ['groupware', 'mailbox', 'storage', 'storage', 'storage']); + $this->assertUserEntitlements( + $user, + ['groupware', 'mailbox', 'storage', 'storage', 'storage'] + ); + $this->assertSame([0, 0, 25], $storage_cost); } /** * Test UsersController::updateEntitlements() */ public function testUpdateEntitlements(): void { - // TODO: Test more cases of entitlements update - $this->markTestIncomplete(); + $jane = $this->getTestUser('jane@kolabnow.com'); + + $kolab = \App\Package::where('title', 'kolab')->first(); + $storage = \App\Sku::where('title', 'storage')->first(); + $activesync = \App\Sku::where('title', 'activesync')->first(); + $groupware = \App\Sku::where('title', 'groupware')->first(); + $mailbox = \App\Sku::where('title', 'mailbox')->first(); + + // standard package, 1 mailbox, 1 groupware, 2 storage + $jane->assignPackage($kolab); + + // add 2 storage, 1 activesync + $post = [ + 'skus' => [ + $mailbox->id => 1, + $groupware->id => 1, + $storage->id => 4, + $activesync->id => 1 + ] + ]; + + $response = $this->actingAs($jane)->put("/api/v4/users/{$jane->id}", $post); + $response->assertStatus(200); + + $this->assertUserEntitlements( + $jane, + [ + 'activesync', + 'groupware', + 'mailbox', + 'storage', + 'storage', + 'storage', + 'storage' + ] + ); + + // add 2 storage, remove 1 activesync + $post = [ + 'skus' => [ + $mailbox->id => 1, + $groupware->id => 1, + $storage->id => 6, + $activesync->id => 0 + ] + ]; + + $response = $this->actingAs($jane)->put("/api/v4/users/{$jane->id}", $post); + $response->assertStatus(200); + + $this->assertUserEntitlements( + $jane, + [ + 'groupware', + 'mailbox', + 'storage', + 'storage', + 'storage', + 'storage', + 'storage', + 'storage' + ] + ); + + // add mailbox + $post = [ + 'skus' => [ + $mailbox->id => 2, + $groupware->id => 1, + $storage->id => 6, + $activesync->id => 0 + ] + ]; + + $response = $this->actingAs($jane)->put("/api/v4/users/{$jane->id}", $post); + $response->assertStatus(500); + + $this->assertUserEntitlements( + $jane, + [ + 'groupware', + 'mailbox', + 'storage', + 'storage', + 'storage', + 'storage', + 'storage', + 'storage' + ] + ); + + // remove mailbox + $post = [ + 'skus' => [ + $mailbox->id => 0, + $groupware->id => 1, + $storage->id => 6, + $activesync->id => 0 + ] + ]; + + $response = $this->actingAs($jane)->put("/api/v4/users/{$jane->id}", $post); + $response->assertStatus(500); + + $this->assertUserEntitlements( + $jane, + [ + 'groupware', + 'mailbox', + 'storage', + 'storage', + 'storage', + 'storage', + 'storage', + 'storage' + ] + ); + + // less than free storage + $post = [ + 'skus' => [ + $mailbox->id => 1, + $groupware->id => 1, + $storage->id => 1, + $activesync->id => 0 + ] + ]; + + $response = $this->actingAs($jane)->put("/api/v4/users/{$jane->id}", $post); + $response->assertStatus(200); + + $this->assertUserEntitlements( + $jane, + [ + 'groupware', + 'mailbox', + 'storage', + 'storage' + ] + ); } /** * Test user data response used in show and info actions */ public function testUserResponse(): void { $provider = \config('services.payment_provider') ?: 'mollie'; $user = $this->getTestUser('john@kolab.org'); $wallet = $user->wallets()->first(); $wallet->setSettings(['mollie_id' => null, 'stripe_id' => null]); $result = $this->invokeMethod(new UsersController(), 'userResponse', [$user]); $this->assertEquals($user->id, $result['id']); $this->assertEquals($user->email, $result['email']); $this->assertEquals($user->status, $result['status']); $this->assertTrue(is_array($result['statusInfo'])); $this->assertTrue(is_array($result['aliases'])); $this->assertCount(1, $result['aliases']); $this->assertSame('john.doe@kolab.org', $result['aliases'][0]); $this->assertTrue(is_array($result['settings'])); $this->assertSame('US', $result['settings']['country']); $this->assertSame('USD', $result['settings']['currency']); $this->assertTrue(is_array($result['accounts'])); $this->assertTrue(is_array($result['wallets'])); $this->assertCount(0, $result['accounts']); $this->assertCount(1, $result['wallets']); $this->assertSame($wallet->id, $result['wallet']['id']); $this->assertArrayNotHasKey('discount', $result['wallet']); $ned = $this->getTestUser('ned@kolab.org'); $ned_wallet = $ned->wallets()->first(); $result = $this->invokeMethod(new UsersController(), 'userResponse', [$ned]); $this->assertEquals($ned->id, $result['id']); $this->assertEquals($ned->email, $result['email']); $this->assertTrue(is_array($result['accounts'])); $this->assertTrue(is_array($result['wallets'])); $this->assertCount(1, $result['accounts']); $this->assertCount(1, $result['wallets']); $this->assertSame($wallet->id, $result['wallet']['id']); $this->assertSame($wallet->id, $result['accounts'][0]['id']); $this->assertSame($ned_wallet->id, $result['wallets'][0]['id']); $this->assertSame($provider, $result['wallet']['provider']); $this->assertSame($provider, $result['wallets'][0]['provider']); // Test discount in a response $discount = Discount::where('code', 'TEST')->first(); $wallet->discount()->associate($discount); $wallet->save(); $mod_provider = $provider == 'mollie' ? 'stripe' : 'mollie'; $wallet->setSetting($mod_provider . '_id', 123); $user->refresh(); $result = $this->invokeMethod(new UsersController(), 'userResponse', [$user]); $this->assertEquals($user->id, $result['id']); $this->assertSame($discount->id, $result['wallet']['discount_id']); $this->assertSame($discount->discount, $result['wallet']['discount']); $this->assertSame($discount->description, $result['wallet']['discount_description']); $this->assertSame($mod_provider, $result['wallet']['provider']); $this->assertSame($discount->id, $result['wallets'][0]['discount_id']); $this->assertSame($discount->discount, $result['wallets'][0]['discount']); $this->assertSame($discount->description, $result['wallets'][0]['discount_description']); $this->assertSame($mod_provider, $result['wallets'][0]['provider']); } /** * List of alias validation cases for testValidateEmail() * * @return array Arguments for testValidateEmail() */ public function dataValidateEmail(): array { $this->refreshApplication(); $public_domains = Domain::getPublicDomains(); $domain = reset($public_domains); $john = $this->getTestUser('john@kolab.org'); $jack = $this->getTestUser('jack@kolab.org'); $user = $this->getTestUser('UsersControllerTest1@userscontroller.com'); return [ // Invalid format ["$domain", $john, true, 'The specified alias is invalid.'], [".@$domain", $john, true, 'The specified alias is invalid.'], ["test123456@localhost", $john, true, 'The specified domain is invalid.'], ["test123456@unknown-domain.org", $john, true, 'The specified domain is invalid.'], ["$domain", $john, false, 'The specified email is invalid.'], [".@$domain", $john, false, 'The specified email is invalid.'], // forbidden local part on public domains ["admin@$domain", $john, true, 'The specified alias is not available.'], ["administrator@$domain", $john, true, 'The specified alias is not available.'], // forbidden (other user's domain) ["testtest@kolab.org", $user, true, 'The specified domain is not available.'], // existing alias of other user ["jack.daniels@kolab.org", $john, true, 'The specified alias is not available.'], // existing user ["jack@kolab.org", $john, true, 'The specified alias is not available.'], // valid (user domain) ["admin@kolab.org", $john, true, null], // valid (public domain) ["test.test@$domain", $john, true, null], ]; } /** * User email/alias validation. * * Note: Technically these include unit tests, but let's keep it here for now. * FIXME: Shall we do a http request for each case? * * @dataProvider dataValidateEmail */ public function testValidateEmail($alias, $user, $is_alias, $expected_result): void { $result = $this->invokeMethod(new \App\Utils(), 'validateEmail', [$alias, $user, $is_alias]); $this->assertSame($expected_result, $result); } } diff --git a/src/tests/Feature/EntitlementTest.php b/src/tests/Feature/EntitlementTest.php index 4e83500a..3886e056 100644 --- a/src/tests/Feature/EntitlementTest.php +++ b/src/tests/Feature/EntitlementTest.php @@ -1,127 +1,168 @@ deleteTestUser('entitlement-test@kolabnow.com'); $this->deleteTestUser('entitled-user@custom-domain.com'); $this->deleteTestDomain('custom-domain.com'); } public function tearDown(): void { $this->deleteTestUser('entitlement-test@kolabnow.com'); $this->deleteTestUser('entitled-user@custom-domain.com'); $this->deleteTestDomain('custom-domain.com'); parent::tearDown(); } public function testCostsPerDay(): void { // 444 // 28 days: 15.86 // 31 days: 14.32 $user = $this->getTestUser('entitlement-test@kolabnow.com'); $package = Package::where('title', 'kolab')->first(); $mailbox = Sku::where('title', 'mailbox')->first(); $user->assignPackage($package); $entitlement = $user->entitlements->where('sku_id', $mailbox->id)->first(); $costsPerDay = $entitlement->costsPerDay(); $this->assertTrue($costsPerDay < 15.86); $this->assertTrue($costsPerDay > 14.32); } /** * Tests for User::AddEntitlement() */ public function testUserAddEntitlement(): void { - $package_domain = Package::where('title', 'domain-hosting')->first(); - $package_kolab = Package::where('title', 'kolab')->first(); + $packageDomain = Package::where('title', 'domain-hosting')->first(); + $packageKolab = Package::where('title', 'kolab')->first(); - $sku_domain = Sku::where('title', 'domain-hosting')->first(); - $sku_mailbox = Sku::where('title', 'mailbox')->first(); + $skuDomain = Sku::where('title', 'domain-hosting')->first(); + $skuMailbox = Sku::where('title', 'mailbox')->first(); $owner = $this->getTestUser('entitlement-test@kolabnow.com'); $user = $this->getTestUser('entitled-user@custom-domain.com'); $domain = $this->getTestDomain( 'custom-domain.com', [ 'status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_EXTERNAL, ] ); - $domain->assignPackage($package_domain, $owner); + $domain->assignPackage($packageDomain, $owner); - $owner->assignPackage($package_kolab); - $owner->assignPackage($package_kolab, $user); + $owner->assignPackage($packageKolab); + $owner->assignPackage($packageKolab, $user); $wallet = $owner->wallets->first(); $this->assertCount(4, $owner->entitlements()->get()); - $this->assertCount(1, $sku_domain->entitlements()->where('wallet_id', $wallet->id)->get()); - $this->assertCount(2, $sku_mailbox->entitlements()->where('wallet_id', $wallet->id)->get()); + $this->assertCount(1, $skuDomain->entitlements()->where('wallet_id', $wallet->id)->get()); + $this->assertCount(2, $skuMailbox->entitlements()->where('wallet_id', $wallet->id)->get()); $this->assertCount(9, $wallet->entitlements); - $this->backdateEntitlements($owner->entitlements, Carbon::now()->subMonthsWithoutOverflow(1)); + $this->backdateEntitlements( + $owner->entitlements, + Carbon::now()->subMonthsWithoutOverflow(1) + ); $wallet->chargeEntitlements(); $this->assertTrue($wallet->fresh()->balance < 0); } public function testAddExistingEntitlement(): void { $this->markTestIncomplete(); } public function testEntitlementFunctions(): void { $user = $this->getTestUser('entitlement-test@kolabnow.com'); $package = \App\Package::where('title', 'kolab')->first(); $user->assignPackage($package); $wallet = $user->wallets()->first(); $this->assertNotNull($wallet); $sku = \App\Sku::where('title', 'mailbox')->first(); $this->assertNotNull($sku); - $entitlement = Entitlement::where('wallet_id', $wallet->id)->where('sku_id', $sku->id)->first(); + $entitlement = Entitlement::where('wallet_id', $wallet->id) + ->where('sku_id', $sku->id)->first(); + $this->assertNotNull($entitlement); - $e_sku = $entitlement->sku; - $this->assertSame($sku->id, $e_sku->id); + $eSKU = $entitlement->sku; + $this->assertSame($sku->id, $eSKU->id); + + $eWallet = $entitlement->wallet; + $this->assertSame($wallet->id, $eWallet->id); + + $eEntitleable = $entitlement->entitleable; + $this->assertEquals($user->id, $eEntitleable->id); + $this->assertTrue($eEntitleable instanceof \App\User); + } + + public function testBillDeletedEntitlement(): void + { + $user = $this->getTestUser('entitlement-test@kolabnow.com'); + $package = \App\Package::where('title', 'kolab')->first(); + + $storage = \App\Sku::where('title', 'storage')->first(); + + $user->assignPackage($package); + // some additional SKUs so we have something to delete. + $user->assignSku($storage, 4); + + // the mailbox, the groupware, the 2 original storage and the additional 4 + $this->assertCount(8, $user->fresh()->entitlements); + + $wallet = $user->wallets()->first(); + + $this->backdateEntitlements($user->entitlements, Carbon::now()->subWeeks(7)); + + $charge = $wallet->chargeEntitlements(); + + $this->assertTrue($wallet->balance < 0); + + $balance = $wallet->balance; + + $user->removeSku($storage, 4); + + // we expect the wallet to have been charged. + $this->assertTrue($wallet->fresh()->balance < $balance); - $e_wallet = $entitlement->wallet; - $this->assertSame($wallet->id, $e_wallet->id); + $transactions = \App\Transaction::where('object_id', $wallet->id) + ->where('object_type', \App\Wallet::class)->get(); - $e_entitleable = $entitlement->entitleable; - $this->assertEquals($user->id, $e_entitleable->id); - $this->assertTrue($e_entitleable instanceof \App\User); + // one round of the monthly invoicing, four sku deletions getting invoiced + $this->assertCount(5, $transactions); } } diff --git a/src/tests/Unit/TransactionTest.php b/src/tests/Unit/TransactionTest.php index eed18510..6ee2f9b1 100644 --- a/src/tests/Unit/TransactionTest.php +++ b/src/tests/Unit/TransactionTest.php @@ -1,62 +1,83 @@ get(); foreach ($transactions as $transaction) { $this->assertNotNull($transaction->toString()); } } public function testWalletPenalty() { $user = $this->getTestUser('jane@kolabnow.com'); $wallet = $user->wallets()->first(); $transaction = Transaction::create( [ 'object_id' => $wallet->id, 'object_type' => \App\Wallet::class, 'type' => Transaction::WALLET_PENALTY, 'amount' => 9 ] ); $this->assertEquals($transaction->{'type'}, Transaction::WALLET_PENALTY); } public function testInvalidType() { $user = $this->getTestUser('jane@kolabnow.com'); $wallet = $user->wallets()->first(); $this->expectException(\Exception::class); $transaction = Transaction::create( [ 'object_id' => $wallet->id, 'object_type' => \App\Wallet::class, 'type' => 'invalid', 'amount' => 9 ] ); } + + public function testEntitlementForWallet(): void + { + $transaction = \App\Transaction::where('object_type', \App\Wallet::class) + ->whereIn('object_id', \App\Wallet::pluck('id'))->first(); + + $entitlement = $transaction->entitlement(); + $this->assertNull($entitlement); + $this->assertNotNull($transaction->wallet()); + } + + public function testWalletForEntitlement(): void + { + $transaction = \App\Transaction::where('object_type', \App\Entitlement::class) + ->whereIn('object_id', \App\Entitlement::pluck('id'))->first(); + + $wallet = $transaction->wallet(); + $this->assertNull($wallet); + + $this->assertNotNull($transaction->entitlement()); + } }