diff --git a/src/app/Domain.php b/src/app/Domain.php index 092ee7bf..725b9372 100644 --- a/src/app/Domain.php +++ b/src/app/Domain.php @@ -1,505 +1,516 @@ isPublic()) { return $this; } // See if this domain is already owned by another user. $wallet = $this->wallet(); if ($wallet) { \Log::error( "Domain {$this->namespace} is already assigned to {$wallet->owner->email}" ); return $this; } $wallet_id = $user->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(), 'fee' => $sku->pivot->fee(), 'entitleable_id' => $this->id, 'entitleable_type' => Domain::class ] ); } } return $this; } /** * The domain entitlement. * * @return \Illuminate\Database\Eloquent\Relations\MorphOne */ public function entitlement() { return $this->morphOne('App\Entitlement', 'entitleable'); } + /** + * Entitlements for this domain. + * + * @return \Illuminate\Database\Eloquent\Relations\HasMany + */ + public function entitlements() + { + return $this->hasMany('App\Entitlement', 'entitleable_id', 'id') + ->where('entitleable_type', Domain::class); + } + /** * Return list of public+active domain names (for current tenant) */ public static function getPublicDomains(): array { return self::withEnvTenantContext() ->whereRaw(sprintf('(type & %s)', Domain::TYPE_PUBLIC)) ->get(['namespace'])->pluck('namespace')->toArray(); } /** * Returns whether this domain is active. * * @return bool */ public function isActive(): bool { return ($this->status & self::STATUS_ACTIVE) > 0; } /** * Returns whether this domain is confirmed the ownership of. * * @return bool */ public function isConfirmed(): bool { return ($this->status & self::STATUS_CONFIRMED) > 0; } /** * Returns whether this domain is deleted. * * @return bool */ public function isDeleted(): bool { return ($this->status & self::STATUS_DELETED) > 0; } /** * Returns whether this domain is registered with us. * * @return bool */ public function isExternal(): bool { return ($this->type & self::TYPE_EXTERNAL) > 0; } /** * Returns whether this domain is hosted with us. * * @return bool */ public function isHosted(): bool { return ($this->type & self::TYPE_HOSTED) > 0; } /** * Returns whether this domain is new. * * @return bool */ public function isNew(): bool { return ($this->status & self::STATUS_NEW) > 0; } /** * Returns whether this domain is public. * * @return bool */ public function isPublic(): bool { return ($this->type & self::TYPE_PUBLIC) > 0; } /** * Returns whether this domain is registered in LDAP. * * @return bool */ public function isLdapReady(): bool { return ($this->status & self::STATUS_LDAP_READY) > 0; } /** * Returns whether this domain is suspended. * * @return bool */ public function isSuspended(): bool { return ($this->status & self::STATUS_SUSPENDED) > 0; } /** * Returns whether this (external) domain has been verified * to exist in DNS. * * @return bool */ public function isVerified(): bool { return ($this->status & self::STATUS_VERIFIED) > 0; } /** * Ensure the namespace is appropriately cased. */ public function setNamespaceAttribute($namespace) { $this->attributes['namespace'] = strtolower($namespace); } /** * Domain 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_CONFIRMED, self::STATUS_VERIFIED, self::STATUS_LDAP_READY, ]; foreach ($allowed_values as $value) { if ($status & $value) { $new_status |= $value; $status ^= $value; } } if ($status > 0) { throw new \Exception("Invalid domain status: {$status}"); } if ($this->isPublic()) { $this->attributes['status'] = $new_status; return; } if ($new_status & self::STATUS_CONFIRMED) { // if we have confirmed ownership of or management access to the domain, then we have // also confirmed the domain exists in DNS. $new_status |= self::STATUS_VERIFIED; $new_status |= self::STATUS_ACTIVE; } if ($new_status & self::STATUS_DELETED && $new_status & self::STATUS_ACTIVE) { $new_status ^= self::STATUS_ACTIVE; } if ($new_status & self::STATUS_SUSPENDED && $new_status & self::STATUS_ACTIVE) { $new_status ^= self::STATUS_ACTIVE; } // if the domain is now active, it is not new anymore. if ($new_status & self::STATUS_ACTIVE && $new_status & self::STATUS_NEW) { $new_status ^= self::STATUS_NEW; } $this->attributes['status'] = $new_status; } /** * Ownership verification by checking for a TXT (or CNAME) record * in the domain's DNS (that matches the verification hash). * * @return bool True if verification was successful, false otherwise * @throws \Exception Throws exception on DNS or DB errors */ public function confirm(): bool { if ($this->isConfirmed()) { return true; } $hash = $this->hash(self::HASH_TEXT); $confirmed = false; // Get DNS records and find a matching TXT entry $records = \dns_get_record($this->namespace, DNS_TXT); if ($records === false) { throw new \Exception("Failed to get DNS record for {$this->namespace}"); } foreach ($records as $record) { if ($record['txt'] === $hash) { $confirmed = true; break; } } // Get DNS records and find a matching CNAME entry // Note: some servers resolve every non-existing name // so we need to define left and right side of the CNAME record // i.e.: kolab-verify IN CNAME .domain.tld. if (!$confirmed) { $cname = $this->hash(self::HASH_CODE) . '.' . $this->namespace; $records = \dns_get_record('kolab-verify.' . $this->namespace, DNS_CNAME); if ($records === false) { throw new \Exception("Failed to get DNS record for {$this->namespace}"); } foreach ($records as $records) { if ($records['target'] === $cname) { $confirmed = true; break; } } } if ($confirmed) { $this->status |= Domain::STATUS_CONFIRMED; $this->save(); } return $confirmed; } /** * Generate a verification hash for this domain * * @param int $mod One of: HASH_CNAME, HASH_CODE (Default), HASH_TEXT * * @return string Verification hash */ public function hash($mod = null): string { $cname = 'kolab-verify'; if ($mod === self::HASH_CNAME) { return $cname; } $hash = \md5('hkccp-verify-' . $this->namespace); return $mod === self::HASH_TEXT ? "$cname=$hash" : $hash; } /** * Any (additional) properties of this domain. * * @return \Illuminate\Database\Eloquent\Relations\HasMany */ public function settings() { return $this->hasMany('App\DomainSetting', 'domain_id'); } /** * Suspend this domain. * * @return void */ public function suspend(): void { if ($this->isSuspended()) { return; } $this->status |= Domain::STATUS_SUSPENDED; $this->save(); } /** * Unsuspend this domain. * * The domain is unsuspended through either of the following courses of actions; * * * The account balance has been topped up, or * * a suspected spammer has resolved their issues, or * * the command-line is triggered. * * Therefore, we can also confidently set the domain status to 'active' should the ownership of or management * access to have been confirmed before. * * @return void */ public function unsuspend(): void { if (!$this->isSuspended()) { return; } $this->status ^= Domain::STATUS_SUSPENDED; if ($this->isConfirmed() && $this->isVerified()) { $this->status |= Domain::STATUS_ACTIVE; } $this->save(); } /** * List the users of a domain, so long as the domain is not a public registration domain. * * @return array */ public function users(): array { if ($this->isPublic()) { return []; } $wallet = $this->wallet(); if (!$wallet) { return []; } $mailboxSKU = \App\Sku::withObjectTenantContext($this)->where('title', 'mailbox')->first(); if (!$mailboxSKU) { \Log::error("No mailbox SKU available."); return []; } $entitlements = $wallet->entitlements() ->where('entitleable_type', \App\User::class) ->where('sku_id', $mailboxSKU->id)->get(); $users = []; foreach ($entitlements as $entitlement) { $users[] = $entitlement->entitleable; } return $users; } /** * Verify if a domain exists in DNS * * @return bool True if registered, False otherwise * @throws \Exception Throws exception on DNS or DB errors */ public function verify(): bool { if ($this->isVerified()) { return true; } $records = \dns_get_record($this->namespace, DNS_ANY); if ($records === false) { throw new \Exception("Failed to get DNS record for {$this->namespace}"); } // It may happen that result contains other domains depending on the host DNS setup // that's why in_array() and not just !empty() if (in_array($this->namespace, array_column($records, 'host'))) { $this->status |= Domain::STATUS_VERIFIED; $this->save(); return true; } return false; } /** * Returns the wallet by which the domain is controlled * * @return \App\Wallet A wallet object */ public function wallet(): ?Wallet { // Note: Not all domains have a entitlement/wallet $entitlement = $this->entitlement()->withTrashed()->orderBy('created_at', 'desc')->first(); return $entitlement ? $entitlement->wallet : null; } } diff --git a/src/app/Entitlement.php b/src/app/Entitlement.php index dd7570a0..22057172 100644 --- a/src/app/Entitlement.php +++ b/src/app/Entitlement.php @@ -1,148 +1,175 @@ 'integer', 'fee' => '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( [ 'object_id' => $this->id, 'object_type' => \App\Entitlement::class, 'type' => $type, 'amount' => $amount ] ); return $transaction->id; } /** * Principally entitleable object such as Domain, User, Group. * Note that it may be trashed (soft-deleted). * * @return mixed */ public function entitleable() { return $this->morphTo()->withTrashed(); // @phpstan-ignore-line } /** * Returns entitleable object title (e.g. email or domain name). * * @return string|null An object title/name */ public function entitleableTitle(): ?string { if ($this->entitleable instanceof \App\Domain) { return $this->entitleable->namespace; } return $this->entitleable->email; } + /** + * Simplified Entitlement/SKU information for a specified entitleable object + * + * @param object $object Entitleable object + * + * @return array Skus list with some metadata + */ + public static function objectEntitlementsSummary($object): array + { + $skus = []; + + // TODO: I agree this format may need to be extended in future + + foreach ($object->entitlements as $ent) { + $sku = $ent->sku; + + if (!isset($skus[$sku->id])) { + $skus[$sku->id] = ['costs' => [], 'count' => 0]; + } + + $skus[$sku->id]['count']++; + $skus[$sku->id]['costs'][] = $ent->cost; + } + + return $skus; + } + /** * 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/Handlers/Base.php b/src/app/Handlers/Base.php index 349641e2..f01487ba 100644 --- a/src/app/Handlers/Base.php +++ b/src/app/Handlers/Base.php @@ -1,107 +1,107 @@ active) { - if (!$user->entitlements()->where('sku_id', $sku->id)->first()) { + if (!$object->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 [ // entitleable type 'type' => $type, // handler (as a keyword) 'handler' => $handler, // readonly entitlement state cannot be changed 'readonly' => false, // is entitlement enabled by default? 'enabled' => false, // priority on the entitlements list 'prio' => static::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/Beta/Base.php b/src/app/Handlers/Beta/Base.php index ea2cc882..bbca315e 100644 --- a/src/app/Handlers/Beta/Base.php +++ b/src/app/Handlers/Beta/Base.php @@ -1,66 +1,70 @@ active) { - return $user->hasSku('beta'); + return $object->hasSku('beta'); } else { - if ($user->entitlements()->where('sku_id', $sku->id)->first()) { + if ($object->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/Distlist.php b/src/app/Handlers/Distlist.php index 9cb9edc3..d4d19ab9 100644 --- a/src/app/Handlers/Distlist.php +++ b/src/app/Handlers/Distlist.php @@ -1,49 +1,49 @@ wallet()->entitlements() + if (parent::isAvailable($sku, $object)) { + return $object->wallet()->entitlements() ->where('entitleable_type', \App\Domain::class)->count() > 0; } return false; } /** * 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 10; } } diff --git a/src/app/Http/Controllers/API/V4/Admin/DomainsController.php b/src/app/Http/Controllers/API/V4/Admin/DomainsController.php index f9049f46..ae93f02b 100644 --- a/src/app/Http/Controllers/API/V4/Admin/DomainsController.php +++ b/src/app/Http/Controllers/API/V4/Admin/DomainsController.php @@ -1,104 +1,116 @@ input('search')); $owner = trim(request()->input('owner')); $result = collect([]); if ($owner) { if ($owner = User::find($owner)) { foreach ($owner->wallets as $wallet) { $entitlements = $wallet->entitlements()->where('entitleable_type', Domain::class)->get(); foreach ($entitlements as $entitlement) { $domain = $entitlement->entitleable; $result->push($domain); } } $result = $result->sortBy('namespace')->values(); } } elseif (!empty($search)) { if ($domain = Domain::where('namespace', $search)->first()) { $result->push($domain); } } // Process the result $result = $result->map(function ($domain) { $data = $domain->toArray(); $data = array_merge($data, self::domainStatuses($domain)); return $data; }); $result = [ 'list' => $result, 'count' => count($result), 'message' => \trans('app.search-foundxdomains', ['x' => count($result)]), ]; return response()->json($result); } + /** + * Create a domain. + * + * @param \Illuminate\Http\Request $request + * + * @return \Illuminate\Http\JsonResponse + */ + public function store(Request $request) + { + return $this->errorResponse(404); + } + /** * Suspend the domain * * @param \Illuminate\Http\Request $request The API request. * @param string $id Domain identifier * * @return \Illuminate\Http\JsonResponse The response */ public function suspend(Request $request, $id) { $domain = Domain::find($id); if (!$this->checkTenant($domain) || $domain->isPublic()) { return $this->errorResponse(404); } $domain->suspend(); return response()->json([ 'status' => 'success', 'message' => \trans('app.domain-suspend-success'), ]); } /** * Un-Suspend the domain * * @param \Illuminate\Http\Request $request The API request. * @param string $id Domain identifier * * @return \Illuminate\Http\JsonResponse The response */ public function unsuspend(Request $request, $id) { $domain = Domain::find($id); if (!$this->checkTenant($domain) || $domain->isPublic()) { return $this->errorResponse(404); } $domain->unsuspend(); return response()->json([ 'status' => 'success', 'message' => \trans('app.domain-unsuspend-success'), ]); } } diff --git a/src/app/Http/Controllers/API/V4/DomainsController.php b/src/app/Http/Controllers/API/V4/DomainsController.php index a288ffe0..8607bac7 100644 --- a/src/app/Http/Controllers/API/V4/DomainsController.php +++ b/src/app/Http/Controllers/API/V4/DomainsController.php @@ -1,425 +1,496 @@ guard()->user(); $list = []; foreach ($user->domains() as $domain) { if (!$domain->isPublic()) { $data = $domain->toArray(); $data = array_merge($data, self::domainStatuses($domain)); $list[] = $data; } } + usort($list, function ($a, $b) { + return strcmp($a['namespace'], $b['namespace']); + }); + return response()->json($list); } /** * Show the form for creating a new resource. * * @return \Illuminate\Http\JsonResponse */ public function create() { return $this->errorResponse(404); } /** * Confirm ownership of the specified domain (via DNS check). * * @param int $id Domain identifier * * @return \Illuminate\Http\JsonResponse|void */ public function confirm($id) { $domain = Domain::find($id); if (!$this->checkTenant($domain)) { return $this->errorResponse(404); } if (!$this->guard()->user()->canRead($domain)) { return $this->errorResponse(403); } if (!$domain->confirm()) { return response()->json([ 'status' => 'error', 'message' => \trans('app.domain-verify-error'), ]); } return response()->json([ 'status' => 'success', 'statusInfo' => self::statusInfo($domain), 'message' => \trans('app.domain-verify-success'), ]); } /** * Remove the specified resource from storage. * * @param int $id * * @return \Illuminate\Http\JsonResponse */ public function destroy($id) { return $this->errorResponse(404); } /** * Show the form for editing the specified resource. * * @param int $id * * @return \Illuminate\Http\JsonResponse */ public function edit($id) { return $this->errorResponse(404); } /** * Set the domain configuration. * * @param int $id Domain identifier * * @return \Illuminate\Http\JsonResponse|void */ public function setConfig($id) { $domain = Domain::find($id); if (empty($domain)) { return $this->errorResponse(404); } // Only owner (or admin) has access to the domain if (!$this->guard()->user()->canRead($domain)) { return $this->errorResponse(403); } $errors = $domain->setConfig(request()->input()); if (!empty($errors)) { return response()->json(['status' => 'error', 'errors' => $errors], 422); } return response()->json([ 'status' => 'success', 'message' => \trans('app.domain-setconfig-success'), ]); } - /** - * Store a newly created resource in storage. + * Create a domain. * * @param \Illuminate\Http\Request $request * * @return \Illuminate\Http\JsonResponse */ public function store(Request $request) { - return $this->errorResponse(404); + $current_user = $this->guard()->user(); + $owner = $current_user->wallet()->owner; + + if ($owner->id != $current_user->id) { + return $this->errorResponse(403); + } + + // Validate the input + $v = Validator::make( + $request->all(), + [ + 'namespace' => ['required', 'string', new UserEmailDomain()] + ] + ); + + if ($v->fails()) { + return response()->json(['status' => 'error', 'errors' => $v->errors()], 422); + } + + $namespace = \strtolower(request()->input('namespace')); + + // Domain already exists + if (Domain::withTrashed()->where('namespace', $namespace)->exists()) { + $errors = ['namespace' => \trans('validation.domainnotavailable')]; + return response()->json(['status' => 'error', 'errors' => $errors], 422); + } + + if (empty($request->package) || !($package = \App\Package::withEnvTenantContext()->find($request->package))) { + $errors = ['package' => \trans('validation.packagerequired')]; + return response()->json(['status' => 'error', 'errors' => $errors], 422); + } + + if (!$package->isDomain()) { + $errors = ['package' => \trans('validation.packageinvalid')]; + return response()->json(['status' => 'error', 'errors' => $errors], 422); + } + + DB::beginTransaction(); + + // TODO: Force-delete domain if it is soft-deleted and belongs to the same user + + // Create the domain + $domain = Domain::create([ + 'namespace' => $namespace, + 'type' => \App\Domain::TYPE_EXTERNAL, + ]); + + $domain->assignPackage($package, $owner); + + DB::commit(); + + return response()->json([ + 'status' => 'success', + 'message' => __('app.domain-create-success'), + ]); } /** * Get the information about the specified domain. * * @param int $id Domain identifier * * @return \Illuminate\Http\JsonResponse|void */ public function show($id) { $domain = Domain::find($id); if (!$this->checkTenant($domain)) { return $this->errorResponse(404); } if (!$this->guard()->user()->canRead($domain)) { return $this->errorResponse(403); } $response = $domain->toArray(); // Add hash information to the response $response['hash_text'] = $domain->hash(Domain::HASH_TEXT); $response['hash_cname'] = $domain->hash(Domain::HASH_CNAME); $response['hash_code'] = $domain->hash(Domain::HASH_CODE); // Add DNS/MX configuration for the domain $response['dns'] = self::getDNSConfig($domain); $response['mx'] = self::getMXConfig($domain->namespace); // Domain configuration, e.g. spf whitelist $response['config'] = $domain->getConfig(); // Status info $response['statusInfo'] = self::statusInfo($domain); + // Entitlements info + $response['skus'] = \App\Entitlement::objectEntitlementsSummary($domain); + $response = array_merge($response, self::domainStatuses($domain)); + // Some basic information about the domain wallet + $wallet = $domain->wallet(); + $response['wallet'] = $wallet->toArray(); + if ($wallet->discount) { + $response['wallet']['discount'] = $wallet->discount->discount; + $response['wallet']['discount_description'] = $wallet->discount->description; + } + return response()->json($response); } /** * Fetch domain status (and reload setup process) * * @param int $id Domain identifier * * @return \Illuminate\Http\JsonResponse */ public function status($id) { $domain = Domain::find($id); if (!$this->checkTenant($domain)) { return $this->errorResponse(404); } if (!$this->guard()->user()->canRead($domain)) { return $this->errorResponse(403); } $response = self::statusInfo($domain); 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($domain, $step['label'])) { break; } $updated = true; } } if ($updated) { $response = self::statusInfo($domain); } $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::domainStatuses($domain)); return response()->json($response); } /** * Update the specified resource in storage. * * @param \Illuminate\Http\Request $request * @param int $id * * @return \Illuminate\Http\JsonResponse */ public function update(Request $request, $id) { return $this->errorResponse(404); } /** * Provide DNS MX information to configure specified domain for */ protected static function getMXConfig(string $namespace): array { $entries = []; // copy MX entries from an existing domain if ($master = \config('dns.copyfrom')) { // TODO: cache this lookup foreach ((array) dns_get_record($master, DNS_MX) as $entry) { $entries[] = sprintf( "@\t%s\t%s\tMX\t%d %s.", \config('dns.ttl', $entry['ttl']), $entry['class'], $entry['pri'], $entry['target'] ); } } elseif ($static = \config('dns.static')) { $entries[] = strtr($static, array('\n' => "\n", '%s' => $namespace)); } // display SPF settings if ($spf = \config('dns.spf')) { $entries[] = ';'; foreach (['TXT', 'SPF'] as $type) { $entries[] = sprintf( "@\t%s\tIN\t%s\t\"%s\"", \config('dns.ttl'), $type, $spf ); } } return $entries; } /** * Provide sample DNS config for domain confirmation */ protected static function getDNSConfig(Domain $domain): array { $serial = date('Ymd01'); $hash_txt = $domain->hash(Domain::HASH_TEXT); $hash_cname = $domain->hash(Domain::HASH_CNAME); $hash = $domain->hash(Domain::HASH_CODE); return [ "@ IN SOA ns1.dnsservice.com. hostmaster.{$domain->namespace}. (", " {$serial} 10800 3600 604800 86400 )", ";", "@ IN A ", "www IN A ", ";", "{$hash_cname}.{$domain->namespace}. IN CNAME {$hash}.{$domain->namespace}.", "@ 3600 TXT \"{$hash_txt}\"", ]; } /** * Prepare domain statuses for the UI * * @param \App\Domain $domain Domain object * * @return array Statuses array */ protected static function domainStatuses(Domain $domain): array { return [ 'isLdapReady' => $domain->isLdapReady(), 'isConfirmed' => $domain->isConfirmed(), 'isVerified' => $domain->isVerified(), 'isSuspended' => $domain->isSuspended(), 'isActive' => $domain->isActive(), 'isDeleted' => $domain->isDeleted() || $domain->trashed(), ]; } /** * Domain status (extended) information. * * @param \App\Domain $domain Domain object * * @return array Status information */ public static function statusInfo(Domain $domain): array { $process = []; // If that is not a public domain, add domain specific steps $steps = [ 'domain-new' => true, 'domain-ldap-ready' => $domain->isLdapReady(), 'domain-verified' => $domain->isVerified(), 'domain-confirmed' => $domain->isConfirmed(), ]; $count = count($steps); // Create a process check list foreach ($steps as $step_name => $state) { $step = [ 'label' => $step_name, 'title' => \trans("app.process-{$step_name}"), 'state' => $state, ]; if ($step_name == 'domain-confirmed' && !$state) { $step['link'] = "/domain/{$domain->id}"; } $process[] = $step; if ($state) { $count--; } } $state = $count === 0 ? 'done' : 'running'; // After 180 seconds assume the process is in failed state, // this should unlock the Refresh button in the UI if ($count > 0 && $domain->created_at->diffInSeconds(Carbon::now()) > 180) { $state = 'failed'; } return [ 'process' => $process, 'processState' => $state, 'isReady' => $count === 0, ]; } /** * Execute (synchronously) specified step in a domain setup process. * * @param \App\Domain $domain Domain object * @param string $step Step identifier (as in self::statusInfo()) * * @return bool True if the execution succeeded, False otherwise */ public static function execProcessStep(Domain $domain, string $step): bool { try { switch ($step) { case 'domain-ldap-ready': // Domain not in LDAP, create it if (!$domain->isLdapReady()) { LDAP::createDomain($domain); $domain->status |= Domain::STATUS_LDAP_READY; $domain->save(); } return $domain->isLdapReady(); case 'domain-verified': // Domain existence not verified $domain->verify(); return $domain->isVerified(); case 'domain-confirmed': // Domain ownership confirmation $domain->confirm(); return $domain->isConfirmed(); } } catch (\Exception $e) { \Log::error($e); } return false; } } diff --git a/src/app/Http/Controllers/API/V4/SkusController.php b/src/app/Http/Controllers/API/V4/SkusController.php index 0d49d694..3ea2360d 100644 --- a/src/app/Http/Controllers/API/V4/SkusController.php +++ b/src/app/Http/Controllers/API/V4/SkusController.php @@ -1,192 +1,226 @@ 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); } + /** + * Get a list of SKUs available to the domain. + * + * @param int $id Domain identifier + * + * @return \Illuminate\Http\JsonResponse + */ + public function domainSkus($id) + { + $domain = \App\Domain::find($id); + + if (!$this->checkTenant($domain)) { + return $this->errorResponse(404); + } + + if (!$this->guard()->user()->canRead($domain)) { + return $this->errorResponse(403); + } + + return $this->objectSkus($domain); + } + /** * Show the form for editing the specified sku. * * @param int $id SKU identifier * * @return \Illuminate\Http\JsonResponse */ public function edit($id) { // TODO return $this->errorResponse(404); } /** * Get a list of active SKUs. * * @return \Illuminate\Http\JsonResponse */ public function index() { // Note: Order by title for consistent ordering in tests $skus = Sku::withSubjectTenantContext()->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 (!$this->checkTenant($user)) { return $this->errorResponse(404); } if (!$this->guard()->user()->canRead($user)) { return $this->errorResponse(403); } - $type = request()->input('type'); + return $this->objectSkus($user); + } + + /** + * Return SKUs available to the specified user/domain. + * + * @param object $object User or Domain object + * + * @return \Illuminate\Http\JsonResponse + */ + protected static function objectSkus($object) + { + $type = $object instanceof \App\Domain ? 'domain' : 'user'; $response = []; // Note: Order by title for consistent ordering in tests - $skus = Sku::withObjectTenantContext($user)->orderBy('title')->get(); + $skus = Sku::withObjectTenantContext($object)->orderBy('title')->get(); foreach ($skus as $sku) { if (!class_exists($sku->handler_class)) { continue; } - if (!$sku->handler_class::isAvailable($sku, $user)) { + if (!$sku->handler_class::isAvailable($sku, $object)) { continue; } - if ($data = $this->skuElement($sku)) { - if ($type && $type != $data['type']) { + if ($data = self::skuElement($sku)) { + if ($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 + protected static function skuElement($sku): ?array { if (!class_exists($sku->handler_class)) { return null; } $data = array_merge($sku->toArray(), $sku->handler_class::metadata($sku)); // ignore incomplete handlers if (empty($data['type'])) { return null; } // Use localized value, toArray() does not get them right $data['name'] = $sku->name; $data['description'] = $sku->description; unset($data['handler_class'], $data['created_at'], $data['updated_at'], $data['fee'], $data['tenant_id']); return $data; } } diff --git a/src/app/Http/Controllers/API/V4/UsersController.php b/src/app/Http/Controllers/API/V4/UsersController.php index ff96a039..f87417da 100644 --- a/src/app/Http/Controllers/API/V4/UsersController.php +++ b/src/app/Http/Controllers/API/V4/UsersController.php @@ -1,846 +1,835 @@ find($id); if (empty($user)) { return $this->errorResponse(404); } // User can't remove himself until he's the controller if (!$this->guard()->user()->canDelete($user)) { return $this->errorResponse(403); } $user->delete(); return response()->json([ 'status' => 'success', 'message' => \trans('app.user-delete-success'), ]); } /** * Listing of users. * * The user-entitlements billed to the current user wallet(s) * * @return \Illuminate\Http\JsonResponse */ public function index() { $user = $this->guard()->user(); $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); } /** * Set user config. * * @param int $id The user * * @return \Illuminate\Http\JsonResponse */ public function setConfig($id) { $user = User::find($id); if (empty($user)) { return $this->errorResponse(404); } if (!$this->guard()->user()->canRead($user)) { return $this->errorResponse(403); } $errors = $user->setConfig(request()->input()); if (!empty($errors)) { return response()->json(['status' => 'error', 'errors' => $errors], 422); } return response()->json([ 'status' => 'success', 'message' => \trans('app.user-setconfig-success'), ]); } /** * Display information on the user account specified by $id. * * @param int $id The account to show information for. * * @return \Illuminate\Http\JsonResponse */ public function show($id) { $user = User::withEnvTenantContext()->find($id); if (empty($user)) { return $this->errorResponse(404); } if (!$this->guard()->user()->canRead($user)) { return $this->errorResponse(403); } $response = $this->userResponse($user); - // 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; - if (!isset($response['skus'][$sku->id])) { - $response['skus'][$sku->id] = ['costs' => [], 'count' => 0]; - } - $response['skus'][$sku->id]['count']++; - $response['skus'][$sku->id]['costs'][] = $ent->cost; - } - + $response['skus'] = \App\Entitlement::objectEntitlementsSummary($user); $response['config'] = $user->getConfig(); return response()->json($response); } /** * Fetch user status (and reload setup process) * * @param int $id User identifier * * @return \Illuminate\Http\JsonResponse */ public function status($id) { $user = User::withEnvTenantContext()->find($id); if (empty($user)) { return $this->errorResponse(404); } if (!$this->guard()->user()->canRead($user)) { return $this->errorResponse(403); } $response = self::statusInfo($user); if (!empty(request()->input('refresh'))) { $updated = false; $async = false; $last_step = 'none'; foreach ($response['process'] as $idx => $step) { $last_step = $step['label']; if (!$step['state']) { $exec = $this->execProcessStep($user, $step['label']); if (!$exec) { if ($exec === null) { $async = true; } break; } $updated = true; } } if ($updated) { $response = self::statusInfo($user); } $success = $response['isReady']; $suffix = $success ? 'success' : 'error-' . $last_step; $response['status'] = $success ? 'success' : 'error'; $response['message'] = \trans('app.process-' . $suffix); if ($async && !$success) { $response['processState'] = 'waiting'; $response['status'] = 'success'; $response['message'] = \trans('app.process-async'); } } $response = array_merge($response, self::userStatuses($user)); return response()->json($response); } /** * User status (extended) information * * @param \App\User $user User object * * @return array Status information */ public static function statusInfo(User $user): array { $process = []; $steps = [ 'user-new' => true, 'user-ldap-ready' => $user->isLdapReady(), 'user-imap-ready' => $user->isImapReady(), ]; // Create a process check list foreach ($steps as $step_name => $state) { $step = [ 'label' => $step_name, 'title' => \trans("app.process-{$step_name}"), 'state' => $state, ]; $process[] = $step; } list ($local, $domain) = explode('@', $user->email); $domain = Domain::where('namespace', $domain)->first(); // If that is not a public domain, add domain specific steps if ($domain && !$domain->isPublic()) { $domain_status = DomainsController::statusInfo($domain); $process = array_merge($process, $domain_status['process']); } $all = count($process); $checked = count(array_filter($process, function ($v) { return $v['state']; })); $state = $all === $checked ? 'done' : 'running'; // After 180 seconds assume the process is in failed state, // this should unlock the Refresh button in the UI if ($all !== $checked && $user->created_at->diffInSeconds(Carbon::now()) > 180) { $state = 'failed'; } // Check if the user is a controller of his wallet $isController = $user->canDelete($user); $hasCustomDomain = $user->wallet()->entitlements() ->where('entitleable_type', Domain::class) ->count() > 0; // Get user's entitlements titles $skus = $user->entitlements()->select('skus.title') ->join('skus', 'skus.id', '=', 'entitlements.sku_id') ->get() ->pluck('title') ->sort() ->unique() ->values() ->all(); return [ 'skus' => $skus, // TODO: This will change when we enable all users to create domains 'enableDomains' => $isController && $hasCustomDomain, // TODO: Make 'enableDistlists' working for wallet controllers that aren't account owners 'enableDistlists' => $isController && $hasCustomDomain && in_array('distlist', $skus), 'enableUsers' => $isController, 'enableWallets' => $isController, 'process' => $process, 'processState' => $state, 'isReady' => $all === $checked, ]; } /** * Create a new user record. * * @param \Illuminate\Http\Request $request The API request. * * @return \Illuminate\Http\JsonResponse The response */ public function store(Request $request) { $current_user = $this->guard()->user(); $owner = $current_user->wallet()->owner; if ($owner->id != $current_user->id) { return $this->errorResponse(403); } $this->deleteBeforeCreate = null; if ($error_response = $this->validateUserRequest($request, null, $settings)) { return $error_response; } if (empty($request->package) || !($package = \App\Package::withEnvTenantContext()->find($request->package))) { $errors = ['package' => \trans('validation.packagerequired')]; return response()->json(['status' => 'error', 'errors' => $errors], 422); } if ($package->isDomain()) { $errors = ['package' => \trans('validation.packageinvalid')]; return response()->json(['status' => 'error', 'errors' => $errors], 422); } DB::beginTransaction(); // @phpstan-ignore-next-line if ($this->deleteBeforeCreate) { $this->deleteBeforeCreate->forceDelete(); } // Create user record $user = User::create([ 'email' => $request->email, 'password' => $request->password, ]); $owner->assignPackage($package, $user); if (!empty($settings)) { $user->setSettings($settings); } if (!empty($request->aliases)) { $user->setAliases($request->aliases); } DB::commit(); return response()->json([ 'status' => 'success', 'message' => \trans('app.user-create-success'), ]); } /** * Update user data. * * @param \Illuminate\Http\Request $request The API request. * @param string $id User identifier * * @return \Illuminate\Http\JsonResponse The response */ public function update(Request $request, $id) { $user = User::withEnvTenantContext()->find($id); if (empty($user)) { return $this->errorResponse(404); } $current_user = $this->guard()->user(); // TODO: Decide what attributes a user can change on his own profile if (!$current_user->canUpdate($user)) { return $this->errorResponse(403); } if ($error_response = $this->validateUserRequest($request, $user, $settings)) { return $error_response; } // Entitlements, only controller can do that if ($request->skus !== null && !$current_user->canDelete($user)) { return $this->errorResponse(422, "You have no permission to change entitlements"); } DB::beginTransaction(); $this->updateEntitlements($user, $request->skus); if (!empty($settings)) { $user->setSettings($settings); } if (!empty($request->password)) { $user->password = $request->password; $user->save(); } if (isset($request->aliases)) { $user->setAliases($request->aliases); } // TODO: Make sure that UserUpdate job is created in case of entitlements update // and no password change. So, for example quota change is applied to LDAP // TODO: Review use of $user->save() in the above context DB::commit(); $response = [ 'status' => 'success', 'message' => \trans('app.user-update-success'), ]; // For self-update refresh the statusInfo in the UI if ($user->id == $current_user->id) { $response['statusInfo'] = self::statusInfo($user); } return response()->json($response); } /** * Update user entitlements. * * @param \App\User $user The user * @param array $rSkus List of SKU IDs requested for the user in the form [id=>qty] */ protected function updateEntitlements(User $user, $rSkus) { if (!is_array($rSkus)) { return; } // list of skus, [id=>obj] $skus = Sku::withEnvTenantContext()->get()->mapWithKeys( function ($sku) { return [$sku->id => $sku]; } ); // existing entitlement's SKUs $eSkus = []; $user->entitlements()->groupBy('sku_id') ->selectRaw('count(*) as total, sku_id')->each( function ($e) use (&$eSkus) { $eSkus[$e->sku_id] = $e->total; } ); foreach ($skus as $skuID => $sku) { $e = array_key_exists($skuID, $eSkus) ? $eSkus[$skuID] : 0; $r = array_key_exists($skuID, $rSkus) ? $rSkus[$skuID] : 0; if ($sku->handler_class == \App\Handlers\Mailbox::class) { if ($r != 1) { throw new \Exception("Invalid quantity of mailboxes"); } } if ($e > $r) { // remove those entitled more than existing $user->removeSku($sku, ($e - $r)); } elseif ($e < $r) { // add those requested more than entitled $user->assignSku($sku, ($r - $e)); } } } /** * Create a response data array for specified user. * * @param \App\User $user User object * * @return array Response data */ public static function userResponse(User $user): array { $response = $user->toArray(); // Settings $response['settings'] = []; foreach ($user->settings()->whereIn('key', self::USER_SETTINGS)->get() as $item) { $response['settings'][$item->key] = $item->value; } // Aliases $response['aliases'] = []; foreach ($user->aliases as $item) { $response['aliases'][] = $item->alias; } // Status info $response['statusInfo'] = self::statusInfo($user); $response = array_merge($response, self::userStatuses($user)); // Add more info to the wallet object output $map_func = function ($wallet) use ($user) { $result = $wallet->toArray(); if ($wallet->discount) { $result['discount'] = $wallet->discount->discount; $result['discount_description'] = $wallet->discount->description; } if ($wallet->user_id != $user->id) { $result['user_email'] = $wallet->owner->email; } $provider = \App\Providers\PaymentProvider::factory($wallet); $result['provider'] = $provider->name(); return $result; }; // Information about wallets and accounts for access checks $response['wallets'] = $user->wallets->map($map_func)->toArray(); $response['accounts'] = $user->accounts->map($map_func)->toArray(); $response['wallet'] = $map_func($user->wallet()); return $response; } /** * Prepare user statuses for the UI * * @param \App\User $user User object * * @return array Statuses array */ protected static function userStatuses(User $user): array { return [ 'isImapReady' => $user->isImapReady(), 'isLdapReady' => $user->isLdapReady(), 'isSuspended' => $user->isSuspended(), 'isActive' => $user->isActive(), 'isDeleted' => $user->isDeleted() || $user->trashed(), ]; } /** * Validate user input * * @param \Illuminate\Http\Request $request The API request. * @param \App\User|null $user User identifier * @param array $settings User settings (from the request) * * @return \Illuminate\Http\JsonResponse|null The error response on error */ protected function validateUserRequest(Request $request, $user, &$settings = []) { $rules = [ 'external_email' => 'nullable|email', 'phone' => 'string|nullable|max:64|regex:/^[0-9+() -]+$/', 'first_name' => 'string|nullable|max:128', 'last_name' => 'string|nullable|max:128', 'organization' => 'string|nullable|max:512', 'billing_address' => 'string|nullable|max:1024', 'country' => 'string|nullable|alpha|size:2', 'currency' => 'string|nullable|alpha|size:3', 'aliases' => 'array|nullable', ]; if (empty($user) || !empty($request->password) || !empty($request->password_confirmation)) { $rules['password'] = 'required|min:4|max:2048|confirmed'; } $errors = []; // Validate input $v = Validator::make($request->all(), $rules); if ($v->fails()) { $errors = $v->errors()->toArray(); } $controller = $user ? $user->wallet()->owner : $this->guard()->user(); // For new user validate email address if (empty($user)) { $email = $request->email; if (empty($email)) { $errors['email'] = \trans('validation.required', ['attribute' => 'email']); } elseif ($error = self::validateEmail($email, $controller, $this->deleteBeforeCreate)) { $errors['email'] = $error; } } // Validate aliases input if (isset($request->aliases)) { $aliases = []; $existing_aliases = $user ? $user->aliases()->get()->pluck('alias')->toArray() : []; foreach ($request->aliases as $idx => $alias) { if (is_string($alias) && !empty($alias)) { // Alias cannot be the same as the email address (new user) if (!empty($email) && Str::lower($alias) == Str::lower($email)) { continue; } // validate new aliases if ( !in_array($alias, $existing_aliases) && ($error = self::validateAlias($alias, $controller)) ) { if (!isset($errors['aliases'])) { $errors['aliases'] = []; } $errors['aliases'][$idx] = $error; continue; } $aliases[] = $alias; } } $request->aliases = $aliases; } if (!empty($errors)) { return response()->json(['status' => 'error', 'errors' => $errors], 422); } // Update user settings $settings = $request->only(array_keys($rules)); unset($settings['password'], $settings['aliases'], $settings['email']); return null; } /** * Execute (synchronously) specified step in a user setup process. * * @param \App\User $user User object * @param string $step Step identifier (as in self::statusInfo()) * * @return bool|null True if the execution succeeded, False if not, Null when * the job has been sent to the worker (result unknown) */ public static function execProcessStep(User $user, string $step): ?bool { try { if (strpos($step, 'domain-') === 0) { list ($local, $domain) = explode('@', $user->email); $domain = Domain::where('namespace', $domain)->first(); return DomainsController::execProcessStep($domain, $step); } switch ($step) { case 'user-ldap-ready': // User not in LDAP, create it $job = new \App\Jobs\User\CreateJob($user->id); $job->handle(); $user->refresh(); return $user->isLdapReady(); case 'user-imap-ready': // User not in IMAP? Verify again // Do it synchronously if the imap admin credentials are available // otherwise let the worker do the job if (!\config('imap.admin_password')) { \App\Jobs\User\VerifyJob::dispatch($user->id); return null; } $job = new \App\Jobs\User\VerifyJob($user->id); $job->handle(); $user->refresh(); return $user->isImapReady(); } } catch (\Exception $e) { \Log::error($e); } return false; } /** * Email address validation for use as a user mailbox (login). * * @param string $email Email address * @param \App\User $user The account owner * @param null|\App\User|\App\Group $deleted Filled with an instance of a deleted user or group * with the specified email address, if exists * * @return ?string Error message on validation error */ public static function validateEmail(string $email, \App\User $user, &$deleted = null): ?string { $deleted = null; if (strpos($email, '@') === false) { return \trans('validation.entryinvalid', ['attribute' => 'email']); } list($login, $domain) = explode('@', Str::lower($email)); if (strlen($login) === 0 || strlen($domain) === 0) { return \trans('validation.entryinvalid', ['attribute' => 'email']); } // Check if domain exists $domain = Domain::withEnvTenantContext()->where('namespace', $domain)->first(); if (empty($domain)) { return \trans('validation.domaininvalid'); } // Validate login part alone $v = Validator::make( ['email' => $login], ['email' => ['required', new UserEmailLocal(!$domain->isPublic())]] ); if ($v->fails()) { return $v->errors()->toArray()['email'][0]; } // Check if it is one of domains available to the user $domains = \collect($user->domains())->pluck('namespace')->all(); if (!in_array($domain->namespace, $domains)) { return \trans('validation.entryexists', ['attribute' => 'domain']); } // Check if a user with specified address already exists if ($existing_user = User::emailExists($email, true)) { // If this is a deleted user in the same custom domain // we'll force delete him before if (!$domain->isPublic() && $existing_user->trashed()) { $deleted = $existing_user; } else { return \trans('validation.entryexists', ['attribute' => 'email']); } } // Check if an alias with specified address already exists. if (User::aliasExists($email)) { return \trans('validation.entryexists', ['attribute' => 'email']); } // Check if a group with specified address already exists if ($existing_group = Group::emailExists($email, true)) { // If this is a deleted group in the same custom domain // we'll force delete it before if (!$domain->isPublic() && $existing_group->trashed()) { $deleted = $existing_group; } else { return \trans('validation.entryexists', ['attribute' => 'email']); } } return null; } /** * Email address validation for use as an alias. * * @param string $email Email address * @param \App\User $user The account owner * * @return ?string Error message on validation error */ public static function validateAlias(string $email, \App\User $user): ?string { if (strpos($email, '@') === false) { return \trans('validation.entryinvalid', ['attribute' => 'alias']); } list($login, $domain) = explode('@', Str::lower($email)); if (strlen($login) === 0 || strlen($domain) === 0) { return \trans('validation.entryinvalid', ['attribute' => 'alias']); } // Check if domain exists $domain = Domain::withEnvTenantContext()->where('namespace', $domain)->first(); if (empty($domain)) { return \trans('validation.domaininvalid'); } // Validate login part alone $v = Validator::make( ['alias' => $login], ['alias' => ['required', new UserEmailLocal(!$domain->isPublic())]] ); if ($v->fails()) { return $v->errors()->toArray()['alias'][0]; } // Check if it is one of domains available to the user $domains = \collect($user->domains())->pluck('namespace')->all(); if (!in_array($domain->namespace, $domains)) { return \trans('validation.entryexists', ['attribute' => 'domain']); } // Check if a user with specified address already exists if ($existing_user = User::emailExists($email, true)) { // Allow an alias in a custom domain to an address that was a user before if ($domain->isPublic() || !$existing_user->trashed()) { return \trans('validation.entryexists', ['attribute' => 'alias']); } } // Check if an alias with specified address already exists if (User::aliasExists($email)) { // Allow assigning the same alias to a user in the same group account, // but only for non-public domains if ($domain->isPublic()) { return \trans('validation.entryexists', ['attribute' => 'alias']); } } // Check if a group with specified address already exists if (Group::emailExists($email)) { return \trans('validation.entryexists', ['attribute' => 'alias']); } return null; } } diff --git a/src/app/Rules/UserEmailDomain.php b/src/app/Rules/UserEmailDomain.php index 7dbecd3a..8153bd12 100644 --- a/src/app/Rules/UserEmailDomain.php +++ b/src/app/Rules/UserEmailDomain.php @@ -1,70 +1,81 @@ domains = $domains; } /** * Determine if the validation rule passes. * - * Validation of local part of an email address that's + * Validation of a domain part of an email address that's * going to be user's login. * * @param string $attribute Attribute name * @param mixed $domain Domain part of email address * * @return bool */ public function passes($attribute, $domain): bool { - // don't allow @localhost and other no-fqdn - if (empty($domain) || strpos($domain, '.') === false || stripos($domain, 'www.') === 0) { + // don't allow @localhost and other non-fqdn + if ( + empty($domain) + || !is_string($domain) + || strpos($domain, '.') === false + || stripos($domain, 'www.') === 0 + ) { + $this->message = \trans('validation.domaininvalid'); + return false; + } + + // Check the max length, according to the database column length + if (strlen($domain) > 191) { $this->message = \trans('validation.domaininvalid'); return false; } $domain = Str::lower($domain); // Use email validator to validate the domain part $v = Validator::make(['email' => 'user@' . $domain], ['email' => 'required|email']); if ($v->fails()) { $this->message = \trans('validation.domaininvalid'); return false; } // Check if specified domain is allowed for signup if (is_array($this->domains) && !in_array($domain, $this->domains)) { $this->message = \trans('validation.domaininvalid'); return false; } return true; } /** * Get the validation error message. * * @return string */ public function message(): ?string { return $this->message; } } diff --git a/src/app/Rules/UserEmailLocal.php b/src/app/Rules/UserEmailLocal.php index 894c04a6..d61bb90b 100644 --- a/src/app/Rules/UserEmailLocal.php +++ b/src/app/Rules/UserEmailLocal.php @@ -1,72 +1,76 @@ external = $external; } /** * Determine if the validation rule passes. * * Validation of local part of an email address that's * going to be user's login. * * @param string $attribute Attribute name * @param mixed $login Local part of email address * * @return bool */ public function passes($attribute, $login): bool { // Strict validation - if (!preg_match('/^[A-Za-z0-9_.-]+$/', $login)) { + if ( + empty($login) + || !is_string($login) + || !preg_match('/^[A-Za-z0-9_.-]+$/', $login) + ) { $this->message = \trans('validation.entryinvalid', ['attribute' => $attribute]); return false; } // Standard email address validation $v = Validator::make([$attribute => $login . '@test.com'], [$attribute => 'required|email']); if ($v->fails()) { $this->message = \trans('validation.entryinvalid', ['attribute' => $attribute]); return false; } // Check if the local part is not one of exceptions // (when signing up for an account in public domain if (!$this->external) { $exceptions = '/^(admin|administrator|sales|root)$/i'; if (preg_match($exceptions, $login)) { $this->message = \trans('validation.entryexists', ['attribute' => $attribute]); return false; } } return true; } /** * Get the validation error message. * * @return string */ public function message(): ?string { return $this->message; } } diff --git a/src/resources/js/app.js b/src/resources/js/app.js index b27fdb0c..e2d868eb 100644 --- a/src/resources/js/app.js +++ b/src/resources/js/app.js @@ -1,525 +1,541 @@ /** * First we will load all of this project's JavaScript dependencies which * includes Vue and other libraries. It is a great starting point when * building robust, powerful web applications using Vue and Laravel. */ require('./bootstrap') import AppComponent from '../vue/App' import MenuComponent from '../vue/Widgets/Menu' import SupportForm from '../vue/Widgets/SupportForm' import store from './store' import { Tab } from 'bootstrap' import { loadLangAsync, i18n } from './locale' const loader = '
Loading
' let isLoading = 0 // Lock the UI with the 'loading...' element const startLoading = () => { isLoading++ let loading = $('#app > .app-loader').removeClass('fadeOut') if (!loading.length) { $('#app').append($(loader)) } } // Hide "loading" overlay const stopLoading = () => { if (isLoading > 0) { $('#app > .app-loader').addClass('fadeOut') isLoading--; } } let loadingRoute // Note: This has to be before the app is created // Note: You cannot use app inside of the function window.router.beforeEach((to, from, next) => { // check if the route requires authentication and user is not logged in if (to.meta.requiresAuth && !store.state.isLoggedIn) { // remember the original request, to use after login store.state.afterLogin = to; // redirect to login page next({ name: 'login' }) return } if (to.meta.loading) { startLoading() loadingRoute = to.name } next() }) window.router.afterEach((to, from) => { if (to.name && loadingRoute === to.name) { stopLoading() loadingRoute = null } // When changing a page remove old: // - error page // - modal backdrop $('#error-page,.modal-backdrop.show').remove() $('body').css('padding', 0) // remove padding added by unclosed modal }) const app = new Vue({ components: { AppComponent, MenuComponent, }, i18n, store, router: window.router, data() { return { isUser: !window.isAdmin && !window.isReseller, appName: window.config['app.name'], appUrl: window.config['app.url'], themeDir: '/themes/' + window.config['app.theme'] } }, methods: { // Clear (bootstrap) form validation state clearFormValidation(form) { $(form).find('.is-invalid').removeClass('is-invalid') $(form).find('.invalid-feedback').remove() }, hasPermission(type) { const authInfo = store.state.authInfo const key = 'enable' + type.charAt(0).toUpperCase() + type.slice(1) return !!(authInfo && authInfo.statusInfo[key]) }, hasRoute(name) { return this.$router.resolve({ name: name }).resolved.matched.length > 0 }, hasSKU(name) { const authInfo = store.state.authInfo return authInfo.statusInfo.skus && authInfo.statusInfo.skus.indexOf(name) != -1 }, isController(wallet_id) { if (wallet_id && store.state.authInfo) { let i for (i = 0; i < store.state.authInfo.wallets.length; i++) { if (wallet_id == store.state.authInfo.wallets[i].id) { return true } } for (i = 0; i < store.state.authInfo.accounts.length; i++) { if (wallet_id == store.state.authInfo.accounts[i].id) { return true } } } return false }, // Set user state to "logged in" loginUser(response, dashboard, update) { if (!update) { store.commit('logoutUser') // destroy old state data store.commit('loginUser') } localStorage.setItem('token', response.access_token) localStorage.setItem('refreshToken', response.refresh_token) axios.defaults.headers.common.Authorization = 'Bearer ' + response.access_token if (response.email) { store.state.authInfo = response } if (dashboard !== false) { this.$router.push(store.state.afterLogin || { name: 'dashboard' }) } store.state.afterLogin = null // Refresh the token before it expires let timeout = response.expires_in || 0 // We'll refresh 60 seconds before the token expires if (timeout > 60) { timeout -= 60 } // TODO: We probably should try a few times in case of an error // TODO: We probably should prevent axios from doing any requests // while the token is being refreshed this.refreshTimeout = setTimeout(() => { axios.post('/api/auth/refresh', {'refresh_token': response.refresh_token}).then(response => { this.loginUser(response.data, false, true) }) }, timeout * 1000) }, // Set user state to "not logged in" logoutUser(redirect) { store.commit('logoutUser') localStorage.setItem('token', '') localStorage.setItem('refreshToken', '') delete axios.defaults.headers.common.Authorization if (redirect !== false) { this.$router.push({ name: 'login' }) } clearTimeout(this.refreshTimeout) }, logo(mode) { let src = this.appUrl + this.themeDir + '/images/logo_' + (mode || 'header') + '.png' return `${this.appName}` }, // Display "loading" overlay inside of the specified element addLoader(elem, small = true, style = null) { if (style) { $(elem).css(style) } else { $(elem).css('position', 'relative') } $(elem).append(small ? $(loader).addClass('small') : $(loader)) }, // Remove loader element added in addLoader() removeLoader(elem) { $(elem).find('.app-loader').remove() }, startLoading, stopLoading, isLoading() { return isLoading > 0 }, tab(e) { e.preventDefault() new Tab(e.target).show() }, errorPage(code, msg, hint) { // Until https://github.com/vuejs/vue-router/issues/977 is implemented // we can't really use router to display error page as it has two side // effects: it changes the URL and adds the error page to browser history. // For now we'll be replacing current view with error page "manually". if (!msg) msg = this.$te('error.' + code) ? this.$t('error.' + code) : this.$t('error.unknown') if (!hint) hint = '' const error_page = '
' + `
${code}
${msg}
${hint}
` + '
' $('#error-page').remove() $('#app').append(error_page) app.updateBodyClass('error') }, errorHandler(error) { this.stopLoading() if (!error.response) { // TODO: probably network connection error } else if (error.response.status === 401) { // Remember requested route to come back to it after log in if (this.$route.meta.requiresAuth) { store.state.afterLogin = this.$route this.logoutUser() } else { this.logoutUser(false) } } else { this.errorPage(error.response.status, error.response.statusText) } }, downloadFile(url) { // TODO: This might not be a best way for big files as the content // will be stored (temporarily) in browser memory // TODO: This method does not show the download progress in the browser // but it could be implemented in the UI, axios has 'progress' property axios.get(url, { responseType: 'blob' }) .then(response => { const link = document.createElement('a') const contentDisposition = response.headers['content-disposition'] let filename = 'unknown' if (contentDisposition) { const match = contentDisposition.match(/filename="(.+)"/); if (match.length === 2) { filename = match[1]; } } link.href = window.URL.createObjectURL(response.data) link.download = filename link.click() }) }, price(price, currency) { // TODO: Set locale argument according to the currently used locale return ((price || 0) / 100).toLocaleString('de-DE', { style: 'currency', currency: currency || 'CHF' }) }, priceLabel(cost, discount, currency) { let index = '' if (discount) { cost = Math.floor(cost * ((100 - discount) / 100)) index = '\u00B9' } return this.price(cost, currency) + '/' + this.$t('wallet.month') + index }, clickRecord(event) { if (!/^(a|button|svg|path)$/i.test(event.target.nodeName)) { $(event.target).closest('tr').find('a').trigger('click') } }, domainStatusClass(domain) { if (domain.isDeleted) { return 'text-muted' } if (domain.isSuspended) { return 'text-warning' } if (!domain.isVerified || !domain.isLdapReady || !domain.isConfirmed) { return 'text-danger' } return 'text-success' }, domainStatusText(domain) { if (domain.isDeleted) { return this.$t('status.deleted') } if (domain.isSuspended) { return this.$t('status.suspended') } if (!domain.isVerified || !domain.isLdapReady || !domain.isConfirmed) { return this.$t('status.notready') } return this.$t('status.active') }, distlistStatusClass(list) { if (list.isDeleted) { return 'text-muted' } if (list.isSuspended) { return 'text-warning' } if (!list.isLdapReady) { return 'text-danger' } return 'text-success' }, distlistStatusText(list) { if (list.isDeleted) { return this.$t('status.deleted') } if (list.isSuspended) { return this.$t('status.suspended') } if (!list.isLdapReady) { return this.$t('status.notready') } return this.$t('status.active') }, pageName(path) { let page = this.$route.path // check if it is a "menu page", find the page name // otherwise we'll use the real path as page name window.config.menu.every(item => { if (item.location == page && item.page) { page = item.page return false } }) page = page.replace(/^\//, '') return page ? page : '404' }, supportDialog(container) { let dialog = $('#support-dialog')[0] if (!dialog) { // FIXME: Find a nicer way of doing this SupportForm.i18n = i18n let form = new Vue(SupportForm) form.$mount($('
').appendTo(container)[0]) form.$root = this form.$toast = this.$toast dialog = form.$el } dialog.__vue__.showDialog() }, userStatusClass(user) { if (user.isDeleted) { return 'text-muted' } if (user.isSuspended) { return 'text-warning' } if (!user.isImapReady || !user.isLdapReady) { return 'text-danger' } return 'text-success' }, userStatusText(user) { if (user.isDeleted) { return this.$t('status.deleted') } if (user.isSuspended) { return this.$t('status.suspended') } if (!user.isImapReady || !user.isLdapReady) { return this.$t('status.notready') } return this.$t('status.active') }, + // Append some wallet properties to the object + userWalletProps(object) { + let wallet = store.state.authInfo.accounts[0] + + if (!wallet) { + wallet = store.state.authInfo.wallets[0] + } + + if (wallet) { + object.currency = wallet.currency + if (wallet.discount) { + object.discount = wallet.discount + object.discount_description = wallet.discount_description + } + } + }, updateBodyClass(name) { // Add 'class' attribute to the body, different for each page // so, we can apply page-specific styles document.body.className = 'page-' + (name || this.pageName()).replace(/\/.*$/, '') } } }) // Fetch the locale file and the start the app loadLangAsync().then(() => app.$mount('#app')) // Add a axios request interceptor window.axios.interceptors.request.use( config => { // This is the only way I found to change configuration options // on a running application. We need this for browser testing. config.headers['X-Test-Payment-Provider'] = window.config.paymentProvider return config }, error => { // Do something with request error return Promise.reject(error) } ) // Add a axios response interceptor for general/validation error handler window.axios.interceptors.response.use( response => { if (response.config.onFinish) { response.config.onFinish() } return response }, error => { let error_msg let status = error.response ? error.response.status : 200 // Do not display the error in a toast message, pass the error as-is if (error.config.ignoreErrors) { return Promise.reject(error) } if (error.config.onFinish) { error.config.onFinish() } if (error.response && status == 422) { error_msg = "Form validation error" const modal = $('div.modal.show') $(modal.length ? modal : 'form').each((i, form) => { form = $(form) $.each(error.response.data.errors || {}, (idx, msg) => { const input_name = (form.data('validation-prefix') || form.find('form').first().data('validation-prefix') || '') + idx let input = form.find('#' + input_name) if (!input.length) { input = form.find('[name="' + input_name + '"]'); } if (input.length) { // Create an error message // API responses can use a string, array or object let msg_text = '' if (typeof(msg) !== 'string') { $.each(msg, (index, str) => { msg_text += str + ' ' }) } else { msg_text = msg } let feedback = $('
').text(msg_text) if (input.is('.list-input')) { // List input widget let controls = input.children(':not(:first-child)') if (!controls.length && typeof msg == 'string') { // this is an empty list (the main input only) // and the error message is not an array input.find('.main-input').addClass('is-invalid') } else { controls.each((index, element) => { if (msg[index]) { $(element).find('input').addClass('is-invalid') } }) } input.addClass('is-invalid').next('.invalid-feedback').remove() input.after(feedback) } else { // Standard form element input.addClass('is-invalid') input.parent().find('.invalid-feedback').remove() input.parent().append(feedback) } } }) form.find('.is-invalid:not(.listinput-widget)').first().focus() }) } else if (error.response && error.response.data) { error_msg = error.response.data.message } else { error_msg = error.request ? error.request.statusText : error.message } app.$toast.error(error_msg || app.$t('error.server')) // Pass the error as-is return Promise.reject(error) } ) diff --git a/src/resources/lang/en/app.php b/src/resources/lang/en/app.php index 93ef6024..6af31c4d 100644 --- a/src/resources/lang/en/app.php +++ b/src/resources/lang/en/app.php @@ -1,93 +1,94 @@ 'Created', 'chart-deleted' => 'Deleted', 'chart-average' => 'average', 'chart-allusers' => 'All Users - last year', 'chart-discounts' => 'Discounts', 'chart-vouchers' => 'Vouchers', 'chart-income' => 'Income in :currency - last 8 weeks', 'chart-users' => 'Users - last 8 weeks', 'mandate-delete-success' => 'The auto-payment has been removed.', 'mandate-update-success' => 'The auto-payment has been updated.', 'planbutton' => 'Choose :plan', 'process-async' => 'Setup process has been pushed. Please wait.', 'process-user-new' => 'Registering a user...', 'process-user-ldap-ready' => 'Creating a user...', 'process-user-imap-ready' => 'Creating a mailbox...', 'process-distlist-new' => 'Registering a distribution list...', 'process-distlist-ldap-ready' => 'Creating a distribution list...', 'process-domain-new' => 'Registering a custom domain...', 'process-domain-ldap-ready' => 'Creating a custom domain...', 'process-domain-verified' => 'Verifying a custom domain...', 'process-domain-confirmed' => 'Verifying an ownership of a custom domain...', 'process-success' => 'Setup process finished successfully.', 'process-error-user-ldap-ready' => 'Failed to create a user.', 'process-error-user-imap-ready' => 'Failed to verify that a mailbox exists.', 'process-error-domain-ldap-ready' => 'Failed to create a domain.', 'process-error-domain-verified' => 'Failed to verify a domain.', 'process-error-domain-confirmed' => 'Failed to verify an ownership of a domain.', 'process-distlist-new' => 'Registering a distribution list...', 'process-distlist-ldap-ready' => 'Creating a distribution list...', 'process-error-distlist-ldap-ready' => 'Failed to create a distribution list.', 'distlist-update-success' => 'Distribution list updated successfully.', 'distlist-create-success' => 'Distribution list created successfully.', 'distlist-delete-success' => 'Distribution list deleted successfully.', 'distlist-suspend-success' => 'Distribution list suspended successfully.', 'distlist-unsuspend-success' => 'Distribution list unsuspended successfully.', + 'domain-create-success' => 'Domain created successfully.', 'domain-verify-success' => 'Domain verified successfully.', 'domain-verify-error' => 'Domain ownership verification failed.', 'domain-suspend-success' => 'Domain suspended successfully.', 'domain-unsuspend-success' => 'Domain unsuspended successfully.', 'domain-setconfig-success' => 'Domain settings updated successfully.', 'user-update-success' => 'User data updated successfully.', 'user-create-success' => 'User created successfully.', 'user-delete-success' => 'User deleted successfully.', 'user-suspend-success' => 'User suspended successfully.', 'user-unsuspend-success' => 'User unsuspended successfully.', 'user-reset-2fa-success' => '2-Factor authentication reset successfully.', 'user-setconfig-success' => 'User settings updated successfully.', 'user-set-sku-success' => 'The subscription added successfully.', 'user-set-sku-already-exists' => 'The subscription already exists.', 'search-foundxdomains' => ':x domains have been found.', 'search-foundxgroups' => ':x distribution lists have been found.', 'search-foundxusers' => ':x user accounts have been found.', 'signup-invitations-created' => 'The invitation has been created.|:count invitations has been created.', 'signup-invitations-csv-empty' => 'Failed to find any valid email addresses in the uploaded file.', 'signup-invitations-csv-invalid-email' => 'Found an invalid email address (:email) on line :line.', 'signup-invitation-delete-success' => 'Invitation deleted successfully.', 'signup-invitation-resend-success' => 'Invitation added to the sending queue successfully.', 'support-request-success' => 'Support request submitted successfully.', 'support-request-error' => 'Failed to submit the support request.', 'siteuser' => ':site User', 'wallet-award-success' => 'The bonus has been added to the wallet successfully.', 'wallet-penalty-success' => 'The penalty has been added to the wallet successfully.', 'wallet-update-success' => 'User wallet updated successfully.', 'wallet-notice-date' => 'With your current subscriptions your account balance will last until about :date (:days).', 'wallet-notice-nocredit' => 'You are out of credit, top up your balance now.', 'wallet-notice-today' => 'You will run out of credit today, top up your balance now.', 'wallet-notice-trial' => 'You are in your free trial period.', 'wallet-notice-trial-end' => 'Your free trial is about to end, top up to continue.', ]; diff --git a/src/resources/lang/en/ui.php b/src/resources/lang/en/ui.php index 243aabf2..5452a262 100644 --- a/src/resources/lang/en/ui.php +++ b/src/resources/lang/en/ui.php @@ -1,422 +1,424 @@ [ 'faq' => "FAQ", ], 'btn' => [ 'add' => "Add", 'accept' => "Accept", 'back' => "Back", 'cancel' => "Cancel", 'close' => "Close", 'continue' => "Continue", 'delete' => "Delete", 'deny' => "Deny", 'download' => "Download", 'edit' => "Edit", 'file' => "Choose file...", 'moreinfo' => "More information", 'refresh' => "Refresh", 'reset' => "Reset", 'resend' => "Resend", 'save' => "Save", 'search' => "Search", 'signup' => "Sign Up", 'submit' => "Submit", 'suspend' => "Suspend", 'unsuspend' => "Unsuspend", 'verify' => "Verify", ], 'dashboard' => [ 'beta' => "beta", 'distlists' => "Distribution lists", 'chat' => "Video chat", 'domains' => "Domains", 'invitations' => "Invitations", 'profile' => "Your profile", 'users' => "User accounts", 'wallet' => "Wallet", 'webmail' => "Webmail", 'stats' => "Stats", ], 'distlist' => [ 'list-title' => "Distribution list | Distribution lists", 'create' => "Create list", 'delete' => "Delete list", 'email' => "Email", 'list-empty' => "There are no distribution lists in this account.", 'new' => "New distribution list", 'recipients' => "Recipients", ], 'domain' => [ 'dns-verify' => "Domain DNS verification sample:", 'dns-config' => "Domain DNS configuration sample:", 'namespace' => "Namespace", 'spf-whitelist' => "SPF Whitelist", 'spf-whitelist-text' => "The Sender Policy Framework allows a sender domain to disclose, through DNS, " . "which systems are allowed to send emails with an envelope sender address within said domain.", 'spf-whitelist-ex' => "Here you can specify a list of allowed servers, for example: .ess.barracuda.com.", 'verify' => "Domain verification", 'verify-intro' => "In order to confirm that you're the actual holder of the domain, we need to run a verification process before finally activating it for email delivery.", 'verify-dns' => "The domain must have one of the following entries in DNS:", 'verify-dns-txt' => "TXT entry with value:", 'verify-dns-cname' => "or CNAME entry:", 'verify-outro' => "When this is done press the button below to start the verification.", 'verify-sample' => "Here's a sample zone file for your domain:", 'config' => "Domain configuration", 'config-intro' => "In order to let {app} receive email traffic for your domain you need to adjust the DNS settings, more precisely the MX entries, accordingly.", 'config-sample' => "Edit your domain's zone file and replace existing MX entries with the following values:", 'config-hint' => "If you don't know how to set DNS entries for your domain, please contact the registration service where you registered the domain or your web hosting provider.", + 'create' => "Create domain", + 'new' => "New domain", ], 'error' => [ '400' => "Bad request", '401' => "Unauthorized", '403' => "Access denied", '404' => "Not found", '405' => "Method not allowed", '500' => "Internal server error", 'unknown' => "Unknown Error", 'server' => "Server Error", ], 'form' => [ 'amount' => "Amount", 'code' => "Confirmation Code", 'config' => "Configuration", 'date' => "Date", 'description' => "Description", 'details' => "Details", 'disabled' => "disabled", 'domain' => "Domain", 'email' => "Email Address", 'enabled' => "enabled", 'firstname' => "First Name", 'general' => "General", 'lastname' => "Last Name", 'none' => "none", 'or' => "or", 'password' => "Password", 'password-confirm' => "Confirm Password", 'phone' => "Phone", 'settings' => "Settings", 'status' => "Status", 'surname' => "Surname", 'user' => "User", 'primary-email' => "Primary Email", 'id' => "ID", 'created' => "Created", 'deleted' => "Deleted", ], 'invitation' => [ 'create' => "Create invite(s)", 'create-title' => "Invite for a signup", 'create-email' => "Enter an email address of the person you want to invite.", 'create-csv' => "To send multiple invitations at once, provide a CSV (comma separated) file, or alternatively a plain-text file, containing one email address per line.", 'empty-list' => "There are no invitations in the database.", 'title' => "Signup invitations", 'search' => "Email address or domain", 'send' => "Send invite(s)", 'status-completed' => "User signed up", 'status-failed' => "Sending failed", 'status-sent' => "Sent", 'status-new' => "Not sent yet", ], 'lang' => [ 'en' => "English", 'de' => "German", 'fr' => "French", 'it' => "Italian", ], 'login' => [ '2fa' => "Second factor code", '2fa_desc' => "Second factor code is optional for users with no 2-Factor Authentication setup.", 'forgot_password' => "Forgot password?", 'header' => "Please sign in", 'sign_in' => "Sign in", 'webmail' => "Webmail" ], 'meet' => [ 'title' => "Voice & Video Conferencing", 'welcome' => "Welcome to our beta program for Voice & Video Conferencing.", 'url' => "You have a room of your own at the URL below. This room is only open when you yourself are in attendance. Use this URL to invite people to join you.", 'notice' => "This is a work in progress and more features will be added over time. Current features include:", 'sharing' => "Screen Sharing", 'sharing-text' => "Share your screen for presentations or show-and-tell.", 'security' => "Room Security", 'security-text' => "Increase the room security by setting a password that attendees will need to know" . " before they can enter, or lock the door so attendees will have to knock, and a moderator can accept or deny those requests.", 'qa' => "Raise Hand (Q&A)", 'qa-text' => "Silent audience members can raise their hand to facilitate a Question & Answer session with the panel members.", 'moderation' => "Moderator Delegation", 'moderation-text' => "Delegate moderator authority for the session, so that a speaker is not needlessly" . " interrupted with attendees knocking and other moderator duties.", 'eject' => "Eject Attendees", 'eject-text' => "Eject attendees from the session in order to force them to reconnect, or address policy" . " violations. Click the user icon for effective dismissal.", 'silent' => "Silent Audience Members", 'silent-text' => "For a webinar-style session, configure the room to force all new attendees to be silent audience members.", 'interpreters' => "Language Specific Audio Channels", 'interpreters-text' => "Designate a participant to interpret the original audio to a target language, for sessions" . " with multi-lingual attendees. The interpreter is expected to be able to relay the original audio, and override it.", 'beta-notice' => "Keep in mind that this is still in beta and might come with some issues." . " Should you encounter any on your way, let us know by contacting support.", // Room options dialog 'options' => "Room options", 'password' => "Password", 'password-none' => "none", 'password-clear' => "Clear password", 'password-set' => "Set password", 'password-text' => "You can add a password to your meeting. Participants will have to provide the password before they are allowed to join the meeting.", 'lock' => "Locked room", 'lock-text' => "When the room is locked participants have to be approved by a moderator before they could join the meeting.", 'nomedia' => "Subscribers only", 'nomedia-text' => "Forces all participants to join as subscribers (with camera and microphone turned off)." . " Moderators will be able to promote them to publishers throughout the session.", // Room menu 'partcnt' => "Number of participants", 'menu-audio-mute' => "Mute audio", 'menu-audio-unmute' => "Unmute audio", 'menu-video-mute' => "Mute video", 'menu-video-unmute' => "Unmute video", 'menu-screen' => "Share screen", 'menu-hand-lower' => "Lower hand", 'menu-hand-raise' => "Raise hand", 'menu-channel' => "Interpreted language channel", 'menu-chat' => "Chat", 'menu-fullscreen' => "Full screen", 'menu-fullscreen-exit' => "Exit full screen", 'menu-leave' => "Leave session", // Room setup screen 'setup-title' => "Set up your session", 'mic' => "Microphone", 'cam' => "Camera", 'nick' => "Nickname", 'nick-placeholder' => "Your name", 'join' => "JOIN", 'joinnow' => "JOIN NOW", 'imaowner' => "I'm the owner", // Room 'qa' => "Q & A", 'leave-title' => "Room closed", 'leave-body' => "The session has been closed by the room owner.", 'media-title' => "Media setup", 'join-request' => "Join request", 'join-requested' => "{user} requested to join.", // Status messages 'status-init' => "Checking the room...", 'status-323' => "The room is closed. Please, wait for the owner to start the session.", 'status-324' => "The room is closed. It will be open for others after you join.", 'status-325' => "The room is ready. Please, provide a valid password.", 'status-326' => "The room is locked. Please, enter your name and try again.", 'status-327' => "Waiting for permission to join the room.", 'status-404' => "The room does not exist.", 'status-429' => "Too many requests. Please, wait.", 'status-500' => "Failed to connect to the room. Server error.", // Other menus 'media-setup' => "Media setup", 'perm' => "Permissions", 'perm-av' => "Audio & Video publishing", 'perm-mod' => "Moderation", 'lang-int' => "Language interpreter", 'menu-options' => "Options", ], 'menu' => [ 'cockpit' => "Cockpit", 'login' => "Login", 'logout' => "Logout", 'signup' => "Signup", 'toggle' => "Toggle navigation", ], 'msg' => [ 'initializing' => "Initializing...", 'loading' => "Loading...", 'loading-failed' => "Failed to load data.", 'notfound' => "Resource not found.", 'info' => "Information", 'error' => "Error", 'warning' => "Warning", 'success' => "Success", ], 'nav' => [ 'more' => "Load more", 'step' => "Step {i}/{n}", ], 'password' => [ 'reset' => "Password Reset", 'reset-step1' => "Enter your email address to reset your password.", 'reset-step1-hint' => "You may need to check your spam folder or unblock {email}.", 'reset-step2' => "We sent out a confirmation code to your external email address." . " Enter the code we sent you, or click the link in the message.", ], 'signup' => [ 'email' => "Existing Email Address", 'login' => "Login", 'title' => "Sign Up", 'step1' => "Sign up to start your free month.", 'step2' => "We sent out a confirmation code to your email address. Enter the code we sent you, or click the link in the message.", 'step3' => "Create your Kolab identity (you can choose additional addresses later).", 'voucher' => "Voucher Code", ], 'status' => [ 'prepare-account' => "We are preparing your account.", 'prepare-domain' => "We are preparing the domain.", 'prepare-distlist' => "We are preparing the distribution list.", 'prepare-user' => "We are preparing the user account.", 'prepare-hint' => "Some features may be missing or readonly at the moment.", 'prepare-refresh' => "The process never ends? Press the \"Refresh\" button, please.", 'ready-account' => "Your account is almost ready.", 'ready-domain' => "The domain is almost ready.", 'ready-distlist' => "The distribution list is almost ready.", 'ready-user' => "The user account is almost ready.", 'verify' => "Verify your domain to finish the setup process.", 'verify-domain' => "Verify domain", 'deleted' => "Deleted", 'suspended' => "Suspended", 'notready' => "Not Ready", 'active' => "Active", ], 'support' => [ 'title' => "Contact Support", 'id' => "Customer number or email address you have with us", 'id-pl' => "e.g. 12345678 or john@kolab.org", 'id-hint' => "Leave blank if you are not a customer yet", 'name' => "Name", 'name-pl' => "how we should call you in our reply", 'email' => "Working email address", 'email-pl' => "make sure we can reach you at this address", 'summary' => "Issue Summary", 'summary-pl' => "one sentence that summarizes your issue", 'expl' => "Issue Explanation", ], 'user' => [ '2fa-hint1' => "This will remove 2-Factor Authentication entitlement as well as the user-configured factors.", '2fa-hint2' => "Please, make sure to confirm the user identity properly.", 'add-beta' => "Enable beta program", 'address' => "Address", 'aliases' => "Aliases", 'aliases-email' => "Email Aliases", 'aliases-none' => "This user has no email aliases.", 'add-bonus' => "Add bonus", 'add-bonus-title' => "Add a bonus to the wallet", 'add-penalty' => "Add penalty", 'add-penalty-title' => "Add a penalty to the wallet", 'auto-payment' => "Auto-payment", 'auto-payment-text' => "Fill up by {amount} when under {balance} using {method}", 'country' => "Country", 'create' => "Create user", 'custno' => "Customer No.", 'delete' => "Delete user", 'delete-account' => "Delete this account?", 'delete-email' => "Delete {email}", 'delete-text' => "Do you really want to delete this user permanently?" . " This will delete all account data and withdraw the permission to access the email account." . " Please note that this action cannot be undone.", 'discount' => "Discount", 'discount-hint' => "applied discount", 'discount-title' => "Account discount", 'distlists' => "Distribution lists", 'distlists-none' => "There are no distribution lists in this account.", 'domains' => "Domains", 'domains-none' => "There are no domains in this account.", 'ext-email' => "External Email", 'finances' => "Finances", 'greylisting' => "Greylisting", 'greylisting-text' => "Greylisting is a method of defending users against spam. Any incoming mail from an unrecognized sender " . "is temporarily rejected. The originating server should try again after a delay. " . "This time the email will be accepted. Spammers usually do not reattempt mail delivery.", 'list-title' => "User accounts", 'managed-by' => "Managed by", 'new' => "New user account", 'org' => "Organization", 'package' => "Package", 'price' => "Price", 'profile-title' => "Your profile", 'profile-delete' => "Delete account", 'profile-delete-title' => "Delete this account?", 'profile-delete-text1' => "This will delete the account as well as all domains, users and aliases associated with this account.", 'profile-delete-warning' => "This operation is irreversible", 'profile-delete-text2' => "As you will not be able to recover anything after this point, please make sure that you have migrated all data before proceeding.", 'profile-delete-support' => "As we always strive to improve, we would like to ask for 2 minutes of your time. " . "The best tool for improvement is feedback from users, and we would like to ask " . "for a few words about your reasons for leaving our service. Please send your feedback to {email}.", 'profile-delete-contact' => "Also feel free to contact {app} Support with any questions or concerns that you may have in this context.", 'reset-2fa' => "Reset 2-Factor Auth", 'reset-2fa-title' => "2-Factor Authentication Reset", 'title' => "User account", 'search-pl' => "User ID, email or domain", 'skureq' => "{sku} requires {list}.", 'subscription' => "Subscription", 'subscriptions' => "Subscriptions", 'subscriptions-none' => "This user has no subscriptions.", 'users' => "Users", 'users-none' => "There are no users in this account.", ], 'wallet' => [ 'add-credit' => "Add credit", 'auto-payment-cancel' => "Cancel auto-payment", 'auto-payment-change' => "Change auto-payment", 'auto-payment-failed' => "The setup of automatic payments failed. Restart the process to enable automatic top-ups.", 'auto-payment-hint' => "Here is how it works: Every time your account runs low, we will charge your preferred payment method for an amount you choose." . " You can cancel or change the auto-payment option at any time.", 'auto-payment-setup' => "Set up auto-payment", 'auto-payment-disabled' => "The configured auto-payment has been disabled. Top up your wallet or raise the auto-payment amount.", 'auto-payment-info' => "Auto-payment is set to fill up your account by {amount} every time your account balance gets under {balance}.", 'auto-payment-inprogress' => "The setup of the automatic payment is still in progress.", 'auto-payment-next' => "Next, you will be redirected to the checkout page, where you can provide your credit card details.", 'auto-payment-disabled-next' => "The auto-payment is disabled. Immediately after you submit new settings we'll enable it and attempt to top up your wallet.", 'auto-payment-update' => "Update auto-payment", 'banktransfer-hint' => "Please note that a bank transfer can take several days to complete.", 'currency-conv' => "Here is how it works: You specify the amount by which you want to top up your wallet in {wc}." . " We will then convert this to {pc}, and on the next page you will be provided with the bank-details to transfer the amount in {pc}.", 'fill-up' => "Fill up by", 'history' => "History", 'month' => "month", 'noperm' => "Only account owners can access a wallet.", 'payment-amount-hint' => "Choose the amount by which you want to top up your wallet.", 'payment-method' => "Method of payment: {method}", 'payment-warning' => "You will be charged for {price}.", 'pending-payments' => "Pending Payments", 'pending-payments-warning' => "You have payments that are still in progress. See the \"Pending Payments\" tab below.", 'pending-payments-none' => "There are no pending payments for this account.", 'receipts' => "Receipts", 'receipts-hint' => "Here you can download receipts (in PDF format) for payments in specified period. Select the period and press the Download button.", 'receipts-none' => "There are no receipts for payments in this account. Please, note that you can download receipts after the month ends.", 'title' => "Account balance", 'top-up' => "Top up your wallet", 'transactions' => "Transactions", 'transactions-none' => "There are no transactions for this account.", 'when-below' => "when account balance is below", ], ]; diff --git a/src/resources/themes/forms.scss b/src/resources/themes/forms.scss index 52bf9860..62177ed4 100644 --- a/src/resources/themes/forms.scss +++ b/src/resources/themes/forms.scss @@ -1,130 +1,127 @@ .list-input { & > div { &:not(:last-child) { margin-bottom: -1px; input, a.btn { border-bottom-right-radius: 0; border-bottom-left-radius: 0; } } &:not(:first-child) { input, a.btn { border-top-right-radius: 0; border-top-left-radius: 0; } } } input.is-invalid { z-index: 2; } .btn svg { vertical-align: middle; } } .range-input { display: flex; label { margin-right: 0.5em; min-width: 4em; text-align: right; line-height: 1.7; } } .input-group-activable { &.active { :not(.activable) { display: none; } } &:not(.active) { .activable { display: none; } } // Label is always visible .label { color: $body-color; display: initial !important; } .input-group-text { border-color: transparent; background: transparent; padding-left: 0; &:not(.label) { flex: 1; } } } .form-control-plaintext .btn-sm { margin-top: -0.25rem; } // Various improvements for mobile @include media-breakpoint-down(sm) { .row.mb-3 { margin-bottom: 0.5rem !important; } .nav-tabs { - flex-wrap: nowrap; - overflow-x: auto; - .nav-link { white-space: nowrap; padding: 0.5rem 0.75rem; } } .tab-content { margin-top: 0.5rem; } .col-form-label { color: #666; font-size: 95%; } .row.plaintext .col-form-label { padding-bottom: 0; } form.read-only.short label { width: 35%; & + * { width: 65%; } } .row.checkbox { position: relative; & > div { padding-top: 0 !important; input { position: absolute; top: 0.5rem; right: 1rem; } } label { padding-right: 2.5rem; } } } diff --git a/src/resources/vue/Domain/Info.vue b/src/resources/vue/Domain/Info.vue index 29ffdd08..f3ac5599 100644 --- a/src/resources/vue/Domain/Info.vue +++ b/src/resources/vue/Domain/Info.vue @@ -1,142 +1,187 @@ diff --git a/src/resources/vue/Domain/List.vue b/src/resources/vue/Domain/List.vue index 1ecd3fd8..3f7ac0ab 100644 --- a/src/resources/vue/Domain/List.vue +++ b/src/resources/vue/Domain/List.vue @@ -1,53 +1,58 @@ diff --git a/src/resources/vue/User/Info.vue b/src/resources/vue/User/Info.vue index f9023a57..bc014335 100644 --- a/src/resources/vue/User/Info.vue +++ b/src/resources/vue/User/Info.vue @@ -1,484 +1,243 @@ diff --git a/src/resources/vue/Widgets/PackageSelect.vue b/src/resources/vue/Widgets/PackageSelect.vue new file mode 100644 index 00000000..171f34d6 --- /dev/null +++ b/src/resources/vue/Widgets/PackageSelect.vue @@ -0,0 +1,87 @@ + + + diff --git a/src/resources/vue/Widgets/SubscriptionSelect.vue b/src/resources/vue/Widgets/SubscriptionSelect.vue new file mode 100644 index 00000000..e26fc1cd --- /dev/null +++ b/src/resources/vue/Widgets/SubscriptionSelect.vue @@ -0,0 +1,208 @@ + + + diff --git a/src/routes/api.php b/src/routes/api.php index eaf6299c..b8ee50ff 100644 --- a/src/routes/api.php +++ b/src/routes/api.php @@ -1,248 +1,251 @@ '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('info', 'API\AuthController@info'); Route::post('logout', 'API\AuthController@logout'); Route::post('refresh', 'API\AuthController@refresh'); } ); } ); Route::group( [ 'domain' => \config('app.website_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::post('signup/init', 'API\SignupController@init'); Route::get('signup/invitations/{id}', 'API\SignupController@invitation'); Route::get('signup/plans', 'API\SignupController@plans'); Route::post('signup/verify', 'API\SignupController@verify'); Route::post('signup', 'API\SignupController@signup'); } ); Route::group( [ 'domain' => \config('app.website_domain'), 'middleware' => 'auth:api', 'prefix' => $prefix . 'api/v4' ], function () { Route::post('companion/register', 'API\V4\CompanionAppsController@register'); Route::post('auth-attempts/{id}/confirm', 'API\V4\AuthAttemptsController@confirm'); Route::post('auth-attempts/{id}/deny', 'API\V4\AuthAttemptsController@deny'); Route::get('auth-attempts/{id}/details', 'API\V4\AuthAttemptsController@details'); Route::get('auth-attempts', 'API\V4\AuthAttemptsController@index'); Route::apiResource('domains', API\V4\DomainsController::class); Route::get('domains/{id}/confirm', 'API\V4\DomainsController@confirm'); + Route::get('domains/{id}/skus', 'API\V4\SkusController@domainSkus'); Route::get('domains/{id}/status', 'API\V4\DomainsController@status'); Route::post('domains/{id}/config', 'API\V4\DomainsController@setConfig'); Route::apiResource('groups', API\V4\GroupsController::class); Route::get('groups/{id}/status', 'API\V4\GroupsController@status'); Route::apiResource('packages', API\V4\PackagesController::class); Route::apiResource('skus', API\V4\SkusController::class); Route::apiResource('users', API\V4\UsersController::class); Route::post('users/{id}/config', 'API\V4\UsersController@setConfig'); 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::delete('payments', 'API\V4\PaymentsController@cancel'); Route::get('payments/mandate', 'API\V4\PaymentsController@mandate'); Route::post('payments/mandate', 'API\V4\PaymentsController@mandateCreate'); Route::put('payments/mandate', 'API\V4\PaymentsController@mandateUpdate'); Route::delete('payments/mandate', 'API\V4\PaymentsController@mandateDelete'); Route::get('payments/methods', 'API\V4\PaymentsController@paymentMethods'); Route::get('payments/pending', 'API\V4\PaymentsController@payments'); Route::get('payments/has-pending', 'API\V4\PaymentsController@hasPayments'); Route::get('openvidu/rooms', 'API\V4\OpenViduController@index'); Route::post('openvidu/rooms/{id}/close', 'API\V4\OpenViduController@closeRoom'); Route::post('openvidu/rooms/{id}/config', 'API\V4\OpenViduController@setRoomConfig'); // FIXME: I'm not sure about this one, should we use DELETE request maybe? Route::post('openvidu/rooms/{id}/connections/{conn}/dismiss', 'API\V4\OpenViduController@dismissConnection'); Route::put('openvidu/rooms/{id}/connections/{conn}', 'API\V4\OpenViduController@updateConnection'); Route::post('openvidu/rooms/{id}/request/{reqid}/accept', 'API\V4\OpenViduController@acceptJoinRequest'); Route::post('openvidu/rooms/{id}/request/{reqid}/deny', 'API\V4\OpenViduController@denyJoinRequest'); } ); // Note: In Laravel 7.x we could just use withoutMiddleware() instead of a separate group Route::group( [ 'domain' => \config('app.website_domain'), 'prefix' => $prefix . 'api/v4' ], function () { Route::post('openvidu/rooms/{id}', 'API\V4\OpenViduController@joinRoom'); Route::post('openvidu/rooms/{id}/connections', 'API\V4\OpenViduController@createConnection'); // FIXME: I'm not sure about this one, should we use DELETE request maybe? Route::post('openvidu/rooms/{id}/connections/{conn}/dismiss', 'API\V4\OpenViduController@dismissConnection'); Route::put('openvidu/rooms/{id}/connections/{conn}', 'API\V4\OpenViduController@updateConnection'); Route::post('openvidu/rooms/{id}/request/{reqid}/accept', 'API\V4\OpenViduController@acceptJoinRequest'); Route::post('openvidu/rooms/{id}/request/{reqid}/deny', 'API\V4\OpenViduController@denyJoinRequest'); } ); Route::group( [ 'domain' => \config('app.website_domain'), 'middleware' => 'api', 'prefix' => $prefix . 'api/v4' ], function ($router) { Route::post('support/request', 'API\V4\SupportController@request'); } ); Route::group( [ 'domain' => \config('app.website_domain'), 'prefix' => $prefix . 'api/webhooks' ], function () { Route::post('payment/{provider}', 'API\V4\PaymentsController@webhook'); Route::post('meet/openvidu', 'API\V4\OpenViduController@webhook'); } ); if (\config('app.with_services')) { Route::group( [ 'domain' => 'services.' . \config('app.website_domain'), 'prefix' => $prefix . 'api/webhooks' ], function () { Route::get('nginx', 'API\V4\NGINXController@authenticate'); Route::post('policy/greylist', 'API\V4\PolicyController@greylist'); Route::post('policy/ratelimit', 'API\V4\PolicyController@ratelimit'); Route::post('policy/spf', 'API\V4\PolicyController@senderPolicyFramework'); } ); } if (\config('app.with_admin')) { Route::group( [ 'domain' => 'admin.' . \config('app.website_domain'), 'middleware' => ['auth:api', 'admin'], 'prefix' => $prefix . 'api/v4', ], function () { Route::apiResource('domains', API\V4\Admin\DomainsController::class); + Route::get('domains/{id}/skus', 'API\V4\Admin\SkusController@domainSkus'); Route::post('domains/{id}/suspend', 'API\V4\Admin\DomainsController@suspend'); Route::post('domains/{id}/unsuspend', 'API\V4\Admin\DomainsController@unsuspend'); Route::apiResource('groups', API\V4\Admin\GroupsController::class); Route::post('groups/{id}/suspend', 'API\V4\Admin\GroupsController@suspend'); Route::post('groups/{id}/unsuspend', 'API\V4\Admin\GroupsController@unsuspend'); Route::apiResource('skus', API\V4\Admin\SkusController::class); Route::apiResource('users', API\V4\Admin\UsersController::class); Route::get('users/{id}/discounts', 'API\V4\Reseller\DiscountsController@userDiscounts'); Route::post('users/{id}/reset2FA', 'API\V4\Admin\UsersController@reset2FA'); Route::get('users/{id}/skus', 'API\V4\Admin\SkusController@userSkus'); Route::post('users/{id}/skus/{sku}', 'API\V4\Admin\UsersController@setSku'); 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::get('stats/chart/{chart}', 'API\V4\Admin\StatsController@chart'); } ); } if (\config('app.with_reseller')) { Route::group( [ 'domain' => 'reseller.' . \config('app.website_domain'), 'middleware' => ['auth:api', 'reseller'], 'prefix' => $prefix . 'api/v4', ], function () { Route::apiResource('domains', API\V4\Reseller\DomainsController::class); + Route::get('domains/{id}/skus', 'API\V4\Reseller\SkusController@domainSkus'); Route::post('domains/{id}/suspend', 'API\V4\Reseller\DomainsController@suspend'); Route::post('domains/{id}/unsuspend', 'API\V4\Reseller\DomainsController@unsuspend'); Route::apiResource('groups', API\V4\Reseller\GroupsController::class); Route::post('groups/{id}/suspend', 'API\V4\Reseller\GroupsController@suspend'); Route::post('groups/{id}/unsuspend', 'API\V4\Reseller\GroupsController@unsuspend'); Route::apiResource('invitations', API\V4\Reseller\InvitationsController::class); Route::post('invitations/{id}/resend', 'API\V4\Reseller\InvitationsController@resend'); Route::post('payments', 'API\V4\Reseller\PaymentsController@store'); Route::get('payments/mandate', 'API\V4\Reseller\PaymentsController@mandate'); Route::post('payments/mandate', 'API\V4\Reseller\PaymentsController@mandateCreate'); Route::put('payments/mandate', 'API\V4\Reseller\PaymentsController@mandateUpdate'); Route::delete('payments/mandate', 'API\V4\Reseller\PaymentsController@mandateDelete'); Route::get('payments/methods', 'API\V4\Reseller\PaymentsController@paymentMethods'); Route::get('payments/pending', 'API\V4\Reseller\PaymentsController@payments'); Route::get('payments/has-pending', 'API\V4\Reseller\PaymentsController@hasPayments'); Route::apiResource('skus', API\V4\Reseller\SkusController::class); Route::apiResource('users', API\V4\Reseller\UsersController::class); Route::get('users/{id}/discounts', 'API\V4\Reseller\DiscountsController@userDiscounts'); Route::post('users/{id}/reset2FA', 'API\V4\Reseller\UsersController@reset2FA'); Route::get('users/{id}/skus', 'API\V4\Reseller\SkusController@userSkus'); Route::post('users/{id}/skus/{sku}', 'API\V4\Admin\UsersController@setSku'); Route::post('users/{id}/suspend', 'API\V4\Reseller\UsersController@suspend'); Route::post('users/{id}/unsuspend', 'API\V4\Reseller\UsersController@unsuspend'); Route::apiResource('wallets', API\V4\Reseller\WalletsController::class); Route::post('wallets/{id}/one-off', 'API\V4\Reseller\WalletsController@oneOff'); Route::get('wallets/{id}/receipts', 'API\V4\Reseller\WalletsController@receipts'); Route::get('wallets/{id}/receipts/{receipt}', 'API\V4\Reseller\WalletsController@receiptDownload'); Route::get('wallets/{id}/transactions', 'API\V4\Reseller\WalletsController@transactions'); Route::get('stats/chart/{chart}', 'API\V4\Reseller\StatsController@chart'); } ); } diff --git a/src/tests/Browser/Admin/DomainTest.php b/src/tests/Browser/Admin/DomainTest.php index c90a464b..dcd4aa51 100644 --- a/src/tests/Browser/Admin/DomainTest.php +++ b/src/tests/Browser/Admin/DomainTest.php @@ -1,143 +1,159 @@ deleteTestUser('test1@domainscontroller.com'); + $this->deleteTestDomain('domainscontroller.com'); + self::useAdminUrl(); } /** * {@inheritDoc} */ public function tearDown(): void { $domain = $this->getTestDomain('kolab.org'); $domain->setSetting('spf_whitelist', null); + $this->deleteTestUser('test1@domainscontroller.com'); + $this->deleteTestDomain('domainscontroller.com'); + parent::tearDown(); } /** * Test domain info page (unauthenticated) */ public function testDomainUnauth(): void { // Test that the page requires authentication $this->browse(function (Browser $browser) { $domain = $this->getTestDomain('kolab.org'); $browser->visit('/domain/' . $domain->id)->on(new Home()); }); } /** * Test domain info page */ public function testDomainInfo(): void { $this->browse(function (Browser $browser) { $domain = $this->getTestDomain('kolab.org'); $domain_page = new DomainPage($domain->id); $john = $this->getTestUser('john@kolab.org'); $user_page = new UserPage($john->id); $domain->setSetting('spf_whitelist', null); // Goto the domain page $browser->visit(new Home()) ->submitLogon('jeroen@jeroen.jeroen', \App\Utils::generatePassphrase(), true) ->on(new Dashboard()) ->visit($user_page) ->on($user_page) ->click('@nav #tab-domains') ->pause(1000) ->click('@user-domains table tbody tr:first-child td a'); $browser->on($domain_page) ->assertSeeIn('@domain-info .card-title', 'kolab.org') ->with('@domain-info form', function (Browser $browser) use ($domain) { $browser->assertElementsCount('.row', 2) ->assertSeeIn('.row:nth-child(1) label', 'ID (Created)') ->assertSeeIn('.row:nth-child(1) #domainid', "{$domain->id} ({$domain->created_at})") ->assertSeeIn('.row:nth-child(2) label', 'Status') ->assertSeeIn('.row:nth-child(2) #status span.text-success', 'Active'); }); // Some tabs are loaded in background, wait a second $browser->pause(500) ->assertElementsCount('@nav a', 2); // Assert Configuration tab $browser->assertSeeIn('@nav #tab-config', 'Configuration') ->with('@domain-config', function (Browser $browser) { $browser->assertSeeIn('pre#dns-verify', 'kolab-verify.kolab.org.') ->assertSeeIn('pre#dns-config', 'kolab.org.'); }); // Assert Settings tab $browser->assertSeeIn('@nav #tab-settings', 'Settings') ->click('@nav #tab-settings') ->with('@domain-settings form', function (Browser $browser) { $browser->assertElementsCount('.row', 1) ->assertSeeIn('.row:first-child label', 'SPF Whitelist') ->assertSeeIn('.row:first-child .form-control-plaintext', 'none'); }); // Assert non-empty SPF whitelist $domain->setSetting('spf_whitelist', json_encode(['.test1.com', '.test2.com'])); $browser->refresh() ->waitFor('@nav #tab-settings') ->click('@nav #tab-settings') ->with('@domain-settings form', function (Browser $browser) { $browser->assertSeeIn('.row:first-child .form-control-plaintext', '.test1.com, .test2.com'); }); }); } /** * Test suspending/unsuspending a domain * * @depends testDomainInfo */ public function testSuspendAndUnsuspend(): void { $this->browse(function (Browser $browser) { + $sku_domain = \App\Sku::withEnvTenantContext()->where('title', 'domain-hosting')->first(); + $user = $this->getTestUser('test1@domainscontroller.com'); $domain = $this->getTestDomain('domainscontroller.com', [ 'status' => Domain::STATUS_NEW | Domain::STATUS_ACTIVE | Domain::STATUS_LDAP_READY | Domain::STATUS_CONFIRMED | Domain::STATUS_VERIFIED, 'type' => Domain::TYPE_EXTERNAL, ]); + \App\Entitlement::create([ + 'wallet_id' => $user->wallets()->first()->id, + 'sku_id' => $sku_domain->id, + 'entitleable_id' => $domain->id, + 'entitleable_type' => Domain::class + ]); + $browser->visit(new DomainPage($domain->id)) ->assertVisible('@domain-info #button-suspend') ->assertMissing('@domain-info #button-unsuspend') ->click('@domain-info #button-suspend') ->assertToast(Toast::TYPE_SUCCESS, 'Domain suspended successfully.') ->assertSeeIn('@domain-info #status span.text-warning', 'Suspended') ->assertMissing('@domain-info #button-suspend') ->click('@domain-info #button-unsuspend') ->assertToast(Toast::TYPE_SUCCESS, 'Domain unsuspended successfully.') ->assertSeeIn('@domain-info #status span.text-success', 'Active') ->assertVisible('@domain-info #button-suspend') ->assertMissing('@domain-info #button-unsuspend'); }); } } diff --git a/src/tests/Browser/DomainTest.php b/src/tests/Browser/DomainTest.php index 9bdfed5c..fdde106b 100644 --- a/src/tests/Browser/DomainTest.php +++ b/src/tests/Browser/DomainTest.php @@ -1,202 +1,298 @@ deleteTestDomain('testdomain.com'); + } + + /** + * {@inheritDoc} + */ + public function tearDown(): void + { + $this->deleteTestDomain('testdomain.com'); + parent::tearDown(); + } /** * Test domain info page (unauthenticated) */ public function testDomainInfoUnauth(): void { // Test that the page requires authentication $this->browse(function ($browser) { $browser->visit('/domain/123')->on(new Home()); }); } /** * Test domain info page (non-existing domain id) */ public function testDomainInfo404(): void { $this->browse(function ($browser) { // FIXME: I couldn't make loginAs() method working // Note: Here we're also testing that unauthenticated request // is passed to logon form and then "redirected" to the requested page $browser->visit('/domain/123') ->on(new Home()) ->submitLogon('john@kolab.org', 'simple123') ->assertErrorPage(404); }); } /** * Test domain info page (existing domain) * * @depends testDomainInfo404 */ public function testDomainInfo(): void { $this->browse(function ($browser) { // Unconfirmed domain $domain = Domain::where('namespace', 'kolab.org')->first(); if ($domain->isConfirmed()) { $domain->status ^= Domain::STATUS_CONFIRMED; $domain->save(); } $domain->setSetting('spf_whitelist', \json_encode(['.test.com'])); $browser->visit('/domain/' . $domain->id) ->on(new DomainInfo()) + ->assertSeeIn('.card-title', 'Domain') + ->whenAvailable('@general', function ($browser) use ($domain) { + $browser->assertSeeIn('form div:nth-child(1) label', 'Status') + ->assertSeeIn('form div:nth-child(1) #status.text-danger', 'Not Ready') + ->assertSeeIn('form div:nth-child(2) label', 'Name') + ->assertValue('form div:nth-child(2) input:disabled', $domain->namespace) + ->assertSeeIn('form div:nth-child(3) label', 'Subscriptions'); + }) + ->whenAvailable('@general form div:nth-child(3) table', function ($browser) { + $browser->assertElementsCount('tbody tr', 1) + ->assertVisible('tbody tr td.selection input:checked:disabled') + ->assertSeeIn('tbody tr td.name', 'External Domain') + ->assertSeeIn('tbody tr td.price', '0,00 CHF/month') + ->assertTip( + 'tbody tr td.buttons button', + 'Host a domain that is externally registered' + ); + }) ->whenAvailable('@verify', function ($browser) use ($domain) { $browser->assertSeeIn('pre', $domain->namespace) ->assertSeeIn('pre', $domain->hash()) ->click('button') ->assertToast(Toast::TYPE_SUCCESS, 'Domain verified successfully.'); // TODO: Test scenario when a domain confirmation failed }) ->whenAvailable('@config', function ($browser) use ($domain) { $browser->assertSeeIn('pre', $domain->namespace); }) + ->assertMissing('@general button[type=submit]') ->assertMissing('@verify'); // Check that confirmed domain page contains only the config box $browser->visit('/domain/' . $domain->id) ->on(new DomainInfo()) ->assertMissing('@verify') ->assertPresent('@config'); }); } /** * Test domain settings */ public function testDomainSettings(): void { $this->browse(function ($browser) { $domain = Domain::where('namespace', 'kolab.org')->first(); $domain->setSetting('spf_whitelist', \json_encode(['.test.com'])); $browser->visit('/domain/' . $domain->id) ->on(new DomainInfo()) ->assertElementsCount('@nav a', 2) - ->assertSeeIn('@nav #tab-general', 'Domain configuration') + ->assertSeeIn('@nav #tab-general', 'General') ->assertSeeIn('@nav #tab-settings', 'Settings') ->click('@nav #tab-settings') ->with('#settings form', function (Browser $browser) { // Test whitelist widget $widget = new ListInput('#spf_whitelist'); $browser->assertSeeIn('div.row:nth-child(1) label', 'SPF Whitelist') ->assertVisible('div.row:nth-child(1) .list-input') ->with($widget, function (Browser $browser) { $browser->assertListInputValue(['.test.com']) ->assertValue('@input', '') ->addListEntry('invalid domain'); }) ->click('button[type=submit]') ->assertToast(Toast::TYPE_ERROR, 'Form validation error') ->with($widget, function (Browser $browser) { $err = 'The entry format is invalid. Expected a domain name starting with a dot.'; $browser->assertFormError(2, $err, false) ->removeListEntry(2) ->removeListEntry(1) ->addListEntry('.new.domain.tld'); }) ->click('button[type=submit]') ->assertToast(Toast::TYPE_SUCCESS, 'Domain settings updated successfully.'); }); }); } /** * Test domains list page (unauthenticated) */ public function testDomainListUnauth(): void { // Test that the page requires authentication $this->browse(function ($browser) { $browser->visit('/logout') ->visit('/domains') ->on(new Home()); }); } /** * Test domains list page * * @depends testDomainListUnauth */ public function testDomainList(): void { $this->browse(function ($browser) { // Login the user $browser->visit('/login') ->on(new Home()) ->submitLogon('john@kolab.org', 'simple123', true) // On dashboard click the "Domains" link ->on(new Dashboard()) ->assertSeeIn('@links a.link-domains', 'Domains') ->click('@links a.link-domains') // On Domains List page click the domain entry ->on(new DomainList()) ->waitFor('@table tbody tr') ->assertVisible('@table tbody tr:first-child td:first-child svg.fa-globe.text-success') ->assertText('@table tbody tr:first-child td:first-child svg title', 'Active') ->assertSeeIn('@table tbody tr:first-child td:first-child', 'kolab.org') ->assertMissing('@table tfoot') ->click('@table tbody tr:first-child td:first-child a') // On Domain Info page verify that's the clicked domain ->on(new DomainInfo()) ->whenAvailable('@config', function ($browser) { $browser->assertSeeIn('pre', 'kolab.org'); }); }); // TODO: Test domains list acting as Ned (John's "delegatee") } /** * Test domains list page (user with no domains) */ public function testDomainListEmpty(): void { $this->browse(function ($browser) { // Login the user $browser->visit('/login') ->on(new Home()) ->submitLogon('jack@kolab.org', 'simple123', true) ->on(new Dashboard()) ->assertVisible('@links a.link-profile') ->assertMissing('@links a.link-domains') ->assertMissing('@links a.link-users') ->assertMissing('@links a.link-wallet'); /* // On dashboard click the "Domains" link ->assertSeeIn('@links a.link-domains', 'Domains') ->click('@links a.link-domains') // On Domains List page click the domain entry ->on(new DomainList()) ->assertMissing('@table tbody') ->assertSeeIn('tfoot td', 'There are no domains in this account.'); */ }); } + + /** + * Test domain creation page + */ + public function testDomainCreate(): void + { + $this->browse(function ($browser) { + $browser->visit('/login') + ->on(new Home()) + ->submitLogon('john@kolab.org', 'simple123') + ->visit('/domains') + ->on(new DomainList()) + ->assertSeeIn('.card-title button.btn-success', 'Create domain') + ->click('.card-title button.btn-success') + ->on(new DomainInfo()) + ->assertSeeIn('.card-title', 'New domain') + ->assertElementsCount('@nav li', 1) + ->assertSeeIn('@nav li:first-child', 'General') + ->whenAvailable('@general', function ($browser) { + $browser->assertSeeIn('form div:nth-child(1) label', 'Name') + ->assertValue('form div:nth-child(1) input:not(:disabled)', '') + ->assertFocused('form div:nth-child(1) input') + ->assertSeeIn('form div:nth-child(2) label', 'Package') + ->assertMissing('form div:nth-child(3)'); + }) + ->whenAvailable('@general form div:nth-child(2) table', function ($browser) { + $browser->assertElementsCount('tbody tr', 1) + ->assertVisible('tbody tr td.selection input:checked[readonly]') + ->assertSeeIn('tbody tr td.name', 'Domain Hosting') + ->assertSeeIn('tbody tr td.price', '0,00 CHF/month') + ->assertTip( + 'tbody tr td.buttons button', + 'Use your own, existing domain.' + ); + }) + ->assertSeeIn('@general button.btn-primary[type=submit]', 'Submit') + ->assertMissing('@config') + ->assertMissing('@verify') + ->assertMissing('@settings') + ->assertMissing('@status') + // Test error handling + ->click('button[type=submit]') + ->waitFor('#namespace + .invalid-feedback') + ->assertSeeIn('#namespace + .invalid-feedback', 'The namespace field is required.') + ->assertFocused('#namespace') + ->assertToast(Toast::TYPE_ERROR, 'Form validation error') + ->type('@general form div:nth-child(1) input', 'testdomain..com') + ->click('button[type=submit]') + ->waitFor('#namespace + .invalid-feedback') + ->assertSeeIn('#namespace + .invalid-feedback', 'The specified domain is invalid.') + ->assertFocused('#namespace') + ->assertToast(Toast::TYPE_ERROR, 'Form validation error') + // Test success + ->type('@general form div:nth-child(1) input', 'testdomain.com') + ->click('button[type=submit]') + ->assertToast(Toast::TYPE_SUCCESS, 'Domain created successfully.') + ->on(new DomainList()) + ->assertSeeIn('@table tr:nth-child(2) a', 'testdomain.com'); + }); + } } diff --git a/src/tests/Browser/Pages/DomainInfo.php b/src/tests/Browser/Pages/DomainInfo.php index 13820c3c..3448cd3d 100644 --- a/src/tests/Browser/Pages/DomainInfo.php +++ b/src/tests/Browser/Pages/DomainInfo.php @@ -1,47 +1,48 @@ waitUntilMissing('@app .app-loader'); } /** * Get the element shortcuts for the page. * * @return array */ public function elements(): array { return [ '@app' => '#app', '@config' => '#domain-config', + '@general' => '#general', '@nav' => 'ul.nav-tabs', '@settings' => '#settings', '@status' => '#status-box', '@verify' => '#domain-verify', ]; } } diff --git a/src/tests/Browser/Reseller/DomainTest.php b/src/tests/Browser/Reseller/DomainTest.php index 85b3ccf1..1ee3dd8b 100644 --- a/src/tests/Browser/Reseller/DomainTest.php +++ b/src/tests/Browser/Reseller/DomainTest.php @@ -1,120 +1,136 @@ deleteTestUser('test1@domainscontroller.com'); + $this->deleteTestDomain('domainscontroller.com'); + self::useResellerUrl(); } /** * {@inheritDoc} */ public function tearDown(): void { + $this->deleteTestUser('test1@domainscontroller.com'); + $this->deleteTestDomain('domainscontroller.com'); + parent::tearDown(); } /** * Test domain info page (unauthenticated) */ public function testDomainUnauth(): void { // Test that the page requires authentication $this->browse(function (Browser $browser) { $domain = $this->getTestDomain('kolab.org'); $browser->visit('/domain/' . $domain->id)->on(new Home()); }); } /** * Test domain info page */ public function testDomainInfo(): void { $this->browse(function (Browser $browser) { $domain = $this->getTestDomain('kolab.org'); $domain_page = new DomainPage($domain->id); $reseller = $this->getTestUser('reseller@' . \config('app.domain')); $user = $this->getTestUser('john@kolab.org'); $user_page = new UserPage($user->id); // Goto the domain page $browser->visit(new Home()) ->submitLogon('reseller@' . \config('app.domain'), \App\Utils::generatePassphrase(), true) ->on(new Dashboard()) ->visit($user_page) ->on($user_page) ->click('@nav #tab-domains') ->pause(1000) ->click('@user-domains table tbody tr:first-child td a'); $browser->on($domain_page) ->assertSeeIn('@domain-info .card-title', 'kolab.org') ->with('@domain-info form', function (Browser $browser) use ($domain) { $browser->assertElementsCount('.row', 2) ->assertSeeIn('.row:nth-child(1) label', 'ID (Created)') ->assertSeeIn('.row:nth-child(1) #domainid', "{$domain->id} ({$domain->created_at})") ->assertSeeIn('.row:nth-child(2) label', 'Status') ->assertSeeIn('.row:nth-child(2) #status span.text-success', 'Active'); }); // Some tabs are loaded in background, wait a second $browser->pause(500) ->assertElementsCount('@nav a', 2); // Assert Configuration tab $browser->assertSeeIn('@nav #tab-config', 'Configuration') ->with('@domain-config', function (Browser $browser) { $browser->assertSeeIn('pre#dns-verify', 'kolab-verify.kolab.org.') ->assertSeeIn('pre#dns-config', 'kolab.org.'); }); }); } /** * Test suspending/unsuspending a domain * * @depends testDomainInfo */ public function testSuspendAndUnsuspend(): void { $this->browse(function (Browser $browser) { + $sku_domain = \App\Sku::withEnvTenantContext()->where('title', 'domain-hosting')->first(); + $user = $this->getTestUser('test1@domainscontroller.com'); $domain = $this->getTestDomain('domainscontroller.com', [ 'status' => Domain::STATUS_NEW | Domain::STATUS_ACTIVE | Domain::STATUS_LDAP_READY | Domain::STATUS_CONFIRMED | Domain::STATUS_VERIFIED, 'type' => Domain::TYPE_EXTERNAL, ]); + \App\Entitlement::create([ + 'wallet_id' => $user->wallets()->first()->id, + 'sku_id' => $sku_domain->id, + 'entitleable_id' => $domain->id, + 'entitleable_type' => Domain::class + ]); + $browser->visit(new DomainPage($domain->id)) ->assertVisible('@domain-info #button-suspend') ->assertMissing('@domain-info #button-unsuspend') ->click('@domain-info #button-suspend') ->assertToast(Toast::TYPE_SUCCESS, 'Domain suspended successfully.') ->assertSeeIn('@domain-info #status span.text-warning', 'Suspended') ->assertMissing('@domain-info #button-suspend') ->click('@domain-info #button-unsuspend') ->assertToast(Toast::TYPE_SUCCESS, 'Domain unsuspended successfully.') ->assertSeeIn('@domain-info #status span.text-success', 'Active') ->assertVisible('@domain-info #button-suspend') ->assertMissing('@domain-info #button-unsuspend'); }); } } diff --git a/src/tests/Browser/UsersTest.php b/src/tests/Browser/UsersTest.php index d64f1dd5..89ce6760 100644 --- a/src/tests/Browser/UsersTest.php +++ b/src/tests/Browser/UsersTest.php @@ -1,835 +1,835 @@ 'John', 'last_name' => 'Doe', 'organization' => 'Kolab Developers', ]; /** * {@inheritDoc} */ public function setUp(): void { parent::setUp(); $this->deleteTestUser('julia.roberts@kolab.org'); $john = User::where('email', 'john@kolab.org')->first(); $john->setSettings($this->profile); UserAlias::where('user_id', $john->id) ->where('alias', 'john.test@kolab.org')->delete(); $activesync_sku = Sku::withEnvTenantContext()->where('title', 'activesync')->first(); $storage_sku = Sku::withEnvTenantContext()->where('title', 'storage')->first(); Entitlement::where('entitleable_id', $john->id)->where('sku_id', $activesync_sku->id)->delete(); Entitlement::where('cost', '>=', 5000)->delete(); Entitlement::where('cost', '=', 25)->where('sku_id', $storage_sku->id)->delete(); $wallet = $john->wallets()->first(); $wallet->discount()->dissociate(); $wallet->currency = 'CHF'; $wallet->save(); $this->clearBetaEntitlements(); $this->clearMeetEntitlements(); } /** * {@inheritDoc} */ public function tearDown(): void { $this->deleteTestUser('julia.roberts@kolab.org'); $john = User::where('email', 'john@kolab.org')->first(); $john->setSettings($this->profile); UserAlias::where('user_id', $john->id) ->where('alias', 'john.test@kolab.org')->delete(); $activesync_sku = Sku::withEnvTenantContext()->where('title', 'activesync')->first(); $storage_sku = Sku::withEnvTenantContext()->where('title', 'storage')->first(); Entitlement::where('entitleable_id', $john->id)->where('sku_id', $activesync_sku->id)->delete(); Entitlement::where('cost', '>=', 5000)->delete(); Entitlement::where('cost', '=', 25)->where('sku_id', $storage_sku->id)->delete(); $wallet = $john->wallets()->first(); $wallet->discount()->dissociate(); $wallet->save(); $this->clearBetaEntitlements(); $this->clearMeetEntitlements(); parent::tearDown(); } /** * Test user info page (unauthenticated) */ public function testInfoUnauth(): void { // Test that the page requires authentication $this->browse(function (Browser $browser) { $user = User::where('email', 'john@kolab.org')->first(); $browser->visit('/user/' . $user->id)->on(new Home()); }); } /** * Test users list page (unauthenticated) */ public function testListUnauth(): void { // Test that the page requires authentication $this->browse(function (Browser $browser) { $browser->visit('/users')->on(new Home()); }); } /** * Test users list page */ public function testList(): void { // Test that the page requires authentication $this->browse(function (Browser $browser) { $browser->visit(new Home()) ->submitLogon('john@kolab.org', 'simple123', true) ->on(new Dashboard()) ->assertSeeIn('@links .link-users', 'User accounts') ->click('@links .link-users') ->on(new UserList()) ->whenAvailable('@table', function (Browser $browser) { $browser->waitFor('tbody tr') ->assertElementsCount('tbody tr', 4) ->assertSeeIn('tbody tr:nth-child(1) a', 'jack@kolab.org') ->assertSeeIn('tbody tr:nth-child(2) a', 'joe@kolab.org') ->assertSeeIn('tbody tr:nth-child(3) a', 'john@kolab.org') ->assertSeeIn('tbody tr:nth-child(4) a', 'ned@kolab.org') ->assertMissing('tfoot'); }); }); } /** * Test user account editing page (not profile page) * * @depends testList */ public function testInfo(): void { $this->browse(function (Browser $browser) { $browser->on(new UserList()) ->click('@table tr:nth-child(3) a') ->on(new UserInfo()) ->assertSeeIn('#user-info .card-title', 'User account') ->with('@general', function (Browser $browser) { // Assert form content $browser->assertSeeIn('div.row:nth-child(1) label', 'Status') ->assertSeeIn('div.row:nth-child(1) #status', 'Active') ->assertFocused('div.row:nth-child(2) input') ->assertSeeIn('div.row:nth-child(2) label', 'First Name') ->assertValue('div.row:nth-child(2) input[type=text]', $this->profile['first_name']) ->assertSeeIn('div.row:nth-child(3) label', 'Last Name') ->assertValue('div.row:nth-child(3) input[type=text]', $this->profile['last_name']) ->assertSeeIn('div.row:nth-child(4) label', 'Organization') ->assertValue('div.row:nth-child(4) input[type=text]', $this->profile['organization']) ->assertSeeIn('div.row:nth-child(5) label', 'Email') ->assertValue('div.row:nth-child(5) input[type=text]', 'john@kolab.org') ->assertDisabled('div.row:nth-child(5) input[type=text]') ->assertSeeIn('div.row:nth-child(6) label', 'Email Aliases') ->assertVisible('div.row:nth-child(6) .list-input') ->with(new ListInput('#aliases'), function (Browser $browser) { $browser->assertListInputValue(['john.doe@kolab.org']) ->assertValue('@input', ''); }) ->assertSeeIn('div.row:nth-child(7) label', 'Password') ->assertValue('div.row:nth-child(7) input[type=password]', '') ->assertSeeIn('div.row:nth-child(8) label', 'Confirm Password') ->assertValue('div.row:nth-child(8) input[type=password]', '') ->assertSeeIn('button[type=submit]', 'Submit') // Clear some fields and submit ->vueClear('#first_name') ->vueClear('#last_name') ->click('button[type=submit]'); }) ->assertToast(Toast::TYPE_SUCCESS, 'User data updated successfully.') ->on(new UserList()) ->click('@table tr:nth-child(3) a') ->on(new UserInfo()) ->assertSeeIn('#user-info .card-title', 'User account') ->with('@general', function (Browser $browser) { // Test error handling (password) $browser->type('#password', 'aaaaaa') ->vueClear('#password_confirmation') ->click('button[type=submit]') ->waitFor('#password + .invalid-feedback') ->assertSeeIn('#password + .invalid-feedback', 'The password confirmation does not match.') ->assertFocused('#password') ->assertToast(Toast::TYPE_ERROR, 'Form validation error'); // TODO: Test password change // Test form error handling (aliases) $browser->vueClear('#password') ->vueClear('#password_confirmation') ->with(new ListInput('#aliases'), function (Browser $browser) { $browser->addListEntry('invalid address'); }) ->click('button[type=submit]') ->assertToast(Toast::TYPE_ERROR, 'Form validation error'); $browser->with(new ListInput('#aliases'), function (Browser $browser) { $browser->assertFormError(2, 'The specified alias is invalid.', false); }); // Test adding aliases $browser->with(new ListInput('#aliases'), function (Browser $browser) { $browser->removeListEntry(2) ->addListEntry('john.test@kolab.org'); }) ->click('button[type=submit]') ->assertToast(Toast::TYPE_SUCCESS, 'User data updated successfully.'); }) ->on(new UserList()) ->click('@table tr:nth-child(3) a') ->on(new UserInfo()); $john = User::where('email', 'john@kolab.org')->first(); $alias = UserAlias::where('user_id', $john->id)->where('alias', 'john.test@kolab.org')->first(); $this->assertTrue(!empty($alias)); // Test subscriptions $browser->with('@general', function (Browser $browser) { $browser->assertSeeIn('div.row:nth-child(9) label', 'Subscriptions') ->assertVisible('@skus.row:nth-child(9)') ->with('@skus', function ($browser) { $browser->assertElementsCount('tbody tr', 6) // Mailbox SKU ->assertSeeIn('tbody tr:nth-child(1) td.name', 'User Mailbox') ->assertSeeIn('tbody tr:nth-child(1) td.price', '5,00 CHF/month') ->assertChecked('tbody tr:nth-child(1) td.selection input') ->assertDisabled('tbody tr:nth-child(1) td.selection input') ->assertTip( 'tbody tr:nth-child(1) td.buttons button', 'Just a mailbox' ) // Storage SKU ->assertSeeIn('tbody tr:nth-child(2) td.name', 'Storage Quota') ->assertSeeIn('tr:nth-child(2) td.price', '0,00 CHF/month') ->assertChecked('tbody tr:nth-child(2) td.selection input') ->assertDisabled('tbody tr:nth-child(2) td.selection input') ->assertTip( 'tbody tr:nth-child(2) td.buttons button', 'Some wiggle room' ) ->with(new QuotaInput('tbody tr:nth-child(2) .range-input'), function ($browser) { $browser->assertQuotaValue(5)->setQuotaValue(6); }) ->assertSeeIn('tr:nth-child(2) td.price', '0,25 CHF/month') // groupware SKU ->assertSeeIn('tbody tr:nth-child(3) td.name', 'Groupware Features') ->assertSeeIn('tbody tr:nth-child(3) td.price', '4,90 CHF/month') ->assertChecked('tbody tr:nth-child(3) td.selection input') ->assertEnabled('tbody tr:nth-child(3) td.selection input') ->assertTip( 'tbody tr:nth-child(3) td.buttons button', 'Groupware functions like Calendar, Tasks, Notes, etc.' ) // ActiveSync SKU ->assertSeeIn('tbody tr:nth-child(4) td.name', 'Activesync') ->assertSeeIn('tbody tr:nth-child(4) td.price', '0,00 CHF/month') ->assertNotChecked('tbody tr:nth-child(4) td.selection input') ->assertEnabled('tbody tr:nth-child(4) td.selection input') ->assertTip( 'tbody tr:nth-child(4) td.buttons button', 'Mobile synchronization' ) // 2FA SKU ->assertSeeIn('tbody tr:nth-child(5) td.name', '2-Factor Authentication') ->assertSeeIn('tbody tr:nth-child(5) td.price', '0,00 CHF/month') ->assertNotChecked('tbody tr:nth-child(5) td.selection input') ->assertEnabled('tbody tr:nth-child(5) td.selection input') ->assertTip( 'tbody tr:nth-child(5) td.buttons button', 'Two factor authentication for webmail and administration panel' ) // Meet SKU ->assertSeeIn('tbody tr:nth-child(6) td.name', 'Voice & Video Conferencing (public beta)') ->assertSeeIn('tbody tr:nth-child(6) td.price', '0,00 CHF/month') ->assertNotChecked('tbody tr:nth-child(6) td.selection input') ->assertEnabled('tbody tr:nth-child(6) td.selection input') ->assertTip( 'tbody tr:nth-child(6) td.buttons button', 'Video conferencing tool' ) ->click('tbody tr:nth-child(4) td.selection input'); }) ->assertMissing('@skus table + .hint') ->click('button[type=submit]') ->assertToast(Toast::TYPE_SUCCESS, 'User data updated successfully.'); }) ->on(new UserList()) ->click('@table tr:nth-child(3) a') ->on(new UserInfo()); $expected = ['activesync', 'groupware', 'mailbox', 'storage', 'storage', 'storage', 'storage', 'storage', 'storage']; - $this->assertUserEntitlements($john, $expected); + $this->assertEntitlements($john, $expected); // Test subscriptions interaction $browser->with('@general', function (Browser $browser) { $browser->with('@skus', function ($browser) { // Uncheck 'groupware', expect activesync unchecked $browser->click('#sku-input-groupware') ->assertNotChecked('#sku-input-groupware') ->assertNotChecked('#sku-input-activesync') ->assertEnabled('#sku-input-activesync') ->assertNotReadonly('#sku-input-activesync') // Check 'activesync', expect an alert ->click('#sku-input-activesync') ->assertDialogOpened('Activesync requires Groupware Features.') ->acceptDialog() ->assertNotChecked('#sku-input-activesync') // Check 'meet', expect an alert ->click('#sku-input-meet') ->assertDialogOpened('Voice & Video Conferencing (public beta) requires Groupware Features.') ->acceptDialog() ->assertNotChecked('#sku-input-meet') // Check '2FA', expect 'activesync' unchecked and readonly ->click('#sku-input-2fa') ->assertChecked('#sku-input-2fa') ->assertNotChecked('#sku-input-activesync') ->assertReadonly('#sku-input-activesync') // Uncheck '2FA' ->click('#sku-input-2fa') ->assertNotChecked('#sku-input-2fa') ->assertNotReadonly('#sku-input-activesync'); }); }); }); } /** * Test user settings tab * * @depends testInfo */ public function testUserSettings(): void { $john = $this->getTestUser('john@kolab.org'); $john->setSetting('greylist_enabled', null); $this->browse(function (Browser $browser) use ($john) { $browser->visit('/user/' . $john->id) ->on(new UserInfo()) ->assertElementsCount('@nav a', 2) ->assertSeeIn('@nav #tab-general', 'General') ->assertSeeIn('@nav #tab-settings', 'Settings') ->click('@nav #tab-settings') ->with('#settings form', function (Browser $browser) { $browser->assertSeeIn('div.row:nth-child(1) label', 'Greylisting') ->click('div.row:nth-child(1) input[type=checkbox]:checked') ->click('button[type=submit]') ->assertToast(Toast::TYPE_SUCCESS, 'User settings updated successfully.'); }); }); $this->assertSame('false', $john->fresh()->getSetting('greylist_enabled')); } /** * Test user adding page * * @depends testList */ public function testNewUser(): void { $this->browse(function (Browser $browser) { $browser->visit(new UserList()) ->assertSeeIn('button.create-user', 'Create user') ->click('button.create-user') ->on(new UserInfo()) ->assertSeeIn('#user-info .card-title', 'New user account') ->with('@general', function (Browser $browser) { // Assert form content $browser->assertFocused('div.row:nth-child(1) input') ->assertSeeIn('div.row:nth-child(1) label', 'First Name') ->assertValue('div.row:nth-child(1) input[type=text]', '') ->assertSeeIn('div.row:nth-child(2) label', 'Last Name') ->assertValue('div.row:nth-child(2) input[type=text]', '') ->assertSeeIn('div.row:nth-child(3) label', 'Organization') ->assertValue('div.row:nth-child(3) input[type=text]', '') ->assertSeeIn('div.row:nth-child(4) label', 'Email') ->assertValue('div.row:nth-child(4) input[type=text]', '') ->assertEnabled('div.row:nth-child(4) input[type=text]') ->assertSeeIn('div.row:nth-child(5) label', 'Email Aliases') ->assertVisible('div.row:nth-child(5) .list-input') ->with(new ListInput('#aliases'), function (Browser $browser) { $browser->assertListInputValue([]) ->assertValue('@input', ''); }) ->assertSeeIn('div.row:nth-child(6) label', 'Password') ->assertValue('div.row:nth-child(6) input[type=password]', '') ->assertSeeIn('div.row:nth-child(7) label', 'Confirm Password') ->assertValue('div.row:nth-child(7) input[type=password]', '') ->assertSeeIn('div.row:nth-child(8) label', 'Package') // assert packages list widget, select "Lite Account" ->with('@packages', function ($browser) { $browser->assertElementsCount('tbody tr', 2) ->assertSeeIn('tbody tr:nth-child(1)', 'Groupware Account') ->assertSeeIn('tbody tr:nth-child(2)', 'Lite Account') ->assertSeeIn('tbody tr:nth-child(1) .price', '9,90 CHF/month') ->assertSeeIn('tbody tr:nth-child(2) .price', '5,00 CHF/month') ->assertChecked('tbody tr:nth-child(1) input') ->click('tbody tr:nth-child(2) input') ->assertNotChecked('tbody tr:nth-child(1) input') ->assertChecked('tbody tr:nth-child(2) input'); }) ->assertMissing('@packages table + .hint') ->assertSeeIn('button[type=submit]', 'Submit'); // Test browser-side required fields and error handling $browser->click('button[type=submit]') ->assertFocused('#email') ->type('#email', 'invalid email') ->click('button[type=submit]') ->assertFocused('#password') ->type('#password', 'simple123') ->click('button[type=submit]') ->assertFocused('#password_confirmation') ->type('#password_confirmation', 'simple') ->click('button[type=submit]') ->assertToast(Toast::TYPE_ERROR, 'Form validation error') ->assertSeeIn('#email + .invalid-feedback', 'The specified email is invalid.') ->assertSeeIn('#password + .invalid-feedback', 'The password confirmation does not match.'); }); // Test form error handling (aliases) $browser->with('@general', function (Browser $browser) { $browser->type('#email', 'julia.roberts@kolab.org') ->type('#password_confirmation', 'simple123') ->with(new ListInput('#aliases'), function (Browser $browser) { $browser->addListEntry('invalid address'); }) ->click('button[type=submit]') ->assertToast(Toast::TYPE_ERROR, 'Form validation error') ->with(new ListInput('#aliases'), function (Browser $browser) { $browser->assertFormError(1, 'The specified alias is invalid.', false); }); }); // Successful account creation $browser->with('@general', function (Browser $browser) { $browser->type('#first_name', 'Julia') ->type('#last_name', 'Roberts') ->type('#organization', 'Test Org') ->with(new ListInput('#aliases'), function (Browser $browser) { $browser->removeListEntry(1) ->addListEntry('julia.roberts2@kolab.org'); }) ->click('button[type=submit]'); }) ->assertToast(Toast::TYPE_SUCCESS, 'User created successfully.') // check redirection to users list ->on(new UserList()) ->whenAvailable('@table', function (Browser $browser) { $browser->assertElementsCount('tbody tr', 5) ->assertSeeIn('tbody tr:nth-child(4) a', 'julia.roberts@kolab.org'); }); $julia = User::where('email', 'julia.roberts@kolab.org')->first(); $alias = UserAlias::where('user_id', $julia->id)->where('alias', 'julia.roberts2@kolab.org')->first(); $this->assertTrue(!empty($alias)); - $this->assertUserEntitlements($julia, ['mailbox', 'storage', 'storage', 'storage', 'storage', 'storage']); + $this->assertEntitlements($julia, ['mailbox', 'storage', 'storage', 'storage', 'storage', 'storage']); $this->assertSame('Julia', $julia->getSetting('first_name')); $this->assertSame('Roberts', $julia->getSetting('last_name')); $this->assertSame('Test Org', $julia->getSetting('organization')); // Some additional tests for the list input widget $browser->click('@table tbody tr:nth-child(4) a') ->on(new UserInfo()) ->with(new ListInput('#aliases'), function (Browser $browser) { $browser->assertListInputValue(['julia.roberts2@kolab.org']) ->addListEntry('invalid address') ->type('.input-group:nth-child(2) input', '@kolab.org') ->keys('.input-group:nth-child(2) input', '{enter}'); }) // TODO: Investigate why this click does not work, for now we // submit the form with Enter key above //->click('@general button[type=submit]') ->assertToast(Toast::TYPE_ERROR, 'Form validation error') ->with(new ListInput('#aliases'), function (Browser $browser) { $browser->assertVisible('.input-group:nth-child(2) input.is-invalid') ->assertVisible('.input-group:nth-child(3) input.is-invalid') ->type('.input-group:nth-child(2) input', 'julia.roberts3@kolab.org') ->type('.input-group:nth-child(3) input', 'julia.roberts4@kolab.org') ->keys('.input-group:nth-child(3) input', '{enter}'); }) // TODO: Investigate why this click does not work, for now we // submit the form with Enter key above //->click('@general button[type=submit]') ->assertToast(Toast::TYPE_SUCCESS, 'User data updated successfully.'); $julia = User::where('email', 'julia.roberts@kolab.org')->first(); $aliases = $julia->aliases()->orderBy('alias')->get()->pluck('alias')->all(); $this->assertSame(['julia.roberts3@kolab.org', 'julia.roberts4@kolab.org'], $aliases); }); } /** * Test user delete * * @depends testNewUser */ public function testDeleteUser(): void { // First create a new user $john = $this->getTestUser('john@kolab.org'); $julia = $this->getTestUser('julia.roberts@kolab.org'); $package_kolab = \App\Package::where('title', 'kolab')->first(); $john->assignPackage($package_kolab, $julia); // Test deleting non-controller user $this->browse(function (Browser $browser) use ($julia) { $browser->visit('/user/' . $julia->id) ->on(new UserInfo()) ->assertSeeIn('button.button-delete', 'Delete user') ->click('button.button-delete') ->with(new Dialog('#delete-warning'), function (Browser $browser) { $browser->assertSeeIn('@title', 'Delete julia.roberts@kolab.org') ->assertFocused('@button-cancel') ->assertSeeIn('@button-cancel', 'Cancel') ->assertSeeIn('@button-action', 'Delete') ->click('@button-cancel'); }) ->waitUntilMissing('#delete-warning') ->click('button.button-delete') ->with(new Dialog('#delete-warning'), function (Browser $browser) { $browser->click('@button-action'); }) ->waitUntilMissing('#delete-warning') ->assertToast(Toast::TYPE_SUCCESS, 'User deleted successfully.') ->on(new UserList()) ->with('@table', function (Browser $browser) { $browser->assertElementsCount('tbody tr', 4) ->assertSeeIn('tbody tr:nth-child(1) a', 'jack@kolab.org') ->assertSeeIn('tbody tr:nth-child(2) a', 'joe@kolab.org') ->assertSeeIn('tbody tr:nth-child(3) a', 'john@kolab.org') ->assertSeeIn('tbody tr:nth-child(4) a', 'ned@kolab.org'); }); $julia = User::where('email', 'julia.roberts@kolab.org')->first(); $this->assertTrue(empty($julia)); }); // Test that non-controller user cannot see/delete himself on the users list $this->browse(function (Browser $browser) { $browser->visit('/logout') ->on(new Home()) ->submitLogon('jack@kolab.org', 'simple123', true) ->visit('/users') ->assertErrorPage(403); }); // Test that controller user (Ned) can see all the users $this->browse(function (Browser $browser) { $browser->visit('/logout') ->on(new Home()) ->submitLogon('ned@kolab.org', 'simple123', true) ->visit(new UserList()) ->whenAvailable('@table', function (Browser $browser) { $browser->assertElementsCount('tbody tr', 4); }); // TODO: Test the delete action in details }); // TODO: Test what happens with the logged in user session after he's been deleted by another user } /** * Test discounted sku/package prices in the UI */ public function testDiscountedPrices(): void { // Add 10% discount $discount = Discount::where('code', 'TEST')->first(); $john = User::where('email', 'john@kolab.org')->first(); $wallet = $john->wallet(); $wallet->discount()->associate($discount); $wallet->save(); // SKUs on user edit page $this->browse(function (Browser $browser) { $browser->visit('/logout') ->on(new Home()) ->submitLogon('john@kolab.org', 'simple123', true) ->visit(new UserList()) ->waitFor('@table tr:nth-child(2)') ->click('@table tr:nth-child(2) a') // joe@kolab.org ->on(new UserInfo()) ->with('@general', function (Browser $browser) { $browser->whenAvailable('@skus', function (Browser $browser) { $quota_input = new QuotaInput('tbody tr:nth-child(2) .range-input'); $browser->waitFor('tbody tr') ->assertElementsCount('tbody tr', 6) // Mailbox SKU ->assertSeeIn('tbody tr:nth-child(1) td.price', '4,50 CHF/month¹') // Storage SKU ->assertSeeIn('tr:nth-child(2) td.price', '0,00 CHF/month¹') ->with($quota_input, function (Browser $browser) { $browser->setQuotaValue(100); }) ->assertSeeIn('tr:nth-child(2) td.price', '21,37 CHF/month¹') // groupware SKU ->assertSeeIn('tbody tr:nth-child(3) td.price', '4,41 CHF/month¹') // ActiveSync SKU ->assertSeeIn('tbody tr:nth-child(4) td.price', '0,00 CHF/month¹') // 2FA SKU ->assertSeeIn('tbody tr:nth-child(5) td.price', '0,00 CHF/month¹'); }) ->assertSeeIn('@skus table + .hint', '¹ applied discount: 10% - Test voucher'); }); }); // Packages on new user page $this->browse(function (Browser $browser) { $browser->visit(new UserList()) ->click('button.create-user') ->on(new UserInfo()) ->with('@general', function (Browser $browser) { $browser->whenAvailable('@packages', function (Browser $browser) { $browser->assertElementsCount('tbody tr', 2) ->assertSeeIn('tbody tr:nth-child(1) .price', '8,91 CHF/month¹') // Groupware ->assertSeeIn('tbody tr:nth-child(2) .price', '4,50 CHF/month¹'); // Lite }) ->assertSeeIn('@packages table + .hint', '¹ applied discount: 10% - Test voucher'); }); }); // Test using entitlement cost instead of the SKU cost $this->browse(function (Browser $browser) use ($wallet) { $joe = User::where('email', 'joe@kolab.org')->first(); $beta_sku = Sku::withEnvTenantContext()->where('title', 'beta')->first(); $storage_sku = Sku::withEnvTenantContext()->where('title', 'storage')->first(); // Add an extra storage and beta entitlement with different prices Entitlement::create([ 'wallet_id' => $wallet->id, 'sku_id' => $beta_sku->id, 'cost' => 5010, 'entitleable_id' => $joe->id, 'entitleable_type' => User::class ]); Entitlement::create([ 'wallet_id' => $wallet->id, 'sku_id' => $storage_sku->id, 'cost' => 5000, 'entitleable_id' => $joe->id, 'entitleable_type' => User::class ]); $browser->visit('/user/' . $joe->id) ->on(new UserInfo()) ->with('@general', function (Browser $browser) { $browser->whenAvailable('@skus', function (Browser $browser) { $quota_input = new QuotaInput('tbody tr:nth-child(2) .range-input'); $browser->waitFor('tbody tr') // Beta SKU ->assertSeeIn('tbody tr:nth-child(7) td.price', '45,09 CHF/month¹') // Storage SKU ->assertSeeIn('tr:nth-child(2) td.price', '45,00 CHF/month¹') ->with($quota_input, function (Browser $browser) { $browser->setQuotaValue(7); }) ->assertSeeIn('tr:nth-child(2) td.price', '45,22 CHF/month¹') ->with($quota_input, function (Browser $browser) { $browser->setQuotaValue(5); }) ->assertSeeIn('tr:nth-child(2) td.price', '0,00 CHF/month¹'); }) ->assertSeeIn('@skus table + .hint', '¹ applied discount: 10% - Test voucher'); }); }); } /** * Test non-default currency in the UI */ public function testCurrency(): void { // Add 10% discount $john = User::where('email', 'john@kolab.org')->first(); $wallet = $john->wallet(); $wallet->balance = -1000; $wallet->currency = 'EUR'; $wallet->save(); // On Dashboard and the wallet page $this->browse(function (Browser $browser) { $browser->visit('/logout') ->on(new Home()) ->submitLogon('john@kolab.org', 'simple123', true) ->on(new Dashboard()) ->assertSeeIn('@links .link-wallet .badge', '-10,00 €') ->click('@links .link-wallet') ->on(new WalletPage()) ->assertSeeIn('#wallet .card-title', 'Account balance -10,00 €'); }); // SKUs on user edit page $this->browse(function (Browser $browser) { $browser->visit(new UserList()) ->waitFor('@table tr:nth-child(2)') ->click('@table tr:nth-child(2) a') // joe@kolab.org ->on(new UserInfo()) ->with('@general', function (Browser $browser) { $browser->whenAvailable('@skus', function (Browser $browser) { $quota_input = new QuotaInput('tbody tr:nth-child(2) .range-input'); $browser->waitFor('tbody tr') ->assertElementsCount('tbody tr', 6) // Mailbox SKU ->assertSeeIn('tbody tr:nth-child(1) td.price', '5,00 €/month') // Storage SKU ->assertSeeIn('tr:nth-child(2) td.price', '0,00 €/month') ->with($quota_input, function (Browser $browser) { $browser->setQuotaValue(100); }) ->assertSeeIn('tr:nth-child(2) td.price', '23,75 €/month'); }); }); }); // Packages on new user page $this->browse(function (Browser $browser) { $browser->visit(new UserList()) ->click('button.create-user') ->on(new UserInfo()) ->with('@general', function (Browser $browser) { $browser->whenAvailable('@packages', function (Browser $browser) { $browser->assertElementsCount('tbody tr', 2) ->assertSeeIn('tbody tr:nth-child(1) .price', '9,90 €/month') // Groupware ->assertSeeIn('tbody tr:nth-child(2) .price', '5,00 €/month'); // Lite }); }); }); } /** * Test beta entitlements * * @depends testList */ public function testBetaEntitlements(): void { $this->browse(function (Browser $browser) { $john = User::where('email', 'john@kolab.org')->first(); $sku = Sku::withEnvTenantContext()->where('title', 'beta')->first(); $john->assignSku($sku); $browser->visit('/user/' . $john->id) ->on(new UserInfo()) ->with('@skus', function ($browser) { $browser->assertElementsCount('tbody tr', 8) // Meet SKU ->assertSeeIn('tbody tr:nth-child(6) td.name', 'Voice & Video Conferencing (public beta)') ->assertSeeIn('tr:nth-child(6) td.price', '0,00 CHF/month') ->assertNotChecked('tbody tr:nth-child(6) td.selection input') ->assertEnabled('tbody tr:nth-child(6) td.selection input') ->assertTip( 'tbody tr:nth-child(6) td.buttons button', 'Video conferencing tool' ) // Beta SKU ->assertSeeIn('tbody tr:nth-child(7) td.name', 'Private Beta (invitation only)') ->assertSeeIn('tbody tr:nth-child(7) td.price', '0,00 CHF/month') ->assertChecked('tbody tr:nth-child(7) td.selection input') ->assertEnabled('tbody tr:nth-child(7) td.selection input') ->assertTip( 'tbody tr:nth-child(7) td.buttons button', 'Access to the private beta program subscriptions' ) // Distlist SKU ->assertSeeIn('tbody tr:nth-child(8) td.name', 'Distribution lists') ->assertSeeIn('tr:nth-child(8) td.price', '0,00 CHF/month') ->assertNotChecked('tbody tr:nth-child(8) td.selection input') ->assertEnabled('tbody tr:nth-child(8) td.selection input') ->assertTip( 'tbody tr:nth-child(8) td.buttons button', 'Access to mail distribution lists' ) // Check Distlist, Uncheck Beta, expect Distlist unchecked ->click('#sku-input-distlist') ->click('#sku-input-beta') ->assertNotChecked('#sku-input-beta') ->assertNotChecked('#sku-input-distlist') // Click Distlist expect an alert ->click('#sku-input-distlist') ->assertDialogOpened('Distribution lists requires Private Beta (invitation only).') ->acceptDialog() // Enable Beta and Distlist and submit ->click('#sku-input-beta') ->click('#sku-input-distlist'); }) ->click('@general button[type=submit]') ->assertToast(Toast::TYPE_SUCCESS, 'User data updated successfully.'); $expected = [ 'beta', 'distlist', 'groupware', 'mailbox', 'storage', 'storage', 'storage', 'storage', 'storage' ]; - $this->assertUserEntitlements($john, $expected); + $this->assertEntitlements($john, $expected); $browser->visit('/user/' . $john->id) ->on(new UserInfo()) ->waitFor('#sku-input-beta') ->click('#sku-input-beta') ->click('@general button[type=submit]') ->assertToast(Toast::TYPE_SUCCESS, 'User data updated successfully.'); $expected = [ 'groupware', 'mailbox', 'storage', 'storage', 'storage', 'storage', 'storage' ]; - $this->assertUserEntitlements($john, $expected); + $this->assertEntitlements($john, $expected); }); // TODO: Test that the Distlist SKU is not available for users that aren't a group account owners // TODO: Test that entitlements change has immediate effect on the available items in dashboard // i.e. does not require a page reload nor re-login. } } diff --git a/src/tests/Feature/Controller/Admin/DomainsTest.php b/src/tests/Feature/Controller/Admin/DomainsTest.php index 6437a5c5..a8311c69 100644 --- a/src/tests/Feature/Controller/Admin/DomainsTest.php +++ b/src/tests/Feature/Controller/Admin/DomainsTest.php @@ -1,223 +1,235 @@ deleteTestDomain('domainscontroller.com'); } /** * {@inheritDoc} */ public function tearDown(): void { $this->deleteTestDomain('domainscontroller.com'); parent::tearDown(); } /** * Test domains confirming (not implemented) */ public function testConfirm(): void { $admin = $this->getTestUser('jeroen@jeroen.jeroen'); $domain = $this->getTestDomain('kolab.org'); // This end-point does not exist for admins $response = $this->actingAs($admin)->get("api/v4/domains/{$domain->id}/confirm"); $response->assertStatus(404); } /** * Test domains searching (/api/v4/domains) */ public function testIndex(): void { $john = $this->getTestUser('john@kolab.org'); $admin = $this->getTestUser('jeroen@jeroen.jeroen'); // Non-admin user $response = $this->actingAs($john)->get("api/v4/domains"); $response->assertStatus(403); // Search with no search criteria $response = $this->actingAs($admin)->get("api/v4/domains"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(0, $json['count']); $this->assertSame([], $json['list']); // Search with no matches expected $response = $this->actingAs($admin)->get("api/v4/domains?search=abcd12.org"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(0, $json['count']); $this->assertSame([], $json['list']); // Search by a domain name $response = $this->actingAs($admin)->get("api/v4/domains?search=kolab.org"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(1, $json['count']); $this->assertCount(1, $json['list']); $this->assertSame('kolab.org', $json['list'][0]['namespace']); // Search by owner $response = $this->actingAs($admin)->get("api/v4/domains?owner={$john->id}"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(1, $json['count']); $this->assertCount(1, $json['list']); $this->assertSame('kolab.org', $json['list'][0]['namespace']); // Search by owner (Ned is a controller on John's wallets, // here we expect only domains assigned to Ned's wallet(s)) $ned = $this->getTestUser('ned@kolab.org'); $response = $this->actingAs($admin)->get("api/v4/domains?owner={$ned->id}"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(0, $json['count']); $this->assertCount(0, $json['list']); } /** * Test fetching domain info */ public function testShow(): void { $sku_domain = Sku::where('title', 'domain-hosting')->first(); $admin = $this->getTestUser('jeroen@jeroen.jeroen'); $user = $this->getTestUser('test1@domainscontroller.com'); $domain = $this->getTestDomain('domainscontroller.com', [ 'status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_EXTERNAL, ]); Entitlement::create([ 'wallet_id' => $user->wallets()->first()->id, 'sku_id' => $sku_domain->id, 'entitleable_id' => $domain->id, 'entitleable_type' => Domain::class ]); // Only admins can access it $response = $this->actingAs($user)->get("api/v4/domains/{$domain->id}"); $response->assertStatus(403); $response = $this->actingAs($admin)->get("api/v4/domains/{$domain->id}"); $response->assertStatus(200); $json = $response->json(); $this->assertEquals($domain->id, $json['id']); $this->assertEquals($domain->namespace, $json['namespace']); $this->assertEquals($domain->status, $json['status']); $this->assertEquals($domain->type, $json['type']); // Note: Other properties are being tested in the user controller tests } /** * Test fetching domain status (GET /api/v4/domains//status) */ public function testStatus(): void { $admin = $this->getTestUser('jeroen@jeroen.jeroen'); $domain = $this->getTestDomain('kolab.org'); // This end-point does not exist for admins $response = $this->actingAs($admin)->get("/api/v4/domains/{$domain->id}/status"); $response->assertStatus(404); } + /** + * Test creeating a domain (POST /api/v4/domains) + */ + public function testStore(): void + { + $admin = $this->getTestUser('jeroen@jeroen.jeroen'); + + // Admins can't create domains + $response = $this->actingAs($admin)->post("api/v4/domains", []); + $response->assertStatus(404); + } + /** * Test domain suspending (POST /api/v4/domains//suspend) */ public function testSuspend(): void { Queue::fake(); // disable jobs $domain = $this->getTestDomain('domainscontroller.com', [ 'status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_EXTERNAL, ]); $user = $this->getTestUser('test@domainscontroller.com'); $admin = $this->getTestUser('jeroen@jeroen.jeroen'); // Test unauthorized access to admin API $response = $this->actingAs($user)->post("/api/v4/domains/{$domain->id}/suspend", []); $response->assertStatus(403); $this->assertFalse($domain->fresh()->isSuspended()); // Test suspending the user $response = $this->actingAs($admin)->post("/api/v4/domains/{$domain->id}/suspend", []); $response->assertStatus(200); $json = $response->json(); $this->assertSame('success', $json['status']); $this->assertSame("Domain suspended successfully.", $json['message']); $this->assertCount(2, $json); $this->assertTrue($domain->fresh()->isSuspended()); } /** * Test user un-suspending (POST /api/v4/users//unsuspend) */ public function testUnsuspend(): void { Queue::fake(); // disable jobs $domain = $this->getTestDomain('domainscontroller.com', [ 'status' => Domain::STATUS_NEW | Domain::STATUS_SUSPENDED, 'type' => Domain::TYPE_EXTERNAL, ]); $user = $this->getTestUser('test@domainscontroller.com'); $admin = $this->getTestUser('jeroen@jeroen.jeroen'); // Test unauthorized access to admin API $response = $this->actingAs($user)->post("/api/v4/domains/{$domain->id}/unsuspend", []); $response->assertStatus(403); $this->assertTrue($domain->fresh()->isSuspended()); // Test suspending the user $response = $this->actingAs($admin)->post("/api/v4/domains/{$domain->id}/unsuspend", []); $response->assertStatus(200); $json = $response->json(); $this->assertSame('success', $json['status']); $this->assertSame("Domain unsuspended successfully.", $json['message']); $this->assertCount(2, $json); $this->assertFalse($domain->fresh()->isSuspended()); } } diff --git a/src/tests/Feature/Controller/Admin/SkusTest.php b/src/tests/Feature/Controller/Admin/SkusTest.php index ce3344c2..962d6158 100644 --- a/src/tests/Feature/Controller/Admin/SkusTest.php +++ b/src/tests/Feature/Controller/Admin/SkusTest.php @@ -1,98 +1,124 @@ delete(); $this->clearBetaEntitlements(); $this->clearMeetEntitlements(); } /** * {@inheritDoc} */ public function tearDown(): void { Sku::where('title', 'test')->delete(); $this->clearBetaEntitlements(); $this->clearMeetEntitlements(); parent::tearDown(); } + /** + * Test fetching SKUs list for a domain (GET /domains//skus) + */ + public function testDomainSkus(): void + { + $admin = $this->getTestUser('jeroen@jeroen.jeroen'); + $user = $this->getTestUser('john@kolab.org'); + $domain = $this->getTestDOmain('kolab.org'); + + // Unauth access not allowed + $response = $this->get("api/v4/domains/{$domain->id}/skus"); + $response->assertStatus(401); + + // Non-admin access not allowed + $response = $this->actingAs($user)->get("api/v4/domains/{$domain->id}/skus"); + $response->assertStatus(403); + + $response = $this->actingAs($admin)->get("api/v4/domains/{$domain->id}/skus"); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertCount(1, $json); + // Note: Details are tested where we test API\V4\SkusController + } + /** * Test fetching SKUs list */ public function testIndex(): void { $admin = $this->getTestUser('jeroen@jeroen.jeroen'); $user = $this->getTestUser('john@kolab.org'); $sku = Sku::withEnvTenantContext()->where('title', 'mailbox')->first(); // Unauth access not allowed $response = $this->get("api/v4/skus"); $response->assertStatus(401); // User access not allowed on admin API $response = $this->actingAs($user)->get("api/v4/skus"); $response->assertStatus(403); $response = $this->actingAs($admin)->get("api/v4/skus"); $response->assertStatus(200); $json = $response->json(); $this->assertCount(9, $json); $this->assertSame(100, $json[0]['prio']); $this->assertSame($sku->id, $json[0]['id']); $this->assertSame($sku->title, $json[0]['title']); $this->assertSame($sku->name, $json[0]['name']); $this->assertSame($sku->description, $json[0]['description']); $this->assertSame($sku->cost, $json[0]['cost']); $this->assertSame($sku->units_free, $json[0]['units_free']); $this->assertSame($sku->period, $json[0]['period']); $this->assertSame($sku->active, $json[0]['active']); $this->assertSame('user', $json[0]['type']); $this->assertSame('mailbox', $json[0]['handler']); } /** * Test fetching SKUs list for a user (GET /users//skus) */ public function testUserSkus(): void { $admin = $this->getTestUser('jeroen@jeroen.jeroen'); $user = $this->getTestUser('john@kolab.org'); // Unauth access not allowed $response = $this->get("api/v4/users/{$user->id}/skus"); $response->assertStatus(401); // Non-admin access not allowed $response = $this->actingAs($user)->get("api/v4/users/{$user->id}/skus"); $response->assertStatus(403); $response = $this->actingAs($admin)->get("api/v4/users/{$user->id}/skus"); $response->assertStatus(200); $json = $response->json(); - $this->assertCount(8, $json); + $this->assertCount(6, $json); // Note: Details are tested where we test API\V4\SkusController } } diff --git a/src/tests/Feature/Controller/DomainsTest.php b/src/tests/Feature/Controller/DomainsTest.php index ae8fbc27..a90e8093 100644 --- a/src/tests/Feature/Controller/DomainsTest.php +++ b/src/tests/Feature/Controller/DomainsTest.php @@ -1,321 +1,443 @@ deleteTestUser('test1@domainscontroller.com'); $this->deleteTestDomain('domainscontroller.com'); } public function tearDown(): void { $this->deleteTestUser('test1@domainscontroller.com'); $this->deleteTestDomain('domainscontroller.com'); $domain = $this->getTestDomain('kolab.org'); $domain->settings()->whereIn('key', ['spf_whitelist'])->delete(); parent::tearDown(); } /** * Test domain confirm request */ public function testConfirm(): void { $sku_domain = Sku::withEnvTenantContext()->where('title', 'domain-hosting')->first(); $john = $this->getTestUser('john@kolab.org'); $ned = $this->getTestUser('ned@kolab.org'); $user = $this->getTestUser('test1@domainscontroller.com'); $domain = $this->getTestDomain('domainscontroller.com', [ 'status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_EXTERNAL, ]); Entitlement::create([ 'wallet_id' => $user->wallets()->first()->id, 'sku_id' => $sku_domain->id, 'entitleable_id' => $domain->id, 'entitleable_type' => Domain::class ]); $response = $this->actingAs($user)->get("api/v4/domains/{$domain->id}/confirm"); $response->assertStatus(200); $json = $response->json(); $this->assertCount(2, $json); $this->assertEquals('error', $json['status']); $this->assertEquals('Domain ownership verification failed.', $json['message']); $domain->status |= Domain::STATUS_CONFIRMED; $domain->save(); $response = $this->actingAs($user)->get("api/v4/domains/{$domain->id}/confirm"); $response->assertStatus(200); $json = $response->json(); $this->assertEquals('success', $json['status']); $this->assertEquals('Domain verified successfully.', $json['message']); $this->assertTrue(is_array($json['statusInfo'])); // Not authorized access $response = $this->actingAs($john)->get("api/v4/domains/{$domain->id}/confirm"); $response->assertStatus(403); // Authorized access by additional account controller $domain = $this->getTestDomain('kolab.org'); $response = $this->actingAs($ned)->get("api/v4/domains/{$domain->id}/confirm"); $response->assertStatus(200); } /** * Test fetching domains list */ public function testIndex(): void { // User with no domains $user = $this->getTestUser('test1@domainscontroller.com'); $response = $this->actingAs($user)->get("api/v4/domains"); $response->assertStatus(200); $json = $response->json(); $this->assertSame([], $json); // User with custom domain(s) $john = $this->getTestUser('john@kolab.org'); $ned = $this->getTestUser('ned@kolab.org'); $response = $this->actingAs($john)->get("api/v4/domains"); $response->assertStatus(200); $json = $response->json(); $this->assertCount(1, $json); $this->assertSame('kolab.org', $json[0]['namespace']); // Values below are tested by Unit tests $this->assertArrayHasKey('isConfirmed', $json[0]); $this->assertArrayHasKey('isDeleted', $json[0]); $this->assertArrayHasKey('isVerified', $json[0]); $this->assertArrayHasKey('isSuspended', $json[0]); $this->assertArrayHasKey('isActive', $json[0]); $this->assertArrayHasKey('isLdapReady', $json[0]); $response = $this->actingAs($ned)->get("api/v4/domains"); $response->assertStatus(200); $json = $response->json(); $this->assertCount(1, $json); $this->assertSame('kolab.org', $json[0]['namespace']); } /** * Test domain config update (POST /api/v4/domains//config) */ public function testSetConfig(): void { $john = $this->getTestUser('john@kolab.org'); $jack = $this->getTestUser('jack@kolab.org'); $domain = $this->getTestDomain('kolab.org'); $domain->setSetting('spf_whitelist', null); // Test unknown domain id $post = ['spf_whitelist' => []]; $response = $this->actingAs($john)->post("/api/v4/domains/123/config", $post); $json = $response->json(); $response->assertStatus(404); // Test access by user not being a wallet controller $post = ['spf_whitelist' => []]; $response = $this->actingAs($jack)->post("/api/v4/domains/{$domain->id}/config", $post); $json = $response->json(); $response->assertStatus(403); $this->assertSame('error', $json['status']); $this->assertSame("Access denied", $json['message']); $this->assertCount(2, $json); // Test some invalid data $post = ['grey' => 1]; $response = $this->actingAs($john)->post("/api/v4/domains/{$domain->id}/config", $post); $response->assertStatus(422); $json = $response->json(); $this->assertSame('error', $json['status']); $this->assertCount(2, $json); $this->assertCount(1, $json['errors']); $this->assertSame('The requested configuration parameter is not supported.', $json['errors']['grey']); $this->assertNull($domain->fresh()->getSetting('spf_whitelist')); // Test some valid data $post = ['spf_whitelist' => ['.test.domain.com']]; $response = $this->actingAs($john)->post("/api/v4/domains/{$domain->id}/config", $post); $response->assertStatus(200); $json = $response->json(); $this->assertCount(2, $json); $this->assertSame('success', $json['status']); $this->assertSame('Domain settings updated successfully.', $json['message']); $expected = \json_encode($post['spf_whitelist']); $this->assertSame($expected, $domain->fresh()->getSetting('spf_whitelist')); // Test input validation $post = ['spf_whitelist' => ['aaa']]; $response = $this->actingAs($john)->post("/api/v4/domains/{$domain->id}/config", $post); $response->assertStatus(422); $json = $response->json(); $this->assertCount(2, $json); $this->assertSame('error', $json['status']); $this->assertCount(1, $json['errors']); $this->assertSame( 'The entry format is invalid. Expected a domain name starting with a dot.', $json['errors']['spf_whitelist'][0] ); $this->assertSame($expected, $domain->fresh()->getSetting('spf_whitelist')); } /** * Test fetching domain info */ public function testShow(): void { $sku_domain = Sku::withEnvTenantContext()->where('title', 'domain-hosting')->first(); $user = $this->getTestUser('test1@domainscontroller.com'); $domain = $this->getTestDomain('domainscontroller.com', [ 'status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_EXTERNAL, ]); + $discount = \App\Discount::withEnvTenantContext()->where('code', 'TEST')->first(); + $wallet = $user->wallet(); + $wallet->discount()->associate($discount); + $wallet->save(); + Entitlement::create([ 'wallet_id' => $user->wallets()->first()->id, 'sku_id' => $sku_domain->id, 'entitleable_id' => $domain->id, 'entitleable_type' => Domain::class ]); $response = $this->actingAs($user)->get("api/v4/domains/{$domain->id}"); $response->assertStatus(200); $json = $response->json(); $this->assertEquals($domain->id, $json['id']); $this->assertEquals($domain->namespace, $json['namespace']); $this->assertEquals($domain->status, $json['status']); $this->assertEquals($domain->type, $json['type']); $this->assertSame($domain->hash(Domain::HASH_TEXT), $json['hash_text']); $this->assertSame($domain->hash(Domain::HASH_CNAME), $json['hash_cname']); $this->assertSame($domain->hash(Domain::HASH_CODE), $json['hash_code']); $this->assertSame([], $json['config']['spf_whitelist']); $this->assertCount(4, $json['mx']); $this->assertTrue(strpos(implode("\n", $json['mx']), $domain->namespace) !== false); $this->assertCount(8, $json['dns']); $this->assertTrue(strpos(implode("\n", $json['dns']), $domain->namespace) !== false); $this->assertTrue(strpos(implode("\n", $json['dns']), $domain->hash()) !== false); $this->assertTrue(is_array($json['statusInfo'])); // Values below are tested by Unit tests $this->assertArrayHasKey('isConfirmed', $json); $this->assertArrayHasKey('isDeleted', $json); $this->assertArrayHasKey('isVerified', $json); $this->assertArrayHasKey('isSuspended', $json); $this->assertArrayHasKey('isActive', $json); $this->assertArrayHasKey('isLdapReady', $json); + $this->assertCount(1, $json['skus']); + $this->assertSame(1, $json['skus'][$sku_domain->id]['count']); + $this->assertSame([0], $json['skus'][$sku_domain->id]['costs']); + $this->assertSame($wallet->id, $json['wallet']['id']); + $this->assertSame($wallet->balance, $json['wallet']['balance']); + $this->assertSame($wallet->currency, $json['wallet']['currency']); + $this->assertSame($discount->discount, $json['wallet']['discount']); + $this->assertSame($discount->description, $json['wallet']['discount_description']); $john = $this->getTestUser('john@kolab.org'); $ned = $this->getTestUser('ned@kolab.org'); $jack = $this->getTestUser('jack@kolab.org'); // Not authorized - Other account domain $response = $this->actingAs($john)->get("api/v4/domains/{$domain->id}"); $response->assertStatus(403); $domain = $this->getTestDomain('kolab.org'); // Ned is an additional controller on kolab.org's wallet $response = $this->actingAs($ned)->get("api/v4/domains/{$domain->id}"); $response->assertStatus(200); // Jack has no entitlement/control over kolab.org $response = $this->actingAs($jack)->get("api/v4/domains/{$domain->id}"); $response->assertStatus(403); } /** * Test fetching domain status (GET /api/v4/domains//status) * and forcing setup process update (?refresh=1) * * @group dns */ public function testStatus(): void { $john = $this->getTestUser('john@kolab.org'); $jack = $this->getTestUser('jack@kolab.org'); $domain = $this->getTestDomain('kolab.org'); // Test unauthorized access $response = $this->actingAs($jack)->get("/api/v4/domains/{$domain->id}/status"); $response->assertStatus(403); $domain->status = Domain::STATUS_NEW | Domain::STATUS_ACTIVE | Domain::STATUS_LDAP_READY; $domain->save(); // Get domain status $response = $this->actingAs($john)->get("/api/v4/domains/{$domain->id}/status"); $response->assertStatus(200); $json = $response->json(); $this->assertFalse($json['isVerified']); $this->assertFalse($json['isReady']); $this->assertCount(4, $json['process']); $this->assertSame('domain-verified', $json['process'][2]['label']); $this->assertSame(false, $json['process'][2]['state']); $this->assertTrue(empty($json['status'])); $this->assertTrue(empty($json['message'])); // Now "reboot" the process and verify the domain $response = $this->actingAs($john)->get("/api/v4/domains/{$domain->id}/status?refresh=1"); $response->assertStatus(200); $json = $response->json(); $this->assertTrue($json['isVerified']); $this->assertTrue($json['isReady']); $this->assertCount(4, $json['process']); $this->assertSame('domain-verified', $json['process'][2]['label']); $this->assertSame(true, $json['process'][2]['state']); $this->assertSame('domain-confirmed', $json['process'][3]['label']); $this->assertSame(true, $json['process'][3]['state']); $this->assertSame('success', $json['status']); $this->assertSame('Setup process finished successfully.', $json['message']); // TODO: Test completing all process steps } + + /** + * Test domain creation (POST /api/v4/domains) + */ + public function testStore(): void + { + Queue::fake(); + + $jack = $this->getTestUser('jack@kolab.org'); + $john = $this->getTestUser('john@kolab.org'); + + // Test empty request + $response = $this->actingAs($john)->post("/api/v4/domains", []); + $response->assertStatus(422); + + $json = $response->json(); + + $this->assertSame('error', $json['status']); + $this->assertSame("The namespace field is required.", $json['errors']['namespace'][0]); + $this->assertCount(1, $json['errors']); + $this->assertCount(1, $json['errors']['namespace']); + $this->assertCount(2, $json); + + // Test access by user not being a wallet controller + $post = ['namespace' => 'domainscontroller.com']; + $response = $this->actingAs($jack)->post("/api/v4/domains", $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 = ['namespace' => '--']; + $response = $this->actingAs($john)->post("/api/v4/domains", $post); + $response->assertStatus(422); + + $json = $response->json(); + + $this->assertSame('error', $json['status']); + $this->assertCount(2, $json); + $this->assertSame('The specified domain is invalid.', $json['errors']['namespace'][0]); + $this->assertCount(1, $json['errors']); + $this->assertCount(1, $json['errors']['namespace']); + + // Test an existing domain + $post = ['namespace' => 'kolab.org']; + $response = $this->actingAs($john)->post("/api/v4/domains", $post); + $response->assertStatus(422); + + $json = $response->json(); + + $this->assertSame('error', $json['status']); + $this->assertCount(2, $json); + $this->assertSame('The specified domain is not available.', $json['errors']['namespace']); + + $package_kolab = \App\Package::withEnvTenantContext()->where('title', 'kolab')->first(); + $package_domain = \App\Package::withEnvTenantContext()->where('title', 'domain-hosting')->first(); + + // Missing package + $post = ['namespace' => 'domainscontroller.com']; + $response = $this->actingAs($john)->post("/api/v4/domains", $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_kolab->id; + $response = $this->actingAs($john)->post("/api/v4/domains", $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_domain->id; + $response = $this->actingAs($john)->post("/api/v4/domains", $post); + $json = $response->json(); + + $response->assertStatus(200); + + $this->assertSame('success', $json['status']); + $this->assertSame("Domain created successfully.", $json['message']); + $this->assertCount(2, $json); + + $domain = Domain::where('namespace', $post['namespace'])->first(); + $this->assertInstanceOf(Domain::class, $domain); + + // Assert the new domain entitlements + $this->assertEntitlements($domain, ['domain-hosting']); + + // Assert the wallet to which the new domain should be assigned to + $wallet = $domain->wallet(); + $this->assertSame($john->wallets->first()->id, $wallet->id); + + // Test acting as account controller (not owner) + + $this->markTestIncomplete(); + } } diff --git a/src/tests/Feature/Controller/Reseller/DomainsTest.php b/src/tests/Feature/Controller/Reseller/DomainsTest.php index 78066c18..e5840755 100644 --- a/src/tests/Feature/Controller/Reseller/DomainsTest.php +++ b/src/tests/Feature/Controller/Reseller/DomainsTest.php @@ -1,286 +1,298 @@ deleteTestDomain('domainscontroller.com'); } /** * {@inheritDoc} */ public function tearDown(): void { $this->deleteTestDomain('domainscontroller.com'); parent::tearDown(); } /** * Test domain confirm request */ public function testConfirm(): void { $reseller1 = $this->getTestUser('reseller@' . \config('app.domain')); $domain = $this->getTestDomain('domainscontroller.com', [ 'status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_EXTERNAL, ]); // THe end-point exists on the users controller, but not reseller's $response = $this->actingAs($reseller1)->get("api/v4/domains/{$domain->id}/confirm"); $response->assertStatus(404); } /** * Test domains searching (/api/v4/domains) */ public function testIndex(): void { $user = $this->getTestUser('john@kolab.org'); $admin = $this->getTestUser('jeroen@jeroen.jeroen'); $reseller1 = $this->getTestUser('reseller@' . \config('app.domain')); $reseller2 = $this->getTestUser('reseller@sample-tenant.dev-local'); // Non-admin user $response = $this->actingAs($user)->get("api/v4/domains"); $response->assertStatus(403); // Admin user $response = $this->actingAs($admin)->get("api/v4/domains"); $response->assertStatus(403); // Search with no matches expected $response = $this->actingAs($reseller1)->get("api/v4/domains?search=abcd12.org"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(0, $json['count']); $this->assertSame([], $json['list']); // Search by a domain name $response = $this->actingAs($reseller1)->get("api/v4/domains?search=kolab.org"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(1, $json['count']); $this->assertCount(1, $json['list']); $this->assertSame('kolab.org', $json['list'][0]['namespace']); // Search by owner $response = $this->actingAs($reseller1)->get("api/v4/domains?owner={$user->id}"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(1, $json['count']); $this->assertCount(1, $json['list']); $this->assertSame('kolab.org', $json['list'][0]['namespace']); // Search by owner (Ned is a controller on John's wallets, // here we expect only domains assigned to Ned's wallet(s)) $ned = $this->getTestUser('ned@kolab.org'); $response = $this->actingAs($reseller1)->get("api/v4/domains?owner={$ned->id}"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(0, $json['count']); $this->assertCount(0, $json['list']); // Test unauth access to other tenant's domains $response = $this->actingAs($reseller2)->get("api/v4/domains?search=kolab.org"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(0, $json['count']); $this->assertSame([], $json['list']); $response = $this->actingAs($reseller2)->get("api/v4/domains?owner={$user->id}"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(0, $json['count']); $this->assertSame([], $json['list']); } /** * Test fetching domain info */ public function testShow(): void { $sku_domain = Sku::withEnvTenantContext()->where('title', 'domain-hosting')->first(); $admin = $this->getTestUser('jeroen@jeroen.jeroen'); $user = $this->getTestUser('test1@domainscontroller.com'); $reseller1 = $this->getTestUser('reseller@' . \config('app.domain')); $reseller2 = $this->getTestUser('reseller@sample-tenant.dev-local'); $domain = $this->getTestDomain('domainscontroller.com', [ 'status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_EXTERNAL, ]); Entitlement::create([ 'wallet_id' => $user->wallets()->first()->id, 'sku_id' => $sku_domain->id, 'entitleable_id' => $domain->id, 'entitleable_type' => Domain::class ]); // Unauthorized access (user) $response = $this->actingAs($user)->get("api/v4/domains/{$domain->id}"); $response->assertStatus(403); // Unauthorized access (admin) $response = $this->actingAs($admin)->get("api/v4/domains/{$domain->id}"); $response->assertStatus(403); // Unauthorized access (tenant != env-tenant) $response = $this->actingAs($reseller2)->get("api/v4/domains/{$domain->id}"); $response->assertStatus(404); $response = $this->actingAs($reseller1)->get("api/v4/domains/{$domain->id}"); $response->assertStatus(200); $json = $response->json(); $this->assertEquals($domain->id, $json['id']); $this->assertEquals($domain->namespace, $json['namespace']); $this->assertEquals($domain->status, $json['status']); $this->assertEquals($domain->type, $json['type']); // Note: Other properties are being tested in the user controller tests } /** * Test fetching domain status (GET /api/v4/domains//status) */ public function testStatus(): void { $reseller1 = $this->getTestUser('reseller@' . \config('app.domain')); $domain = $this->getTestDomain('kolab.org'); // This end-point does not exist for resellers $response = $this->actingAs($reseller1)->get("/api/v4/domains/{$domain->id}/status"); $response->assertStatus(404); } + /** + * Test creeating a domain (POST /api/v4/domains) + */ + public function testStore(): void + { + $reseller1 = $this->getTestUser('reseller@' . \config('app.domain')); + + // Resellers can't create domains + $response = $this->actingAs($reseller1)->post("api/v4/domains", []); + $response->assertStatus(404); + } + /** * Test domain suspending (POST /api/v4/domains//suspend) */ public function testSuspend(): void { Queue::fake(); // disable jobs $admin = $this->getTestUser('jeroen@jeroen.jeroen'); $reseller1 = $this->getTestUser('reseller@' . \config('app.domain')); $reseller2 = $this->getTestUser('reseller@sample-tenant.dev-local'); \config(['app.tenant_id' => $reseller2->tenant_id]); $domain = $this->getTestDomain('domainscontroller.com', [ 'status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_EXTERNAL, ]); $user = $this->getTestUser('test@domainscontroller.com'); // Test unauthorized access to the reseller API (user) $response = $this->actingAs($user)->post("/api/v4/domains/{$domain->id}/suspend", []); $response->assertStatus(403); $this->assertFalse($domain->fresh()->isSuspended()); // Test unauthorized access to the reseller API (admin) $response = $this->actingAs($admin)->post("/api/v4/domains/{$domain->id}/suspend", []); $response->assertStatus(403); $this->assertFalse($domain->fresh()->isSuspended()); // Test unauthorized access to the reseller API (reseller in another tenant) $response = $this->actingAs($reseller1)->post("/api/v4/domains/{$domain->id}/suspend", []); $response->assertStatus(404); $this->assertFalse($domain->fresh()->isSuspended()); // Test suspending the domain $response = $this->actingAs($reseller2)->post("/api/v4/domains/{$domain->id}/suspend", []); $response->assertStatus(200); $json = $response->json(); $this->assertSame('success', $json['status']); $this->assertSame("Domain suspended successfully.", $json['message']); $this->assertCount(2, $json); $this->assertTrue($domain->fresh()->isSuspended()); } /** * Test user un-suspending (POST /api/v4/users//unsuspend) */ public function testUnsuspend(): void { Queue::fake(); // disable jobs $admin = $this->getTestUser('jeroen@jeroen.jeroen'); $reseller1 = $this->getTestUser('reseller@' . \config('app.domain')); $reseller2 = $this->getTestUser('reseller@sample-tenant.dev-local'); \config(['app.tenant_id' => $reseller2->tenant_id]); $domain = $this->getTestDomain('domainscontroller.com', [ 'status' => Domain::STATUS_NEW | Domain::STATUS_SUSPENDED, 'type' => Domain::TYPE_EXTERNAL, ]); $user = $this->getTestUser('test@domainscontroller.com'); // Test unauthorized access to reseller API (user) $response = $this->actingAs($user)->post("/api/v4/domains/{$domain->id}/unsuspend", []); $response->assertStatus(403); $this->assertTrue($domain->fresh()->isSuspended()); // Test unauthorized access to reseller API (admin) $response = $this->actingAs($admin)->post("/api/v4/domains/{$domain->id}/unsuspend", []); $response->assertStatus(403); $this->assertTrue($domain->fresh()->isSuspended()); // Test unauthorized access to reseller API (another tenant) $response = $this->actingAs($reseller1)->post("/api/v4/domains/{$domain->id}/unsuspend", []); $response->assertStatus(404); $this->assertTrue($domain->fresh()->isSuspended()); // Test suspending the user $response = $this->actingAs($reseller2)->post("/api/v4/domains/{$domain->id}/unsuspend", []); $response->assertStatus(200); $json = $response->json(); $this->assertSame('success', $json['status']); $this->assertSame("Domain unsuspended successfully.", $json['message']); $this->assertCount(2, $json); $this->assertFalse($domain->fresh()->isSuspended()); } } diff --git a/src/tests/Feature/Controller/Reseller/SkusTest.php b/src/tests/Feature/Controller/Reseller/SkusTest.php index f841c64e..392c5d4e 100644 --- a/src/tests/Feature/Controller/Reseller/SkusTest.php +++ b/src/tests/Feature/Controller/Reseller/SkusTest.php @@ -1,136 +1,173 @@ delete(); $this->clearBetaEntitlements(); $this->clearMeetEntitlements(); } /** * {@inheritDoc} */ public function tearDown(): void { Sku::where('title', 'test')->delete(); $this->clearBetaEntitlements(); $this->clearMeetEntitlements(); parent::tearDown(); } + /** + * Test fetching SKUs list for a domain (GET /domains//skus) + */ + public function testDomainSkus(): void + { + $reseller1 = $this->getTestUser('reseller@' . \config('app.domain')); + $reseller2 = $this->getTestUser('reseller@sample-tenant.dev-local'); + $admin = $this->getTestUser('jeroen@jeroen.jeroen'); + $user = $this->getTestUser('john@kolab.org'); + $domain = $this->getTestDomain('kolab.org'); + + // Unauth access not allowed + $response = $this->get("api/v4/domains/{$domain->id}/skus"); + $response->assertStatus(401); + + // User access not allowed + $response = $this->actingAs($user)->get("api/v4/domains/{$domain->id}/skus"); + $response->assertStatus(403); + + // Admin access not allowed + $response = $this->actingAs($admin)->get("api/v4/domains/{$domain->id}/skus"); + $response->assertStatus(403); + + // Reseller from another tenant + $response = $this->actingAs($reseller2)->get("api/v4/domains/{$domain->id}/skus"); + $response->assertStatus(404); + + // Reseller access + $response = $this->actingAs($reseller1)->get("api/v4/domains/{$domain->id}/skus"); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertCount(1, $json); + // Note: Details are tested where we test API\V4\SkusController + } + /** * Test fetching SKUs list */ public function testIndex(): void { $reseller1 = $this->getTestUser('reseller@' . \config('app.domain')); $reseller2 = $this->getTestUser('reseller@sample-tenant.dev-local'); $admin = $this->getTestUser('jeroen@jeroen.jeroen'); $user = $this->getTestUser('john@kolab.org'); $sku = Sku::withEnvTenantContext()->where('title', 'mailbox')->first(); // Unauth access not allowed $response = $this->get("api/v4/skus"); $response->assertStatus(401); // User access not allowed $response = $this->actingAs($user)->get("api/v4/skus"); $response->assertStatus(403); // Admin access not allowed $response = $this->actingAs($admin)->get("api/v4/skus"); $response->assertStatus(403); $response = $this->actingAs($reseller1)->get("api/v4/skus"); $response->assertStatus(200); $json = $response->json(); $this->assertCount(9, $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 with another tenant $sku = Sku::where('title', 'mailbox')->where('tenant_id', $reseller2->tenant_id)->first(); $response = $this->actingAs($reseller2)->get("api/v4/skus"); $response->assertStatus(200); $json = $response->json(); $this->assertCount(6, $json); $this->assertSame(100, $json[0]['prio']); $this->assertSame($sku->id, $json[0]['id']); $this->assertSame($sku->title, $json[0]['title']); $this->assertSame($sku->name, $json[0]['name']); $this->assertSame($sku->description, $json[0]['description']); $this->assertSame($sku->cost, $json[0]['cost']); $this->assertSame($sku->units_free, $json[0]['units_free']); $this->assertSame($sku->period, $json[0]['period']); $this->assertSame($sku->active, $json[0]['active']); $this->assertSame('user', $json[0]['type']); $this->assertSame('mailbox', $json[0]['handler']); } /** * Test fetching SKUs list for a user (GET /users//skus) */ public function testUserSkus(): void { $reseller1 = $this->getTestUser('reseller@' . \config('app.domain')); $reseller2 = $this->getTestUser('reseller@sample-tenant.dev-local'); $admin = $this->getTestUser('jeroen@jeroen.jeroen'); $user = $this->getTestUser('john@kolab.org'); // Unauth access not allowed $response = $this->get("api/v4/users/{$user->id}/skus"); $response->assertStatus(401); // User access not allowed $response = $this->actingAs($user)->get("api/v4/users/{$user->id}/skus"); $response->assertStatus(403); // Admin access not allowed $response = $this->actingAs($admin)->get("api/v4/users/{$user->id}/skus"); $response->assertStatus(403); // Reseller from another tenant $response = $this->actingAs($reseller2)->get("api/v4/users/{$user->id}/skus"); $response->assertStatus(404); // Reseller access $response = $this->actingAs($reseller1)->get("api/v4/users/{$user->id}/skus"); $response->assertStatus(200); $json = $response->json(); - $this->assertCount(8, $json); + $this->assertCount(6, $json); // Note: Details are tested where we test API\V4\SkusController } } diff --git a/src/tests/Feature/Controller/SkusTest.php b/src/tests/Feature/Controller/SkusTest.php index 441783a2..1fcb7894 100644 --- a/src/tests/Feature/Controller/SkusTest.php +++ b/src/tests/Feature/Controller/SkusTest.php @@ -1,249 +1,264 @@ clearBetaEntitlements(); $this->clearMeetEntitlements(); Sku::where('title', 'test')->delete(); } /** * {@inheritDoc} */ public function tearDown(): void { $this->clearBetaEntitlements(); $this->clearMeetEntitlements(); Sku::where('title', 'test')->delete(); parent::tearDown(); } + /** + * Test fetching SKUs list for a domain (GET /domains//skus) + */ + public function testDomainSkus(): void + { + $user = $this->getTestUser('john@kolab.org'); + $domain = $this->getTestDomain('kolab.org'); + + // Unauth access not allowed + $response = $this->get("api/v4/domains/{$domain->id}/skus"); + $response->assertStatus(401); + + // Create an sku for another tenant, to make sure it is not included in the result + $nsku = Sku::create([ + 'title' => 'test', + 'name' => 'Test', + 'description' => '', + 'active' => true, + 'cost' => 100, + 'handler_class' => 'App\Handlers\Domain', + ]); + $tenant = Tenant::whereNotIn('id', [\config('app.tenant_id')])->first(); + $nsku->tenant_id = $tenant->id; + $nsku->save(); + + $response = $this->actingAs($user)->get("api/v4/domains/{$domain->id}/skus"); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertCount(1, $json); + + $this->assertSkuElement('domain-hosting', $json[0], [ + 'prio' => 0, + 'type' => 'domain', + 'handler' => 'domainhosting', + 'enabled' => false, + 'readonly' => false, + ]); + } /** * 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::withEnvTenantContext()->where('title', 'mailbox')->first(); // Create an sku for another tenant, to make sure it is not included in the result $nsku = Sku::create([ 'title' => 'test', 'name' => 'Test', 'description' => '', 'active' => true, 'cost' => 100, 'handler_class' => 'App\Handlers\Mailbox', ]); $tenant = Tenant::whereNotIn('id', [\config('app.tenant_id')])->first(); $nsku->tenant_id = $tenant->id; $nsku->save(); $response = $this->actingAs($user)->get("api/v4/skus"); $response->assertStatus(200); $json = $response->json(); $this->assertCount(9, $json); $this->assertSame(100, $json[0]['prio']); $this->assertSame($sku->id, $json[0]['id']); $this->assertSame($sku->title, $json[0]['title']); $this->assertSame($sku->name, $json[0]['name']); $this->assertSame($sku->description, $json[0]['description']); $this->assertSame($sku->cost, $json[0]['cost']); $this->assertSame($sku->units_free, $json[0]['units_free']); $this->assertSame($sku->period, $json[0]['period']); $this->assertSame($sku->active, $json[0]['active']); $this->assertSame('user', $json[0]['type']); $this->assertSame('mailbox', $json[0]['handler']); } /** * Test fetching SKUs list for a user (GET /users//skus) */ public function testUserSkus(): void { $user = $this->getTestUser('john@kolab.org'); // Unauth access not allowed $response = $this->get("api/v4/users/{$user->id}/skus"); $response->assertStatus(401); // Create an sku for another tenant, to make sure it is not included in the result $nsku = Sku::create([ 'title' => 'test', 'name' => 'Test', 'description' => '', 'active' => true, 'cost' => 100, 'handler_class' => 'App\Handlers\Mailbox', ]); $tenant = Tenant::whereNotIn('id', [\config('app.tenant_id')])->first(); $nsku->tenant_id = $tenant->id; $nsku->save(); $response = $this->actingAs($user)->get("api/v4/users/{$user->id}/skus"); $response->assertStatus(200); $json = $response->json(); - $this->assertCount(8, $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' => 5, '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('meet', $json[5], [ 'prio' => 50, 'type' => 'user', 'handler' => 'meet', 'enabled' => false, 'readonly' => false, 'required' => ['groupware'], ]); - $this->assertSkuElement('domain-hosting', $json[6], [ - 'prio' => 0, - 'type' => 'domain', - 'handler' => 'domainhosting', - 'enabled' => false, - 'readonly' => false, - ]); - - $this->assertSkuElement('group', $json[7], [ - 'prio' => 0, - 'type' => 'group', - 'handler' => 'group', - '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::withEnvTenantContext()->where('title', 'beta')->first(); $user->assignSku($sku); - $response = $this->actingAs($user)->get("api/v4/users/{$user->id}/skus?type=user"); + $response = $this->actingAs($user)->get("api/v4/users/{$user->id}/skus"); $response->assertStatus(200); $json = $response->json(); $this->assertCount(8, $json); - $this->assertSkuElement('meet', $json[5], [ - 'prio' => 50, + $this->assertSkuElement('beta', $json[6], [ + 'prio' => 10, 'type' => 'user', - 'handler' => 'meet', + 'handler' => 'beta', 'enabled' => false, 'readonly' => false, - 'required' => ['groupware'], ]); - $this->assertSkuElement('beta', $json[6], [ + $this->assertSkuElement('distlist', $json[7], [ 'prio' => 10, 'type' => 'user', - 'handler' => 'beta', + 'handler' => 'distlist', '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::withEnvTenantContext()->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']); foreach ($other as $key => $value) { $this->assertSame($value, $result[$key]); } $this->assertCount(8 + count($other), $result); } } diff --git a/src/tests/Feature/Controller/UsersTest.php b/src/tests/Feature/Controller/UsersTest.php index 13ee461a..e00019b6 100644 --- a/src/tests/Feature/Controller/UsersTest.php +++ b/src/tests/Feature/Controller/UsersTest.php @@ -1,1357 +1,1357 @@ deleteTestUser('jane@kolabnow.com'); $this->deleteTestUser('UsersControllerTest1@userscontroller.com'); $this->deleteTestUser('UsersControllerTest2@userscontroller.com'); $this->deleteTestUser('UsersControllerTest3@userscontroller.com'); $this->deleteTestUser('UserEntitlement2A@UserEntitlement.com'); $this->deleteTestUser('john2.doe2@kolab.org'); $this->deleteTestUser('deleted@kolab.org'); $this->deleteTestUser('deleted@kolabnow.com'); $this->deleteTestDomain('userscontroller.com'); $this->deleteTestGroup('group-test@kolabnow.com'); $this->deleteTestGroup('group-test@kolab.org'); $user = $this->getTestUser('john@kolab.org'); $wallet = $user->wallets()->first(); $wallet->discount()->dissociate(); $wallet->settings()->whereIn('key', ['mollie_id', 'stripe_id'])->delete(); $wallet->save(); $user->status |= User::STATUS_IMAP_READY; $user->save(); } /** * {@inheritDoc} */ public function tearDown(): void { $this->deleteTestUser('jane@kolabnow.com'); $this->deleteTestUser('UsersControllerTest1@userscontroller.com'); $this->deleteTestUser('UsersControllerTest2@userscontroller.com'); $this->deleteTestUser('UsersControllerTest3@userscontroller.com'); $this->deleteTestUser('UserEntitlement2A@UserEntitlement.com'); $this->deleteTestUser('john2.doe2@kolab.org'); $this->deleteTestUser('deleted@kolab.org'); $this->deleteTestUser('deleted@kolabnow.com'); $this->deleteTestDomain('userscontroller.com'); $this->deleteTestGroup('group-test@kolabnow.com'); $this->deleteTestGroup('group-test@kolab.org'); $user = $this->getTestUser('john@kolab.org'); $wallet = $user->wallets()->first(); $wallet->discount()->dissociate(); $wallet->settings()->whereIn('key', ['mollie_id', 'stripe_id'])->delete(); $wallet->save(); $user->settings()->whereIn('key', ['greylist_enabled'])->delete(); $user->status |= User::STATUS_IMAP_READY; $user->save(); parent::tearDown(); } /** * Test user deleting (DELETE /api/v4/users/) */ 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->assertTrue($json['config']['greylist_enabled']); $this->assertSame([], $json['skus']); // Values below are tested by Unit tests $this->assertArrayHasKey('isDeleted', $json); $this->assertArrayHasKey('isSuspended', $json); $this->assertArrayHasKey('isActive', $json); $this->assertArrayHasKey('isLdapReady', $json); $this->assertArrayHasKey('isImapReady', $json); $john = $this->getTestUser('john@kolab.org'); $jack = $this->getTestUser('jack@kolab.org'); $ned = $this->getTestUser('ned@kolab.org'); // Test unauthorized access to a profile of other user $response = $this->actingAs($jack)->get("/api/v4/users/{$userA->id}"); $response->assertStatus(403); // Test authorized access to a profile of other user // Ned: Additional account controller $response = $this->actingAs($ned)->get("/api/v4/users/{$john->id}"); $response->assertStatus(200); $response = $this->actingAs($ned)->get("/api/v4/users/{$jack->id}"); $response->assertStatus(200); // John: Account owner $response = $this->actingAs($john)->get("/api/v4/users/{$jack->id}"); $response->assertStatus(200); $response = $this->actingAs($john)->get("/api/v4/users/{$ned->id}"); $response->assertStatus(200); $json = $response->json(); $storage_sku = Sku::withEnvTenantContext()->where('title', 'storage')->first(); $groupware_sku = Sku::withEnvTenantContext()->where('title', 'groupware')->first(); $mailbox_sku = Sku::withEnvTenantContext()->where('title', 'mailbox')->first(); $secondfactor_sku = Sku::withEnvTenantContext()->where('title', '2fa')->first(); $this->assertCount(5, $json['skus']); $this->assertSame(5, $json['skus'][$storage_sku->id]['count']); $this->assertSame([0,0,0,0,0], $json['skus'][$storage_sku->id]['costs']); $this->assertSame(1, $json['skus'][$groupware_sku->id]['count']); $this->assertSame([490], $json['skus'][$groupware_sku->id]['costs']); $this->assertSame(1, $json['skus'][$mailbox_sku->id]['count']); $this->assertSame([500], $json['skus'][$mailbox_sku->id]['costs']); $this->assertSame(1, $json['skus'][$secondfactor_sku->id]['count']); $this->assertSame([0], $json['skus'][$secondfactor_sku->id]['costs']); } /** * Test fetching user status (GET /api/v4/users//status) * and forcing setup process update (?refresh=1) * * @group imap * @group dns */ public function testStatus(): void { Queue::fake(); $john = $this->getTestUser('john@kolab.org'); $jack = $this->getTestUser('jack@kolab.org'); // Test unauthorized access $response = $this->actingAs($jack)->get("/api/v4/users/{$john->id}/status"); $response->assertStatus(403); if ($john->isImapReady()) { $john->status ^= User::STATUS_IMAP_READY; $john->save(); } // Get user status $response = $this->actingAs($john)->get("/api/v4/users/{$john->id}/status"); $response->assertStatus(200); $json = $response->json(); $this->assertFalse($json['isImapReady']); $this->assertFalse($json['isReady']); $this->assertCount(7, $json['process']); $this->assertSame('user-imap-ready', $json['process'][2]['label']); $this->assertSame(false, $json['process'][2]['state']); $this->assertTrue(empty($json['status'])); $this->assertTrue(empty($json['message'])); // Make sure the domain is confirmed (other test might unset that status) $domain = $this->getTestDomain('kolab.org'); $domain->status |= Domain::STATUS_CONFIRMED; $domain->save(); // Now "reboot" the process and verify the user in imap synchronously $response = $this->actingAs($john)->get("/api/v4/users/{$john->id}/status?refresh=1"); $response->assertStatus(200); $json = $response->json(); $this->assertTrue($json['isImapReady']); $this->assertTrue($json['isReady']); $this->assertCount(7, $json['process']); $this->assertSame('user-imap-ready', $json['process'][2]['label']); $this->assertSame(true, $json['process'][2]['state']); $this->assertSame('success', $json['status']); $this->assertSame('Setup process finished successfully.', $json['message']); Queue::size(1); // Test case for when the verify job is dispatched to the worker $john->refresh(); $john->status ^= User::STATUS_IMAP_READY; $john->save(); \config(['imap.admin_password' => null]); $response = $this->actingAs($john)->get("/api/v4/users/{$john->id}/status?refresh=1"); $response->assertStatus(200); $json = $response->json(); $this->assertFalse($json['isImapReady']); $this->assertFalse($json['isReady']); $this->assertSame('success', $json['status']); $this->assertSame('waiting', $json['processState']); $this->assertSame('Setup process has been pushed. Please wait.', $json['message']); Queue::assertPushed(\App\Jobs\User\VerifyJob::class, 1); } /** * Test UsersController::statusInfo() */ public function testStatusInfo(): void { $user = $this->getTestUser('UsersControllerTest1@userscontroller.com'); $domain = $this->getTestDomain('userscontroller.com', [ 'status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_PUBLIC, ]); $user->created_at = Carbon::now(); $user->status = User::STATUS_NEW; $user->save(); $result = UsersController::statusInfo($user); $this->assertFalse($result['isReady']); $this->assertSame([], $result['skus']); $this->assertCount(3, $result['process']); $this->assertSame('user-new', $result['process'][0]['label']); $this->assertSame(true, $result['process'][0]['state']); $this->assertSame('user-ldap-ready', $result['process'][1]['label']); $this->assertSame(false, $result['process'][1]['state']); $this->assertSame('user-imap-ready', $result['process'][2]['label']); $this->assertSame(false, $result['process'][2]['state']); $this->assertSame('running', $result['processState']); $user->created_at = Carbon::now()->subSeconds(181); $user->save(); $result = UsersController::statusInfo($user); $this->assertSame('failed', $result['processState']); $user->status |= User::STATUS_LDAP_READY | User::STATUS_IMAP_READY; $user->save(); $result = UsersController::statusInfo($user); $this->assertTrue($result['isReady']); $this->assertCount(3, $result['process']); $this->assertSame('user-new', $result['process'][0]['label']); $this->assertSame(true, $result['process'][0]['state']); $this->assertSame('user-ldap-ready', $result['process'][1]['label']); $this->assertSame(true, $result['process'][1]['state']); $this->assertSame('user-imap-ready', $result['process'][2]['label']); $this->assertSame(true, $result['process'][2]['state']); $this->assertSame('done', $result['processState']); $domain->status |= Domain::STATUS_VERIFIED; $domain->type = Domain::TYPE_EXTERNAL; $domain->save(); $result = UsersController::statusInfo($user); $this->assertFalse($result['isReady']); $this->assertSame([], $result['skus']); $this->assertCount(7, $result['process']); $this->assertSame('user-new', $result['process'][0]['label']); $this->assertSame(true, $result['process'][0]['state']); $this->assertSame('user-ldap-ready', $result['process'][1]['label']); $this->assertSame(true, $result['process'][1]['state']); $this->assertSame('user-imap-ready', $result['process'][2]['label']); $this->assertSame(true, $result['process'][2]['state']); $this->assertSame('domain-new', $result['process'][3]['label']); $this->assertSame(true, $result['process'][3]['state']); $this->assertSame('domain-ldap-ready', $result['process'][4]['label']); $this->assertSame(false, $result['process'][4]['state']); $this->assertSame('domain-verified', $result['process'][5]['label']); $this->assertSame(true, $result['process'][5]['state']); $this->assertSame('domain-confirmed', $result['process'][6]['label']); $this->assertSame(false, $result['process'][6]['state']); // Test 'skus' property $user->assignSku(Sku::withEnvTenantContext()->where('title', 'beta')->first()); $result = UsersController::statusInfo($user); $this->assertSame(['beta'], $result['skus']); $user->assignSku(Sku::withEnvTenantContext()->where('title', 'meet')->first()); $result = UsersController::statusInfo($user); $this->assertSame(['beta', 'meet'], $result['skus']); $user->assignSku(Sku::withEnvTenantContext()->where('title', 'meet')->first()); $result = UsersController::statusInfo($user); $this->assertSame(['beta', 'meet'], $result['skus']); } /** * Test user config update (POST /api/v4/users//config) */ public function testSetConfig(): void { $jack = $this->getTestUser('jack@kolab.org'); $john = $this->getTestUser('john@kolab.org'); $john->setSetting('greylist_enabled', null); // Test unknown user id $post = ['greylist_enabled' => 1]; $response = $this->actingAs($john)->post("/api/v4/users/123/config", $post); $json = $response->json(); $response->assertStatus(404); // Test access by user not being a wallet controller $post = ['greylist_enabled' => 1]; $response = $this->actingAs($jack)->post("/api/v4/users/{$john->id}/config", $post); $json = $response->json(); $response->assertStatus(403); $this->assertSame('error', $json['status']); $this->assertSame("Access denied", $json['message']); $this->assertCount(2, $json); // Test some invalid data $post = ['grey' => 1]; $response = $this->actingAs($john)->post("/api/v4/users/{$john->id}/config", $post); $response->assertStatus(422); $json = $response->json(); $this->assertSame('error', $json['status']); $this->assertCount(2, $json); $this->assertCount(1, $json['errors']); $this->assertSame('The requested configuration parameter is not supported.', $json['errors']['grey']); $this->assertNull($john->fresh()->getSetting('greylist_enabled')); // Test some valid data $post = ['greylist_enabled' => 1]; $response = $this->actingAs($john)->post("/api/v4/users/{$john->id}/config", $post); $response->assertStatus(200); $json = $response->json(); $this->assertCount(2, $json); $this->assertSame('success', $json['status']); $this->assertSame('User settings updated successfully.', $json['message']); $this->assertSame('true', $john->fresh()->getSetting('greylist_enabled')); // Test some valid data $post = ['greylist_enabled' => 0]; $response = $this->actingAs($john)->post("/api/v4/users/{$john->id}/config", $post); $response->assertStatus(200); $json = $response->json(); $this->assertCount(2, $json); $this->assertSame('success', $json['status']); $this->assertSame('User settings updated successfully.', $json['message']); $this->assertSame('false', $john->fresh()->getSetting('greylist_enabled')); } /** * Test user creation (POST /api/v4/users) */ public function testStore(): void { Queue::fake(); $jack = $this->getTestUser('jack@kolab.org'); $john = $this->getTestUser('john@kolab.org'); $deleted_priv = $this->getTestUser('deleted@kolab.org'); $deleted_priv->delete(); // Test empty request $response = $this->actingAs($john)->post("/api/v4/users", []); $response->assertStatus(422); $json = $response->json(); $this->assertSame('error', $json['status']); $this->assertSame("The email field is required.", $json['errors']['email']); $this->assertSame("The password field is required.", $json['errors']['password'][0]); $this->assertCount(2, $json); // Test access by user not being a wallet controller $post = ['first_name' => 'Test']; $response = $this->actingAs($jack)->post("/api/v4/users", $post); $json = $response->json(); $response->assertStatus(403); $this->assertSame('error', $json['status']); $this->assertSame("Access denied", $json['message']); $this->assertCount(2, $json); // Test some invalid data $post = ['password' => '12345678', 'email' => 'invalid']; $response = $this->actingAs($john)->post("/api/v4/users", $post); $response->assertStatus(422); $json = $response->json(); $this->assertSame('error', $json['status']); $this->assertCount(2, $json); $this->assertSame('The password confirmation does not match.', $json['errors']['password'][0]); $this->assertSame('The specified email is invalid.', $json['errors']['email']); // Test existing user email $post = [ 'password' => 'simple', 'password_confirmation' => 'simple', 'first_name' => 'John2', 'last_name' => 'Doe2', 'email' => 'jack.daniels@kolab.org', ]; $response = $this->actingAs($john)->post("/api/v4/users", $post); $response->assertStatus(422); $json = $response->json(); $this->assertSame('error', $json['status']); $this->assertCount(2, $json); $this->assertSame('The specified email is not available.', $json['errors']['email']); $package_kolab = \App\Package::withEnvTenantContext()->where('title', 'kolab')->first(); $package_domain = \App\Package::withEnvTenantContext()->where('title', 'domain-hosting')->first(); $post = [ 'password' => 'simple', 'password_confirmation' => 'simple', 'first_name' => 'John2', 'last_name' => 'Doe2', 'email' => 'john2.doe2@kolab.org', 'organization' => 'TestOrg', 'aliases' => ['useralias1@kolab.org', 'deleted@kolab.org'], ]; // Missing package $response = $this->actingAs($john)->post("/api/v4/users", $post); $json = $response->json(); $response->assertStatus(422); $this->assertSame('error', $json['status']); $this->assertSame("Package is required.", $json['errors']['package']); $this->assertCount(2, $json); // Invalid package $post['package'] = $package_domain->id; $response = $this->actingAs($john)->post("/api/v4/users", $post); $json = $response->json(); $response->assertStatus(422); $this->assertSame('error', $json['status']); $this->assertSame("Invalid package selected.", $json['errors']['package']); $this->assertCount(2, $json); // Test full and valid data $post['package'] = $package_kolab->id; $response = $this->actingAs($john)->post("/api/v4/users", $post); $json = $response->json(); $response->assertStatus(200); $this->assertSame('success', $json['status']); $this->assertSame("User created successfully.", $json['message']); $this->assertCount(2, $json); $user = User::where('email', 'john2.doe2@kolab.org')->first(); $this->assertInstanceOf(User::class, $user); $this->assertSame('John2', $user->getSetting('first_name')); $this->assertSame('Doe2', $user->getSetting('last_name')); $this->assertSame('TestOrg', $user->getSetting('organization')); $aliases = $user->aliases()->orderBy('alias')->get(); $this->assertCount(2, $aliases); $this->assertSame('deleted@kolab.org', $aliases[0]->alias); $this->assertSame('useralias1@kolab.org', $aliases[1]->alias); // Assert the new user entitlements - $this->assertUserEntitlements($user, ['groupware', 'mailbox', + $this->assertEntitlements($user, ['groupware', 'mailbox', 'storage', 'storage', 'storage', 'storage', 'storage']); // Assert the wallet to which the new user should be assigned to $wallet = $user->wallet(); $this->assertSame($john->wallets()->first()->id, $wallet->id); // Attempt to create a user previously deleted $user->delete(); $post['package'] = $package_kolab->id; $post['aliases'] = []; $response = $this->actingAs($john)->post("/api/v4/users", $post); $json = $response->json(); $response->assertStatus(200); $this->assertSame('success', $json['status']); $this->assertSame("User created successfully.", $json['message']); $this->assertCount(2, $json); $user = User::where('email', 'john2.doe2@kolab.org')->first(); $this->assertInstanceOf(User::class, $user); $this->assertSame('John2', $user->getSetting('first_name')); $this->assertSame('Doe2', $user->getSetting('last_name')); $this->assertSame('TestOrg', $user->getSetting('organization')); $this->assertCount(0, $user->aliases()->get()); - $this->assertUserEntitlements($user, ['groupware', 'mailbox', + $this->assertEntitlements($user, ['groupware', 'mailbox', 'storage', 'storage', 'storage', 'storage', 'storage']); // Test acting as account controller (not owner) $this->markTestIncomplete(); } /** * Test user update (PUT /api/v4/users/) */ public function testUpdate(): void { $userA = $this->getTestUser('UsersControllerTest1@userscontroller.com'); $jack = $this->getTestUser('jack@kolab.org'); $john = $this->getTestUser('john@kolab.org'); $ned = $this->getTestUser('ned@kolab.org'); $domain = $this->getTestDomain( 'userscontroller.com', ['status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_EXTERNAL] ); // Test unauthorized update of other user profile $response = $this->actingAs($jack)->get("/api/v4/users/{$userA->id}", []); $response->assertStatus(403); // Test authorized update of account owner by account controller $response = $this->actingAs($ned)->get("/api/v4/users/{$john->id}", []); $response->assertStatus(200); // Test updating of self (empty request) $response = $this->actingAs($userA)->put("/api/v4/users/{$userA->id}", []); $response->assertStatus(200); $json = $response->json(); $this->assertSame('success', $json['status']); $this->assertSame("User data updated successfully.", $json['message']); $this->assertTrue(!empty($json['statusInfo'])); $this->assertCount(3, $json); // Test some invalid data $post = ['password' => '12345678', 'currency' => 'invalid']; $response = $this->actingAs($userA)->put("/api/v4/users/{$userA->id}", $post); $response->assertStatus(422); $json = $response->json(); $this->assertSame('error', $json['status']); $this->assertCount(2, $json); $this->assertSame('The password confirmation does not match.', $json['errors']['password'][0]); $this->assertSame('The currency must be 3 characters.', $json['errors']['currency'][0]); // Test full profile update including password $post = [ 'password' => 'simple', 'password_confirmation' => 'simple', 'first_name' => 'John2', 'last_name' => 'Doe2', 'organization' => 'TestOrg', 'phone' => '+123 123 123', 'external_email' => 'external@gmail.com', 'billing_address' => 'billing', 'country' => 'CH', 'currency' => 'CHF', 'aliases' => ['useralias1@' . \config('app.domain'), 'useralias2@' . \config('app.domain')] ]; $response = $this->actingAs($userA)->put("/api/v4/users/{$userA->id}", $post); $json = $response->json(); $response->assertStatus(200); $this->assertSame('success', $json['status']); $this->assertSame("User data updated successfully.", $json['message']); $this->assertTrue(!empty($json['statusInfo'])); $this->assertCount(3, $json); $this->assertTrue($userA->password != $userA->fresh()->password); unset($post['password'], $post['password_confirmation'], $post['aliases']); foreach ($post as $key => $value) { $this->assertSame($value, $userA->getSetting($key)); } $aliases = $userA->aliases()->orderBy('alias')->get(); $this->assertCount(2, $aliases); $this->assertSame('useralias1@' . \config('app.domain'), $aliases[0]->alias); $this->assertSame('useralias2@' . \config('app.domain'), $aliases[1]->alias); // Test unsetting values $post = [ 'first_name' => '', 'last_name' => '', 'organization' => '', 'phone' => '', 'external_email' => '', 'billing_address' => '', 'country' => '', 'currency' => '', 'aliases' => ['useralias2@' . \config('app.domain')] ]; $response = $this->actingAs($userA)->put("/api/v4/users/{$userA->id}", $post); $json = $response->json(); $response->assertStatus(200); $this->assertSame('success', $json['status']); $this->assertSame("User data updated successfully.", $json['message']); $this->assertTrue(!empty($json['statusInfo'])); $this->assertCount(3, $json); unset($post['aliases']); foreach ($post as $key => $value) { $this->assertNull($userA->getSetting($key)); } $aliases = $userA->aliases()->get(); $this->assertCount(1, $aliases); $this->assertSame('useralias2@' . \config('app.domain'), $aliases[0]->alias); // Test error on some invalid aliases missing password confirmation $post = [ 'password' => 'simple123', 'aliases' => [ 'useralias2@' . \config('app.domain'), 'useralias1@kolab.org', '@kolab.org', ] ]; $response = $this->actingAs($userA)->put("/api/v4/users/{$userA->id}", $post); $json = $response->json(); $response->assertStatus(422); $this->assertSame('error', $json['status']); $this->assertCount(2, $json['errors']); $this->assertCount(2, $json['errors']['aliases']); $this->assertSame("The specified domain is not available.", $json['errors']['aliases'][1]); $this->assertSame("The specified alias is invalid.", $json['errors']['aliases'][2]); $this->assertSame("The password confirmation does not match.", $json['errors']['password'][0]); // Test authorized update of other user $response = $this->actingAs($ned)->put("/api/v4/users/{$jack->id}", []); $response->assertStatus(200); $json = $response->json(); $this->assertTrue(empty($json['statusInfo'])); // TODO: Test error on aliases with invalid/non-existing/other-user's domain // Create entitlements and additional user for following tests $owner = $this->getTestUser('UsersControllerTest1@userscontroller.com'); $user = $this->getTestUser('UsersControllerTest2@userscontroller.com'); $package_domain = Package::withEnvTenantContext()->where('title', 'domain-hosting')->first(); $package_kolab = Package::withEnvTenantContext()->where('title', 'kolab')->first(); $package_lite = Package::withEnvTenantContext()->where('title', 'lite')->first(); $sku_mailbox = Sku::withEnvTenantContext()->where('title', 'mailbox')->first(); $sku_storage = Sku::withEnvTenantContext()->where('title', 'storage')->first(); $sku_groupware = Sku::withEnvTenantContext()->where('title', 'groupware')->first(); $domain = $this->getTestDomain( 'userscontroller.com', [ 'status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_EXTERNAL, ] ); $domain->assignPackage($package_domain, $owner); $owner->assignPackage($package_kolab); $owner->assignPackage($package_lite, $user); // Non-controller cannot update his own entitlements $post = ['skus' => []]; $response = $this->actingAs($user)->put("/api/v4/users/{$user->id}", $post); $response->assertStatus(422); // Test updating entitlements $post = [ 'skus' => [ $sku_mailbox->id => 1, $sku_storage->id => 6, $sku_groupware->id => 1, ], ]; $response = $this->actingAs($owner)->put("/api/v4/users/{$user->id}", $post); $response->assertStatus(200); $json = $response->json(); $storage_cost = $user->entitlements() ->where('sku_id', $sku_storage->id) ->orderBy('cost') ->pluck('cost')->all(); - $this->assertUserEntitlements( + $this->assertEntitlements( $user, ['groupware', 'mailbox', 'storage', 'storage', 'storage', 'storage', 'storage', 'storage'] ); $this->assertSame([0, 0, 0, 0, 0, 25], $storage_cost); $this->assertTrue(empty($json['statusInfo'])); } /** * Test UsersController::updateEntitlements() */ public function testUpdateEntitlements(): void { $jane = $this->getTestUser('jane@kolabnow.com'); $kolab = Package::withEnvTenantContext()->where('title', 'kolab')->first(); $storage = Sku::withEnvTenantContext()->where('title', 'storage')->first(); $activesync = Sku::withEnvTenantContext()->where('title', 'activesync')->first(); $groupware = Sku::withEnvTenantContext()->where('title', 'groupware')->first(); $mailbox = Sku::withEnvTenantContext()->where('title', 'mailbox')->first(); // standard package, 1 mailbox, 1 groupware, 2 storage $jane->assignPackage($kolab); // add 2 storage, 1 activesync $post = [ 'skus' => [ $mailbox->id => 1, $groupware->id => 1, $storage->id => 7, $activesync->id => 1 ] ]; $response = $this->actingAs($jane)->put("/api/v4/users/{$jane->id}", $post); $response->assertStatus(200); - $this->assertUserEntitlements( + $this->assertEntitlements( $jane, [ 'activesync', 'groupware', 'mailbox', 'storage', 'storage', 'storage', 'storage', 'storage', 'storage', 'storage' ] ); // add 2 storage, remove 1 activesync $post = [ 'skus' => [ $mailbox->id => 1, $groupware->id => 1, $storage->id => 9, $activesync->id => 0 ] ]; $response = $this->actingAs($jane)->put("/api/v4/users/{$jane->id}", $post); $response->assertStatus(200); - $this->assertUserEntitlements( + $this->assertEntitlements( $jane, [ 'groupware', 'mailbox', 'storage', 'storage', 'storage', 'storage', 'storage', 'storage', 'storage', 'storage', 'storage' ] ); // add mailbox $post = [ 'skus' => [ $mailbox->id => 2, $groupware->id => 1, $storage->id => 9, $activesync->id => 0 ] ]; $response = $this->actingAs($jane)->put("/api/v4/users/{$jane->id}", $post); $response->assertStatus(500); - $this->assertUserEntitlements( + $this->assertEntitlements( $jane, [ 'groupware', 'mailbox', 'storage', 'storage', 'storage', 'storage', 'storage', 'storage', 'storage', 'storage', 'storage' ] ); // remove mailbox $post = [ 'skus' => [ $mailbox->id => 0, $groupware->id => 1, $storage->id => 9, $activesync->id => 0 ] ]; $response = $this->actingAs($jane)->put("/api/v4/users/{$jane->id}", $post); $response->assertStatus(500); - $this->assertUserEntitlements( + $this->assertEntitlements( $jane, [ 'groupware', 'mailbox', 'storage', 'storage', 'storage', 'storage', 'storage', 'storage', 'storage', 'storage', 'storage' ] ); // less than free storage $post = [ 'skus' => [ $mailbox->id => 1, $groupware->id => 1, $storage->id => 1, $activesync->id => 0 ] ]; $response = $this->actingAs($jane)->put("/api/v4/users/{$jane->id}", $post); $response->assertStatus(200); - $this->assertUserEntitlements( + $this->assertEntitlements( $jane, [ 'groupware', 'mailbox', 'storage', 'storage', 'storage', 'storage', 'storage' ] ); } /** * Test user data response used in show and info actions */ public function testUserResponse(): void { $provider = \config('services.payment_provider') ?: 'mollie'; $user = $this->getTestUser('john@kolab.org'); $wallet = $user->wallets()->first(); $wallet->setSettings(['mollie_id' => null, 'stripe_id' => null]); $result = $this->invokeMethod(new UsersController(), 'userResponse', [$user]); $this->assertEquals($user->id, $result['id']); $this->assertEquals($user->email, $result['email']); $this->assertEquals($user->status, $result['status']); $this->assertTrue(is_array($result['statusInfo'])); $this->assertTrue(is_array($result['aliases'])); $this->assertCount(1, $result['aliases']); $this->assertSame('john.doe@kolab.org', $result['aliases'][0]); $this->assertTrue(is_array($result['settings'])); $this->assertSame('US', $result['settings']['country']); $this->assertSame('USD', $result['settings']['currency']); $this->assertTrue(is_array($result['accounts'])); $this->assertTrue(is_array($result['wallets'])); $this->assertCount(0, $result['accounts']); $this->assertCount(1, $result['wallets']); $this->assertSame($wallet->id, $result['wallet']['id']); $this->assertArrayNotHasKey('discount', $result['wallet']); $this->assertTrue($result['statusInfo']['enableDomains']); $this->assertTrue($result['statusInfo']['enableWallets']); $this->assertTrue($result['statusInfo']['enableUsers']); // Ned is John's wallet controller $ned = $this->getTestUser('ned@kolab.org'); $ned_wallet = $ned->wallets()->first(); $result = $this->invokeMethod(new UsersController(), 'userResponse', [$ned]); $this->assertEquals($ned->id, $result['id']); $this->assertEquals($ned->email, $result['email']); $this->assertTrue(is_array($result['accounts'])); $this->assertTrue(is_array($result['wallets'])); $this->assertCount(1, $result['accounts']); $this->assertCount(1, $result['wallets']); $this->assertSame($wallet->id, $result['wallet']['id']); $this->assertSame($wallet->id, $result['accounts'][0]['id']); $this->assertSame($ned_wallet->id, $result['wallets'][0]['id']); $this->assertSame($provider, $result['wallet']['provider']); $this->assertSame($provider, $result['wallets'][0]['provider']); $this->assertTrue($result['statusInfo']['enableDomains']); $this->assertTrue($result['statusInfo']['enableWallets']); $this->assertTrue($result['statusInfo']['enableUsers']); // Test discount in a response $discount = Discount::where('code', 'TEST')->first(); $wallet->discount()->associate($discount); $wallet->save(); $mod_provider = $provider == 'mollie' ? 'stripe' : 'mollie'; $wallet->setSetting($mod_provider . '_id', 123); $user->refresh(); $result = $this->invokeMethod(new UsersController(), 'userResponse', [$user]); $this->assertEquals($user->id, $result['id']); $this->assertSame($discount->id, $result['wallet']['discount_id']); $this->assertSame($discount->discount, $result['wallet']['discount']); $this->assertSame($discount->description, $result['wallet']['discount_description']); $this->assertSame($mod_provider, $result['wallet']['provider']); $this->assertSame($discount->id, $result['wallets'][0]['discount_id']); $this->assertSame($discount->discount, $result['wallets'][0]['discount']); $this->assertSame($discount->description, $result['wallets'][0]['discount_description']); $this->assertSame($mod_provider, $result['wallets'][0]['provider']); // Jack is not a John's wallet controller $jack = $this->getTestUser('jack@kolab.org'); $result = $this->invokeMethod(new UsersController(), 'userResponse', [$jack]); $this->assertFalse($result['statusInfo']['enableDomains']); $this->assertFalse($result['statusInfo']['enableWallets']); $this->assertFalse($result['statusInfo']['enableUsers']); } /** * List of email address validation cases for testValidateEmail() * * @return array Arguments for testValidateEmail() */ public function dataValidateEmail(): array { $this->refreshApplication(); $public_domains = Domain::getPublicDomains(); $domain = reset($public_domains); $john = $this->getTestUser('john@kolab.org'); $jack = $this->getTestUser('jack@kolab.org'); $user = $this->getTestUser('UsersControllerTest1@userscontroller.com'); return [ // Invalid format ["$domain", $john, 'The specified email is invalid.'], [".@$domain", $john, 'The specified email is invalid.'], ["test123456@localhost", $john, 'The specified domain is invalid.'], ["test123456@unknown-domain.org", $john, 'The specified domain is invalid.'], ["$domain", $john, 'The specified email is invalid.'], [".@$domain", $john, 'The specified email is invalid.'], // forbidden local part on public domains ["admin@$domain", $john, 'The specified email is not available.'], ["administrator@$domain", $john, 'The specified email is not available.'], // forbidden (other user's domain) ["testtest@kolab.org", $user, 'The specified domain is not available.'], // existing alias of other user, to be a user email ["jack.daniels@kolab.org", $john, 'The specified email is not available.'], // valid (user domain) ["admin@kolab.org", $john, null], // valid (public domain) ["test.test@$domain", $john, null], ]; } /** * User email address validation. * * Note: Technically these include unit tests, but let's keep it here for now. * FIXME: Shall we do a http request for each case? * * @dataProvider dataValidateEmail */ public function testValidateEmail($email, $user, $expected_result): void { $result = UsersController::validateEmail($email, $user); $this->assertSame($expected_result, $result); } /** * User email validation - tests for $deleted argument * * Note: Technically these include unit tests, but let's keep it here for now. * FIXME: Shall we do a http request for each case? */ public function testValidateEmailDeleted(): void { Queue::fake(); $john = $this->getTestUser('john@kolab.org'); $deleted_priv = $this->getTestUser('deleted@kolab.org'); $deleted_priv->delete(); $deleted_pub = $this->getTestUser('deleted@kolabnow.com'); $deleted_pub->delete(); $result = UsersController::validateEmail('deleted@kolab.org', $john, $deleted); $this->assertSame(null, $result); $this->assertSame($deleted_priv->id, $deleted->id); $result = UsersController::validateEmail('deleted@kolabnow.com', $john, $deleted); $this->assertSame('The specified email is not available.', $result); $this->assertSame(null, $deleted); $result = UsersController::validateEmail('jack@kolab.org', $john, $deleted); $this->assertSame('The specified email is not available.', $result); $this->assertSame(null, $deleted); } /** * User email validation - tests for an address being a group email address * * Note: Technically these include unit tests, but let's keep it here for now. * FIXME: Shall we do a http request for each case? */ public function testValidateEmailGroup(): void { Queue::fake(); $john = $this->getTestUser('john@kolab.org'); $pub_group = $this->getTestGroup('group-test@kolabnow.com'); $priv_group = $this->getTestGroup('group-test@kolab.org'); // A group in a public domain, existing $result = UsersController::validateEmail($pub_group->email, $john, $deleted); $this->assertSame('The specified email is not available.', $result); $this->assertNull($deleted); $pub_group->delete(); // A group in a public domain, deleted $result = UsersController::validateEmail($pub_group->email, $john, $deleted); $this->assertSame('The specified email is not available.', $result); $this->assertNull($deleted); // A group in a private domain, existing $result = UsersController::validateEmail($priv_group->email, $john, $deleted); $this->assertSame('The specified email is not available.', $result); $this->assertNull($deleted); $priv_group->delete(); // A group in a private domain, deleted $result = UsersController::validateEmail($priv_group->email, $john, $deleted); $this->assertSame(null, $result); $this->assertSame($priv_group->id, $deleted->id); } /** * List of alias validation cases for testValidateAlias() * * @return array Arguments for testValidateAlias() */ public function dataValidateAlias(): array { $this->refreshApplication(); $public_domains = Domain::getPublicDomains(); $domain = reset($public_domains); $john = $this->getTestUser('john@kolab.org'); $user = $this->getTestUser('UsersControllerTest1@userscontroller.com'); return [ // Invalid format ["$domain", $john, 'The specified alias is invalid.'], [".@$domain", $john, 'The specified alias is invalid.'], ["test123456@localhost", $john, 'The specified domain is invalid.'], ["test123456@unknown-domain.org", $john, 'The specified domain is invalid.'], ["$domain", $john, 'The specified alias is invalid.'], [".@$domain", $john, 'The specified alias is invalid.'], // forbidden local part on public domains ["admin@$domain", $john, 'The specified alias is not available.'], ["administrator@$domain", $john, 'The specified alias is not available.'], // forbidden (other user's domain) ["testtest@kolab.org", $user, 'The specified domain is not available.'], // existing alias of other user, to be an alias, user in the same group account ["jack.daniels@kolab.org", $john, null], // existing user ["jack@kolab.org", $john, 'The specified alias is not available.'], // valid (user domain) ["admin@kolab.org", $john, null], // valid (public domain) ["test.test@$domain", $john, null], ]; } /** * User email alias validation. * * Note: Technically these include unit tests, but let's keep it here for now. * FIXME: Shall we do a http request for each case? * * @dataProvider dataValidateAlias */ public function testValidateAlias($alias, $user, $expected_result): void { $result = UsersController::validateAlias($alias, $user); $this->assertSame($expected_result, $result); } /** * User alias validation - more cases. * * Note: Technically these include unit tests, but let's keep it here for now. * FIXME: Shall we do a http request for each case? */ public function testValidateAlias2(): void { Queue::fake(); $john = $this->getTestUser('john@kolab.org'); $jack = $this->getTestUser('jack@kolab.org'); $user = $this->getTestUser('UsersControllerTest1@userscontroller.com'); $deleted_priv = $this->getTestUser('deleted@kolab.org'); $deleted_priv->setAliases(['deleted-alias@kolab.org']); $deleted_priv->delete(); $deleted_pub = $this->getTestUser('deleted@kolabnow.com'); $deleted_pub->setAliases(['deleted-alias@kolabnow.com']); $deleted_pub->delete(); $group = $this->getTestGroup('group-test@kolabnow.com'); // An alias that was a user email before is allowed, but only for custom domains $result = UsersController::validateAlias('deleted@kolab.org', $john); $this->assertSame(null, $result); $result = UsersController::validateAlias('deleted-alias@kolab.org', $john); $this->assertSame(null, $result); $result = UsersController::validateAlias('deleted@kolabnow.com', $john); $this->assertSame('The specified alias is not available.', $result); $result = UsersController::validateAlias('deleted-alias@kolabnow.com', $john); $this->assertSame('The specified alias is not available.', $result); // A grpoup with the same email address exists $result = UsersController::validateAlias($group->email, $john); $this->assertSame('The specified alias is not available.', $result); } } diff --git a/src/tests/TestCaseTrait.php b/src/tests/TestCaseTrait.php index 147b0953..7817f2ca 100644 --- a/src/tests/TestCaseTrait.php +++ b/src/tests/TestCaseTrait.php @@ -1,479 +1,479 @@ 'John', 'last_name' => 'Doe', 'organization' => 'Test Domain Owner', ]; /** * Some users for the hosted domain, ultimately including the owner. * * @var \App\User[] */ protected $domainUsers = []; /** * A specific user that is a regular user in the hosted domain. * * @var ?\App\User */ protected $jack; /** * A specific user that is a controller on the wallet to which the hosted domain is charged. * * @var ?\App\User */ protected $jane; /** * A specific user that has a second factor configured. * * @var ?\App\User */ protected $joe; /** * One of the domains that is available for public registration. * * @var ?\App\Domain */ protected $publicDomain; /** * A newly generated user in a public domain. * * @var ?\App\User */ protected $publicDomainUser; /** * A placeholder for a password that can be generated. * * Should be generated with `\App\Utils::generatePassphrase()`. * * @var ?string */ protected $userPassword; /** * Assert that the entitlements for the user match the expected list of entitlements. * - * @param \App\User $user The user for which the entitlements need to be pulled. - * @param array $expected An array of expected \App\SKU titles. + * @param \App\User|\App\Domain $object The object for which the entitlements need to be pulled. + * @param array $expected An array of expected \App\SKU titles. */ - protected function assertUserEntitlements($user, $expected) + protected function assertEntitlements($object, $expected) { // Assert the user entitlements - $skus = $user->entitlements()->get() + $skus = $object->entitlements()->get() ->map(function ($ent) { return $ent->sku->title; }) ->toArray(); sort($skus); Assert::assertSame($expected, $skus); } protected function backdateEntitlements($entitlements, $targetDate) { $wallets = []; $ids = []; foreach ($entitlements as $entitlement) { $ids[] = $entitlement->id; $wallets[] = $entitlement->wallet_id; } \App\Entitlement::whereIn('id', $ids)->update([ 'created_at' => $targetDate, 'updated_at' => $targetDate, ]); if (!empty($wallets)) { $wallets = array_unique($wallets); $owners = \App\Wallet::whereIn('id', $wallets)->pluck('user_id')->all(); \App\User::whereIn('id', $owners)->update(['created_at' => $targetDate]); } } /** * Removes all beta entitlements from the database */ protected function clearBetaEntitlements(): void { $beta_handlers = [ 'App\Handlers\Beta', 'App\Handlers\Distlist', ]; $betas = \App\Sku::whereIn('handler_class', $beta_handlers)->pluck('id')->all(); \App\Entitlement::whereIn('sku_id', $betas)->delete(); } /** * Creates the application. * * @return \Illuminate\Foundation\Application */ public function createApplication() { $app = require __DIR__ . '/../bootstrap/app.php'; $app->make(Kernel::class)->bootstrap(); return $app; } /** * Create a set of transaction log entries for a wallet */ protected function createTestTransactions($wallet) { $result = []; $date = Carbon::now(); $debit = 0; $entitlementTransactions = []; foreach ($wallet->entitlements as $entitlement) { if ($entitlement->cost) { $debit += $entitlement->cost; $entitlementTransactions[] = $entitlement->createTransaction( Transaction::ENTITLEMENT_BILLED, $entitlement->cost ); } } $transaction = Transaction::create( [ 'user_email' => 'jeroen@jeroen.jeroen', 'object_id' => $wallet->id, 'object_type' => \App\Wallet::class, 'type' => Transaction::WALLET_DEBIT, 'amount' => $debit * -1, 'description' => 'Payment', ] ); $result[] = $transaction; Transaction::whereIn('id', $entitlementTransactions)->update(['transaction_id' => $transaction->id]); $transaction = Transaction::create( [ 'user_email' => null, 'object_id' => $wallet->id, 'object_type' => \App\Wallet::class, 'type' => Transaction::WALLET_CREDIT, 'amount' => 2000, 'description' => 'Payment', ] ); $transaction->created_at = $date->next(Carbon::MONDAY); $transaction->save(); $result[] = $transaction; $types = [ Transaction::WALLET_AWARD, Transaction::WALLET_PENALTY, ]; // The page size is 10, so we generate so many to have at least two pages $loops = 10; while ($loops-- > 0) { $type = $types[count($result) % count($types)]; $transaction = Transaction::create([ 'user_email' => 'jeroen.@jeroen.jeroen', 'object_id' => $wallet->id, 'object_type' => \App\Wallet::class, 'type' => $type, 'amount' => 11 * (count($result) + 1) * ($type == Transaction::WALLET_PENALTY ? -1 : 1), 'description' => 'TRANS' . $loops, ]); $transaction->created_at = $date->next(Carbon::MONDAY); $transaction->save(); $result[] = $transaction; } return $result; } /** * Delete a test domain whatever it takes. * * @coversNothing */ protected function deleteTestDomain($name) { Queue::fake(); $domain = Domain::withTrashed()->where('namespace', $name)->first(); if (!$domain) { return; } $job = new \App\Jobs\Domain\DeleteJob($domain->id); $job->handle(); $domain->forceDelete(); } /** * Delete a test group whatever it takes. * * @coversNothing */ protected function deleteTestGroup($email) { Queue::fake(); $group = Group::withTrashed()->where('email', $email)->first(); if (!$group) { return; } $job = new \App\Jobs\Group\DeleteJob($group->id); $job->handle(); $group->forceDelete(); } /** * Delete a test user whatever it takes. * * @coversNothing */ protected function deleteTestUser($email) { Queue::fake(); $user = User::withTrashed()->where('email', $email)->first(); if (!$user) { return; } $job = new \App\Jobs\User\DeleteJob($user->id); $job->handle(); $user->forceDelete(); } /** * Helper to access protected property of an object */ protected static function getObjectProperty($object, $property_name) { $reflection = new \ReflectionClass($object); $property = $reflection->getProperty($property_name); $property->setAccessible(true); return $property->getValue($object); } /** * Get Domain object by namespace, create it if needed. * Skip LDAP jobs. * * @coversNothing */ protected function getTestDomain($name, $attrib = []) { // Disable jobs (i.e. skip LDAP oprations) Queue::fake(); return Domain::firstOrCreate(['namespace' => $name], $attrib); } /** * Get Group object by email, create it if needed. * Skip LDAP jobs. */ protected function getTestGroup($email, $attrib = []) { // Disable jobs (i.e. skip LDAP oprations) Queue::fake(); return Group::firstOrCreate(['email' => $email], $attrib); } /** * Get User object by email, create it if needed. * Skip LDAP jobs. * * @coversNothing */ protected function getTestUser($email, $attrib = []) { // Disable jobs (i.e. skip LDAP oprations) Queue::fake(); $user = User::firstOrCreate(['email' => $email], $attrib); if ($user->trashed()) { // Note: we do not want to use user restore here User::where('id', $user->id)->forceDelete(); $user = User::create(['email' => $email] + $attrib); } return $user; } /** * Call protected/private method of a class. * * @param object $object Instantiated object that we will run method on. * @param string $methodName Method name to call * @param array $parameters Array of parameters to pass into method. * * @return mixed Method return. */ protected function invokeMethod($object, $methodName, array $parameters = array()) { $reflection = new \ReflectionClass(get_class($object)); $method = $reflection->getMethod($methodName); $method->setAccessible(true); return $method->invokeArgs($object, $parameters); } protected function setUpTest() { $this->userPassword = \App\Utils::generatePassphrase(); $this->domainHosted = $this->getTestDomain( 'test.domain', [ 'type' => \App\Domain::TYPE_EXTERNAL, 'status' => \App\Domain::STATUS_ACTIVE | \App\Domain::STATUS_CONFIRMED | \App\Domain::STATUS_VERIFIED ] ); $this->getTestDomain( 'test2.domain2', [ 'type' => \App\Domain::TYPE_EXTERNAL, 'status' => \App\Domain::STATUS_ACTIVE | \App\Domain::STATUS_CONFIRMED | \App\Domain::STATUS_VERIFIED ] ); $packageKolab = \App\Package::where('title', 'kolab')->first(); $this->domainOwner = $this->getTestUser('john@test.domain', ['password' => $this->userPassword]); $this->domainOwner->assignPackage($packageKolab); $this->domainOwner->setSettings($this->domainOwnerSettings); $this->domainOwner->setAliases(['alias1@test2.domain2']); // separate for regular user $this->jack = $this->getTestUser('jack@test.domain', ['password' => $this->userPassword]); // separate for wallet controller $this->jane = $this->getTestUser('jane@test.domain', ['password' => $this->userPassword]); $this->joe = $this->getTestUser('joe@test.domain', ['password' => $this->userPassword]); $this->domainUsers[] = $this->jack; $this->domainUsers[] = $this->jane; $this->domainUsers[] = $this->joe; $this->domainUsers[] = $this->getTestUser('jill@test.domain', ['password' => $this->userPassword]); foreach ($this->domainUsers as $user) { $this->domainOwner->assignPackage($packageKolab, $user); } $this->domainUsers[] = $this->domainOwner; // assign second factor to joe $this->joe->assignSku(\App\Sku::where('title', '2fa')->first()); \App\Auth\SecondFactor::seed($this->joe->email); usort( $this->domainUsers, function ($a, $b) { return $a->email > $b->email; } ); $this->domainHosted->assignPackage( \App\Package::where('title', 'domain-hosting')->first(), $this->domainOwner ); $wallet = $this->domainOwner->wallets()->first(); $wallet->addController($this->jane); $this->publicDomain = \App\Domain::where('type', \App\Domain::TYPE_PUBLIC)->first(); $this->publicDomainUser = $this->getTestUser( 'john@' . $this->publicDomain->namespace, ['password' => $this->userPassword] ); $this->publicDomainUser->assignPackage($packageKolab); } public function tearDown(): void { foreach ($this->domainUsers as $user) { if ($user == $this->domainOwner) { continue; } $this->deleteTestUser($user->email); } if ($this->domainOwner) { $this->deleteTestUser($this->domainOwner->email); } if ($this->domainHosted) { $this->deleteTestDomain($this->domainHosted->namespace); } if ($this->publicDomainUser) { $this->deleteTestUser($this->publicDomainUser->email); } parent::tearDown(); } } diff --git a/src/tests/Unit/Rules/UserEmailDomainTest.php b/src/tests/Unit/Rules/UserEmailDomainTest.php index 559d337c..42d6c9a3 100644 --- a/src/tests/Unit/Rules/UserEmailDomainTest.php +++ b/src/tests/Unit/Rules/UserEmailDomainTest.php @@ -1,18 +1,68 @@ markTestIncomplete(); + $rules = ['domain' => [new UserEmailDomain()]]; + + // Non-string input + $v = Validator::make(['domain' => ['domain.tld']], $rules); + + $this->assertTrue($v->fails()); + $this->assertSame(['domain' => ['The specified domain is invalid.']], $v->errors()->toArray()); + + // Non-fqdn name + $v = Validator::make(['domain' => 'local'], $rules); + + $this->assertTrue($v->fails()); + $this->assertSame(['domain' => ['The specified domain is invalid.']], $v->errors()->toArray()); + + // www. prefix not allowed + $v = Validator::make(['domain' => 'www.local.tld'], $rules); + + $this->assertTrue($v->fails()); + $this->assertSame(['domain' => ['The specified domain is invalid.']], $v->errors()->toArray()); + + // invalid domain + $v = Validator::make(['domain' => 'local..tld'], $rules); + + $this->assertTrue($v->fails()); + $this->assertSame(['domain' => ['The specified domain is invalid.']], $v->errors()->toArray()); + + // Valid domain + $domain = str_repeat('abcdefghi.', 18) . 'abcdefgh.pl'; // 191 chars + $v = Validator::make(['domain' => $domain], $rules); + + $this->assertFalse($v->fails()); + + // Domain too long + $domain = str_repeat('abcdefghi.', 18) . 'abcdefghi.pl'; // too long domain, 192 chars + $v = Validator::make(['domain' => $domain], $rules); + + $this->assertTrue($v->fails()); + $this->assertSame(['domain' => ['The specified domain is invalid.']], $v->errors()->toArray()); + + $rules = ['domain' => [new UserEmailDomain(['kolabnow.com'])]]; + + // Domain not belongs to a set of allowed domains + $v = Validator::make(['domain' => 'domain.tld'], $rules); + + $this->assertTrue($v->fails()); + $this->assertSame(['domain' => ['The specified domain is invalid.']], $v->errors()->toArray()); + + // Domain on the allowed domains list + $v = Validator::make(['domain' => 'kolabNow.com'], $rules); + + $this->assertFalse($v->fails()); } } diff --git a/src/tests/Unit/Rules/UserEmailLocalTest.php b/src/tests/Unit/Rules/UserEmailLocalTest.php index 8476a8fb..ef732b02 100644 --- a/src/tests/Unit/Rules/UserEmailLocalTest.php +++ b/src/tests/Unit/Rules/UserEmailLocalTest.php @@ -1,18 +1,58 @@ markTestIncomplete(); + $rules = ['user' => [new UserEmailLocal($external)]]; + + $v = Validator::make(['user' => $user], $rules); + + if ($error) { + $this->assertTrue($v->fails()); + $this->assertSame(['user' => [$error]], $v->errors()->toArray()); + } else { + $this->assertFalse($v->fails()); + } } }