diff --git a/src/app/Console/Commands/Tenant/CreateCommand.php b/src/app/Console/Commands/Tenant/CreateCommand.php index 73023c06..859d7f27 100644 --- a/src/app/Console/Commands/Tenant/CreateCommand.php +++ b/src/app/Console/Commands/Tenant/CreateCommand.php @@ -1,166 +1,166 @@ getUser($this->argument('user')); if (!$user) { $this->error('User not found.'); return 1; } DB::beginTransaction(); // Create a tenant $tenant = \App\Tenant::create(['title' => $this->option('title') ?: $user->name()]); // Clone plans, packages, skus for the tenant $sku_map = []; - \App\Sku::withEnvTenant()->where('active', true)->get() + \App\Sku::withEnvTenantContext()->where('active', true)->get() ->each(function ($sku) use ($sku_map, $tenant) { $sku_new = \App\Sku::create([ 'title' => $sku->title, 'name' => $sku->getTranslations('name'), 'description' => $sku->getTranslations('description'), 'cost' => $sku->cost, 'units_free' => $sku->units_free, 'period' => $sku->period, 'handler_class' => $sku->handler_class, 'active' => true, 'fee' => $sku->fee, ]); $sku_new->tenant_id = $tenant->id; $sku_new->save(); $sku_map[$sku->id] = $sku_new->id; }); $plan_map = []; - \App\Plan::withEnvTenant()->get() + \App\Plan::withEnvTenantContext()->get() ->each(function ($plan) use ($plan_map, $tenant) { $plan_new = \App\Plan::create([ 'title' => $plan->title, 'name' => $plan->getTranslations('name'), 'description' => $plan->getTranslations('description'), 'promo_from' => $plan->promo_from, 'promo_to' => $plan->promo_to, 'qty_min' => $plan->qty_min, 'qty_max' => $plan->qty_max, 'discount_qty' => $plan->discount_qty, 'discount_rate' => $plan->discount_rate, ]); $plan_new->tenant_id = $tenant->id; $plan_new->save(); $plan_map[$plan->id] = $plan_new->id; }); $package_map = []; - \App\Package::withEnvTenant()->get() + \App\Package::withEnvTenantContext()->get() ->each(function ($package) use ($package_map, $tenant) { $package_new = \App\Package::create([ 'title' => $package->title, 'name' => $package->getTranslations('name'), 'description' => $package->getTranslations('description'), 'discount_rate' => $package->discount_rate, ]); $package_new->tenant_id = $tenant->id; $package_new->save(); $package_map[$package->id] = $package_new->id; }); DB::table('package_skus')->whereIn('package_id', array_keys($package_map))->get() ->each(function ($item) use ($package_map, $sku_map) { if (isset($sku_map[$item->sku_id])) { DB::table('package_skus')->insert([ 'qty' => $item->qty, 'cost' => $item->cost, 'sku_id' => $sku_map[$item->sku_id], 'package_id' => $package_map[$item->package_id], ]); } }); DB::table('plan_packages')->whereIn('plan_id', array_keys($plan_map))->get() ->each(function ($item) use ($package_map, $plan_map) { if (isset($package_map[$item->package_id])) { DB::table('plan_packages')->insert([ 'qty' => $item->qty, 'qty_min' => $item->qty_min, 'qty_max' => $item->qty_max, 'discount_qty' => $item->discount_qty, 'discount_rate' => $item->discount_rate, 'plan_id' => $plan_map[$item->plan_id], 'package_id' => $package_map[$item->package_id], ]); } }); // Disable jobs, they would fail anyway as the TENANT_ID is different // TODO: We could probably do config(['app.tenant' => $tenant->id]) here Queue::fake(); // Assign 'reseller' role to the user $user->role = 'reseller'; $user->tenant_id = $tenant->id; $user->save(); // Switch tenant_id for all of the user belongings $user->wallets->each(function ($wallet) use ($tenant) { $wallet->entitlements->each(function ($entitlement) use ($tenant) { $entitlement->entitleable->tenant_id = $tenant->id; $entitlement->entitleable->save(); // TODO: If user already has any entitlements, they will have to be // removed/replaced by SKUs in the newly created tenant // I think we don't really support this yet anyway. }); // TODO: If the wallet has a discount we should remove/replace it too // I think we don't really support this yet anyway. }); DB::commit(); // Make sure the transaction wasn't aborted $tenant = \App\Tenant::find($tenant->id); if (!$tenant) { $this->error("Failed to create a tenant."); return 1; } $this->info("Created tenant {$tenant->id}."); } } diff --git a/src/app/Http/Controllers/API/V4/DomainsController.php b/src/app/Http/Controllers/API/V4/DomainsController.php index 790e8033..a288ffe0 100644 --- a/src/app/Http/Controllers/API/V4/DomainsController.php +++ b/src/app/Http/Controllers/API/V4/DomainsController.php @@ -1,425 +1,425 @@ guard()->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::find($id); if (!$this->checkTenant($domain)) { return $this->errorResponse(404); } if (!$this->guard()->user()->canRead($domain)) { return $this->errorResponse(403); } if (!$domain->confirm()) { return response()->json([ 'status' => 'error', 'message' => \trans('app.domain-verify-error'), ]); } return response()->json([ 'status' => 'success', 'statusInfo' => self::statusInfo($domain), 'message' => \trans('app.domain-verify-success'), ]); } /** * Remove the specified resource from storage. * * @param int $id * * @return \Illuminate\Http\JsonResponse */ public function destroy($id) { return $this->errorResponse(404); } /** * Show the form for editing the specified resource. * * @param int $id * * @return \Illuminate\Http\JsonResponse */ public function edit($id) { return $this->errorResponse(404); } /** * Set the domain configuration. * * @param int $id Domain identifier * * @return \Illuminate\Http\JsonResponse|void */ public function setConfig($id) { $domain = Domain::find($id); if (empty($domain)) { return $this->errorResponse(404); } // Only owner (or admin) has access to the domain if (!$this->guard()->user()->canRead($domain)) { return $this->errorResponse(403); } $errors = $domain->setConfig(request()->input()); if (!empty($errors)) { return response()->json(['status' => 'error', 'errors' => $errors], 422); } return response()->json([ 'status' => 'success', 'message' => \trans('app.domain-setconfig-success'), ]); } /** * Store a newly created resource in storage. * * @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::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); $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::withEnvTenant()->findOrFail($id); + $domain = Domain::find($id); if (!$this->checkTenant($domain)) { return $this->errorResponse(404); } if (!$this->guard()->user()->canRead($domain)) { return $this->errorResponse(403); } $response = self::statusInfo($domain); if (!empty(request()->input('refresh'))) { $updated = false; $last_step = 'none'; foreach ($response['process'] as $idx => $step) { $last_step = $step['label']; if (!$step['state']) { if (!$this->execProcessStep($domain, $step['label'])) { break; } $updated = true; } } if ($updated) { $response = self::statusInfo($domain); } $success = $response['isReady']; $suffix = $success ? 'success' : 'error-' . $last_step; $response['status'] = $success ? 'success' : 'error'; $response['message'] = \trans('app.process-' . $suffix); } $response = array_merge($response, self::domainStatuses($domain)); return response()->json($response); } /** * Update the specified resource in storage. * * @param \Illuminate\Http\Request $request * @param int $id * * @return \Illuminate\Http\JsonResponse */ public function update(Request $request, $id) { return $this->errorResponse(404); } /** * Provide DNS MX information to configure specified domain for */ protected static function getMXConfig(string $namespace): array { $entries = []; // copy MX entries from an existing domain if ($master = \config('dns.copyfrom')) { // TODO: cache this lookup foreach ((array) dns_get_record($master, DNS_MX) as $entry) { $entries[] = sprintf( "@\t%s\t%s\tMX\t%d %s.", \config('dns.ttl', $entry['ttl']), $entry['class'], $entry['pri'], $entry['target'] ); } } elseif ($static = \config('dns.static')) { $entries[] = strtr($static, array('\n' => "\n", '%s' => $namespace)); } // display SPF settings if ($spf = \config('dns.spf')) { $entries[] = ';'; foreach (['TXT', 'SPF'] as $type) { $entries[] = sprintf( "@\t%s\tIN\t%s\t\"%s\"", \config('dns.ttl'), $type, $spf ); } } return $entries; } /** * Provide sample DNS config for domain confirmation */ protected static function getDNSConfig(Domain $domain): array { $serial = date('Ymd01'); $hash_txt = $domain->hash(Domain::HASH_TEXT); $hash_cname = $domain->hash(Domain::HASH_CNAME); $hash = $domain->hash(Domain::HASH_CODE); return [ "@ IN SOA ns1.dnsservice.com. hostmaster.{$domain->namespace}. (", " {$serial} 10800 3600 604800 86400 )", ";", "@ IN A ", "www IN A ", ";", "{$hash_cname}.{$domain->namespace}. IN CNAME {$hash}.{$domain->namespace}.", "@ 3600 TXT \"{$hash_txt}\"", ]; } /** * Prepare domain statuses for the UI * * @param \App\Domain $domain Domain object * * @return array Statuses array */ protected static function domainStatuses(Domain $domain): array { return [ 'isLdapReady' => $domain->isLdapReady(), 'isConfirmed' => $domain->isConfirmed(), 'isVerified' => $domain->isVerified(), 'isSuspended' => $domain->isSuspended(), 'isActive' => $domain->isActive(), 'isDeleted' => $domain->isDeleted() || $domain->trashed(), ]; } /** * Domain status (extended) information. * * @param \App\Domain $domain Domain object * * @return array Status information */ public static function statusInfo(Domain $domain): array { $process = []; // If that is not a public domain, add domain specific steps $steps = [ 'domain-new' => true, 'domain-ldap-ready' => $domain->isLdapReady(), 'domain-verified' => $domain->isVerified(), 'domain-confirmed' => $domain->isConfirmed(), ]; $count = count($steps); // Create a process check list foreach ($steps as $step_name => $state) { $step = [ 'label' => $step_name, 'title' => \trans("app.process-{$step_name}"), 'state' => $state, ]; if ($step_name == 'domain-confirmed' && !$state) { $step['link'] = "/domain/{$domain->id}"; } $process[] = $step; if ($state) { $count--; } } $state = $count === 0 ? 'done' : 'running'; // After 180 seconds assume the process is in failed state, // this should unlock the Refresh button in the UI if ($count > 0 && $domain->created_at->diffInSeconds(Carbon::now()) > 180) { $state = 'failed'; } return [ 'process' => $process, 'processState' => $state, 'isReady' => $count === 0, ]; } /** * Execute (synchronously) specified step in a domain setup process. * * @param \App\Domain $domain Domain object * @param string $step Step identifier (as in self::statusInfo()) * * @return bool True if the execution succeeded, False otherwise */ public static function execProcessStep(Domain $domain, string $step): bool { try { switch ($step) { case 'domain-ldap-ready': // Domain not in LDAP, create it if (!$domain->isLdapReady()) { LDAP::createDomain($domain); $domain->status |= Domain::STATUS_LDAP_READY; $domain->save(); } return $domain->isLdapReady(); case 'domain-verified': // Domain existence not verified $domain->verify(); return $domain->isVerified(); case 'domain-confirmed': // Domain ownership confirmation $domain->confirm(); return $domain->isConfirmed(); } } catch (\Exception $e) { \Log::error($e); } return false; } } diff --git a/src/tests/Feature/Console/Tenant/CreateTest.php b/src/tests/Feature/Console/Tenant/CreateTest.php index cd6d8017..628b3779 100644 --- a/src/tests/Feature/Console/Tenant/CreateTest.php +++ b/src/tests/Feature/Console/Tenant/CreateTest.php @@ -1,98 +1,98 @@ deleteTestUser('test-tenant@kolabnow.com'); } /** * {@inheritDoc} */ public function tearDown(): void { if ($this->tenantId) { Queue::fake(); \App\User::where('tenant_id', $this->tenantId)->forceDelete(); \App\Plan::where('tenant_id', $this->tenantId)->delete(); \App\Package::where('tenant_id', $this->tenantId)->delete(); \App\Sku::where('tenant_id', $this->tenantId)->delete(); \App\Tenant::find($this->tenantId)->delete(); } parent::tearDown(); } /** * Test command runs */ public function testHandle(): void { Queue::fake(); // Warning: We're not using artisan() here, as this will not // allow us to test "empty output" cases // User not existing $code = \Artisan::call("tenant:create unknown@user.com"); $output = trim(\Artisan::output()); $this->assertSame(1, $code); $this->assertSame("User not found.", $output); $user = $this->getTestUser('test-tenant@kolabnow.com'); $this->assertEmpty($user->role); $this->assertEquals($user->tenant_id, \config('app.tenant_id')); // User not existing $code = \Artisan::call("tenant:create {$user->email} --title=\"Test Tenant\""); $output = trim(\Artisan::output()); $this->assertSame(0, $code); - $this->assertRegExp("/^Created tenant [0-9]+./", $output); + $this->assertMatchesRegularExpression("/^Created tenant [0-9]+./", $output); preg_match("/^Created tenant ([0-9]+)./", $output, $matches); $this->tenantId = $matches[1]; $tenant = \App\Tenant::find($this->tenantId); $user->refresh(); $this->assertNotEmpty($tenant); $this->assertSame('Test Tenant', $tenant->title); $this->assertSame('reseller', $user->role); $this->assertSame($tenant->id, $user->tenant_id); // Assert cloned SKUs $skus = \App\Sku::where('tenant_id', \config('app.tenant_id'))->where('active', true); $skus->each(function ($sku) use ($tenant) { $sku_new = \App\Sku::where('tenant_id', $tenant->id) ->where('title', $sku->title)->get(); $this->assertSame(1, $sku_new->count()); $sku_new = $sku_new->first(); $this->assertSame($sku->name, $sku_new->name); $this->assertSame($sku->description, $sku_new->description); $this->assertSame($sku->cost, $sku_new->cost); $this->assertSame($sku->units_free, $sku_new->units_free); $this->assertSame($sku->period, $sku_new->period); $this->assertSame($sku->handler_class, $sku_new->handler_class); $this->assertNotEmpty($sku_new->active); }); // TODO: Plans, packages } }