diff --git a/src/app/Http/Controllers/API/V4/Admin/DiscountsController.php b/src/app/Http/Controllers/API/V4/Admin/DiscountsController.php index e6487db4..072d4981 100644 --- a/src/app/Http/Controllers/API/V4/Admin/DiscountsController.php +++ b/src/app/Http/Controllers/API/V4/Admin/DiscountsController.php @@ -1,42 +1,45 @@ orderBy('discount')->get() + Discount::withEnvTenant() + ->where('active', true) + ->orderBy('discount') + ->get() ->map(function ($discount) use (&$discounts) { $label = $discount->discount . '% - ' . $discount->description; if ($discount->code) { $label .= " [{$discount->code}]"; } $discounts[] = [ 'id' => $discount->id, 'discount' => $discount->discount, 'code' => $discount->code, 'description' => $discount->description, 'label' => $label, ]; }); return response()->json([ 'status' => 'success', 'list' => $discounts, 'count' => count($discounts), ]); } } diff --git a/src/app/Http/Controllers/API/V4/Admin/DomainsController.php b/src/app/Http/Controllers/API/V4/Admin/DomainsController.php index e83d929a..aead2568 100644 --- a/src/app/Http/Controllers/API/V4/Admin/DomainsController.php +++ b/src/app/Http/Controllers/API/V4/Admin/DomainsController.php @@ -1,104 +1,104 @@ input('search')); $owner = trim(request()->input('owner')); $result = collect([]); if ($owner) { - if ($owner = User::find($owner)) { + if ($owner = User::withEnvTenant()->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()) { + if ($domain = Domain::withEnvTenant()->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); } /** * 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); + $domain = Domain::withEnvTenant()->find($id); if (empty($domain) || $domain->isPublic()) { return $this->errorResponse(404); } $domain->suspend(); return response()->json([ 'status' => 'success', 'message' => __('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); + $domain = Domain::withEnvTenant()->find($id); if (empty($domain) || $domain->isPublic()) { return $this->errorResponse(404); } $domain->unsuspend(); return response()->json([ 'status' => 'success', 'message' => __('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 631891ff..46652db2 100644 --- a/src/app/Http/Controllers/API/V4/DomainsController.php +++ b/src/app/Http/Controllers/API/V4/DomainsController.php @@ -1,381 +1,381 @@ user(); $list = []; foreach ($user->domains() as $domain) { if (!$domain->isPublic()) { $data = $domain->toArray(); $data = array_merge($data, self::domainStatuses($domain)); $list[] = $data; } } return response()->json($list); } /** * Show the form for creating a new resource. * * @return \Illuminate\Http\JsonResponse */ public function create() { return $this->errorResponse(404); } /** * Confirm ownership of the specified domain (via DNS check). * * @param int $id Domain identifier * * @return \Illuminate\Http\JsonResponse|void */ public function confirm($id) { $domain = Domain::findOrFail($id); // Only owner (or admin) has access to the domain if (!Auth::guard()->user()->canRead($domain)) { return $this->errorResponse(403); } if (!$domain->confirm()) { return response()->json([ 'status' => 'error', 'message' => \trans('app.domain-verify-error'), ]); } return response()->json([ 'status' => 'success', 'statusInfo' => self::statusInfo($domain), 'message' => \trans('app.domain-verify-success'), ]); } /** * Remove the specified resource from storage. * * @param int $id * * @return \Illuminate\Http\JsonResponse */ public function destroy($id) { return $this->errorResponse(404); } /** * Show the form for editing the specified resource. * * @param int $id * * @return \Illuminate\Http\JsonResponse */ public function edit($id) { return $this->errorResponse(404); } /** * Store a newly created resource in storage. * * @param \Illuminate\Http\Request $request * * @return \Illuminate\Http\JsonResponse */ public function store(Request $request) { return $this->errorResponse(404); } /** * Get the information about the specified domain. * * @param int $id Domain identifier * * @return \Illuminate\Http\JsonResponse|void */ public function show($id) { - $domain = Domain::findOrFail($id); + $domain = Domain::withEnvTenant()->findOrFail($id); // Only owner (or admin) has access to the domain if (!Auth::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['config'] = self::getMXConfig($domain->namespace); // Status info $response['statusInfo'] = self::statusInfo($domain); $response = array_merge($response, self::domainStatuses($domain)); 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); // Only owner (or admin) has access to the domain if (!Auth::guard()->user()->canRead($domain)) { return $this->errorResponse(403); } $response = self::statusInfo($domain); if (!empty(request()->input('refresh'))) { $updated = false; $last_step = 'none'; foreach ($response['process'] as $idx => $step) { $last_step = $step['label']; if (!$step['state']) { if (!$this->execProcessStep($domain, $step['label'])) { break; } $updated = true; } } if ($updated) { $response = self::statusInfo($domain); } $success = $response['isReady']; $suffix = $success ? 'success' : 'error-' . $last_step; $response['status'] = $success ? 'success' : 'error'; $response['message'] = \trans('app.process-' . $suffix); } $response = array_merge($response, self::domainStatuses($domain)); return response()->json($response); } /** * Update the specified resource in storage. * * @param \Illuminate\Http\Request $request * @param int $id * * @return \Illuminate\Http\JsonResponse */ public function update(Request $request, $id) { return $this->errorResponse(404); } /** * Provide DNS MX information to configure specified domain for */ protected static function getMXConfig(string $namespace): array { $entries = []; // copy MX entries from an existing domain if ($master = \config('dns.copyfrom')) { // TODO: cache this lookup foreach ((array) dns_get_record($master, DNS_MX) as $entry) { $entries[] = sprintf( "@\t%s\t%s\tMX\t%d %s.", \config('dns.ttl', $entry['ttl']), $entry['class'], $entry['pri'], $entry['target'] ); } } elseif ($static = \config('dns.static')) { $entries[] = strtr($static, array('\n' => "\n", '%s' => $namespace)); } // display SPF settings if ($spf = \config('dns.spf')) { $entries[] = ';'; foreach (['TXT', 'SPF'] as $type) { $entries[] = sprintf( "@\t%s\tIN\t%s\t\"%s\"", \config('dns.ttl'), $type, $spf ); } } return $entries; } /** * Provide sample DNS config for domain confirmation */ protected static function getDNSConfig(Domain $domain): array { $serial = date('Ymd01'); $hash_txt = $domain->hash(Domain::HASH_TEXT); $hash_cname = $domain->hash(Domain::HASH_CNAME); $hash = $domain->hash(Domain::HASH_CODE); return [ "@ IN SOA ns1.dnsservice.com. hostmaster.{$domain->namespace}. (", " {$serial} 10800 3600 604800 86400 )", ";", "@ IN A ", "www IN A ", ";", "{$hash_cname}.{$domain->namespace}. IN CNAME {$hash}.{$domain->namespace}.", "@ 3600 TXT \"{$hash_txt}\"", ]; } /** * Prepare domain statuses for the UI * * @param \App\Domain $domain Domain object * * @return array Statuses array */ protected static function domainStatuses(Domain $domain): array { return [ 'isLdapReady' => $domain->isLdapReady(), 'isConfirmed' => $domain->isConfirmed(), 'isVerified' => $domain->isVerified(), 'isSuspended' => $domain->isSuspended(), 'isActive' => $domain->isActive(), 'isDeleted' => $domain->isDeleted() || $domain->trashed(), ]; } /** * Domain status (extended) information. * * @param \App\Domain $domain Domain object * * @return array Status information */ public static function statusInfo(Domain $domain): array { $process = []; // If that is not a public domain, add domain specific steps $steps = [ 'domain-new' => true, 'domain-ldap-ready' => $domain->isLdapReady(), 'domain-verified' => $domain->isVerified(), 'domain-confirmed' => $domain->isConfirmed(), ]; $count = count($steps); // Create a process check list foreach ($steps as $step_name => $state) { $step = [ 'label' => $step_name, 'title' => \trans("app.process-{$step_name}"), 'state' => $state, ]; if ($step_name == 'domain-confirmed' && !$state) { $step['link'] = "/domain/{$domain->id}"; } $process[] = $step; if ($state) { $count--; } } $state = $count === 0 ? 'done' : 'running'; // After 180 seconds assume the process is in failed state, // this should unlock the Refresh button in the UI if ($count > 0 && $domain->created_at->diffInSeconds(Carbon::now()) > 180) { $state = 'failed'; } return [ 'process' => $process, 'processState' => $state, 'isReady' => $count === 0, ]; } /** * Execute (synchronously) specified step in a domain setup process. * * @param \App\Domain $domain Domain object * @param string $step Step identifier (as in self::statusInfo()) * * @return bool True if the execution succeeded, False otherwise */ public static function execProcessStep(Domain $domain, string $step): bool { try { switch ($step) { case 'domain-ldap-ready': // Domain not in LDAP, create it if (!$domain->isLdapReady()) { LDAP::createDomain($domain); $domain->status |= Domain::STATUS_LDAP_READY; $domain->save(); } return $domain->isLdapReady(); case 'domain-verified': // Domain existence not verified $domain->verify(); return $domain->isVerified(); case 'domain-confirmed': // Domain ownership confirmation $domain->confirm(); return $domain->isConfirmed(); } } catch (\Exception $e) { \Log::error($e); } return false; } } diff --git a/src/app/Http/Controllers/API/V4/Reseller/DiscountsController.php b/src/app/Http/Controllers/API/V4/Reseller/DiscountsController.php index 31d264ec..6edddbc8 100644 --- a/src/app/Http/Controllers/API/V4/Reseller/DiscountsController.php +++ b/src/app/Http/Controllers/API/V4/Reseller/DiscountsController.php @@ -1,45 +1,7 @@ user(); - - $discounts = $user->tenant->discounts() - ->where('active', true) - ->orderBy('discount') - ->get() - ->map(function ($discount) { - $label = $discount->discount . '% - ' . $discount->description; - - if ($discount->code) { - $label .= " [{$discount->code}]"; - } - - return [ - 'id' => $discount->id, - 'discount' => $discount->discount, - 'code' => $discount->code, - 'description' => $discount->description, - 'label' => $label, - ]; - }); - - return response()->json([ - 'status' => 'success', - 'list' => $discounts, - 'count' => count($discounts), - ]); - } } diff --git a/src/app/Http/Controllers/API/V4/Reseller/DomainsController.php b/src/app/Http/Controllers/API/V4/Reseller/DomainsController.php index 8f15fe63..c4565e6b 100644 --- a/src/app/Http/Controllers/API/V4/Reseller/DomainsController.php +++ b/src/app/Http/Controllers/API/V4/Reseller/DomainsController.php @@ -1,55 +1,7 @@ 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'); - } - } 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); - } } diff --git a/src/app/Http/Controllers/Controller.php b/src/app/Http/Controllers/Controller.php index 9e957987..be92cf19 100644 --- a/src/app/Http/Controllers/Controller.php +++ b/src/app/Http/Controllers/Controller.php @@ -1,49 +1,49 @@ "Bad request", 401 => "Unauthorized", 403 => "Access denied", 404 => "Not found", 422 => "Input validation error", 405 => "Method not allowed", 500 => "Internal server error", ]; $response = [ 'status' => 'error', 'message' => $message ?: (isset($errors[$code]) ? $errors[$code] : "Server error"), ]; if (!empty($data)) { $response = $response + $data; } return response()->json($response, $code); } } diff --git a/src/app/Utils.php b/src/app/Utils.php index 70f67644..e6f47d8f 100644 --- a/src/app/Utils.php +++ b/src/app/Utils.php @@ -1,411 +1,410 @@ = INET_ATON(?) ORDER BY INET_ATON(net_number), net_mask DESC LIMIT 1 "; } else { $query = " SELECT id FROM ip6nets WHERE INET6_ATON(net_number) <= INET6_ATON(?) AND INET6_ATON(net_broadcast) >= INET6_ATON(?) ORDER BY INET6_ATON(net_number), net_mask DESC LIMIT 1 "; } $nets = \Illuminate\Support\Facades\DB::select($query, [$ip, $ip]); if (sizeof($nets) > 0) { return $nets[0]->country; } return 'CH'; } /** * Return the country ISO code for the current request. */ public static function countryForRequest() { $request = \request(); $ip = $request->ip(); return self::countryForIP($ip); } /** * Shortcut to creating a progress bar of a particular format with a particular message. * * @param \Illuminate\Console\OutputStyle $output Console output object * @param int $count Number of progress steps * @param string $message The description * * @return \Symfony\Component\Console\Helper\ProgressBar */ public static function createProgressBar($output, $count, $message = null) { $bar = $output->createProgressBar($count); $bar->setFormat( '%current:7s%/%max:7s% [%bar%] %percent:3s%% %elapsed:7s%/%estimated:-7s% %message% ' ); if ($message) { $bar->setMessage($message . " ..."); } $bar->start(); return $bar; } /** * Return the number of days in the month prior to this one. * * @return int */ public static function daysInLastMonth() { $start = new Carbon('first day of last month'); $end = new Carbon('last day of last month'); return $start->diffInDays($end) + 1; } /** * Download a file from the interwebz and store it locally. * * @param string $source The source location * @param string $target The target location * @param bool $force Force the download (and overwrite target) * * @return void */ public static function downloadFile($source, $target, $force = false) { if (is_file($target) && !$force) { return; } \Log::info("Retrieving {$source}"); $fp = fopen($target, 'w'); $curl = curl_init(); curl_setopt($curl, CURLOPT_URL, $source); curl_setopt($curl, CURLOPT_RETURNTRANSFER, 1); curl_setopt($curl, CURLOPT_FILE, $fp); curl_exec($curl); if (curl_errno($curl)) { \Log::error("Request error on {$source}: " . curl_error($curl)); curl_close($curl); fclose($fp); unlink($target); return; } curl_close($curl); fclose($fp); } /** * Calculate the broadcast address provided a net number and a prefix. * * @param string $net A valid IPv6 network number. * @param int $prefix The network prefix. * * @return string */ public static function ip6Broadcast($net, $prefix) { $netHex = bin2hex(inet_pton($net)); // Overwriting first address string to make sure notation is optimal $net = inet_ntop(hex2bin($netHex)); // Calculate the number of 'flexible' bits $flexbits = 128 - $prefix; // Build the hexadecimal string of the last address $lastAddrHex = $netHex; // We start at the end of the string (which is always 32 characters long) $pos = 31; while ($flexbits > 0) { // Get the character at this position $orig = substr($lastAddrHex, $pos, 1); // Convert it to an integer $origval = hexdec($orig); // OR it with (2^flexbits)-1, with flexbits limited to 4 at a time $newval = $origval | (pow(2, min(4, $flexbits)) - 1); // Convert it back to a hexadecimal character $new = dechex($newval); // And put that character back in the string $lastAddrHex = substr_replace($lastAddrHex, $new, $pos, 1); // We processed one nibble, move to previous position $flexbits -= 4; $pos -= 1; } // Convert the hexadecimal string to a binary string # Using pack() here # Newer PHP version can use hex2bin() $lastaddrbin = pack('H*', $lastAddrHex); // And create an IPv6 address from the binary string $lastaddrstr = inet_ntop($lastaddrbin); return $lastaddrstr; } /** * Provide all unique combinations of elements in $input, with order and duplicates irrelevant. * * @param array $input The input array of elements. * * @return array[] */ public static function powerSet(array $input): array { $output = []; for ($x = 0; $x < count($input); $x++) { self::combine($input, $x + 1, 0, [], 0, $output); } return $output; } /** * Returns the current user's email address or null. * * @return string */ public static function userEmailOrNull(): ?string { $user = Auth::user(); if (!$user) { return null; } return $user->email; } /** * Returns a random string consisting of a quantity of segments of a certain length joined. * * Example: * * ```php * $roomName = strtolower(\App\Utils::randStr(3, 3, '-'); * // $roomName == '3qb-7cs-cjj' * ``` * * @param int $length The length of each segment * @param int $qty The quantity of segments * @param string $join The string to use to join the segments * * @return string */ public static function randStr($length, $qty = 1, $join = '') { $chars = env('SHORTCODE_CHARS', self::CHARS); $randStrs = []; for ($x = 0; $x < $qty; $x++) { $randStrs[$x] = []; for ($y = 0; $y < $length; $y++) { $randStrs[$x][] = $chars[rand(0, strlen($chars) - 1)]; } shuffle($randStrs[$x]); $randStrs[$x] = implode('', $randStrs[$x]); } return implode($join, $randStrs); } /** * Returns a UUID in the form of an integer. * * @return integer */ public static function uuidInt(): int { $hex = Uuid::uuid4(); $bin = pack('h*', str_replace('-', '', $hex)); $ids = unpack('L', $bin); $id = array_shift($ids); return $id; } /** * Returns a UUID in the form of a string. * * @return string */ public static function uuidStr(): string { return Uuid::uuid4()->toString(); } private static function combine($input, $r, $index, $data, $i, &$output): void { $n = count($input); // Current cobination is ready if ($index == $r) { $output[] = array_slice($data, 0, $r); return; } // When no more elements are there to put in data[] if ($i >= $n) { return; } // current is included, put next at next location $data[$index] = $input[$i]; self::combine($input, $r, $index + 1, $data, $i + 1, $output); // current is excluded, replace it with next (Note that i+1 // is passed, but index is not changed) self::combine($input, $r, $index, $data, $i + 1, $output); } /** * Create self URL * * @param string $route Route/Path * @todo Move this to App\Http\Controllers\Controller * * @return string Full URL */ public static function serviceUrl(string $route): string { $url = \config('app.public_url'); if (!$url) { $url = \config('app.url'); } return rtrim(trim($url, '/') . '/' . ltrim($route, '/'), '/'); } /** * Create a configuration/environment data to be passed to * the UI * * @todo Move this to App\Http\Controllers\Controller * * @return array Configuration data */ public static function uiEnv(): array { $countries = include resource_path('countries.php'); $req_domain = preg_replace('/:[0-9]+$/', '', request()->getHttpHost()); $sys_domain = \config('app.domain'); - $path = request()->path(); $opts = [ 'app.name', 'app.url', 'app.domain', 'app.theme', 'app.webmail_url', 'app.support_email', 'mail.from.address' ]; $env = \app('config')->getMany($opts); $env['countries'] = $countries ?: []; $env['view'] = 'root'; $env['jsapp'] = 'user.js'; if ($req_domain == "admin.$sys_domain") { $env['jsapp'] = 'admin.js'; } elseif ($req_domain == "reseller.$sys_domain") { $env['jsapp'] = 'reseller.js'; } $env['paymentProvider'] = \config('services.payment_provider'); $env['stripePK'] = \config('services.stripe.public_key'); $theme_file = resource_path("themes/{$env['app.theme']}/theme.json"); $menu = []; if (file_exists($theme_file)) { $theme = json_decode(file_get_contents($theme_file), true); if (json_last_error() != JSON_ERROR_NONE) { \Log::error("Failed to parse $theme_file: " . json_last_error_msg()); } elseif (!empty($theme['menu'])) { $menu = $theme['menu']; } } $env['menu'] = $menu; return $env; } } diff --git a/src/routes/api.php b/src/routes/api.php index 554f4fa6..f74f7ff7 100644 --- a/src/routes/api.php +++ b/src/routes/api.php @@ -1,183 +1,185 @@ 'api', 'prefix' => $prefix . 'api/auth' ], function ($router) { Route::post('login', 'API\AuthController@login'); Route::group( ['middleware' => 'auth:api'], function ($router) { Route::get('info', 'API\AuthController@info'); Route::post('logout', 'API\AuthController@logout'); Route::post('refresh', 'API\AuthController@refresh'); } ); } ); Route::group( [ 'domain' => \config('app.domain'), 'middleware' => 'api', 'prefix' => $prefix . 'api/auth' ], function ($router) { Route::post('password-reset/init', 'API\PasswordResetController@init'); Route::post('password-reset/verify', 'API\PasswordResetController@verify'); Route::post('password-reset', 'API\PasswordResetController@reset'); Route::get('signup/plans', 'API\SignupController@plans'); Route::post('signup/init', 'API\SignupController@init'); Route::post('signup/verify', 'API\SignupController@verify'); Route::post('signup', 'API\SignupController@signup'); } ); Route::group( [ 'domain' => \config('app.domain'), 'middleware' => 'auth:api', 'prefix' => $prefix . 'api/v4' ], function () { Route::apiResource('domains', API\V4\DomainsController::class); Route::get('domains/{id}/confirm', 'API\V4\DomainsController@confirm'); Route::get('domains/{id}/status', 'API\V4\DomainsController@status'); Route::apiResource('entitlements', API\V4\EntitlementsController::class); Route::apiResource('packages', API\V4\PackagesController::class); Route::apiResource('skus', API\V4\SkusController::class); Route::apiResource('users', API\V4\UsersController::class); Route::get('users/{id}/skus', 'API\V4\SkusController@userSkus'); Route::get('users/{id}/status', 'API\V4\UsersController@status'); Route::apiResource('wallets', API\V4\WalletsController::class); Route::get('wallets/{id}/transactions', 'API\V4\WalletsController@transactions'); Route::get('wallets/{id}/receipts', 'API\V4\WalletsController@receipts'); Route::get('wallets/{id}/receipts/{receipt}', 'API\V4\WalletsController@receiptDownload'); Route::post('payments', 'API\V4\PaymentsController@store'); Route::get('payments/mandate', 'API\V4\PaymentsController@mandate'); Route::post('payments/mandate', 'API\V4\PaymentsController@mandateCreate'); Route::put('payments/mandate', 'API\V4\PaymentsController@mandateUpdate'); Route::delete('payments/mandate', 'API\V4\PaymentsController@mandateDelete'); Route::get('openvidu/rooms', 'API\V4\OpenViduController@index'); Route::post('openvidu/rooms/{id}/close', 'API\V4\OpenViduController@closeRoom'); Route::post('openvidu/rooms/{id}/config', 'API\V4\OpenViduController@setRoomConfig'); // FIXME: I'm not sure about this one, should we use DELETE request maybe? Route::post('openvidu/rooms/{id}/connections/{conn}/dismiss', 'API\V4\OpenViduController@dismissConnection'); Route::put('openvidu/rooms/{id}/connections/{conn}', 'API\V4\OpenViduController@updateConnection'); Route::post('openvidu/rooms/{id}/request/{reqid}/accept', 'API\V4\OpenViduController@acceptJoinRequest'); Route::post('openvidu/rooms/{id}/request/{reqid}/deny', 'API\V4\OpenViduController@denyJoinRequest'); } ); // Note: In Laravel 7.x we could just use withoutMiddleware() instead of a separate group Route::group( [ 'domain' => \config('app.domain'), 'prefix' => $prefix . 'api/v4' ], function () { Route::post('openvidu/rooms/{id}', 'API\V4\OpenViduController@joinRoom'); Route::post('openvidu/rooms/{id}/connections', 'API\V4\OpenViduController@createConnection'); // FIXME: I'm not sure about this one, should we use DELETE request maybe? Route::post('openvidu/rooms/{id}/connections/{conn}/dismiss', 'API\V4\OpenViduController@dismissConnection'); Route::put('openvidu/rooms/{id}/connections/{conn}', 'API\V4\OpenViduController@updateConnection'); Route::post('openvidu/rooms/{id}/request/{reqid}/accept', 'API\V4\OpenViduController@acceptJoinRequest'); Route::post('openvidu/rooms/{id}/request/{reqid}/deny', 'API\V4\OpenViduController@denyJoinRequest'); } ); Route::group( [ 'domain' => \config('app.domain'), 'middleware' => 'api', 'prefix' => $prefix . 'api/v4' ], function ($router) { Route::post('support/request', 'API\V4\SupportController@request'); } ); Route::group( [ 'domain' => \config('app.domain'), 'prefix' => $prefix . 'api/webhooks', ], function () { Route::post('payment/{provider}', 'API\V4\PaymentsController@webhook'); Route::post('meet/openvidu', 'API\V4\OpenViduController@webhook'); } ); Route::group( [ 'domain' => 'admin.' . \config('app.domain'), 'middleware' => ['auth:api', 'admin'], 'prefix' => $prefix . 'api/v4', ], function () { Route::apiResource('domains', API\V4\Admin\DomainsController::class); - Route::get('domains/{id}/confirm', 'API\V4\Admin\DomainsController@confirm'); + // Route::get('domains/{id}/confirm', 'API\V4\Admin\DomainsController@confirm'); Route::post('domains/{id}/suspend', 'API\V4\Admin\DomainsController@suspend'); Route::post('domains/{id}/unsuspend', 'API\V4\Admin\DomainsController@unsuspend'); Route::apiResource('entitlements', API\V4\Admin\EntitlementsController::class); Route::apiResource('packages', API\V4\Admin\PackagesController::class); Route::apiResource('skus', API\V4\Admin\SkusController::class); Route::apiResource('users', API\V4\Admin\UsersController::class); Route::post('users/{id}/reset2FA', 'API\V4\Admin\UsersController@reset2FA'); Route::get('users/{id}/skus', 'API\V4\Admin\SkusController@userSkus'); Route::post('users/{id}/suspend', 'API\V4\Admin\UsersController@suspend'); Route::post('users/{id}/unsuspend', 'API\V4\Admin\UsersController@unsuspend'); Route::apiResource('wallets', API\V4\Admin\WalletsController::class); Route::post('wallets/{id}/one-off', 'API\V4\Admin\WalletsController@oneOff'); Route::get('wallets/{id}/transactions', 'API\V4\Admin\WalletsController@transactions'); Route::apiResource('discounts', API\V4\Admin\DiscountsController::class); Route::get('stats/chart/{chart}', 'API\V4\Admin\StatsController@chart'); } ); Route::group( [ 'domain' => 'reseller.' . \config('app.domain'), 'middleware' => ['auth:api', 'reseller'], 'prefix' => $prefix . 'api/v4', ], function () { Route::apiResource('domains', API\V4\Reseller\DomainsController::class); - Route::get('domains/{id}/confirm', 'API\V4\Reseller\DomainsController@confirm'); + // Route::get('domains/{id}/confirm', 'API\V4\Reseller\DomainsController@confirm'); + Route::post('domains/{id}/suspend', 'API\V4\Admin\DomainsController@suspend'); + Route::post('domains/{id}/unsuspend', 'API\V4\Admin\DomainsController@unsuspend'); Route::apiResource('entitlements', API\V4\Reseller\EntitlementsController::class); Route::apiResource('packages', API\V4\Reseller\PackagesController::class); Route::apiResource('skus', API\V4\Reseller\SkusController::class); Route::apiResource('users', API\V4\Reseller\UsersController::class); Route::get('users/{id}/skus', 'API\V4\Reseller\SkusController@userSkus'); Route::apiResource('wallets', API\V4\Reseller\WalletsController::class); Route::get('wallets/{id}/transactions', 'API\V4\Reseller\WalletsController@transactions'); Route::apiResource('discounts', API\V4\Reseller\DiscountsController::class); } ); diff --git a/src/routes/web.php b/src/routes/web.php index 560a4160..b51fc1cc 100644 --- a/src/routes/web.php +++ b/src/routes/web.php @@ -1,23 +1,28 @@ \config('app.domain'), ], function () { Route::get('content/page/{page}', 'ContentController@pageContent') ->where('page', '(.*)'); Route::get('content/faq/{page}', 'ContentController@faqContent') ->where('page', '(.*)'); Route::fallback( function () { + // Return 404 for requests to the API end-points that do not exist + if (strpos(request()->path(), 'api/') === 0) { + return \App\Http\Controllers\Controller::errorResponse(404); + } + $env = \App\Utils::uiEnv(); return view($env['view'])->with('env', $env); } ); } ); diff --git a/src/tests/Browser/Reseller/DashboardTest.php b/src/tests/Browser/Reseller/DashboardTest.php new file mode 100644 index 00000000..4493c81e --- /dev/null +++ b/src/tests/Browser/Reseller/DashboardTest.php @@ -0,0 +1,140 @@ +getTestUser('jack@kolab.org'); + $jack->setSetting('external_email', null); + + $this->deleteTestUser('test@testsearch.com'); + $this->deleteTestDomain('testsearch.com'); + } + + /** + * {@inheritDoc} + */ + public function tearDown(): void + { + $jack = $this->getTestUser('jack@kolab.org'); + $jack->setSetting('external_email', null); + + $this->deleteTestUser('test@testsearch.com'); + $this->deleteTestDomain('testsearch.com'); + + parent::tearDown(); + } + + /** + * Test user search + */ + public function testSearch(): void + { + $this->browse(function (Browser $browser) { + $browser->visit(new Home()) + ->submitLogon('reseller@kolabnow.com', 'reseller', true) + ->on(new Dashboard()) + ->assertFocused('@search input') + ->assertMissing('@search table'); + + // Test search with no results + $browser->type('@search input', 'unknown') + ->click('@search form button') + ->assertToast(Toast::TYPE_INFO, '0 user accounts have been found.') + ->assertMissing('@search table'); + + $john = $this->getTestUser('john@kolab.org'); + $jack = $this->getTestUser('jack@kolab.org'); + $jack->setSetting('external_email', 'john.doe.external@gmail.com'); + + // Test search with multiple results + $browser->type('@search input', 'john.doe.external@gmail.com') + ->click('@search form button') + ->assertToast(Toast::TYPE_INFO, '2 user accounts have been found.') + ->whenAvailable('@search table', function (Browser $browser) use ($john, $jack) { + $browser->assertElementsCount('tbody tr', 2) + ->with('tbody tr:first-child', function (Browser $browser) use ($jack) { + $browser->assertSeeIn('td:nth-child(1) a', $jack->email) + ->assertSeeIn('td:nth-child(2) a', $jack->id) + ->assertVisible('td:nth-child(3)') + ->assertTextRegExp('td:nth-child(3)', '/^[0-9]{4}-[0-9]{2}-[0-9]{2}$/') + ->assertVisible('td:nth-child(4)') + ->assertText('td:nth-child(4)', ''); + }) + ->with('tbody tr:last-child', function (Browser $browser) use ($john) { + $browser->assertSeeIn('td:nth-child(1) a', $john->email) + ->assertSeeIn('td:nth-child(2) a', $john->id) + ->assertVisible('td:nth-child(3)') + ->assertTextRegExp('td:nth-child(3)', '/^[0-9]{4}-[0-9]{2}-[0-9]{2}$/') + ->assertVisible('td:nth-child(4)') + ->assertText('td:nth-child(4)', ''); + }); + }); + + // Test search with single record result -> redirect to user page + $browser->type('@search input', 'kolab.org') + ->click('@search form button') + ->assertMissing('@search table') + ->waitForLocation('/user/' . $john->id) + ->waitUntilMissing('.app-loader') + ->whenAvailable('#user-info', function (Browser $browser) use ($john) { + $browser->assertSeeIn('.card-title', $john->email); + }); + }); + } + + /** + * Test user search deleted user/domain + */ + public function testSearchDeleted(): void + { + $this->browse(function (Browser $browser) { + $browser->visit(new Home()) + ->submitLogon('reseller@kolabnow.com', 'reseller', true) + ->on(new Dashboard()) + ->assertFocused('@search input') + ->assertMissing('@search table'); + + // Deleted users/domains + $domain = $this->getTestDomain('testsearch.com', ['type' => \App\Domain::TYPE_EXTERNAL]); + $user = $this->getTestUser('test@testsearch.com'); + $plan = \App\Plan::where('title', 'group')->first(); + $user->assignPlan($plan, $domain); + $user->setAliases(['alias@testsearch.com']); + Queue::fake(); + $user->delete(); + + // Test search with multiple results + $browser->type('@search input', 'testsearch.com') + ->click('@search form button') + ->assertToast(Toast::TYPE_INFO, '1 user accounts have been found.') + ->whenAvailable('@search table', function (Browser $browser) use ($user) { + $browser->assertElementsCount('tbody tr', 1) + ->assertVisible('tbody tr:first-child.text-secondary') + ->with('tbody tr:first-child', function (Browser $browser) use ($user) { + $browser->assertSeeIn('td:nth-child(1) span', $user->email) + ->assertSeeIn('td:nth-child(2) span', $user->id) + ->assertVisible('td:nth-child(3)') + ->assertTextRegExp('td:nth-child(3)', '/^[0-9]{4}-[0-9]{2}-[0-9]{2}$/') + ->assertVisible('td:nth-child(4)') + ->assertTextRegExp('td:nth-child(4)', '/^[0-9]{4}-[0-9]{2}-[0-9]{2}$/'); + }); + }); + }); + } +} diff --git a/src/tests/Browser/Reseller/DomainTest.php b/src/tests/Browser/Reseller/DomainTest.php new file mode 100644 index 00000000..e57bcfc9 --- /dev/null +++ b/src/tests/Browser/Reseller/DomainTest.php @@ -0,0 +1,120 @@ +browse(function (Browser $browser) { + $domain = $this->getTestDomain('kolab.org'); + $browser->visit('/domain/' . $domain->id)->on(new Home()); + }); + } + + /** + * Test domain info page + */ + public function testDomainInfo(): void + { + $this->browse(function (Browser $browser) { + $domain = $this->getTestDomain('kolab.org'); + $domain_page = new DomainPage($domain->id); + $reseller = $this->getTestUser('reseller@kolabnow.com'); + $user = $this->getTestUser('john@kolab.org'); + $user_page = new UserPage($user->id); + + // Goto the domain page + $browser->visit(new Home()) + ->submitLogon('reseller@kolabnow.com', 'reseller', true) + ->on(new Dashboard()) + ->visit($user_page) + ->on($user_page) + ->click('@nav #tab-domains') + ->pause(1000) + ->click('@user-domains table tbody tr:first-child td a'); + + $browser->on($domain_page) + ->assertSeeIn('@domain-info .card-title', 'kolab.org') + ->with('@domain-info form', function (Browser $browser) use ($domain) { + $browser->assertElementsCount('.row', 2) + ->assertSeeIn('.row:nth-child(1) label', 'ID (Created at)') + ->assertSeeIn('.row:nth-child(1) #domainid', "{$domain->id} ({$domain->created_at})") + ->assertSeeIn('.row:nth-child(2) label', 'Status') + ->assertSeeIn('.row:nth-child(2) #status span.text-success', 'Active'); + }); + + // Some tabs are loaded in background, wait a second + $browser->pause(500) + ->assertElementsCount('@nav a', 1); + + // Assert Configuration tab + $browser->assertSeeIn('@nav #tab-config', 'Configuration') + ->with('@domain-config', function (Browser $browser) { + $browser->assertSeeIn('pre#dns-verify', 'kolab-verify.kolab.org.') + ->assertSeeIn('pre#dns-config', 'kolab.org.'); + }); + }); + } + + /** + * Test suspending/unsuspending a domain + * + * @depends testDomainInfo + */ + public function testSuspendAndUnsuspend(): void + { + $this->browse(function (Browser $browser) { + $domain = $this->getTestDomain('domainscontroller.com', [ + 'status' => Domain::STATUS_NEW | Domain::STATUS_ACTIVE + | Domain::STATUS_LDAP_READY | Domain::STATUS_CONFIRMED + | Domain::STATUS_VERIFIED, + 'type' => Domain::TYPE_EXTERNAL, + ]); + + $browser->visit(new DomainPage($domain->id)) + ->assertVisible('@domain-info #button-suspend') + ->assertMissing('@domain-info #button-unsuspend') + ->click('@domain-info #button-suspend') + ->assertToast(Toast::TYPE_SUCCESS, 'Domain suspended successfully.') + ->assertSeeIn('@domain-info #status span.text-warning', 'Suspended') + ->assertMissing('@domain-info #button-suspend') + ->click('@domain-info #button-unsuspend') + ->assertToast(Toast::TYPE_SUCCESS, 'Domain unsuspended successfully.') + ->assertSeeIn('@domain-info #status span.text-success', 'Active') + ->assertVisible('@domain-info #button-suspend') + ->assertMissing('@domain-info #button-unsuspend'); + }); + } +} diff --git a/src/tests/Feature/Controller/Admin/DomainsTest.php b/src/tests/Feature/Controller/Admin/DomainsTest.php index 5dcaa7a8..6437a5c5 100644 --- a/src/tests/Feature/Controller/Admin/DomainsTest.php +++ b/src/tests/Feature/Controller/Admin/DomainsTest.php @@ -1,159 +1,223 @@ deleteTestDomain('domainscontroller.com'); } /** * {@inheritDoc} */ public function tearDown(): void { $this->deleteTestDomain('domainscontroller.com'); parent::tearDown(); } + /** + * Test domains confirming (not implemented) + */ + public function testConfirm(): void + { + $admin = $this->getTestUser('jeroen@jeroen.jeroen'); + $domain = $this->getTestDomain('kolab.org'); + + // This end-point does not exist for admins + $response = $this->actingAs($admin)->get("api/v4/domains/{$domain->id}/confirm"); + $response->assertStatus(404); + } + /** * Test domains searching (/api/v4/domains) */ public function testIndex(): void { $john = $this->getTestUser('john@kolab.org'); $admin = $this->getTestUser('jeroen@jeroen.jeroen'); // Non-admin user $response = $this->actingAs($john)->get("api/v4/domains"); $response->assertStatus(403); // Search with no search criteria $response = $this->actingAs($admin)->get("api/v4/domains"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(0, $json['count']); $this->assertSame([], $json['list']); // Search with no matches expected $response = $this->actingAs($admin)->get("api/v4/domains?search=abcd12.org"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(0, $json['count']); $this->assertSame([], $json['list']); // Search by a domain name $response = $this->actingAs($admin)->get("api/v4/domains?search=kolab.org"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(1, $json['count']); $this->assertCount(1, $json['list']); $this->assertSame('kolab.org', $json['list'][0]['namespace']); // Search by owner $response = $this->actingAs($admin)->get("api/v4/domains?owner={$john->id}"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(1, $json['count']); $this->assertCount(1, $json['list']); $this->assertSame('kolab.org', $json['list'][0]['namespace']); // Search by owner (Ned is a controller on John's wallets, // here we expect only domains assigned to Ned's wallet(s)) $ned = $this->getTestUser('ned@kolab.org'); $response = $this->actingAs($admin)->get("api/v4/domains?owner={$ned->id}"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(0, $json['count']); $this->assertCount(0, $json['list']); } + /** + * Test fetching domain info + */ + public function testShow(): void + { + $sku_domain = Sku::where('title', 'domain-hosting')->first(); + $admin = $this->getTestUser('jeroen@jeroen.jeroen'); + $user = $this->getTestUser('test1@domainscontroller.com'); + $domain = $this->getTestDomain('domainscontroller.com', [ + 'status' => Domain::STATUS_NEW, + 'type' => Domain::TYPE_EXTERNAL, + ]); + + Entitlement::create([ + 'wallet_id' => $user->wallets()->first()->id, + 'sku_id' => $sku_domain->id, + 'entitleable_id' => $domain->id, + 'entitleable_type' => Domain::class + ]); + + // Only admins can access it + $response = $this->actingAs($user)->get("api/v4/domains/{$domain->id}"); + $response->assertStatus(403); + + $response = $this->actingAs($admin)->get("api/v4/domains/{$domain->id}"); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertEquals($domain->id, $json['id']); + $this->assertEquals($domain->namespace, $json['namespace']); + $this->assertEquals($domain->status, $json['status']); + $this->assertEquals($domain->type, $json['type']); + // Note: Other properties are being tested in the user controller tests + } + + /** + * Test fetching domain status (GET /api/v4/domains//status) + */ + public function testStatus(): void + { + $admin = $this->getTestUser('jeroen@jeroen.jeroen'); + $domain = $this->getTestDomain('kolab.org'); + + // This end-point does not exist for admins + $response = $this->actingAs($admin)->get("/api/v4/domains/{$domain->id}/status"); + $response->assertStatus(404); + } + /** * Test domain suspending (POST /api/v4/domains//suspend) */ public function testSuspend(): void { Queue::fake(); // disable jobs $domain = $this->getTestDomain('domainscontroller.com', [ 'status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_EXTERNAL, ]); $user = $this->getTestUser('test@domainscontroller.com'); $admin = $this->getTestUser('jeroen@jeroen.jeroen'); // Test unauthorized access to admin API $response = $this->actingAs($user)->post("/api/v4/domains/{$domain->id}/suspend", []); $response->assertStatus(403); $this->assertFalse($domain->fresh()->isSuspended()); // Test suspending the user $response = $this->actingAs($admin)->post("/api/v4/domains/{$domain->id}/suspend", []); $response->assertStatus(200); $json = $response->json(); $this->assertSame('success', $json['status']); $this->assertSame("Domain suspended successfully.", $json['message']); $this->assertCount(2, $json); $this->assertTrue($domain->fresh()->isSuspended()); } /** * Test user un-suspending (POST /api/v4/users//unsuspend) */ public function testUnsuspend(): void { Queue::fake(); // disable jobs $domain = $this->getTestDomain('domainscontroller.com', [ 'status' => Domain::STATUS_NEW | Domain::STATUS_SUSPENDED, 'type' => Domain::TYPE_EXTERNAL, ]); $user = $this->getTestUser('test@domainscontroller.com'); $admin = $this->getTestUser('jeroen@jeroen.jeroen'); // Test unauthorized access to admin API $response = $this->actingAs($user)->post("/api/v4/domains/{$domain->id}/unsuspend", []); $response->assertStatus(403); $this->assertTrue($domain->fresh()->isSuspended()); // Test suspending the user $response = $this->actingAs($admin)->post("/api/v4/domains/{$domain->id}/unsuspend", []); $response->assertStatus(200); $json = $response->json(); $this->assertSame('success', $json['status']); $this->assertSame("Domain unsuspended successfully.", $json['message']); $this->assertCount(2, $json); $this->assertFalse($domain->fresh()->isSuspended()); } } diff --git a/src/tests/Feature/Controller/Reseller/DomainsTest.php b/src/tests/Feature/Controller/Reseller/DomainsTest.php new file mode 100644 index 00000000..846dcbe3 --- /dev/null +++ b/src/tests/Feature/Controller/Reseller/DomainsTest.php @@ -0,0 +1,308 @@ +deleteTestDomain('domainscontroller.com'); + } + + /** + * {@inheritDoc} + */ + public function tearDown(): void + { + $this->deleteTestDomain('domainscontroller.com'); + + parent::tearDown(); + } + + /** + * Test domain confirm request + */ + public function testConfirm(): void + { + $reseller1 = $this->getTestUser('reseller@kolabnow.com'); + $domain = $this->getTestDomain('domainscontroller.com', [ + 'status' => Domain::STATUS_NEW, + 'type' => Domain::TYPE_EXTERNAL, + ]); + + // THe end-point exists on the users controller, but not reseller's + $response = $this->actingAs($reseller1)->get("api/v4/domains/{$domain->id}/confirm"); + $response->assertStatus(404); + } + + /** + * Test domains searching (/api/v4/domains) + */ + public function testIndex(): void + { + $user = $this->getTestUser('john@kolab.org'); + $admin = $this->getTestUser('jeroen@jeroen.jeroen'); + $reseller1 = $this->getTestUser('reseller@kolabnow.com'); + $reseller2 = $this->getTestUser('reseller@reseller.com'); + + // Non-admin user + $response = $this->actingAs($user)->get("api/v4/domains"); + $response->assertStatus(403); + + // Search with no search criteria + $response = $this->actingAs($admin)->get("api/v4/domains"); + $response->assertStatus(403); + + // Search with no search criteria + $response = $this->actingAs($reseller2)->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 + \config(['app.tenant_id' => 2]); + + $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::where('title', 'domain-hosting')->first(); + $admin = $this->getTestUser('jeroen@jeroen.jeroen'); + $user = $this->getTestUser('test1@domainscontroller.com'); + $reseller1 = $this->getTestUser('reseller@kolabnow.com'); + $reseller2 = $this->getTestUser('reseller@reseller.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 + ]); + + // 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(403); + + $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 + + // Unauthorized access (other domain's tenant) + \config(['app.tenant_id' => 2]); + $response = $this->actingAs($reseller2)->get("api/v4/domains/{$domain->id}"); + $response->assertStatus(404); + } + + /** + * Test fetching domain status (GET /api/v4/domains//status) + */ + public function testStatus(): void + { + $reseller1 = $this->getTestUser('reseller@kolabnow.com'); + $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 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@kolabnow.com'); + $reseller2 = $this->getTestUser('reseller@reseller.com'); + + \config(['app.tenant_id' => 2]); + + $domain = $this->getTestDomain('domainscontroller.com', [ + 'status' => Domain::STATUS_NEW, + 'type' => Domain::TYPE_EXTERNAL, + ]); + $user = $this->getTestUser('test@domainscontroller.com'); + + // Test unauthorized access to the reseller API (user) + $response = $this->actingAs($user)->post("/api/v4/domains/{$domain->id}/suspend", []); + $response->assertStatus(403); + + $this->assertFalse($domain->fresh()->isSuspended()); + + // Test unauthorized access to the reseller API (admin) + $response = $this->actingAs($admin)->post("/api/v4/domains/{$domain->id}/suspend", []); + $response->assertStatus(403); + + $this->assertFalse($domain->fresh()->isSuspended()); + + // Test unauthorized access to the reseller API (reseller in another tenant) + $response = $this->actingAs($reseller1)->post("/api/v4/domains/{$domain->id}/suspend", []); + $response->assertStatus(403); + + $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 authenticated reseller, but domain belongs to another tenant + \config(['app.tenant_id' => 1]); + $response = $this->actingAs($reseller1)->post("/api/v4/domains/{$domain->id}/suspend", []); + $response->assertStatus(404); + } + + /** + * 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@kolabnow.com'); + $reseller2 = $this->getTestUser('reseller@reseller.com'); + + \config(['app.tenant_id' => 2]); + + $domain = $this->getTestDomain('domainscontroller.com', [ + 'status' => Domain::STATUS_NEW | Domain::STATUS_SUSPENDED, + 'type' => Domain::TYPE_EXTERNAL, + ]); + $user = $this->getTestUser('test@domainscontroller.com'); + + // Test unauthorized access to reseller API (user) + $response = $this->actingAs($user)->post("/api/v4/domains/{$domain->id}/unsuspend", []); + $response->assertStatus(403); + + $this->assertTrue($domain->fresh()->isSuspended()); + + // Test unauthorized access to reseller API (admin) + $response = $this->actingAs($admin)->post("/api/v4/domains/{$domain->id}/unsuspend", []); + $response->assertStatus(403); + + $this->assertTrue($domain->fresh()->isSuspended()); + + // Test unauthorized access to reseller API (another tenant) + $response = $this->actingAs($reseller1)->post("/api/v4/domains/{$domain->id}/unsuspend", []); + $response->assertStatus(403); + + $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()); + + // Test unauthorized access to reseller API (another tenant) + \config(['app.tenant_id' => 1]); + $response = $this->actingAs($reseller1)->post("/api/v4/domains/{$domain->id}/unsuspend", []); + $response->assertStatus(404); + } +}