feat(hetzner): add endpoints and UI for attaching firewalls and internal networks to the Server Creation dialog
This commit is contained in:
parent
3a8f52ce16
commit
a4b27641c8
7 changed files with 516 additions and 1 deletions
|
|
@ -454,6 +454,193 @@ public function sshKeys(Request $request)
|
|||
}
|
||||
}
|
||||
|
||||
#[OA\Get(
|
||||
summary: 'Get Hetzner Firewalls',
|
||||
description: 'Get all existing Hetzner firewalls for the current project.',
|
||||
path: '/hetzner/firewalls',
|
||||
operationId: 'get-hetzner-firewalls',
|
||||
security: [
|
||||
['bearerAuth' => []],
|
||||
],
|
||||
tags: ['Hetzner'],
|
||||
parameters: [
|
||||
new OA\Parameter(
|
||||
name: 'cloud_provider_token_uuid',
|
||||
in: 'query',
|
||||
required: false,
|
||||
description: 'Cloud provider token UUID. Required if cloud_provider_token_id is not provided.',
|
||||
schema: new OA\Schema(type: 'string')
|
||||
),
|
||||
new OA\Parameter(
|
||||
name: 'cloud_provider_token_id',
|
||||
in: 'query',
|
||||
required: false,
|
||||
deprecated: true,
|
||||
description: 'Deprecated: Use cloud_provider_token_uuid instead. Cloud provider token UUID.',
|
||||
schema: new OA\Schema(type: 'string')
|
||||
),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 200,
|
||||
description: 'List of Hetzner firewalls.',
|
||||
content: [
|
||||
new OA\MediaType(
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(
|
||||
type: 'array',
|
||||
items: new OA\Items(
|
||||
type: 'object',
|
||||
properties: [
|
||||
'id' => ['type' => 'integer'],
|
||||
'name' => ['type' => 'string'],
|
||||
]
|
||||
)
|
||||
)
|
||||
),
|
||||
]),
|
||||
new OA\Response(
|
||||
response: 401,
|
||||
ref: '#/components/responses/401',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 404,
|
||||
ref: '#/components/responses/404',
|
||||
),
|
||||
]
|
||||
)]
|
||||
public function firewalls(Request $request)
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
$validator = customApiValidator($request->all(), [
|
||||
'cloud_provider_token_uuid' => 'required_without:cloud_provider_token_id|string',
|
||||
'cloud_provider_token_id' => 'required_without:cloud_provider_token_uuid|string',
|
||||
]);
|
||||
|
||||
if ($validator->fails()) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => $validator->errors(),
|
||||
], 422);
|
||||
}
|
||||
|
||||
$tokenUuid = $this->getCloudProviderTokenUuid($request);
|
||||
$token = CloudProviderToken::whereTeamId($teamId)
|
||||
->whereUuid($tokenUuid)
|
||||
->where('provider', 'hetzner')
|
||||
->first();
|
||||
|
||||
if (! $token) {
|
||||
return response()->json(['message' => 'Hetzner cloud provider token not found.'], 404);
|
||||
}
|
||||
|
||||
try {
|
||||
$hetznerService = new HetznerService($token->token);
|
||||
|
||||
return response()->json($hetznerService->getFirewalls());
|
||||
} catch (\Throwable $e) {
|
||||
return response()->json(['message' => 'Failed to fetch firewalls: '.$e->getMessage()], 500);
|
||||
}
|
||||
}
|
||||
|
||||
#[OA\Get(
|
||||
summary: 'Get Hetzner Networks',
|
||||
description: 'Get all existing Hetzner private networks for the current project.',
|
||||
path: '/hetzner/networks',
|
||||
operationId: 'get-hetzner-networks',
|
||||
security: [
|
||||
['bearerAuth' => []],
|
||||
],
|
||||
tags: ['Hetzner'],
|
||||
parameters: [
|
||||
new OA\Parameter(
|
||||
name: 'cloud_provider_token_uuid',
|
||||
in: 'query',
|
||||
required: false,
|
||||
description: 'Cloud provider token UUID. Required if cloud_provider_token_id is not provided.',
|
||||
schema: new OA\Schema(type: 'string')
|
||||
),
|
||||
new OA\Parameter(
|
||||
name: 'cloud_provider_token_id',
|
||||
in: 'query',
|
||||
required: false,
|
||||
deprecated: true,
|
||||
description: 'Deprecated: Use cloud_provider_token_uuid instead. Cloud provider token UUID.',
|
||||
schema: new OA\Schema(type: 'string')
|
||||
),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 200,
|
||||
description: 'List of Hetzner networks.',
|
||||
content: [
|
||||
new OA\MediaType(
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(
|
||||
type: 'array',
|
||||
items: new OA\Items(
|
||||
type: 'object',
|
||||
properties: [
|
||||
'id' => ['type' => 'integer'],
|
||||
'name' => ['type' => 'string'],
|
||||
'ip_range' => ['type' => 'string'],
|
||||
]
|
||||
)
|
||||
)
|
||||
),
|
||||
]),
|
||||
new OA\Response(
|
||||
response: 401,
|
||||
ref: '#/components/responses/401',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 404,
|
||||
ref: '#/components/responses/404',
|
||||
),
|
||||
]
|
||||
)]
|
||||
public function networks(Request $request)
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
$validator = customApiValidator($request->all(), [
|
||||
'cloud_provider_token_uuid' => 'required_without:cloud_provider_token_id|string',
|
||||
'cloud_provider_token_id' => 'required_without:cloud_provider_token_uuid|string',
|
||||
]);
|
||||
|
||||
if ($validator->fails()) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => $validator->errors(),
|
||||
], 422);
|
||||
}
|
||||
|
||||
$tokenUuid = $this->getCloudProviderTokenUuid($request);
|
||||
$token = CloudProviderToken::whereTeamId($teamId)
|
||||
->whereUuid($tokenUuid)
|
||||
->where('provider', 'hetzner')
|
||||
->first();
|
||||
|
||||
if (! $token) {
|
||||
return response()->json(['message' => 'Hetzner cloud provider token not found.'], 404);
|
||||
}
|
||||
|
||||
try {
|
||||
$hetznerService = new HetznerService($token->token);
|
||||
|
||||
return response()->json($hetznerService->getNetworks());
|
||||
} catch (\Throwable $e) {
|
||||
return response()->json(['message' => 'Failed to fetch networks: '.$e->getMessage()], 500);
|
||||
}
|
||||
}
|
||||
|
||||
#[OA\Post(
|
||||
summary: 'Create Hetzner Server',
|
||||
description: 'Create a new server on Hetzner and register it in Coolify.',
|
||||
|
|
@ -482,6 +669,8 @@ public function sshKeys(Request $request)
|
|||
'enable_ipv4' => ['type' => 'boolean', 'example' => true, 'description' => 'Enable IPv4 (default: true)'],
|
||||
'enable_ipv6' => ['type' => 'boolean', 'example' => true, 'description' => 'Enable IPv6 (default: true)'],
|
||||
'hetzner_ssh_key_ids' => ['type' => 'array', 'items' => ['type' => 'integer'], 'description' => 'Additional Hetzner SSH key IDs'],
|
||||
'hetzner_firewall_ids' => ['type' => 'array', 'items' => ['type' => 'integer'], 'description' => 'Existing Hetzner firewall IDs to apply during server creation'],
|
||||
'hetzner_network_ids' => ['type' => 'array', 'items' => ['type' => 'integer'], 'description' => 'Existing Hetzner network IDs to attach during server creation'],
|
||||
'cloud_init_script' => ['type' => 'string', 'description' => 'Cloud-init YAML script (optional)'],
|
||||
'instant_validate' => ['type' => 'boolean', 'example' => false, 'description' => 'Validate server immediately after creation'],
|
||||
],
|
||||
|
|
@ -540,6 +729,8 @@ public function createServer(Request $request)
|
|||
'enable_ipv4',
|
||||
'enable_ipv6',
|
||||
'hetzner_ssh_key_ids',
|
||||
'hetzner_firewall_ids',
|
||||
'hetzner_network_ids',
|
||||
'cloud_init_script',
|
||||
'instant_validate',
|
||||
];
|
||||
|
|
@ -566,6 +757,10 @@ public function createServer(Request $request)
|
|||
'enable_ipv6' => 'nullable|boolean',
|
||||
'hetzner_ssh_key_ids' => 'nullable|array',
|
||||
'hetzner_ssh_key_ids.*' => 'integer',
|
||||
'hetzner_firewall_ids' => 'nullable|array',
|
||||
'hetzner_firewall_ids.*' => 'integer',
|
||||
'hetzner_network_ids' => 'nullable|array',
|
||||
'hetzner_network_ids.*' => 'integer',
|
||||
'cloud_init_script' => ['nullable', 'string', new ValidCloudInitYaml],
|
||||
'instant_validate' => 'nullable|boolean',
|
||||
]);
|
||||
|
|
@ -604,6 +799,12 @@ public function createServer(Request $request)
|
|||
if (is_null($request->hetzner_ssh_key_ids)) {
|
||||
$request->offsetSet('hetzner_ssh_key_ids', []);
|
||||
}
|
||||
if (is_null($request->hetzner_firewall_ids)) {
|
||||
$request->offsetSet('hetzner_firewall_ids', []);
|
||||
}
|
||||
if (is_null($request->hetzner_network_ids)) {
|
||||
$request->offsetSet('hetzner_network_ids', []);
|
||||
}
|
||||
if (is_null($request->instant_validate)) {
|
||||
$request->offsetSet('instant_validate', false);
|
||||
}
|
||||
|
|
@ -679,6 +880,18 @@ public function createServer(Request $request)
|
|||
],
|
||||
];
|
||||
|
||||
$firewallIds = array_values(array_unique($request->hetzner_firewall_ids));
|
||||
if ($firewallIds !== []) {
|
||||
$params['firewalls'] = array_map(function (int $firewallId): array {
|
||||
return ['firewall' => $firewallId];
|
||||
}, $firewallIds);
|
||||
}
|
||||
|
||||
$networkIds = array_values(array_unique($request->hetzner_network_ids));
|
||||
if ($networkIds !== []) {
|
||||
$params['networks'] = $networkIds;
|
||||
}
|
||||
|
||||
// Add cloud-init script if provided
|
||||
if (! empty($request->cloud_init_script)) {
|
||||
$params['user_data'] = $request->cloud_init_script;
|
||||
|
|
|
|||
|
|
@ -46,6 +46,10 @@ class ByHetzner extends Component
|
|||
|
||||
public array $hetznerSshKeys = [];
|
||||
|
||||
public array $hetznerFirewalls = [];
|
||||
|
||||
public array $hetznerNetworks = [];
|
||||
|
||||
public ?string $selected_location = null;
|
||||
|
||||
public ?int $selected_image = null;
|
||||
|
|
@ -54,6 +58,10 @@ class ByHetzner extends Component
|
|||
|
||||
public array $selectedHetznerSshKeyIds = [];
|
||||
|
||||
public array $selectedHetznerFirewallIds = [];
|
||||
|
||||
public array $selectedHetznerNetworkIds = [];
|
||||
|
||||
public string $server_name = '';
|
||||
|
||||
public ?int $private_key_id = null;
|
||||
|
|
@ -112,6 +120,9 @@ public function resetSelection()
|
|||
$this->save_cloud_init_script = false;
|
||||
$this->cloud_init_script_name = null;
|
||||
$this->selected_cloud_init_script_id = null;
|
||||
$this->selectedHetznerSshKeyIds = [];
|
||||
$this->selectedHetznerFirewallIds = [];
|
||||
$this->selectedHetznerNetworkIds = [];
|
||||
}
|
||||
|
||||
public function loadTokens()
|
||||
|
|
@ -160,6 +171,10 @@ protected function rules(): array
|
|||
'private_key_id' => 'required|integer|exists:private_keys,id,team_id,'.currentTeam()->id,
|
||||
'selectedHetznerSshKeyIds' => 'nullable|array',
|
||||
'selectedHetznerSshKeyIds.*' => 'integer',
|
||||
'selectedHetznerFirewallIds' => 'nullable|array',
|
||||
'selectedHetznerFirewallIds.*' => 'integer',
|
||||
'selectedHetznerNetworkIds' => 'nullable|array',
|
||||
'selectedHetznerNetworkIds.*' => 'integer',
|
||||
'enable_ipv4' => 'required|boolean',
|
||||
'enable_ipv6' => 'required|boolean',
|
||||
'cloud_init_script' => ['nullable', 'string', new ValidCloudInitYaml],
|
||||
|
|
@ -241,6 +256,9 @@ public function previousStep()
|
|||
private function loadHetznerData(string $token)
|
||||
{
|
||||
$this->loading_data = true;
|
||||
$this->selectedHetznerSshKeyIds = [];
|
||||
$this->selectedHetznerFirewallIds = [];
|
||||
$this->selectedHetznerNetworkIds = [];
|
||||
|
||||
try {
|
||||
$hetznerService = new HetznerService($token);
|
||||
|
|
@ -270,6 +288,14 @@ private function loadHetznerData(string $token)
|
|||
->toArray();
|
||||
// Load SSH keys from Hetzner
|
||||
$this->hetznerSshKeys = $hetznerService->getSshKeys();
|
||||
$this->hetznerFirewalls = collect($hetznerService->getFirewalls())
|
||||
->sortBy('name')
|
||||
->values()
|
||||
->toArray();
|
||||
$this->hetznerNetworks = collect($hetznerService->getNetworks())
|
||||
->sortBy('name')
|
||||
->values()
|
||||
->toArray();
|
||||
$this->loading_data = false;
|
||||
} catch (\Throwable $e) {
|
||||
$this->loading_data = false;
|
||||
|
|
@ -345,6 +371,37 @@ public function getAvailableImagesProperty()
|
|||
return $filtered;
|
||||
}
|
||||
|
||||
public function getAvailableNetworksProperty(): array
|
||||
{
|
||||
$attachableNetworks = collect($this->hetznerNetworks)
|
||||
->filter(function (array $network) {
|
||||
return collect($network['subnets'] ?? [])->contains(function (array $subnet) {
|
||||
return in_array($subnet['type'] ?? null, ['cloud', 'server'], true);
|
||||
});
|
||||
});
|
||||
|
||||
if (! $this->selected_location) {
|
||||
return $attachableNetworks->values()->toArray();
|
||||
}
|
||||
|
||||
$location = collect($this->locations)->firstWhere('name', $this->selected_location);
|
||||
$networkZone = $location['network_zone'] ?? null;
|
||||
|
||||
if (! $networkZone) {
|
||||
return $attachableNetworks->values()->toArray();
|
||||
}
|
||||
|
||||
return $attachableNetworks
|
||||
->filter(function (array $network) use ($networkZone) {
|
||||
return collect($network['subnets'] ?? [])->contains(function (array $subnet) use ($networkZone) {
|
||||
return in_array($subnet['type'] ?? null, ['cloud', 'server'], true)
|
||||
&& ($subnet['network_zone'] ?? null) === $networkZone;
|
||||
});
|
||||
})
|
||||
->values()
|
||||
->toArray();
|
||||
}
|
||||
|
||||
public function getSelectedServerPriceProperty(): ?string
|
||||
{
|
||||
if (! $this->selected_server_type) {
|
||||
|
|
@ -367,6 +424,13 @@ public function updatedSelectedLocation($value)
|
|||
// Reset server type and image when location changes
|
||||
$this->selected_server_type = null;
|
||||
$this->selected_image = null;
|
||||
|
||||
$this->selectedHetznerNetworkIds = array_values(array_filter(
|
||||
$this->selectedHetznerNetworkIds,
|
||||
function (int $selectedNetworkId): bool {
|
||||
return collect($this->availableNetworks)->contains('id', $selectedNetworkId);
|
||||
}
|
||||
));
|
||||
}
|
||||
|
||||
public function updatedSelectedServerType($value)
|
||||
|
|
@ -454,6 +518,18 @@ private function createHetznerServer(string $token): array
|
|||
],
|
||||
];
|
||||
|
||||
$firewallIds = array_values(array_unique($this->selectedHetznerFirewallIds));
|
||||
if ($firewallIds !== []) {
|
||||
$params['firewalls'] = array_map(function (int $firewallId): array {
|
||||
return ['firewall' => $firewallId];
|
||||
}, $firewallIds);
|
||||
}
|
||||
|
||||
$networkIds = array_values(array_unique($this->selectedHetznerNetworkIds));
|
||||
if ($networkIds !== []) {
|
||||
$params['networks'] = $networkIds;
|
||||
}
|
||||
|
||||
// Add cloud-init script if provided
|
||||
if (! empty($this->cloud_init_script)) {
|
||||
$params['user_data'] = $this->cloud_init_script;
|
||||
|
|
|
|||
|
|
@ -117,6 +117,16 @@ public function getSshKeys(): array
|
|||
return $this->requestPaginated('get', '/ssh_keys', 'ssh_keys');
|
||||
}
|
||||
|
||||
public function getFirewalls(): array
|
||||
{
|
||||
return $this->requestPaginated('get', '/firewalls', 'firewalls');
|
||||
}
|
||||
|
||||
public function getNetworks(): array
|
||||
{
|
||||
return $this->requestPaginated('get', '/networks', 'networks');
|
||||
}
|
||||
|
||||
public function uploadSshKey(string $name, string $publicKey): array
|
||||
{
|
||||
$response = $this->request('post', '/ssh_keys', [
|
||||
|
|
|
|||
|
|
@ -149,6 +149,37 @@ class="p-4 border border-warning-500 dark:border-warning-600 rounded bg-warning-
|
|||
</x-forms.datalist>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<x-forms.datalist label="Firewalls (from Hetzner)" id="selectedHetznerFirewallIds"
|
||||
helper="Optionally apply existing Hetzner firewalls when the server is created."
|
||||
:multiple="true" :disabled="count($hetznerFirewalls) === 0" :placeholder="count($hetznerFirewalls) > 0
|
||||
? 'Search and select firewalls...'
|
||||
: 'No firewalls found in Hetzner account'">
|
||||
@foreach ($hetznerFirewalls as $firewall)
|
||||
<option value="{{ $firewall['id'] }}">
|
||||
{{ $firewall['name'] }}
|
||||
@if (isset($firewall['rules']))
|
||||
- {{ count($firewall['rules']) }} rules
|
||||
@endif
|
||||
</option>
|
||||
@endforeach
|
||||
</x-forms.datalist>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<x-forms.datalist label="Internal Networks (from Hetzner)" id="selectedHetznerNetworkIds"
|
||||
helper="Optionally attach one or more private networks. Networks are filtered to the selected location's network zone when possible."
|
||||
:multiple="true" :disabled="count($this->availableNetworks) === 0" :placeholder="count($this->availableNetworks) > 0
|
||||
? 'Search and select networks...'
|
||||
: 'No compatible networks found'">
|
||||
@foreach ($this->availableNetworks as $network)
|
||||
<option value="{{ $network['id'] }}">
|
||||
{{ $network['name'] }} - {{ $network['ip_range'] }}
|
||||
</option>
|
||||
@endforeach
|
||||
</x-forms.datalist>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="text-sm font-medium">Network Configuration</label>
|
||||
<div class="flex gap-4">
|
||||
|
|
@ -201,4 +232,4 @@ class="p-4 border border-warning-500 dark:border-warning-600 rounded bg-warning-
|
|||
@endif
|
||||
@endif
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -94,6 +94,8 @@
|
|||
Route::get('/hetzner/server-types', [HetznerController::class, 'serverTypes'])->middleware(['api.ability:read']);
|
||||
Route::get('/hetzner/images', [HetznerController::class, 'images'])->middleware(['api.ability:read']);
|
||||
Route::get('/hetzner/ssh-keys', [HetznerController::class, 'sshKeys'])->middleware(['api.ability:read']);
|
||||
Route::get('/hetzner/firewalls', [HetznerController::class, 'firewalls'])->middleware(['api.ability:read']);
|
||||
Route::get('/hetzner/networks', [HetznerController::class, 'networks'])->middleware(['api.ability:read']);
|
||||
Route::post('/servers/hetzner', [HetznerController::class, 'createServer'])->middleware(['api.ability:write']);
|
||||
|
||||
Route::get('/resources', [ResourcesController::class, 'resources'])->middleware(['api.ability:read']);
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
use App\Models\PrivateKey;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\Client\Request as HttpRequest;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
|
|
@ -210,6 +211,52 @@
|
|||
});
|
||||
});
|
||||
|
||||
describe('GET /api/v1/hetzner/firewalls', function () {
|
||||
test('gets Hetzner firewalls', function () {
|
||||
Http::fake([
|
||||
'https://api.hetzner.cloud/v1/firewalls*' => Http::response([
|
||||
'firewalls' => [
|
||||
['id' => 38, 'name' => 'web-firewall', 'rules' => []],
|
||||
['id' => 39, 'name' => 'ssh-firewall', 'rules' => []],
|
||||
],
|
||||
'meta' => ['pagination' => ['next_page' => null]],
|
||||
], 200),
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->getJson('/api/v1/hetzner/firewalls?cloud_provider_token_id='.$this->hetznerToken->uuid);
|
||||
|
||||
$response->assertSuccessful();
|
||||
$response->assertJsonCount(2);
|
||||
$response->assertJsonFragment(['name' => 'web-firewall']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/v1/hetzner/networks', function () {
|
||||
test('gets Hetzner networks', function () {
|
||||
Http::fake([
|
||||
'https://api.hetzner.cloud/v1/networks*' => Http::response([
|
||||
'networks' => [
|
||||
['id' => 456, 'name' => 'private-eu', 'ip_range' => '10.0.0.0/16', 'subnets' => []],
|
||||
['id' => 457, 'name' => 'private-us', 'ip_range' => '10.1.0.0/16', 'subnets' => []],
|
||||
],
|
||||
'meta' => ['pagination' => ['next_page' => null]],
|
||||
], 200),
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->getJson('/api/v1/hetzner/networks?cloud_provider_token_id='.$this->hetznerToken->uuid);
|
||||
|
||||
$response->assertSuccessful();
|
||||
$response->assertJsonCount(2);
|
||||
$response->assertJsonFragment(['name' => 'private-eu']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/v1/servers/hetzner', function () {
|
||||
test('creates a Hetzner server', function () {
|
||||
// Mock Hetzner API calls
|
||||
|
|
@ -418,6 +465,53 @@
|
|||
$response->assertJsonFragment(['ip' => '2001:db8::1']);
|
||||
});
|
||||
|
||||
test('passes selected firewalls and networks to Hetzner server creation', function () {
|
||||
Http::fake([
|
||||
'https://api.hetzner.cloud/v1/ssh_keys*' => Http::response([
|
||||
'ssh_keys' => [],
|
||||
'meta' => ['pagination' => ['next_page' => null]],
|
||||
], 200),
|
||||
'https://api.hetzner.cloud/v1/ssh_keys' => Http::response([
|
||||
'ssh_key' => ['id' => 123],
|
||||
], 201),
|
||||
'https://api.hetzner.cloud/v1/servers' => Http::response([
|
||||
'server' => [
|
||||
'id' => 456,
|
||||
'public_net' => [
|
||||
'ipv4' => ['ip' => '1.2.3.4'],
|
||||
'ipv6' => ['ip' => '2001:db8::1'],
|
||||
],
|
||||
],
|
||||
], 201),
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->postJson('/api/v1/servers/hetzner', [
|
||||
'cloud_provider_token_id' => $this->hetznerToken->uuid,
|
||||
'location' => 'nbg1',
|
||||
'server_type' => 'cx11',
|
||||
'image' => 15512617,
|
||||
'name' => 'test-server',
|
||||
'private_key_uuid' => $this->privateKey->uuid,
|
||||
'hetzner_firewall_ids' => [38, 39],
|
||||
'hetzner_network_ids' => [456, 457, 456],
|
||||
]);
|
||||
|
||||
$response->assertCreated();
|
||||
|
||||
Http::assertSent(function (HttpRequest $request): bool {
|
||||
return $request->method() === 'POST'
|
||||
&& $request->url() === 'https://api.hetzner.cloud/v1/servers'
|
||||
&& $request['networks'] === [456, 457]
|
||||
&& $request['firewalls'] === [
|
||||
['firewall' => 38],
|
||||
['firewall' => 39],
|
||||
];
|
||||
});
|
||||
});
|
||||
|
||||
test('rejects extra fields not in allowed list', function () {
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
|
|
|
|||
|
|
@ -1,9 +1,12 @@
|
|||
<?php
|
||||
|
||||
use App\Livewire\Server\New\ByHetzner;
|
||||
use App\Models\CloudProviderToken;
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Livewire\Livewire;
|
||||
|
||||
// Note: Full Livewire integration tests require database setup
|
||||
|
|
@ -141,6 +144,15 @@
|
|||
->and(count($sshKeys))->toBe(3);
|
||||
});
|
||||
|
||||
it('validates network array merging removes duplicate Hetzner network ids', function () {
|
||||
$selectedHetznerNetworkIds = [456, 789, 456];
|
||||
|
||||
$networkIds = array_values(array_unique($selectedHetznerNetworkIds));
|
||||
|
||||
expect($networkIds)->toBe([456, 789])
|
||||
->and(count($networkIds))->toBe(2);
|
||||
});
|
||||
|
||||
describe('Boarding Flow Integration', function () {
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
|
|
@ -186,3 +198,80 @@
|
|||
expect($this->team->fresh()->show_boarding)->toBeTrue();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Hetzner data loading', function () {
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
$this->team = Team::factory()->create();
|
||||
$this->user = User::factory()->create();
|
||||
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
|
||||
|
||||
$this->actingAs($this->user);
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
$this->hetznerToken = CloudProviderToken::factory()->create([
|
||||
'team_id' => $this->team->id,
|
||||
'provider' => 'hetzner',
|
||||
'token' => 'test-hetzner-api-token',
|
||||
]);
|
||||
|
||||
PrivateKey::factory()->create([
|
||||
'team_id' => $this->team->id,
|
||||
]);
|
||||
});
|
||||
|
||||
test('loads firewalls and networks for the selected Hetzner token', function () {
|
||||
Http::fake([
|
||||
'https://api.hetzner.cloud/v1/locations*' => Http::response([
|
||||
'locations' => [
|
||||
['id' => 1, 'name' => 'nbg1', 'city' => 'Nuremberg', 'country' => 'DE', 'network_zone' => 'eu-central'],
|
||||
],
|
||||
'meta' => ['pagination' => ['next_page' => null]],
|
||||
], 200),
|
||||
'https://api.hetzner.cloud/v1/server_types*' => Http::response([
|
||||
'server_types' => [
|
||||
['id' => 1, 'name' => 'cx11', 'description' => 'CX11', 'locations' => [['name' => 'nbg1']], 'architecture' => 'x86'],
|
||||
],
|
||||
'meta' => ['pagination' => ['next_page' => null]],
|
||||
], 200),
|
||||
'https://api.hetzner.cloud/v1/images*' => Http::response([
|
||||
'images' => [
|
||||
['id' => 15512617, 'name' => 'ubuntu-24.04', 'type' => 'system', 'deprecated' => false, 'architecture' => 'x86'],
|
||||
],
|
||||
'meta' => ['pagination' => ['next_page' => null]],
|
||||
], 200),
|
||||
'https://api.hetzner.cloud/v1/ssh_keys*' => Http::response([
|
||||
'ssh_keys' => [],
|
||||
'meta' => ['pagination' => ['next_page' => null]],
|
||||
], 200),
|
||||
'https://api.hetzner.cloud/v1/firewalls*' => Http::response([
|
||||
'firewalls' => [
|
||||
['id' => 38, 'name' => 'web-firewall', 'rules' => []],
|
||||
],
|
||||
'meta' => ['pagination' => ['next_page' => null]],
|
||||
], 200),
|
||||
'https://api.hetzner.cloud/v1/networks*' => Http::response([
|
||||
'networks' => [
|
||||
[
|
||||
'id' => 456,
|
||||
'name' => 'private-eu',
|
||||
'ip_range' => '10.0.0.0/16',
|
||||
'subnets' => [
|
||||
['type' => 'cloud', 'network_zone' => 'eu-central'],
|
||||
],
|
||||
],
|
||||
],
|
||||
'meta' => ['pagination' => ['next_page' => null]],
|
||||
], 200),
|
||||
]);
|
||||
|
||||
$component = Livewire::test(ByHetzner::class)
|
||||
->set('selected_token_id', $this->hetznerToken->id)
|
||||
->call('nextStep')
|
||||
->assertSet('current_step', 2);
|
||||
|
||||
expect($component->get('hetznerFirewalls'))->toHaveCount(1)
|
||||
->and($component->get('hetznerNetworks'))->toHaveCount(1);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue