Add platform admin hosting API for Ladill account provisioning.
Deploy Ladill Hosting / deploy (push) Successful in 1m29s
Deploy Ladill Hosting / deploy (push) Successful in 1m29s
Expose authenticated service endpoints for assigning, listing, renewing, and managing hosting accounts from the platform, with tests and deploy docs. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -65,10 +65,17 @@ On the **platform** `.env`:
|
|||||||
```env
|
```env
|
||||||
BILLING_API_KEY_HOSTING=<same as this app>
|
BILLING_API_KEY_HOSTING=<same as this app>
|
||||||
IDENTITY_API_KEY_HOSTING=<same as this app>
|
IDENTITY_API_KEY_HOSTING=<same as this app>
|
||||||
|
HOSTING_API_KEY_ACCOUNT=<same as PLATFORM_API_KEY_HOSTING below>
|
||||||
RP_HOSTING_FRONTCHANNEL_LOGOUT=https://hosting.ladill.com/sso/logout-frontchannel
|
RP_HOSTING_FRONTCHANNEL_LOGOUT=https://hosting.ladill.com/sso/logout-frontchannel
|
||||||
LADILL_HOSTING_APP_URL=https://hosting.ladill.com
|
LADILL_HOSTING_APP_URL=https://hosting.ladill.com
|
||||||
```
|
```
|
||||||
|
|
||||||
|
On this app's `.env`:
|
||||||
|
|
||||||
|
```env
|
||||||
|
PLATFORM_API_KEY_HOSTING=<same as platform HOSTING_API_KEY_ACCOUNT>
|
||||||
|
```
|
||||||
|
|
||||||
Then `php artisan config:cache` on the platform.
|
Then `php artisan config:cache` on the platform.
|
||||||
|
|
||||||
## 5. nginx + TLS
|
## 5. nginx + TLS
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\HostingAccount;
|
||||||
|
use App\Services\Hosting\AdminHostingAssignmentService;
|
||||||
|
use App\Services\Hosting\HostingResourcePolicyService;
|
||||||
|
use App\Services\Platform\PlatformUserResolver;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class PlatformHostingController extends Controller
|
||||||
|
{
|
||||||
|
public function products(AdminHostingAssignmentService $assignments): JsonResponse
|
||||||
|
{
|
||||||
|
return response()->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]]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Middleware;
|
||||||
|
|
||||||
|
use Closure;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generic service-to-service auth for internal APIs. Validates the bearer token
|
||||||
|
* against per-consumer keys in config("{namespace}.service_api_keys").
|
||||||
|
*/
|
||||||
|
class AuthenticateService
|
||||||
|
{
|
||||||
|
public function handle(Request $request, Closure $next, string $namespace = 'platform'): Response
|
||||||
|
{
|
||||||
|
$token = (string) $request->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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Services\Domains;
|
namespace App\Services\Domains;
|
||||||
|
|
||||||
|
use Illuminate\Http\Client\Response;
|
||||||
use Illuminate\Support\Facades\Http;
|
use Illuminate\Support\Facades\Http;
|
||||||
use Illuminate\Support\Facades\Log;
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
@@ -11,9 +12,11 @@ class LadillDomainsClient
|
|||||||
/** @return list<string> Active domain names owned by the account (lowercase). */
|
/** @return list<string> Active domain names owned by the account (lowercase). */
|
||||||
public function ownedForUser(string $publicId): array
|
public function ownedForUser(string $publicId): array
|
||||||
{
|
{
|
||||||
|
[$storefront, $platform] = $this->fetchOwnedDomainsInParallel($publicId);
|
||||||
|
|
||||||
return collect()
|
return collect()
|
||||||
->merge($this->fetchFromStorefront($publicId))
|
->merge($storefront)
|
||||||
->merge($this->fetchFromPlatform($publicId))
|
->merge($platform)
|
||||||
->map(fn ($d) => strtolower(trim((string) $d)))
|
->map(fn ($d) => strtolower(trim((string) $d)))
|
||||||
->filter()
|
->filter()
|
||||||
->unique()
|
->unique()
|
||||||
@@ -22,61 +25,60 @@ class LadillDomainsClient
|
|||||||
->all();
|
->all();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @return list<string> */
|
/**
|
||||||
private function fetchFromStorefront(string $publicId): array
|
* @return array{0: list<string>, 1: list<string>}
|
||||||
|
*/
|
||||||
|
private function fetchOwnedDomainsInParallel(string $publicId): array
|
||||||
{
|
{
|
||||||
$key = (string) (config('domains.api_key') ?? '');
|
$storefront = [];
|
||||||
if ($key === '') {
|
$platform = [];
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$res = Http::withToken($key)
|
$responses = Http::pool(function ($pool) use ($publicId) {
|
||||||
->acceptJson()
|
$requests = [];
|
||||||
->timeout(10)
|
$storefrontKey = (string) (config('domains.api_key') ?? '');
|
||||||
->get(rtrim((string) config('domains.api_url'), '/').'/owned-domains', [
|
if ($storefrontKey !== '') {
|
||||||
'user' => $publicId,
|
$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) {
|
} catch (\Throwable $e) {
|
||||||
Log::warning('LadillDomainsClient: storefront owned domains unavailable', [
|
Log::warning('LadillDomainsClient: owned domains lookup failed', [
|
||||||
'user' => $publicId,
|
'user' => $publicId,
|
||||||
'error' => $e->getMessage(),
|
'error' => $e->getMessage(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @return list<string> */
|
|
||||||
private function fetchFromPlatform(string $publicId): array
|
|
||||||
{
|
|
||||||
$key = (string) (config('domains.platform_api_key') ?? '');
|
|
||||||
if ($key === '') {
|
|
||||||
return [];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
return [$storefront, $platform];
|
||||||
$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 [];
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,243 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Hosting;
|
||||||
|
|
||||||
|
use App\Jobs\ProvisionHostingAccountJob;
|
||||||
|
use App\Models\HostingAccount;
|
||||||
|
use App\Models\HostingProduct;
|
||||||
|
use App\Models\User;
|
||||||
|
use Carbon\CarbonInterface;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
|
|
||||||
|
class AdminHostingAssignmentService
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private NodeCapacityService $nodeCapacity,
|
||||||
|
private HostingResourcePolicyService $resourcePolicy,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $input
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
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<string, mixed>
|
||||||
|
*/
|
||||||
|
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<array<string, mixed>>
|
||||||
|
*/
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -909,16 +909,7 @@ NGINX;
|
|||||||
$domain = $site->domain;
|
$domain = $site->domain;
|
||||||
$docRoot = $site->document_root ?: "/home/{$username}/public_html/{$domain}";
|
$docRoot = $site->document_root ?: "/home/{$username}/public_html/{$domain}";
|
||||||
|
|
||||||
// Create document root
|
$this->ensureSiteDocumentRoot($ssh, $username, $docRoot);
|
||||||
$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->hardenAccountFilesystem($account, [$docRoot]);
|
$this->hardenAccountFilesystem($account, [$docRoot]);
|
||||||
|
|
||||||
$this->applyManagedSiteConfig($ssh, $site, $docRoot, $username, [
|
$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
|
public function hardenAccountFilesystem(HostingAccount $account, array $additionalDocumentRoots = []): void
|
||||||
{
|
{
|
||||||
$node = $account->node;
|
$node = $account->node;
|
||||||
@@ -1002,6 +1011,7 @@ NGINX;
|
|||||||
$domain = $site->domain;
|
$domain = $site->domain;
|
||||||
$email = $account->user?->email ?: 'admin@' . $domain;
|
$email = $account->user?->email ?: 'admin@' . $domain;
|
||||||
$docRoot = $site->document_root ?: "/home/{$account->username}/public_html/{$domain}";
|
$docRoot = $site->document_root ?: "/home/{$account->username}/public_html/{$domain}";
|
||||||
|
$this->ensureSiteDocumentRoot($ssh, $account->username, $docRoot);
|
||||||
$hadCertificate = $this->siteHasCertificate($ssh, $domain);
|
$hadCertificate = $this->siteHasCertificate($ssh, $domain);
|
||||||
|
|
||||||
Log::info("SSL: Starting certificate request", [
|
Log::info("SSL: Starting certificate request", [
|
||||||
@@ -1677,7 +1687,7 @@ LIMITS;
|
|||||||
'runtime_port' => $port,
|
'runtime_port' => $port,
|
||||||
'runtime_type' => $appType,
|
'runtime_type' => $appType,
|
||||||
]),
|
]),
|
||||||
]);
|
])->save();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$this->applyManagedSiteConfig($ssh, $site, $site->document_root, $username, [
|
$this->applyManagedSiteConfig($ssh, $site, $site->document_root, $username, [
|
||||||
@@ -2451,14 +2461,7 @@ SERVICE;
|
|||||||
NGINX;
|
NGINX;
|
||||||
|
|
||||||
if ($proxyPort !== null) {
|
if ($proxyPort !== null) {
|
||||||
$httpBody = <<<NGINX
|
$proxyHeaders = <<<NGINX
|
||||||
access_log /home/{$username}/logs/{$domain}-access.log;
|
|
||||||
error_log /home/{$username}/logs/{$domain}-error.log;
|
|
||||||
|
|
||||||
{$acmeLocation}
|
|
||||||
|
|
||||||
location / {
|
|
||||||
proxy_pass http://127.0.0.1:{$proxyPort};
|
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Upgrade \$http_upgrade;
|
proxy_set_header Upgrade \$http_upgrade;
|
||||||
proxy_set_header Connection "upgrade";
|
proxy_set_header Connection "upgrade";
|
||||||
@@ -2469,6 +2472,27 @@ NGINX;
|
|||||||
proxy_cache_bypass \$http_upgrade;
|
proxy_cache_bypass \$http_upgrade;
|
||||||
proxy_read_timeout 300;
|
proxy_read_timeout 300;
|
||||||
proxy_connect_timeout 300;
|
proxy_connect_timeout 300;
|
||||||
|
NGINX;
|
||||||
|
$apiPort = data_get($site->app_config, 'api_runtime_port');
|
||||||
|
$apiLocation = is_numeric($apiPort)
|
||||||
|
? <<<NGINX
|
||||||
|
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://127.0.0.1:{$apiPort};
|
||||||
|
{$proxyHeaders}
|
||||||
|
}
|
||||||
|
NGINX
|
||||||
|
: '';
|
||||||
|
$httpBody = <<<NGINX
|
||||||
|
access_log /home/{$username}/logs/{$domain}-access.log;
|
||||||
|
error_log /home/{$username}/logs/{$domain}-error.log;
|
||||||
|
|
||||||
|
{$acmeLocation}
|
||||||
|
{$apiLocation}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://127.0.0.1:{$proxyPort};
|
||||||
|
{$proxyHeaders}
|
||||||
}
|
}
|
||||||
|
|
||||||
client_max_body_size 64M;
|
client_max_body_size 64M;
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Platform;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
|
||||||
|
class PlatformUserResolver
|
||||||
|
{
|
||||||
|
public function resolveOrCreate(string $publicId, string $email = '', string $name = ''): ?User
|
||||||
|
{
|
||||||
|
$publicId = trim($publicId);
|
||||||
|
if ($publicId === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$user = User::query()->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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,6 +22,7 @@ return Application::configure(basePath: dirname(__DIR__))
|
|||||||
]);
|
]);
|
||||||
$middleware->alias([
|
$middleware->alias([
|
||||||
'platform.session' => \App\Http\Middleware\EnsurePlatformSession::class,
|
'platform.session' => \App\Http\Middleware\EnsurePlatformSession::class,
|
||||||
|
'auth.service' => \App\Http\Middleware\AuthenticateService::class,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// External registrar webhook posts have no CSRF token.
|
// External registrar webhook posts have no CSRF token.
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Platform service-to-service API keys
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Bearer tokens for internal APIs called by account.ladill.com (the platform).
|
||||||
|
| The hosting app is the sole writer for hosting accounts; admin assignment
|
||||||
|
| and lifecycle actions arrive over these routes.
|
||||||
|
*/
|
||||||
|
'service_api_keys' => array_filter([
|
||||||
|
'account' => env('PLATFORM_API_KEY_HOSTING'),
|
||||||
|
]),
|
||||||
|
];
|
||||||
@@ -14,9 +14,11 @@ registerLadillDomainPurchase();
|
|||||||
|
|
||||||
|
|
||||||
import Alpine from 'alpinejs';
|
import Alpine from 'alpinejs';
|
||||||
|
import { registerLadillClipboard } from './ladill-clipboard';
|
||||||
import collapse from '@alpinejs/collapse';
|
import collapse from '@alpinejs/collapse';
|
||||||
|
|
||||||
Alpine.plugin(collapse);
|
Alpine.plugin(collapse);
|
||||||
|
registerLadillClipboard(Alpine);
|
||||||
|
|
||||||
Alpine.data('notificationDropdown', (config = {}) => ({
|
Alpine.data('notificationDropdown', (config = {}) => ({
|
||||||
open: false,
|
open: false,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
@props([
|
||||||
|
'text' => '',
|
||||||
|
'label' => 'Copy',
|
||||||
|
'copiedLabel' => 'Copied!',
|
||||||
|
'icon' => false,
|
||||||
|
'copiedClass' => '',
|
||||||
|
])
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
x-data="copyButton(@js($text))"
|
||||||
|
@click="copy()"
|
||||||
|
:title="copied ? @js($copiedLabel) : @js($icon ? 'Copy' : $label)"
|
||||||
|
:class="copied ? @js($copiedClass) : ''"
|
||||||
|
{{ $attributes->class($icon ? 'inline-flex shrink-0 items-center justify-center' : '') }}
|
||||||
|
>
|
||||||
|
@if ($icon)
|
||||||
|
<svg x-show="!copied" class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"/>
|
||||||
|
</svg>
|
||||||
|
<svg x-show="copied" x-cloak class="h-4 w-4 text-emerald-600" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5"/>
|
||||||
|
</svg>
|
||||||
|
@elseif ($slot->isNotEmpty())
|
||||||
|
{{ $slot }}
|
||||||
|
@else
|
||||||
|
<span x-text="copied ? @js($copiedLabel) : @js($label)"></span>
|
||||||
|
@endif
|
||||||
|
</button>
|
||||||
@@ -1,9 +1,24 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
use App\Http\Controllers\Api\PlatformHostingController;
|
||||||
use App\Services\Mailbox\MailboxClient;
|
use App\Services\Mailbox\MailboxClient;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
|
Route::middleware('auth.service:platform')->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
|
// Ladill Email API (v1) — authenticated with Sanctum personal access tokens
|
||||||
// minted on the Developers page. Read-only for now: identity + mailboxes.
|
// minted on the Developers page. Read-only for now: identity + mailboxes.
|
||||||
Route::middleware('auth:sanctum')->prefix('v1')->group(function () {
|
Route::middleware('auth:sanctum')->prefix('v1')->group(function () {
|
||||||
|
|||||||
@@ -0,0 +1,154 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use App\Models\HostingAccount;
|
||||||
|
use App\Models\HostingProduct;
|
||||||
|
use App\Models\User;
|
||||||
|
use Carbon\Carbon;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class PlatformHostingAdminApiTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
private const API_KEY = 'test-platform-hosting-key';
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
|
||||||
|
config(['platform.service_api_keys' => ['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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user