diff --git a/DEPLOY.md b/DEPLOY.md index e28bbf4..2c3ce89 100644 --- a/DEPLOY.md +++ b/DEPLOY.md @@ -65,10 +65,17 @@ On the **platform** `.env`: ```env BILLING_API_KEY_HOSTING= IDENTITY_API_KEY_HOSTING= +HOSTING_API_KEY_ACCOUNT= RP_HOSTING_FRONTCHANNEL_LOGOUT=https://hosting.ladill.com/sso/logout-frontchannel LADILL_HOSTING_APP_URL=https://hosting.ladill.com ``` +On this app's `.env`: + +```env +PLATFORM_API_KEY_HOSTING= +``` + Then `php artisan config:cache` on the platform. ## 5. nginx + TLS diff --git a/app/Http/Controllers/Api/PlatformHostingController.php b/app/Http/Controllers/Api/PlatformHostingController.php new file mode 100644 index 0000000..9805c3d --- /dev/null +++ b/app/Http/Controllers/Api/PlatformHostingController.php @@ -0,0 +1,123 @@ +json([ + 'data' => $assignments->listProducts(), + ]); + } + + public function index(string $publicId, PlatformUserResolver $users, AdminHostingAssignmentService $assignments): JsonResponse + { + $owner = $users->resolveOrCreate($publicId); + if (! $owner) { + return response()->json(['data' => []]); + } + + $accounts = HostingAccount::query() + ->where('user_id', $owner->id) + ->with('product') + ->latest() + ->get() + ->map(fn (HostingAccount $account) => $assignments->serializeAccount($account)) + ->values() + ->all(); + + return response()->json(['data' => $accounts]); + } + + public function assign( + Request $request, + string $publicId, + PlatformUserResolver $users, + AdminHostingAssignmentService $assignments, + ): JsonResponse { + $validated = $request->validate([ + 'hosting_product_id' => ['required', 'integer', 'exists:hosting_products,id'], + 'duration_months' => ['required', 'integer', 'min:1', 'max:120'], + 'primary_domain' => ['nullable', 'string', 'max:255'], + 'username' => ['nullable', 'string', 'max:32'], + 'notes' => ['nullable', 'string', 'max:1000'], + 'owner_email' => ['required', 'email', 'max:255'], + 'owner_name' => ['nullable', 'string', 'max:255'], + 'assigned_by_admin' => ['nullable', 'integer'], + ]); + + $owner = $users->resolveOrCreate( + $publicId, + (string) $validated['owner_email'], + (string) ($validated['owner_name'] ?? ''), + ); + + if (! $owner) { + return response()->json(['error' => 'User not found.'], 404); + } + + $result = $assignments->assign($owner, $validated); + + return response()->json(['data' => $result], 201); + } + + public function updateDuration( + Request $request, + int $account, + AdminHostingAssignmentService $assignments, + ): JsonResponse { + $validated = $request->validate([ + 'duration_months' => ['required', 'integer', 'min:1', 'max:120'], + 'admin_id' => ['nullable', 'integer'], + ]); + + $hostingAccount = HostingAccount::query()->findOrFail($account); + + $data = $assignments->updateDuration( + $hostingAccount, + (int) $validated['duration_months'], + isset($validated['admin_id']) ? (int) $validated['admin_id'] : null, + ); + + return response()->json(['data' => $data]); + } + + public function renew( + Request $request, + int $account, + AdminHostingAssignmentService $assignments, + ): JsonResponse { + $validated = $request->validate([ + 'admin_id' => ['nullable', 'integer'], + ]); + + $hostingAccount = HostingAccount::query()->findOrFail($account); + + $data = $assignments->renew( + $hostingAccount, + isset($validated['admin_id']) ? (int) $validated['admin_id'] : null, + ); + + return response()->json(['data' => $data]); + } + + public function unsuspend( + int $account, + AdminHostingAssignmentService $assignments, + HostingResourcePolicyService $resourcePolicy, + ): JsonResponse { + $hostingAccount = HostingAccount::query()->findOrFail($account); + $assignments->unsuspend($hostingAccount, $resourcePolicy); + + return response()->json(['data' => ['ok' => true]]); + } +} diff --git a/app/Http/Middleware/AuthenticateService.php b/app/Http/Middleware/AuthenticateService.php new file mode 100644 index 0000000..35aab0f --- /dev/null +++ b/app/Http/Middleware/AuthenticateService.php @@ -0,0 +1,37 @@ +bearerToken(); + $caller = null; + + if ($token !== '') { + foreach ((array) config("{$namespace}.service_api_keys", []) as $name => $key) { + if (is_string($key) && $key !== '' && hash_equals($key, $token)) { + $caller = $name; + break; + } + } + } + + if ($caller === null) { + return response()->json(['error' => 'Unauthorized.'], 401); + } + + $request->attributes->set('service_caller', $caller); + + return $next($request); + } +} diff --git a/app/Services/Domains/LadillDomainsClient.php b/app/Services/Domains/LadillDomainsClient.php index 4b266c3..d3d12e6 100644 --- a/app/Services/Domains/LadillDomainsClient.php +++ b/app/Services/Domains/LadillDomainsClient.php @@ -2,6 +2,7 @@ namespace App\Services\Domains; +use Illuminate\Http\Client\Response; use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Log; @@ -11,9 +12,11 @@ class LadillDomainsClient /** @return list Active domain names owned by the account (lowercase). */ public function ownedForUser(string $publicId): array { + [$storefront, $platform] = $this->fetchOwnedDomainsInParallel($publicId); + return collect() - ->merge($this->fetchFromStorefront($publicId)) - ->merge($this->fetchFromPlatform($publicId)) + ->merge($storefront) + ->merge($platform) ->map(fn ($d) => strtolower(trim((string) $d))) ->filter() ->unique() @@ -22,61 +25,60 @@ class LadillDomainsClient ->all(); } - /** @return list */ - private function fetchFromStorefront(string $publicId): array + /** + * @return array{0: list, 1: list} + */ + private function fetchOwnedDomainsInParallel(string $publicId): array { - $key = (string) (config('domains.api_key') ?? ''); - if ($key === '') { - return []; - } + $storefront = []; + $platform = []; try { - $res = Http::withToken($key) - ->acceptJson() - ->timeout(10) - ->get(rtrim((string) config('domains.api_url'), '/').'/owned-domains', [ - 'user' => $publicId, - ]); + $responses = Http::pool(function ($pool) use ($publicId) { + $requests = []; + $storefrontKey = (string) (config('domains.api_key') ?? ''); + if ($storefrontKey !== '') { + $requests['storefront'] = $pool->as('storefront') + ->withToken($storefrontKey) + ->acceptJson() + ->timeout(10) + ->get(rtrim((string) config('domains.api_url'), '/').'/owned-domains', [ + 'user' => $publicId, + ]); + } + $platformKey = (string) (config('domains.platform_api_key') ?? ''); + if ($platformKey !== '') { + $requests['platform'] = $pool->as('platform') + ->withToken($platformKey) + ->acceptJson() + ->timeout(10) + ->get(rtrim((string) config('domains.platform_api_url'), '/').'/owned-domains', [ + 'user' => $publicId, + ]); + } - $res->throw(); + return $requests; + }); - return (array) $res->json('data', []); + foreach ($responses as $name => $response) { + if (! $response instanceof Response || ! $response->successful()) { + continue; + } + + $data = (array) $response->json('data', []); + if ($name === 'storefront') { + $storefront = $data; + } elseif ($name === 'platform') { + $platform = $data; + } + } } catch (\Throwable $e) { - Log::warning('LadillDomainsClient: storefront owned domains unavailable', [ + Log::warning('LadillDomainsClient: owned domains lookup failed', [ 'user' => $publicId, 'error' => $e->getMessage(), ]); - - return []; - } - } - - /** @return list */ - private function fetchFromPlatform(string $publicId): array - { - $key = (string) (config('domains.platform_api_key') ?? ''); - if ($key === '') { - return []; } - try { - $res = Http::withToken($key) - ->acceptJson() - ->timeout(10) - ->get(rtrim((string) config('domains.platform_api_url'), '/').'/owned-domains', [ - 'user' => $publicId, - ]); - - $res->throw(); - - return (array) $res->json('data', []); - } catch (\Throwable $e) { - Log::warning('LadillDomainsClient: platform owned domains unavailable', [ - 'user' => $publicId, - 'error' => $e->getMessage(), - ]); - - return []; - } + return [$storefront, $platform]; } } diff --git a/app/Services/Hosting/AdminHostingAssignmentService.php b/app/Services/Hosting/AdminHostingAssignmentService.php new file mode 100644 index 0000000..8856f51 --- /dev/null +++ b/app/Services/Hosting/AdminHostingAssignmentService.php @@ -0,0 +1,243 @@ + $input + * @return array + */ + public function assign(User $owner, array $input): array + { + $product = HostingProduct::query()->findOrFail((int) $input['hosting_product_id']); + + if (! $product->is_active) { + throw ValidationException::withMessages([ + 'hosting_product_id' => 'Selected hosting product is not active.', + ]); + } + + $username = isset($input['username']) && $input['username'] !== '' + ? (string) $input['username'] + : $this->generateUsername($owner); + + if (HostingAccount::query()->where('username', $username)->exists()) { + throw ValidationException::withMessages([ + 'username' => 'This username is already taken.', + ]); + } + + $primaryDomain = isset($input['primary_domain']) && $input['primary_domain'] !== '' + ? (string) $input['primary_domain'] + : null; + + $accountType = match ($product->type) { + 'single_domain', 'multi_domain', 'wordpress' => HostingAccount::TYPE_SHARED, + 'vps' => HostingAccount::TYPE_VPS, + 'dedicated' => HostingAccount::TYPE_DEDICATED, + 'email_standalone' => HostingAccount::TYPE_SHARED, + default => HostingAccount::TYPE_SHARED, + }; + + $node = null; + $nodeId = null; + if ($accountType === HostingAccount::TYPE_SHARED) { + $node = $this->nodeCapacity->findAvailableNodeForProduct($product); + $nodeId = $node?->id; + } + + $resourceLimits = $this->resourcePolicy->defaultLimitsForProduct($product); + $durationMonths = (int) $input['duration_months']; + + $hostingAccount = HostingAccount::create([ + 'user_id' => $owner->id, + 'hosting_product_id' => $product->id, + 'hosting_node_id' => $nodeId, + 'username' => $username, + 'primary_domain' => $primaryDomain, + 'type' => $accountType, + 'status' => HostingAccount::STATUS_PENDING, + 'allocated_disk_gb' => (int) ($product->disk_gb ?? 0), + 'cpu_limit_percent' => $resourceLimits['cpu_limit_percent'], + 'memory_limit_mb' => $resourceLimits['memory_limit_mb'], + 'process_limit' => $resourceLimits['process_limit'], + 'io_limit_mb' => $resourceLimits['io_limit_mb'], + 'inode_limit' => $resourceLimits['inode_limit'], + 'resource_status' => HostingAccount::RESOURCE_STATUS_ACTIVE, + 'expires_at' => $this->expiresAtFromDuration($durationMonths), + 'resource_limits' => array_merge([ + 'disk_gb' => (int) ($product->disk_gb ?? 0), + 'max_domains' => $product->max_domains, + 'max_databases' => $product->max_databases, + ], $resourceLimits), + 'metadata' => [ + 'assigned_by_admin' => $input['assigned_by_admin'] ?? null, + 'assigned_at' => now()->toISOString(), + 'assigned_duration_months' => $durationMonths, + 'notes' => $input['notes'] ?? null, + ], + ]); + + if ($node) { + ProvisionHostingAccountJob::dispatch($hostingAccount->id); + + return [ + 'account' => $this->serializeAccount($hostingAccount->fresh(['product'])), + 'provisioning' => true, + 'message' => "Hosting product '{$product->name}' assigned. Provisioning is in progress.", + ]; + } + + $hostingAccount->update([ + 'status' => HostingAccount::STATUS_ACTIVE, + 'provisioned_at' => now(), + ]); + + return [ + 'account' => $this->serializeAccount($hostingAccount->fresh(['product'])), + 'provisioning' => false, + 'message' => "Hosting product '{$product->name}' assigned.", + ]; + } + + public function updateDuration(HostingAccount $account, int $durationMonths, ?int $adminId = null): array + { + $metadata = (array) ($account->metadata ?? []); + $metadata['assigned_duration_months'] = $durationMonths; + $metadata['duration_updated_by_admin'] = $adminId; + $metadata['duration_updated_at'] = now()->toISOString(); + + $account->update([ + 'expires_at' => $this->expiresAtFromDuration($durationMonths), + 'metadata' => $metadata, + ]); + + return $this->serializeAccount($account->fresh(['product'])); + } + + public function renew(HostingAccount $account, ?int $adminId = null): array + { + if (! $account->canBeRenewed()) { + throw ValidationException::withMessages([ + 'account' => 'This hosting account does not have a configured renewal duration.', + ]); + } + + $months = $account->assignedDurationMonths(); + $account->renew($months, [ + 'renewed_by_admin' => $adminId, + 'renewed_at' => now()->toISOString(), + ]); + + return $this->serializeAccount($account->fresh(['product'])); + } + + public function unsuspend(HostingAccount $account, HostingResourcePolicyService $resourcePolicy): void + { + if ( + $account->status !== HostingAccount::STATUS_SUSPENDED + && $account->resource_status !== HostingAccount::RESOURCE_STATUS_SUSPENDED + ) { + throw ValidationException::withMessages([ + 'account' => 'This hosting account is not suspended.', + ]); + } + + $resourcePolicy->unsuspendAccount($account, 'Manual unsuspension by admin.'); + } + + /** + * @return array + */ + public function serializeAccount(HostingAccount $account): array + { + $product = $account->product; + + return [ + 'id' => $account->id, + 'username' => $account->username, + 'primary_domain' => $account->primary_domain, + 'type' => $account->type, + 'status' => $account->status, + 'resource_status' => $account->resource_status, + 'expires_at' => $account->expires_at?->toIso8601String(), + 'provisioned_at' => $account->provisioned_at?->toIso8601String(), + 'metadata' => (array) ($account->metadata ?? []), + 'product' => $product ? [ + 'id' => $product->id, + 'name' => $product->name, + 'type' => $product->type, + 'slug' => $product->slug, + ] : null, + ]; + } + + /** + * @return list> + */ + public function listProducts(): array + { + return HostingProduct::query() + ->where('is_active', true) + ->orderBy('sort_order') + ->orderBy('name') + ->get() + ->map(fn (HostingProduct $product) => [ + 'id' => $product->id, + 'name' => $product->name, + 'type' => $product->type, + 'slug' => $product->slug, + 'display_currency' => $product->display_currency, + 'display_price_monthly' => $product->display_price_monthly, + ]) + ->values() + ->all(); + } + + private function generateUsername(User $user): string + { + $baseUsername = strtolower(str_replace([' ', '.', '_'], '', $user->name)); + $baseUsername = preg_replace('/[^a-z0-9]/', '', $baseUsername); + + if (strlen($baseUsername) < 3) { + $baseUsername = strtolower(substr($user->email, 0, str_contains($user->email, '@') ? strpos($user->email, '@') : strlen($user->email))); + $baseUsername = preg_replace('/[^a-z0-9]/', '', $baseUsername); + } + + if (strlen($baseUsername) < 3) { + $baseUsername = 'user'.$user->id; + } + + $username = substr($baseUsername, 0, 16); + $counter = 1; + $originalUsername = $username; + + while (HostingAccount::query()->where('username', $username)->exists()) { + $suffix = (string) $counter; + $maxBaseLength = 16 - strlen($suffix); + $username = substr($originalUsername, 0, $maxBaseLength).$suffix; + $counter++; + } + + return $username; + } + + private function expiresAtFromDuration(int $durationMonths): CarbonInterface + { + return now()->addMonthsNoOverflow($durationMonths); + } +} diff --git a/app/Services/Hosting/Providers/SharedNodeProvider.php b/app/Services/Hosting/Providers/SharedNodeProvider.php index 4783b15..2cceaf8 100644 --- a/app/Services/Hosting/Providers/SharedNodeProvider.php +++ b/app/Services/Hosting/Providers/SharedNodeProvider.php @@ -909,16 +909,7 @@ NGINX; $domain = $site->domain; $docRoot = $site->document_root ?: "/home/{$username}/public_html/{$domain}"; - // Create document root - $quotedDocRoot = escapeshellarg($docRoot); - $this->ensureAdministrativeResultSucceeded( - $this->runLocalAdminOperationOrRemote($ssh, 'mkdir', [$docRoot], "mkdir -p {$quotedDocRoot}"), - "Failed to create document root for {$domain}" - ); - $this->ensureAdministrativeResultSucceeded( - $this->runLocalAdminOperationOrRemote($ssh, 'chown', ["{$username}:{$username}", $docRoot], "chown {$username}:{$username} {$quotedDocRoot}"), - "Failed to assign document root ownership for {$domain}" - ); + $this->ensureSiteDocumentRoot($ssh, $username, $docRoot); $this->hardenAccountFilesystem($account, [$docRoot]); $this->applyManagedSiteConfig($ssh, $site, $docRoot, $username, [ @@ -931,6 +922,24 @@ NGINX; ]; } + private function ensureSiteDocumentRoot(mixed $ssh, string $username, string $docRoot): void + { + $quotedDocRoot = escapeshellarg($docRoot); + $this->ensureAdministrativeResultSucceeded( + $this->runLocalAdminOperationOrRemote($ssh, 'mkdir', [$docRoot], "mkdir -p {$quotedDocRoot}"), + "Failed to create document root at {$docRoot}" + ); + $this->ensureAdministrativeResultSucceeded( + $this->runLocalAdminOperationOrRemote( + $ssh, + 'run-cmd', + ["chown {$username}:" . self::WEB_SERVER_GROUP . " {$quotedDocRoot} && chmod 2750 {$quotedDocRoot}"], + "chown {$username}:" . self::WEB_SERVER_GROUP . " {$quotedDocRoot} && chmod 2750 {$quotedDocRoot}" + ), + "Failed to set document root ownership at {$docRoot}" + ); + } + public function hardenAccountFilesystem(HostingAccount $account, array $additionalDocumentRoots = []): void { $node = $account->node; @@ -1002,6 +1011,7 @@ NGINX; $domain = $site->domain; $email = $account->user?->email ?: 'admin@' . $domain; $docRoot = $site->document_root ?: "/home/{$account->username}/public_html/{$domain}"; + $this->ensureSiteDocumentRoot($ssh, $account->username, $docRoot); $hadCertificate = $this->siteHasCertificate($ssh, $domain); Log::info("SSL: Starting certificate request", [ @@ -1677,7 +1687,7 @@ LIMITS; 'runtime_port' => $port, 'runtime_type' => $appType, ]), - ]); + ])->save(); try { $this->applyManagedSiteConfig($ssh, $site, $site->document_root, $username, [ @@ -2451,14 +2461,7 @@ SERVICE; NGINX; if ($proxyPort !== null) { - $httpBody = <<app_config, 'api_runtime_port'); + $apiLocation = is_numeric($apiPort) + ? <<where('public_id', $publicId)->first(); + if ($user) { + return $user; + } + + if ($email === '') { + return null; + } + + return User::query()->create([ + 'public_id' => $publicId, + 'email' => $email, + 'name' => $name !== '' ? $name : $email, + ]); + } +} diff --git a/bootstrap/app.php b/bootstrap/app.php index fa19ef7..8c1e18a 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -22,6 +22,7 @@ return Application::configure(basePath: dirname(__DIR__)) ]); $middleware->alias([ 'platform.session' => \App\Http\Middleware\EnsurePlatformSession::class, + 'auth.service' => \App\Http\Middleware\AuthenticateService::class, ]); // External registrar webhook posts have no CSRF token. diff --git a/config/platform.php b/config/platform.php new file mode 100644 index 0000000..721685e --- /dev/null +++ b/config/platform.php @@ -0,0 +1,15 @@ + array_filter([ + 'account' => env('PLATFORM_API_KEY_HOSTING'), + ]), +]; diff --git a/resources/js/app.js b/resources/js/app.js index 7086c8f..3f555fa 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -14,9 +14,11 @@ registerLadillDomainPurchase(); import Alpine from 'alpinejs'; +import { registerLadillClipboard } from './ladill-clipboard'; import collapse from '@alpinejs/collapse'; Alpine.plugin(collapse); +registerLadillClipboard(Alpine); Alpine.data('notificationDropdown', (config = {}) => ({ open: false, diff --git a/resources/js/ladill-clipboard.js b/resources/js/ladill-clipboard.js new file mode 100644 index 0000000..ac19b3c --- /dev/null +++ b/resources/js/ladill-clipboard.js @@ -0,0 +1,57 @@ +const COPY_FEEDBACK_MS = 2000; + +export async function writeClipboardText(text) { + const value = String(text ?? ''); + if (!value) { + return false; + } + + try { + if (navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(value); + + return true; + } + } catch { + // fall through to legacy copy + } + + try { + const textarea = document.createElement('textarea'); + textarea.value = value; + textarea.style.position = 'fixed'; + textarea.style.opacity = '0'; + document.body.appendChild(textarea); + textarea.select(); + const ok = document.execCommand('copy'); + document.body.removeChild(textarea); + + return ok; + } catch { + return false; + } +} + +export function registerLadillClipboard(Alpine) { + Alpine.data('copyButton', (text = '') => ({ + copied: false, + text: text ?? '', + resetTimer: null, + async copy() { + const ok = await writeClipboardText(this.text); + if (!ok) { + return; + } + + this.copied = true; + + if (this.resetTimer) { + clearTimeout(this.resetTimer); + } + + this.resetTimer = setTimeout(() => { + this.copied = false; + }, COPY_FEEDBACK_MS); + }, + })); +} diff --git a/resources/views/components/copy-button.blade.php b/resources/views/components/copy-button.blade.php new file mode 100644 index 0000000..06279be --- /dev/null +++ b/resources/views/components/copy-button.blade.php @@ -0,0 +1,29 @@ +@props([ + 'text' => '', + 'label' => 'Copy', + 'copiedLabel' => 'Copied!', + 'icon' => false, + 'copiedClass' => '', +]) + + diff --git a/routes/api.php b/routes/api.php index bea963b..62323ae 100644 --- a/routes/api.php +++ b/routes/api.php @@ -1,9 +1,24 @@ prefix('platform')->group(function (): void { + Route::get('/hosting-products', [PlatformHostingController::class, 'products']); + Route::get('/users/{publicId}/hosting-accounts', [PlatformHostingController::class, 'index']) + ->where('publicId', '[A-Za-z0-9\-]+'); + Route::post('/users/{publicId}/hosting-accounts', [PlatformHostingController::class, 'assign']) + ->where('publicId', '[A-Za-z0-9\-]+'); + Route::patch('/hosting-accounts/{account}/duration', [PlatformHostingController::class, 'updateDuration']) + ->whereNumber('account'); + Route::post('/hosting-accounts/{account}/renew', [PlatformHostingController::class, 'renew']) + ->whereNumber('account'); + Route::post('/hosting-accounts/{account}/unsuspend', [PlatformHostingController::class, 'unsuspend']) + ->whereNumber('account'); +}); + // Ladill Email API (v1) — authenticated with Sanctum personal access tokens // minted on the Developers page. Read-only for now: identity + mailboxes. Route::middleware('auth:sanctum')->prefix('v1')->group(function () { diff --git a/tests/Feature/PlatformHostingAdminApiTest.php b/tests/Feature/PlatformHostingAdminApiTest.php new file mode 100644 index 0000000..1060ddf --- /dev/null +++ b/tests/Feature/PlatformHostingAdminApiTest.php @@ -0,0 +1,154 @@ + ['account' => self::API_KEY]]); + } + + protected function tearDown(): void + { + Carbon::setTestNow(); + + parent::tearDown(); + } + + public function test_platform_can_assign_hosting_package_to_user(): void + { + Carbon::setTestNow('2026-04-05 10:00:00'); + + $owner = User::query()->create([ + 'public_id' => (string) \Illuminate\Support\Str::uuid(), + 'name' => 'Hosting Owner', + 'email' => 'owner@example.com', + ]); + $product = $this->createHostingProduct(); + + $response = $this->withToken(self::API_KEY) + ->postJson('/api/platform/users/'.$owner->public_id.'/hosting-accounts', [ + 'hosting_product_id' => $product->id, + 'duration_months' => 6, + 'primary_domain' => 'example.com', + 'username' => 'exampleuser', + 'owner_email' => $owner->email, + 'owner_name' => $owner->name, + 'assigned_by_admin' => 99, + ]); + + $response->assertCreated() + ->assertJsonPath('data.account.username', 'exampleuser') + ->assertJsonPath('data.account.primary_domain', 'example.com') + ->assertJsonPath('data.account.status', 'active') + ->assertJsonPath('data.account.metadata.assigned_duration_months', 6) + ->assertJsonPath('data.account.metadata.assigned_by_admin', 99); + + $account = HostingAccount::query()->where('user_id', $owner->id)->firstOrFail(); + $this->assertTrue($account->expires_at->equalTo(Carbon::parse('2026-10-05 10:00:00'))); + } + + public function test_platform_can_list_user_hosting_accounts(): void + { + $owner = User::query()->create([ + 'public_id' => (string) \Illuminate\Support\Str::uuid(), + 'name' => 'Hosting Owner', + 'email' => 'owner@example.com', + ]); + $product = $this->createHostingProduct(); + + HostingAccount::query()->create([ + 'user_id' => $owner->id, + 'hosting_product_id' => $product->id, + 'username' => 'listeduser', + 'type' => 'shared', + 'status' => 'active', + 'metadata' => ['assigned_duration_months' => 3], + ]); + + $response = $this->withToken(self::API_KEY) + ->getJson('/api/platform/users/'.$owner->public_id.'/hosting-accounts'); + + $response->assertOk() + ->assertJsonCount(1, 'data') + ->assertJsonPath('data.0.username', 'listeduser'); + } + + public function test_platform_can_update_duration_and_renew(): void + { + Carbon::setTestNow('2026-04-05 10:00:00'); + + $owner = User::query()->create([ + 'public_id' => (string) \Illuminate\Support\Str::uuid(), + 'name' => 'Hosting Owner', + 'email' => 'owner@example.com', + ]); + $product = $this->createHostingProduct(); + + $account = HostingAccount::query()->create([ + 'user_id' => $owner->id, + 'hosting_product_id' => $product->id, + 'username' => 'durationuser', + 'type' => 'shared', + 'status' => 'active', + 'expires_at' => Carbon::parse('2026-05-05 10:00:00'), + 'metadata' => ['assigned_duration_months' => 1], + ]); + + $this->withToken(self::API_KEY) + ->patchJson('/api/platform/hosting-accounts/'.$account->id.'/duration', [ + 'duration_months' => 12, + 'admin_id' => 7, + ]) + ->assertOk() + ->assertJsonPath('data.metadata.assigned_duration_months', 12); + + $account->refresh(); + $this->assertTrue($account->expires_at->equalTo(Carbon::parse('2027-04-05 10:00:00'))); + + $this->withToken(self::API_KEY) + ->postJson('/api/platform/hosting-accounts/'.$account->id.'/renew', ['admin_id' => 7]) + ->assertOk(); + + $account->refresh(); + $this->assertTrue($account->expires_at->equalTo(Carbon::parse('2028-04-05 10:00:00'))); + } + + public function test_unauthenticated_platform_requests_are_rejected(): void + { + $this->getJson('/api/platform/hosting-products')->assertUnauthorized(); + } + + private function createHostingProduct(): HostingProduct + { + return HostingProduct::query()->create([ + 'name' => 'Admin Assigned Shared Hosting', + 'slug' => 'admin-assigned-shared-hosting', + 'category' => 'shared', + 'type' => 'single_domain', + 'price_monthly' => 10, + 'price_quarterly' => 30, + 'price_yearly' => 120, + 'price_biennial' => 240, + 'currency' => 'GHS', + 'max_domains' => 1, + 'is_active' => true, + 'is_visible' => true, + 'sort_order' => 1, + ]); + } +}