diff --git a/src/app/Domain.php b/src/app/Domain.php index 725b9372..c428ac96 100644 --- a/src/app/Domain.php +++ b/src/app/Domain.php @@ -1,516 +1,542 @@ 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; } + /** + * Checks if there are any objects (users/aliases/groups) in a domain. + * Note: Public domains are always reported not empty. + * + * @return bool True if there are no objects assigned, False otherwise + */ + public function isEmpty(): bool + { + if ($this->isPublic()) { + return false; + } + + // FIXME: These queries will not use indexes, so maybe we should consider + // wallet/entitlements to search in objects that belong to this domain account? + + $suffix = '@' . $this->namespace; + $suffixLen = strlen($suffix); + + return !( + \App\User::whereRaw('substr(email, ?) = ?', [-$suffixLen, $suffix])->exists() + || \App\UserAlias::whereRaw('substr(alias, ?) = ?', [-$suffixLen, $suffix])->exists() + || \App\Group::whereRaw('substr(email, ?) = ?', [-$suffixLen, $suffix])->exists() + ); + } + /** * 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. + * Note: It returns only users with a mailbox. * - * @return array + * @return \App\User[] A list of users */ 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/Http/Controllers/API/V4/Admin/DomainsController.php b/src/app/Http/Controllers/API/V4/Admin/DomainsController.php index ae93f02b..7ad9c324 100644 --- a/src/app/Http/Controllers/API/V4/Admin/DomainsController.php +++ b/src/app/Http/Controllers/API/V4/Admin/DomainsController.php @@ -1,116 +1,128 @@ errorResponse(404); + } + /** * Search for domains * * @return \Illuminate\Http\JsonResponse */ public function index() { $search = trim(request()->input('search')); $owner = trim(request()->input('owner')); $result = collect([]); if ($owner) { if ($owner = User::find($owner)) { 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 8607bac7..2f1abfbd 100644 --- a/src/app/Http/Controllers/API/V4/DomainsController.php +++ b/src/app/Http/Controllers/API/V4/DomainsController.php @@ -1,496 +1,526 @@ 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. + * Show the form for creating a new domain. * * @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. + * Remove the specified domain. * - * @param int $id + * @param int $id Domain identifier * * @return \Illuminate\Http\JsonResponse */ public function destroy($id) { - return $this->errorResponse(404); + $domain = Domain::withEnvTenantContext()->find($id); + + if (empty($domain)) { + return $this->errorResponse(404); + } + + if (!$this->guard()->user()->canDelete($domain)) { + return $this->errorResponse(403); + } + + // It is possible to delete domain only if there are no users/aliases/groups using it. + if (!$domain->isEmpty()) { + $response = ['status' => 'error', 'message' => \trans('app.domain-notempty-error')]; + return response()->json($response, 422); + } + + $domain->delete(); + + return response()->json([ + 'status' => 'success', + 'message' => \trans('app.domain-delete-success'), + ]); } /** - * Show the form for editing the specified resource. + * Show the form for editing the specified domain. * - * @param int $id + * @param int $id Domain identifier * * @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'), ]); } /** * Create a domain. * * @param \Illuminate\Http\Request $request * * @return \Illuminate\Http\JsonResponse */ 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); } // 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 ($domain = Domain::withTrashed()->where('namespace', $namespace)->first()) { + // Check if the domain is soft-deleted and belongs to the same user + $deleteBeforeCreate = $domain->trashed() && ($wallet = $domain->wallet()) + && $wallet->owner && $wallet->owner->id == $owner->id; + + if (!$deleteBeforeCreate) { + $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 + // Force-delete the existing domain if it is soft-deleted and belongs to the same user + if (!empty($deleteBeforeCreate)) { + $domain->forceDelete(); + } // 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. + * Update the specified domain. * * @param \Illuminate\Http\Request $request - * @param int $id + * @param int $id Domain identifier * * @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/resources/js/app.js b/src/resources/js/app.js index e2d868eb..aad37ace 100644 --- a/src/resources/js/app.js +++ b/src/resources/js/app.js @@ -1,541 +1,543 @@ /** * 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" + let error_msg + + const status = error.response ? error.response.status : 200 + const data = error.response ? error.response.data : {} + + if (status == 422 && data.errors) { + error_msg = app.$t('error.form') const modal = $('div.modal.show') $(modal.length ? modal : 'form').each((i, form) => { form = $(form) - $.each(error.response.data.errors || {}, (idx, msg) => { + $.each(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 if (data.status == 'error') { + error_msg = 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 6af31c4d..566723fc 100644 --- a/src/resources/lang/en/app.php +++ b/src/resources/lang/en/app.php @@ -1,94 +1,96 @@ '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-delete-success' => 'Domain deleted successfully.', + 'domain-notempty-error' => 'Unable to delete a domain with assigned users or other objects.', '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 5452a262..8c13a1f2 100644 --- a/src/resources/lang/en/ui.php +++ b/src/resources/lang/en/ui.php @@ -1,424 +1,430 @@ [ 'faq' => "FAQ", ], 'btn' => [ 'add' => "Add", 'accept' => "Accept", 'back' => "Back", 'cancel' => "Cancel", 'close' => "Close", 'continue' => "Continue", 'delete' => "Delete", 'deny' => "Deny", 'download' => "Download", 'edit' => "Edit", 'file' => "Choose file...", 'moreinfo' => "More information", 'refresh' => "Refresh", 'reset' => "Reset", 'resend' => "Resend", 'save' => "Save", 'search' => "Search", 'signup' => "Sign Up", 'submit' => "Submit", 'suspend' => "Suspend", 'unsuspend' => "Unsuspend", 'verify' => "Verify", ], 'dashboard' => [ 'beta' => "beta", 'distlists' => "Distribution lists", 'chat' => "Video chat", 'domains' => "Domains", 'invitations' => "Invitations", 'profile' => "Your profile", 'users' => "User accounts", 'wallet' => "Wallet", 'webmail' => "Webmail", 'stats' => "Stats", ], 'distlist' => [ 'list-title' => "Distribution list | Distribution lists", 'create' => "Create list", 'delete' => "Delete list", 'email' => "Email", 'list-empty' => "There are no distribution lists in this account.", 'new' => "New distribution list", 'recipients' => "Recipients", ], 'domain' => [ + 'delete' => "Delete domain", + 'delete-domain' => "Delete {domain}", + 'delete-text' => "Do you really want to delete this domain permanently?" + . " This is only possible if there are no users, aliases or other objects in this domain." + . " Please note that this action cannot be undone.", 'dns-verify' => "Domain DNS verification sample:", 'dns-config' => "Domain DNS configuration sample:", 'namespace' => "Namespace", 'spf-whitelist' => "SPF Whitelist", 'spf-whitelist-text' => "The Sender Policy Framework allows a sender domain to disclose, through DNS, " . "which systems are allowed to send emails with an envelope sender address within said domain.", 'spf-whitelist-ex' => "Here you can specify a list of allowed servers, for example: .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' => "Form validation error", ], 'form' => [ 'amount' => "Amount", 'code' => "Confirmation Code", 'config' => "Configuration", 'date' => "Date", 'description' => "Description", 'details' => "Details", 'disabled' => "disabled", 'domain' => "Domain", 'email' => "Email Address", 'enabled' => "enabled", 'firstname' => "First Name", 'general' => "General", 'lastname' => "Last Name", 'none' => "none", 'or' => "or", 'password' => "Password", 'password-confirm' => "Confirm Password", 'phone' => "Phone", 'settings' => "Settings", 'status' => "Status", 'surname' => "Surname", 'user' => "User", 'primary-email' => "Primary Email", 'id' => "ID", 'created' => "Created", 'deleted' => "Deleted", ], 'invitation' => [ 'create' => "Create invite(s)", 'create-title' => "Invite for a signup", 'create-email' => "Enter an email address of the person you want to invite.", 'create-csv' => "To send multiple invitations at once, provide a CSV (comma separated) file, or alternatively a plain-text file, containing one email address per line.", 'empty-list' => "There are no invitations in the database.", 'title' => "Signup invitations", 'search' => "Email address or domain", 'send' => "Send invite(s)", 'status-completed' => "User signed up", 'status-failed' => "Sending failed", 'status-sent' => "Sent", 'status-new' => "Not sent yet", ], 'lang' => [ 'en' => "English", 'de' => "German", 'fr' => "French", 'it' => "Italian", ], 'login' => [ '2fa' => "Second factor code", '2fa_desc' => "Second factor code is optional for users with no 2-Factor Authentication setup.", 'forgot_password' => "Forgot password?", 'header' => "Please sign in", 'sign_in' => "Sign in", 'webmail' => "Webmail" ], 'meet' => [ 'title' => "Voice & Video Conferencing", 'welcome' => "Welcome to our beta program for Voice & Video Conferencing.", 'url' => "You have a room of your own at the URL below. This room is only open when you yourself are in attendance. Use this URL to invite people to join you.", 'notice' => "This is a work in progress and more features will be added over time. Current features include:", 'sharing' => "Screen Sharing", 'sharing-text' => "Share your screen for presentations or show-and-tell.", 'security' => "Room Security", 'security-text' => "Increase the room security by setting a password that attendees will need to know" . " before they can enter, or lock the door so attendees will have to knock, and a moderator can accept or deny those requests.", 'qa' => "Raise Hand (Q&A)", 'qa-text' => "Silent audience members can raise their hand to facilitate a Question & Answer session with the panel members.", 'moderation' => "Moderator Delegation", 'moderation-text' => "Delegate moderator authority for the session, so that a speaker is not needlessly" . " interrupted with attendees knocking and other moderator duties.", 'eject' => "Eject Attendees", 'eject-text' => "Eject attendees from the session in order to force them to reconnect, or address policy" . " violations. Click the user icon for effective dismissal.", 'silent' => "Silent Audience Members", 'silent-text' => "For a webinar-style session, configure the room to force all new attendees to be silent audience members.", 'interpreters' => "Language Specific Audio Channels", 'interpreters-text' => "Designate a participant to interpret the original audio to a target language, for sessions" . " with multi-lingual attendees. The interpreter is expected to be able to relay the original audio, and override it.", 'beta-notice' => "Keep in mind that this is still in beta and might come with some issues." . " Should you encounter any on your way, let us know by contacting support.", // Room options dialog 'options' => "Room options", 'password' => "Password", 'password-none' => "none", 'password-clear' => "Clear password", 'password-set' => "Set password", 'password-text' => "You can add a password to your meeting. Participants will have to provide the password before they are allowed to join the meeting.", 'lock' => "Locked room", 'lock-text' => "When the room is locked participants have to be approved by a moderator before they could join the meeting.", 'nomedia' => "Subscribers only", 'nomedia-text' => "Forces all participants to join as subscribers (with camera and microphone turned off)." . " Moderators will be able to promote them to publishers throughout the session.", // Room menu 'partcnt' => "Number of participants", 'menu-audio-mute' => "Mute audio", 'menu-audio-unmute' => "Unmute audio", 'menu-video-mute' => "Mute video", 'menu-video-unmute' => "Unmute video", 'menu-screen' => "Share screen", 'menu-hand-lower' => "Lower hand", 'menu-hand-raise' => "Raise hand", 'menu-channel' => "Interpreted language channel", 'menu-chat' => "Chat", 'menu-fullscreen' => "Full screen", 'menu-fullscreen-exit' => "Exit full screen", 'menu-leave' => "Leave session", // Room setup screen 'setup-title' => "Set up your session", 'mic' => "Microphone", 'cam' => "Camera", 'nick' => "Nickname", 'nick-placeholder' => "Your name", 'join' => "JOIN", 'joinnow' => "JOIN NOW", 'imaowner' => "I'm the owner", // Room 'qa' => "Q & A", 'leave-title' => "Room closed", 'leave-body' => "The session has been closed by the room owner.", 'media-title' => "Media setup", 'join-request' => "Join request", 'join-requested' => "{user} requested to join.", // Status messages 'status-init' => "Checking the room...", 'status-323' => "The room is closed. Please, wait for the owner to start the session.", 'status-324' => "The room is closed. It will be open for others after you join.", 'status-325' => "The room is ready. Please, provide a valid password.", 'status-326' => "The room is locked. Please, enter your name and try again.", 'status-327' => "Waiting for permission to join the room.", 'status-404' => "The room does not exist.", 'status-429' => "Too many requests. Please, wait.", 'status-500' => "Failed to connect to the room. Server error.", // Other menus 'media-setup' => "Media setup", 'perm' => "Permissions", 'perm-av' => "Audio & Video publishing", 'perm-mod' => "Moderation", 'lang-int' => "Language interpreter", 'menu-options' => "Options", ], 'menu' => [ 'cockpit' => "Cockpit", 'login' => "Login", 'logout' => "Logout", 'signup' => "Signup", 'toggle' => "Toggle navigation", ], 'msg' => [ 'initializing' => "Initializing...", 'loading' => "Loading...", 'loading-failed' => "Failed to load data.", 'notfound' => "Resource not found.", 'info' => "Information", 'error' => "Error", 'warning' => "Warning", 'success' => "Success", ], 'nav' => [ 'more' => "Load more", 'step' => "Step {i}/{n}", ], 'password' => [ 'reset' => "Password Reset", 'reset-step1' => "Enter your email address to reset your password.", 'reset-step1-hint' => "You may need to check your spam folder or unblock {email}.", 'reset-step2' => "We sent out a confirmation code to your external email address." . " Enter the code we sent you, or click the link in the message.", ], 'signup' => [ 'email' => "Existing Email Address", 'login' => "Login", 'title' => "Sign Up", 'step1' => "Sign up to start your free month.", 'step2' => "We sent out a confirmation code to your email address. Enter the code we sent you, or click the link in the message.", 'step3' => "Create your Kolab identity (you can choose additional addresses later).", 'voucher' => "Voucher Code", ], 'status' => [ 'prepare-account' => "We are preparing your account.", 'prepare-domain' => "We are preparing the domain.", 'prepare-distlist' => "We are preparing the distribution list.", 'prepare-user' => "We are preparing the user account.", 'prepare-hint' => "Some features may be missing or readonly at the moment.", 'prepare-refresh' => "The process never ends? Press the \"Refresh\" button, please.", 'ready-account' => "Your account is almost ready.", 'ready-domain' => "The domain is almost ready.", 'ready-distlist' => "The distribution list is almost ready.", 'ready-user' => "The user account is almost ready.", 'verify' => "Verify your domain to finish the setup process.", 'verify-domain' => "Verify domain", 'deleted' => "Deleted", 'suspended' => "Suspended", 'notready' => "Not Ready", 'active' => "Active", ], 'support' => [ 'title' => "Contact Support", 'id' => "Customer number or email address you have with us", 'id-pl' => "e.g. 12345678 or john@kolab.org", 'id-hint' => "Leave blank if you are not a customer yet", 'name' => "Name", 'name-pl' => "how we should call you in our reply", 'email' => "Working email address", 'email-pl' => "make sure we can reach you at this address", 'summary' => "Issue Summary", 'summary-pl' => "one sentence that summarizes your issue", 'expl' => "Issue Explanation", ], 'user' => [ '2fa-hint1' => "This will remove 2-Factor Authentication entitlement as well as the user-configured factors.", '2fa-hint2' => "Please, make sure to confirm the user identity properly.", 'add-beta' => "Enable beta program", 'address' => "Address", 'aliases' => "Aliases", 'aliases-email' => "Email Aliases", 'aliases-none' => "This user has no email aliases.", 'add-bonus' => "Add bonus", 'add-bonus-title' => "Add a bonus to the wallet", 'add-penalty' => "Add penalty", 'add-penalty-title' => "Add a penalty to the wallet", 'auto-payment' => "Auto-payment", 'auto-payment-text' => "Fill up by {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/vue/Domain/Info.vue b/src/resources/vue/Domain/Info.vue index f3ac5599..87127731 100644 --- a/src/resources/vue/Domain/Info.vue +++ b/src/resources/vue/Domain/Info.vue @@ -1,187 +1,231 @@ diff --git a/src/tests/Browser/DomainTest.php b/src/tests/Browser/DomainTest.php index fdde106b..136d3000 100644 --- a/src/tests/Browser/DomainTest.php +++ b/src/tests/Browser/DomainTest.php @@ -1,298 +1,353 @@ 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', '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'); }); } + + /** + * Test domain deletion + */ + public function testDomainDelete(): void + { + // Create the domain to delete + $john = $this->getTestUser('john@kolab.org'); + $domain = $this->getTestDomain('testdomain.com', ['type' => Domain::TYPE_EXTERNAL]); + $packageDomain = \App\Package::withEnvTenantContext()->where('title', 'domain-hosting')->first(); + $domain->assignPackage($packageDomain, $john); + + $this->browse(function ($browser) { + $browser->visit('/login') + ->on(new Home()) + ->submitLogon('john@kolab.org', 'simple123') + ->visit('/domains') + ->on(new DomainList()) + ->assertElementsCount('@table tbody tr', 2) + ->assertSeeIn('@table tr:nth-child(2) a', 'testdomain.com') + ->click('@table tbody tr:nth-child(2) a') + ->on(new DomainInfo()) + ->waitFor('button.button-delete') + ->assertSeeIn('button.button-delete', 'Delete domain') + ->click('button.button-delete') + ->with(new Dialog('#delete-warning'), function ($browser) { + $browser->assertSeeIn('@title', 'Delete testdomain.com') + ->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, 'Domain deleted successfully.') + ->on(new DomainList()) + ->assertElementsCount('@table tbody tr', 1); + + // Test error handling on deleting a non-empty domain + $err = 'Unable to delete a domain with assigned users or other objects.'; + $browser->click('@table tbody tr:nth-child(1) a') + ->on(new DomainInfo()) + ->waitFor('button.button-delete') + ->click('button.button-delete') + ->with(new Dialog('#delete-warning'), function ($browser) { + $browser->click('@button-action'); + }) + ->assertToast(Toast::TYPE_ERROR, $err); + }); + } } diff --git a/src/tests/Feature/Controller/Admin/DomainsTest.php b/src/tests/Feature/Controller/Admin/DomainsTest.php index a8311c69..cbec81fa 100644 --- a/src/tests/Feature/Controller/Admin/DomainsTest.php +++ b/src/tests/Feature/Controller/Admin/DomainsTest.php @@ -1,235 +1,249 @@ deleteTestDomain('domainscontroller.com'); } /** * {@inheritDoc} */ public function tearDown(): void { + $this->deleteTestUser('test1@domainscontroller.com'); $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 deleting a domain (DELETE /api/v4/domains/) + */ + public function testDestroy(): void + { + $admin = $this->getTestUser('jeroen@jeroen.jeroen'); + $domain = $this->getTestDomain('kolab.org'); + + // This end-point does not exist for admins + $response = $this->actingAs($admin)->delete("api/v4/domains/{$domain->id}"); + $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'); + $user = $this->getTestUser('test1@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'); + $user = $this->getTestUser('test1@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/DomainsTest.php b/src/tests/Feature/Controller/DomainsTest.php index a90e8093..bfc0ab10 100644 --- a/src/tests/Feature/Controller/DomainsTest.php +++ b/src/tests/Feature/Controller/DomainsTest.php @@ -1,443 +1,555 @@ deleteTestUser('test1@' . \config('app.domain')); + $this->deleteTestUser('test2@' . \config('app.domain')); $this->deleteTestUser('test1@domainscontroller.com'); $this->deleteTestDomain('domainscontroller.com'); } public function tearDown(): void { + $this->deleteTestUser('test1@' . \config('app.domain')); + $this->deleteTestUser('test2@' . \config('app.domain')); $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 { + Queue::fake(); + $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 domain delete request (DELETE /api/v4/domains/) + */ + public function testDestroy(): void + { + Queue::fake(); + + $sku_domain = Sku::withEnvTenantContext()->where('title', 'domain-hosting')->first(); + $john = $this->getTestUser('john@kolab.org'); + $johns_domain = $this->getTestDomain('kolab.org'); + $user1 = $this->getTestUser('test1@' . \config('app.domain')); + $user2 = $this->getTestUser('test2@' . \config('app.domain')); + $domain = $this->getTestDomain('domainscontroller.com', [ + 'status' => Domain::STATUS_NEW, + 'type' => Domain::TYPE_EXTERNAL, + ]); + + Entitlement::create([ + 'wallet_id' => $user1->wallets()->first()->id, + 'sku_id' => $sku_domain->id, + 'entitleable_id' => $domain->id, + 'entitleable_type' => Domain::class + ]); + + // Not authorized access + $response = $this->actingAs($john)->delete("api/v4/domains/{$domain->id}"); + $response->assertStatus(403); + + // Can't delete non-empty domain + $response = $this->actingAs($john)->delete("api/v4/domains/{$johns_domain->id}"); + $response->assertStatus(422); + + $json = $response->json(); + + $this->assertCount(2, $json); + $this->assertEquals('error', $json['status']); + $this->assertEquals('Unable to delete a domain with assigned users or other objects.', $json['message']); + + // Successful deletion + $response = $this->actingAs($user1)->delete("api/v4/domains/{$domain->id}"); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertCount(2, $json); + $this->assertEquals('success', $json['status']); + $this->assertEquals('Domain deleted successfully.', $json['message']); + $this->assertTrue($domain->fresh()->trashed()); + + // Authorized access by additional account controller + $this->deleteTestDomain('domainscontroller.com'); + $domain = $this->getTestDomain('domainscontroller.com', [ + 'status' => Domain::STATUS_NEW, + 'type' => Domain::TYPE_EXTERNAL, + ]); + + Entitlement::create([ + 'wallet_id' => $user1->wallets()->first()->id, + 'sku_id' => $sku_domain->id, + 'entitleable_id' => $domain->id, + 'entitleable_type' => Domain::class + ]); + + $user1->wallets()->first()->addController($user2); + + $response = $this->actingAs($user2)->delete("api/v4/domains/{$domain->id}"); + $response->assertStatus(200); + + $json = $response->json(); + $this->assertCount(2, $json); + $this->assertEquals('success', $json['status']); + $this->assertEquals('Domain deleted successfully.', $json['message']); + $this->assertTrue($domain->fresh()->trashed()); + } + /** * 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 re-creating a domain + $domain->delete(); + + $response = $this->actingAs($john)->post("/api/v4/domains", $post); + $response->assertStatus(200); + + $json = $response->json(); + + $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); + $this->assertEntitlements($domain, ['domain-hosting']); + $wallet = $domain->wallet(); + $this->assertSame($john->wallets->first()->id, $wallet->id); + + // Test creating a domain that is soft-deleted and belongs to another user + $domain->delete(); + $domain->entitlement()->withTrashed()->update(['wallet_id' => $jack->wallets->first()->id]); + + $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']); + // 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 e5840755..626a9014 100644 --- a/src/tests/Feature/Controller/Reseller/DomainsTest.php +++ b/src/tests/Feature/Controller/Reseller/DomainsTest.php @@ -1,298 +1,312 @@ deleteTestDomain('domainscontroller.com'); } /** * {@inheritDoc} */ public function tearDown(): void { + $this->deleteTestUser('test1@domainscontroller.com'); $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 deleting a domain (DELETE /api/v4/domains/) + */ + public function testDestroy(): 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)->delete("api/v4/domains/{$domain->id}"); + $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'); + $user = $this->getTestUser('test1@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'); + $user = $this->getTestUser('test1@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/DomainTest.php b/src/tests/Feature/DomainTest.php index 5ffa4a7d..7f71fb62 100644 --- a/src/tests/Feature/DomainTest.php +++ b/src/tests/Feature/DomainTest.php @@ -1,308 +1,336 @@ domains as $domain) { $this->deleteTestDomain($domain); } $this->deleteTestUser('user@gmail.com'); } /** * {@inheritDoc} */ public function tearDown(): void { foreach ($this->domains as $domain) { $this->deleteTestDomain($domain); } $this->deleteTestUser('user@gmail.com'); parent::tearDown(); } /** * Test domain create/creating observer */ public function testCreate(): void { Queue::fake(); $domain = Domain::create([ 'namespace' => 'GMAIL.COM', 'status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_EXTERNAL, ]); $result = Domain::where('namespace', 'gmail.com')->first(); $this->assertSame('gmail.com', $result->namespace); $this->assertSame($domain->id, $result->id); $this->assertSame($domain->type, $result->type); $this->assertSame(Domain::STATUS_NEW, $result->status); } /** * Test domain creating jobs */ public function testCreateJobs(): void { // Fake the queue, assert that no jobs were pushed... Queue::fake(); Queue::assertNothingPushed(); $domain = Domain::create([ 'namespace' => 'gmail.com', 'status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_EXTERNAL, ]); Queue::assertPushed(\App\Jobs\Domain\CreateJob::class, 1); Queue::assertPushed( \App\Jobs\Domain\CreateJob::class, function ($job) use ($domain) { $domainId = TestCase::getObjectProperty($job, 'domainId'); $domainNamespace = TestCase::getObjectProperty($job, 'domainNamespace'); return $domainId === $domain->id && $domainNamespace === $domain->namespace; } ); $job = new \App\Jobs\Domain\CreateJob($domain->id); $job->handle(); } /** * Tests getPublicDomains() method */ public function testGetPublicDomains(): void { $public_domains = Domain::getPublicDomains(); $this->assertNotContains('public-active.com', $public_domains); $queue = Queue::fake(); $domain = Domain::create([ 'namespace' => 'public-active.com', 'status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_EXTERNAL, ]); // External domains should not be returned $public_domains = Domain::getPublicDomains(); $this->assertNotContains('public-active.com', $public_domains); $domain->type = Domain::TYPE_PUBLIC; $domain->save(); $public_domains = Domain::getPublicDomains(); $this->assertContains('public-active.com', $public_domains); // Domains of other tenants should not be returned $tenant = Tenant::whereNotIn('id', [\config('app.tenant_id')])->first(); $domain->tenant_id = $tenant->id; $domain->save(); $public_domains = Domain::getPublicDomains(); $this->assertNotContains('public-active.com', $public_domains); } /** * Test domain (ownership) confirmation * * @group dns */ public function testConfirm(): void { /* DNS records for positive and negative tests - kolab.org: ci-success-cname A 212.103.80.148 ci-success-cname MX 10 mx01.kolabnow.com. ci-success-cname TXT "v=spf1 mx -all" kolab-verify.ci-success-cname CNAME 2b719cfa4e1033b1e1e132977ed4fe3e.ci-success-cname ci-failure-cname A 212.103.80.148 ci-failure-cname MX 10 mx01.kolabnow.com. kolab-verify.ci-failure-cname CNAME 2b719cfa4e1033b1e1e132977ed4fe3e.ci-failure-cname ci-success-txt A 212.103.80.148 ci-success-txt MX 10 mx01.kolabnow.com. ci-success-txt TXT "v=spf1 mx -all" ci-success-txt TXT "kolab-verify=de5d04ababb52d52e2519a2f16d11422" ci-failure-txt A 212.103.80.148 ci-failure-txt MX 10 mx01.kolabnow.com. kolab-verify.ci-failure-txt TXT "kolab-verify=de5d04ababb52d52e2519a2f16d11422" ci-failure-none A 212.103.80.148 ci-failure-none MX 10 mx01.kolabnow.com. */ $queue = Queue::fake(); $domain_props = ['status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_EXTERNAL]; $domain = $this->getTestDomain('ci-failure-none.kolab.org', $domain_props); $this->assertTrue($domain->confirm() === false); $this->assertFalse($domain->isConfirmed()); $domain = $this->getTestDomain('ci-failure-txt.kolab.org', $domain_props); $this->assertTrue($domain->confirm() === false); $this->assertFalse($domain->isConfirmed()); $domain = $this->getTestDomain('ci-failure-cname.kolab.org', $domain_props); $this->assertTrue($domain->confirm() === false); $this->assertFalse($domain->isConfirmed()); $domain = $this->getTestDomain('ci-success-txt.kolab.org', $domain_props); $this->assertTrue($domain->confirm()); $this->assertTrue($domain->isConfirmed()); $domain = $this->getTestDomain('ci-success-cname.kolab.org', $domain_props); $this->assertTrue($domain->confirm()); $this->assertTrue($domain->isConfirmed()); } /** * Test domain deletion */ public function testDelete(): void { Queue::fake(); $domain = $this->getTestDomain('gmail.com', [ 'status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_PUBLIC, ]); $domain->delete(); $this->assertTrue($domain->fresh()->trashed()); $this->assertFalse($domain->fresh()->isDeleted()); // Delete the domain for real $job = new \App\Jobs\Domain\DeleteJob($domain->id); $job->handle(); $this->assertTrue(Domain::withTrashed()->where('id', $domain->id)->first()->isDeleted()); $domain->forceDelete(); $this->assertCount(0, Domain::withTrashed()->where('id', $domain->id)->get()); } + /** + * Test isEmpty() method + */ + public function testIsEmpty(): void + { + Queue::fake(); + + // Empty domain + $domain = $this->getTestDomain('gmail.com', [ + 'status' => Domain::STATUS_NEW, + 'type' => Domain::TYPE_EXTERNAL, + ]); + + $this->assertTrue($domain->isEmpty()); + + // TODO: Test with adding a group/alias/user, each separately + + // Empty public domain + $domain = Domain::where('namespace', 'libertymail.net')->first(); + + $this->assertFalse($domain->isEmpty()); + + // Non-empty private domain + $domain = Domain::where('namespace', 'kolab.org')->first(); + + $this->assertFalse($domain->isEmpty()); + } + /** * Test domain restoring */ public function testRestore(): void { Queue::fake(); $domain = $this->getTestDomain('gmail.com', [ 'status' => Domain::STATUS_NEW | Domain::STATUS_SUSPENDED | Domain::STATUS_LDAP_READY | Domain::STATUS_CONFIRMED, 'type' => Domain::TYPE_PUBLIC, ]); $user = $this->getTestUser('user@gmail.com'); $sku = \App\Sku::where('title', 'domain-hosting')->first(); $now = \Carbon\Carbon::now(); // Assign two entitlements to the domain, so we can assert that only the // ones deleted last will be restored $ent1 = \App\Entitlement::create([ 'wallet_id' => $user->wallets->first()->id, 'sku_id' => $sku->id, 'cost' => 0, 'entitleable_id' => $domain->id, 'entitleable_type' => Domain::class, ]); $ent2 = \App\Entitlement::create([ 'wallet_id' => $user->wallets->first()->id, 'sku_id' => $sku->id, 'cost' => 0, 'entitleable_id' => $domain->id, 'entitleable_type' => Domain::class, ]); $domain->delete(); $this->assertTrue($domain->fresh()->trashed()); $this->assertFalse($domain->fresh()->isDeleted()); $this->assertTrue($ent1->fresh()->trashed()); $this->assertTrue($ent2->fresh()->trashed()); // Backdate some properties \App\Entitlement::withTrashed()->where('id', $ent2->id)->update(['deleted_at' => $now->subMinutes(2)]); \App\Entitlement::withTrashed()->where('id', $ent1->id)->update(['updated_at' => $now->subMinutes(10)]); Queue::fake(); $domain->restore(); $domain->refresh(); $this->assertFalse($domain->trashed()); $this->assertFalse($domain->isDeleted()); $this->assertFalse($domain->isSuspended()); $this->assertFalse($domain->isLdapReady()); $this->assertTrue($domain->isActive()); $this->assertTrue($domain->isConfirmed()); // Assert entitlements $this->assertTrue($ent2->fresh()->trashed()); $this->assertFalse($ent1->fresh()->trashed()); $this->assertTrue($ent1->updated_at->greaterThan(\Carbon\Carbon::now()->subSeconds(5))); // We expect only one CreateJob and one UpdateJob // Because how Illuminate/Database/Eloquent/SoftDeletes::restore() method // is implemented we cannot skip the UpdateJob in any way. // I don't want to overwrite this method, the extra job shouldn't do any harm. $this->assertCount(2, Queue::pushedJobs()); // @phpstan-ignore-line Queue::assertPushed(\App\Jobs\Domain\UpdateJob::class, 1); Queue::assertPushed(\App\Jobs\Domain\CreateJob::class, 1); Queue::assertPushed( \App\Jobs\Domain\CreateJob::class, function ($job) use ($domain) { return $domain->id === TestCase::getObjectProperty($job, 'domainId'); } ); } }