fix(hetzner): secure token-backed option fetches

Authorize stored Hetzner cloud provider tokens before fetching
firewalls or networks, hide provider error details, and keep server
creation intact if backup activation fails.

Collapse advanced Hetzner server options by default while preserving
visibility when advanced values are selected.
This commit is contained in:
Andras Bacsai
2026-07-07 14:24:15 +02:00
parent 2ad11fdd9a
commit d723a52d9c
6 changed files with 330 additions and 85 deletions
+12 -6
View File
@@ -543,13 +543,14 @@ class HetznerController extends Controller
if (! $token) {
return response()->json(['message' => 'Hetzner cloud provider token not found.'], 404);
}
$this->authorize('view', $token);
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);
return response()->json(['message' => 'Failed to fetch Hetzner firewalls.'], 500);
}
}
@@ -637,13 +638,14 @@ class HetznerController extends Controller
if (! $token) {
return response()->json(['message' => 'Hetzner cloud provider token not found.'], 404);
}
$this->authorize('view', $token);
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);
return response()->json(['message' => 'Failed to fetch Hetzner networks.'], 500);
}
}
@@ -914,10 +916,6 @@ class HetznerController extends Controller
// Create server on Hetzner
$hetznerServer = $hetznerService->createServer($params);
if ($request->enable_backups) {
$hetznerService->enableServerBackup((int) $hetznerServer['id']);
}
// Determine IP address to use (prefer IPv4, fallback to IPv6)
$ipAddress = null;
if ($request->enable_ipv4 && isset($hetznerServer['public_net']['ipv4']['ip'])) {
@@ -946,6 +944,14 @@ class HetznerController extends Controller
$server->proxy->set('type', ProxyTypes::TRAEFIK->value);
$server->save();
if ($request->enable_backups) {
try {
$hetznerService->enableServerBackup((int) $hetznerServer['id']);
} catch (\Throwable $e) {
report($e);
}
}
// Validate server if requested
if ($request->instant_validate) {
ValidateServer::dispatch($server);
+87 -4
View File
@@ -74,6 +74,10 @@ class ByHetzner extends Component
public bool $enable_backups = false;
public bool $show_advanced_hetzner_options = false;
public bool $show_cloud_init_script = false;
public ?string $cloud_init_script = null;
public bool $save_cloud_init_script = false;
@@ -127,6 +131,8 @@ class ByHetzner extends Component
$this->save_cloud_init_script = false;
$this->cloud_init_script_name = null;
$this->selected_cloud_init_script_id = null;
$this->show_advanced_hetzner_options = false;
$this->show_cloud_init_script = false;
$this->selectedHetznerSshKeyIds = [];
$this->selectedHetznerFirewallIds = [];
$this->selectedHetznerNetworkIds = [];
@@ -185,6 +191,8 @@ class ByHetzner extends Component
'enable_ipv4' => 'required|boolean',
'enable_ipv6' => 'required|boolean',
'enable_backups' => 'required|boolean',
'show_advanced_hetzner_options' => 'boolean',
'show_cloud_init_script' => 'boolean',
'cloud_init_script' => ['nullable', 'string', new ValidCloudInitYaml],
'save_cloud_init_script' => 'boolean',
'cloud_init_script_name' => 'nullable|string|max:255',
@@ -444,6 +452,67 @@ class ByHetzner extends Component
return '€'.number_format($price * 0.2, 2);
}
public function getShouldShowAdvancedHetznerOptionsProperty(): bool
{
return $this->show_advanced_hetzner_options
|| $this->selectedHetznerSshKeyIds !== []
|| $this->selectedHetznerFirewallIds !== []
|| $this->selectedHetznerNetworkIds !== []
|| $this->enable_backups
|| ! $this->enable_ipv4
|| ! $this->enable_ipv6
|| $this->show_cloud_init_script
|| filled($this->cloud_init_script)
|| $this->save_cloud_init_script
|| filled($this->cloud_init_script_name)
|| filled($this->selected_cloud_init_script_id);
}
public function getAdvancedHetznerOptionsSummaryProperty(): array
{
$summary = [];
if (count($this->selectedHetznerSshKeyIds) > 0) {
$summary[] = count($this->selectedHetznerSshKeyIds).' extra SSH '.str('key')->plural(count($this->selectedHetznerSshKeyIds));
}
if (count($this->selectedHetznerFirewallIds) > 0) {
$summary[] = count($this->selectedHetznerFirewallIds).' '.str('firewall')->plural(count($this->selectedHetznerFirewallIds));
}
if (count($this->selectedHetznerNetworkIds) > 0) {
$summary[] = count($this->selectedHetznerNetworkIds).' private '.str('network')->plural(count($this->selectedHetznerNetworkIds));
}
if ($this->enable_backups) {
$summary[] = 'Backups on';
}
if (! $this->enable_ipv4 || ! $this->enable_ipv6) {
$summary[] = collect([
$this->enable_ipv4 ? 'IPv4' : null,
$this->enable_ipv6 ? 'IPv6' : null,
])->filter()->join(' + ') ?: 'No public IP';
}
if ($this->show_cloud_init_script || filled($this->cloud_init_script) || filled($this->selected_cloud_init_script_id)) {
$summary[] = 'Cloud-init';
}
return $summary;
}
public function toggleAdvancedHetznerOptions(): void
{
$this->show_advanced_hetzner_options = ! $this->show_advanced_hetzner_options;
}
public function showCloudInitScript(): void
{
$this->show_cloud_init_script = true;
$this->show_advanced_hetzner_options = true;
}
public function updatedSelectedLocation($value)
{
// Reset server type and image when location changes
@@ -475,6 +544,15 @@ class ByHetzner extends Component
$script = CloudInitScript::ownedByCurrentTeam()->findOrFail($value);
$this->cloud_init_script = $script->script;
$this->cloud_init_script_name = $script->name;
$this->show_cloud_init_script = true;
$this->show_advanced_hetzner_options = true;
}
}
public function updatedSaveCloudInitScript(bool $value): void
{
if (! $value) {
$this->cloud_init_script_name = null;
}
}
@@ -484,6 +562,7 @@ class ByHetzner extends Component
$this->cloud_init_script = '';
$this->cloud_init_script_name = '';
$this->save_cloud_init_script = false;
$this->show_cloud_init_script = false;
}
private function createHetznerServer(HetznerService $hetznerService): array
@@ -592,10 +671,6 @@ class ByHetzner extends Component
// Create server on Hetzner
$hetznerServer = $this->createHetznerServer($hetznerService);
if ($this->enable_backups) {
$hetznerService->enableServerBackup((int) $hetznerServer['id']);
}
// Determine IP address to use (prefer IPv4, fallback to IPv6)
$ipAddress = null;
if ($this->enable_ipv4 && isset($hetznerServer['public_net']['ipv4']['ip'])) {
@@ -624,6 +699,14 @@ class ByHetzner extends Component
$server->proxy->set('type', ProxyTypes::TRAEFIK->value);
$server->save();
if ($this->enable_backups) {
try {
$hetznerService->enableServerBackup((int) $hetznerServer['id']);
} catch (\Throwable $e) {
report($e);
}
}
if ($this->from_onboarding) {
// Complete the boarding when server is successfully created via Hetzner
currentTeam()->update([
+30 -30
View File
@@ -192,7 +192,7 @@
"category": "media",
"logo": "svgs/audiobookshelf.svg",
"minversion": "0.0.0",
"template_last_updated_at": "2025-08-17T18:23:57+02:00",
"template_last_updated_at": "2026-05-12T09:09:36+02:00",
"port": "80"
},
"authentik": {
@@ -280,7 +280,7 @@
"category": "monitoring",
"logo": "svgs/beszel.svg",
"minversion": "0.0.0",
"template_last_updated_at": "2026-02-21T23:17:23+05:30"
"template_last_updated_at": "2026-04-24T02:22:08+05:30"
},
"beszel": {
"documentation": "https://github.com/henrygd/beszel?tab=readme-ov-file#getting-started?utm_source=coolify.io",
@@ -296,7 +296,7 @@
"category": "monitoring",
"logo": "svgs/beszel.svg",
"minversion": "0.0.0",
"template_last_updated_at": "2026-02-21T23:17:23+05:30",
"template_last_updated_at": "2026-04-24T02:21:33+05:30",
"port": "8090"
},
"bitcoin-core": {
@@ -326,7 +326,7 @@
"category": "backend",
"logo": "svgs/bluesky.svg",
"minversion": "0.0.0",
"template_last_updated_at": "2026-02-03T22:12:21+01:00",
"template_last_updated_at": "2026-05-09T19:19:48+05:30",
"port": "3000"
},
"bookstack": {
@@ -475,7 +475,7 @@
"category": "security",
"logo": "svgs/cap-captcha.png",
"minversion": "0.0.0",
"template_last_updated_at": null,
"template_last_updated_at": "2026-04-23T01:07:14+05:30",
"port": "3000"
},
"cap": {
@@ -573,7 +573,7 @@
"category": "helpdesk",
"logo": "svgs/chatwoot.svg",
"minversion": "0.0.0",
"template_last_updated_at": "2026-04-06T11:35:16-05:00",
"template_last_updated_at": "2026-05-27T09:31:29-03:00",
"port": "3000"
},
"checkmate": {
@@ -704,7 +704,7 @@
"category": "automation",
"logo": "svgs/cloudflare-ddns.svg",
"minversion": "0.0.0",
"template_last_updated_at": null
"template_last_updated_at": "2026-05-18T15:42:31+10:00"
},
"cloudflared": {
"documentation": "https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/?utm_source=coolify.io",
@@ -803,7 +803,7 @@
"category": "backend",
"logo": "svgs/convex.svg",
"minversion": "0.0.0",
"template_last_updated_at": "2026-04-03T18:01:53+05:30",
"template_last_updated_at": "2026-06-12T10:45:52+02:00",
"port": "6791"
},
"cryptgeon": {
@@ -968,7 +968,7 @@
"category": "productivity",
"logo": "svgs/docmost.png",
"minversion": "0.0.0",
"template_last_updated_at": "2025-09-27T04:00:56+02:00",
"template_last_updated_at": "2026-05-15T13:36:02+02:00",
"port": "3000"
},
"documenso": {
@@ -1248,7 +1248,7 @@
"category": "Networking",
"logo": "svgs/emqx-enterprise.svg",
"minversion": "0.0.0",
"template_last_updated_at": null,
"template_last_updated_at": "2026-04-27T09:25:48+03:00",
"port": "18083"
},
"ente-photos-with-s3": {
@@ -1732,7 +1732,7 @@
"category": "storage",
"logo": "svgs/garage.svg",
"minversion": "0.0.0",
"template_last_updated_at": "2025-12-10T12:57:34+01:00",
"template_last_updated_at": "2026-05-31T23:57:46+02:00",
"port": "3900"
},
"getoutline": {
@@ -1746,7 +1746,7 @@
"category": "productivity",
"logo": "svgs/getoutline.jpeg",
"minversion": "0.0.0",
"template_last_updated_at": "2025-12-16T14:12:15+07:00",
"template_last_updated_at": "2026-05-09T19:19:29+05:30",
"port": "3000"
},
"ghost": {
@@ -1779,7 +1779,7 @@
"category": "devtools",
"logo": "svgs/gitea.svg",
"minversion": "0.0.0",
"template_last_updated_at": null
"template_last_updated_at": "2026-06-06T00:11:24+02:00"
},
"gitea-with-mariadb": {
"documentation": "https://docs.gitea.com?utm_source=coolify.io",
@@ -2114,7 +2114,7 @@
"category": "productivity",
"logo": "svgs/grocy.svg",
"minversion": "0.0.0",
"template_last_updated_at": "2025-08-17T18:23:57+02:00"
"template_last_updated_at": "2026-05-12T09:10:44+02:00"
},
"hatchet": {
"documentation": "https://docs.hatchet.run/self-hosting/docker-compose?utm_source=coolify.io",
@@ -2148,7 +2148,7 @@
"category": "monitoring",
"logo": "svgs/healthchecks.webp",
"minversion": "0.0.0",
"template_last_updated_at": null,
"template_last_updated_at": "2026-05-25T13:06:59-04:00",
"port": "80000"
},
"heimdall": {
@@ -2183,7 +2183,7 @@
"category": "ai",
"logo": "svgs/hermes-agent.png",
"minversion": "0.0.0",
"template_last_updated_at": null,
"template_last_updated_at": "2026-05-29T12:14:17+05:30",
"port": "8787"
},
"heyform": {
@@ -2218,7 +2218,7 @@
"category": "productivity",
"logo": "svgs/homarr.svg",
"minversion": "0.0.0",
"template_last_updated_at": "2025-10-07T12:19:45+02:00",
"template_last_updated_at": "2026-05-09T19:30:07+05:30",
"port": "7575"
},
"home-assistant": {
@@ -2361,7 +2361,7 @@
"category": "automation",
"logo": "svgs/inngest.png",
"minversion": "0.0.0",
"template_last_updated_at": null,
"template_last_updated_at": "2026-07-02T13:25:47+02:00",
"port": "8288"
},
"invoice-ninja": {
@@ -2410,7 +2410,7 @@
"category": "media",
"logo": "svgs/jellyfin.svg",
"minversion": "0.0.0",
"template_last_updated_at": "2025-08-17T18:23:57+02:00",
"template_last_updated_at": "2026-05-12T08:58:05+02:00",
"port": "8096"
},
"jenkins": {
@@ -2443,7 +2443,7 @@
"category": "productivity",
"logo": "svgs/jitsi.svg",
"minversion": "0.0.0",
"template_last_updated_at": "2025-08-17T18:23:57+02:00",
"template_last_updated_at": "2026-04-24T09:40:01+05:30",
"port": "80"
},
"joomla-with-mariadb": {
@@ -2672,7 +2672,7 @@
"category": "ai",
"logo": "svgs/langfuse.svg",
"minversion": "0.0.0",
"template_last_updated_at": "2026-03-28T16:21:57+01:00",
"template_last_updated_at": "2026-04-23T18:08:40+02:00",
"port": "3000"
},
"leantime": {
@@ -2881,7 +2881,7 @@
"category": "auth",
"logo": "svgs/logto_dark.svg",
"minversion": "0.0.0",
"template_last_updated_at": "2025-08-17T18:23:57+02:00"
"template_last_updated_at": "2026-04-01T13:19:47Z"
},
"lowcoder": {
"documentation": "https://docs.lowcoder.cloud/?utm_source=coolify.io",
@@ -3021,7 +3021,7 @@
"category": "productivity",
"logo": "svgs/mealie.png",
"minversion": "0.0.0",
"template_last_updated_at": "2025-08-17T18:23:57+02:00",
"template_last_updated_at": "2026-05-12T09:22:15+02:00",
"port": "9000"
},
"mediawiki": {
@@ -3712,7 +3712,7 @@
"category": "email",
"logo": "svgs/openarchiver.svg",
"minversion": "0.0.0",
"template_last_updated_at": "2026-04-06T11:35:16-05:00",
"template_last_updated_at": "2026-05-09T19:35:15+05:30",
"port": "3000"
},
"open-webui": {
@@ -3770,7 +3770,7 @@
"category": "monitoring",
"logo": "svgs/openobserve.svg",
"minversion": "0.0.0",
"template_last_updated_at": null,
"template_last_updated_at": "2026-05-19T16:40:18+05:30",
"port": "5080"
},
"openpanel": {
@@ -3891,7 +3891,7 @@
"category": "storage",
"logo": "svgs/owncloud.svg",
"minversion": "0.0.0",
"template_last_updated_at": "2025-08-17T18:23:57+02:00",
"template_last_updated_at": "2026-06-02T12:12:42+03:00",
"port": "8080"
},
"pairdrop": {
@@ -4075,7 +4075,7 @@
"category": "productivity",
"logo": "svgs/plane.svg",
"minversion": "0.0.0",
"template_last_updated_at": "2026-02-24T02:34:35+05:30",
"template_last_updated_at": "2026-04-24T00:12:17+05:30",
"port": "80"
},
"plex": {
@@ -4351,7 +4351,7 @@
"category": "productivity",
"logo": "svgs/rallly.svg",
"minversion": "0.0.0",
"template_last_updated_at": "2025-08-17T18:23:57+02:00",
"template_last_updated_at": "2026-03-19T16:11:50+01:00",
"port": "3000"
},
"reactive-resume": {
@@ -4502,7 +4502,7 @@
"category": "productivity",
"logo": "svgs/ryot.svg",
"minversion": "0.0.0",
"template_last_updated_at": "2025-08-17T18:23:57+02:00",
"template_last_updated_at": "2026-05-12T08:34:57+02:00",
"port": "8000"
},
"satisfactory": {
@@ -5176,7 +5176,7 @@
"category": "productivity",
"logo": "svgs/twenty.svg",
"minversion": "0.0.0",
"template_last_updated_at": "2026-04-06T11:35:16-05:00",
"template_last_updated_at": "2026-04-16T23:18:19+05:30",
"port": "3000"
},
"typesense": {
+30 -30
View File
@@ -192,7 +192,7 @@
"category": "media",
"logo": "svgs/audiobookshelf.svg",
"minversion": "0.0.0",
"template_last_updated_at": "2025-08-17T18:23:57+02:00",
"template_last_updated_at": "2026-05-12T09:09:36+02:00",
"port": "80"
},
"authentik": {
@@ -280,7 +280,7 @@
"category": "monitoring",
"logo": "svgs/beszel.svg",
"minversion": "0.0.0",
"template_last_updated_at": "2026-02-21T23:17:23+05:30"
"template_last_updated_at": "2026-04-24T02:22:08+05:30"
},
"beszel": {
"documentation": "https://github.com/henrygd/beszel?tab=readme-ov-file#getting-started?utm_source=coolify.io",
@@ -296,7 +296,7 @@
"category": "monitoring",
"logo": "svgs/beszel.svg",
"minversion": "0.0.0",
"template_last_updated_at": "2026-02-21T23:17:23+05:30",
"template_last_updated_at": "2026-04-24T02:21:33+05:30",
"port": "8090"
},
"bitcoin-core": {
@@ -326,7 +326,7 @@
"category": "backend",
"logo": "svgs/bluesky.svg",
"minversion": "0.0.0",
"template_last_updated_at": "2026-02-03T22:12:21+01:00",
"template_last_updated_at": "2026-05-09T19:19:48+05:30",
"port": "3000"
},
"bookstack": {
@@ -475,7 +475,7 @@
"category": "security",
"logo": "svgs/cap-captcha.png",
"minversion": "0.0.0",
"template_last_updated_at": null,
"template_last_updated_at": "2026-04-23T01:07:14+05:30",
"port": "3000"
},
"cap": {
@@ -573,7 +573,7 @@
"category": "helpdesk",
"logo": "svgs/chatwoot.svg",
"minversion": "0.0.0",
"template_last_updated_at": "2026-04-06T11:35:16-05:00",
"template_last_updated_at": "2026-05-27T09:31:29-03:00",
"port": "3000"
},
"checkmate": {
@@ -704,7 +704,7 @@
"category": "automation",
"logo": "svgs/cloudflare-ddns.svg",
"minversion": "0.0.0",
"template_last_updated_at": null
"template_last_updated_at": "2026-05-18T15:42:31+10:00"
},
"cloudflared": {
"documentation": "https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/?utm_source=coolify.io",
@@ -803,7 +803,7 @@
"category": "backend",
"logo": "svgs/convex.svg",
"minversion": "0.0.0",
"template_last_updated_at": "2026-04-03T18:01:53+05:30",
"template_last_updated_at": "2026-06-12T10:45:52+02:00",
"port": "6791"
},
"cryptgeon": {
@@ -968,7 +968,7 @@
"category": "productivity",
"logo": "svgs/docmost.png",
"minversion": "0.0.0",
"template_last_updated_at": "2025-09-27T04:00:56+02:00",
"template_last_updated_at": "2026-05-15T13:36:02+02:00",
"port": "3000"
},
"documenso": {
@@ -1248,7 +1248,7 @@
"category": "Networking",
"logo": "svgs/emqx-enterprise.svg",
"minversion": "0.0.0",
"template_last_updated_at": null,
"template_last_updated_at": "2026-04-27T09:25:48+03:00",
"port": "18083"
},
"ente-photos-with-s3": {
@@ -1732,7 +1732,7 @@
"category": "storage",
"logo": "svgs/garage.svg",
"minversion": "0.0.0",
"template_last_updated_at": "2025-12-10T12:57:34+01:00",
"template_last_updated_at": "2026-05-31T23:57:46+02:00",
"port": "3900"
},
"getoutline": {
@@ -1746,7 +1746,7 @@
"category": "productivity",
"logo": "svgs/getoutline.jpeg",
"minversion": "0.0.0",
"template_last_updated_at": "2025-12-16T14:12:15+07:00",
"template_last_updated_at": "2026-05-09T19:19:29+05:30",
"port": "3000"
},
"ghost": {
@@ -1779,7 +1779,7 @@
"category": "devtools",
"logo": "svgs/gitea.svg",
"minversion": "0.0.0",
"template_last_updated_at": null
"template_last_updated_at": "2026-06-06T00:11:24+02:00"
},
"gitea-with-mariadb": {
"documentation": "https://docs.gitea.com?utm_source=coolify.io",
@@ -2114,7 +2114,7 @@
"category": "productivity",
"logo": "svgs/grocy.svg",
"minversion": "0.0.0",
"template_last_updated_at": "2025-08-17T18:23:57+02:00"
"template_last_updated_at": "2026-05-12T09:10:44+02:00"
},
"hatchet": {
"documentation": "https://docs.hatchet.run/self-hosting/docker-compose?utm_source=coolify.io",
@@ -2148,7 +2148,7 @@
"category": "monitoring",
"logo": "svgs/healthchecks.webp",
"minversion": "0.0.0",
"template_last_updated_at": null,
"template_last_updated_at": "2026-05-25T13:06:59-04:00",
"port": "80000"
},
"heimdall": {
@@ -2183,7 +2183,7 @@
"category": "ai",
"logo": "svgs/hermes-agent.png",
"minversion": "0.0.0",
"template_last_updated_at": null,
"template_last_updated_at": "2026-05-29T12:14:17+05:30",
"port": "8787"
},
"heyform": {
@@ -2218,7 +2218,7 @@
"category": "productivity",
"logo": "svgs/homarr.svg",
"minversion": "0.0.0",
"template_last_updated_at": "2025-10-07T12:19:45+02:00",
"template_last_updated_at": "2026-05-09T19:30:07+05:30",
"port": "7575"
},
"home-assistant": {
@@ -2361,7 +2361,7 @@
"category": "automation",
"logo": "svgs/inngest.png",
"minversion": "0.0.0",
"template_last_updated_at": null,
"template_last_updated_at": "2026-07-02T13:25:47+02:00",
"port": "8288"
},
"invoice-ninja": {
@@ -2410,7 +2410,7 @@
"category": "media",
"logo": "svgs/jellyfin.svg",
"minversion": "0.0.0",
"template_last_updated_at": "2025-08-17T18:23:57+02:00",
"template_last_updated_at": "2026-05-12T08:58:05+02:00",
"port": "8096"
},
"jenkins": {
@@ -2443,7 +2443,7 @@
"category": "productivity",
"logo": "svgs/jitsi.svg",
"minversion": "0.0.0",
"template_last_updated_at": "2025-08-17T18:23:57+02:00",
"template_last_updated_at": "2026-04-24T09:40:01+05:30",
"port": "80"
},
"joomla-with-mariadb": {
@@ -2672,7 +2672,7 @@
"category": "ai",
"logo": "svgs/langfuse.svg",
"minversion": "0.0.0",
"template_last_updated_at": "2026-03-28T16:21:57+01:00",
"template_last_updated_at": "2026-04-23T18:08:40+02:00",
"port": "3000"
},
"leantime": {
@@ -2881,7 +2881,7 @@
"category": "auth",
"logo": "svgs/logto_dark.svg",
"minversion": "0.0.0",
"template_last_updated_at": "2025-08-17T18:23:57+02:00"
"template_last_updated_at": "2026-04-01T13:19:47Z"
},
"lowcoder": {
"documentation": "https://docs.lowcoder.cloud/?utm_source=coolify.io",
@@ -3021,7 +3021,7 @@
"category": "productivity",
"logo": "svgs/mealie.png",
"minversion": "0.0.0",
"template_last_updated_at": "2025-08-17T18:23:57+02:00",
"template_last_updated_at": "2026-05-12T09:22:15+02:00",
"port": "9000"
},
"mediawiki": {
@@ -3712,7 +3712,7 @@
"category": "email",
"logo": "svgs/openarchiver.svg",
"minversion": "0.0.0",
"template_last_updated_at": "2026-04-06T11:35:16-05:00",
"template_last_updated_at": "2026-05-09T19:35:15+05:30",
"port": "3000"
},
"open-webui": {
@@ -3770,7 +3770,7 @@
"category": "monitoring",
"logo": "svgs/openobserve.svg",
"minversion": "0.0.0",
"template_last_updated_at": null,
"template_last_updated_at": "2026-05-19T16:40:18+05:30",
"port": "5080"
},
"openpanel": {
@@ -3891,7 +3891,7 @@
"category": "storage",
"logo": "svgs/owncloud.svg",
"minversion": "0.0.0",
"template_last_updated_at": "2025-08-17T18:23:57+02:00",
"template_last_updated_at": "2026-06-02T12:12:42+03:00",
"port": "8080"
},
"pairdrop": {
@@ -4075,7 +4075,7 @@
"category": "productivity",
"logo": "svgs/plane.svg",
"minversion": "0.0.0",
"template_last_updated_at": "2026-02-24T02:34:35+05:30",
"template_last_updated_at": "2026-04-24T00:12:17+05:30",
"port": "80"
},
"plex": {
@@ -4351,7 +4351,7 @@
"category": "productivity",
"logo": "svgs/rallly.svg",
"minversion": "0.0.0",
"template_last_updated_at": "2025-08-17T18:23:57+02:00",
"template_last_updated_at": "2026-03-19T16:11:50+01:00",
"port": "3000"
},
"reactive-resume": {
@@ -4502,7 +4502,7 @@
"category": "productivity",
"logo": "svgs/ryot.svg",
"minversion": "0.0.0",
"template_last_updated_at": "2025-08-17T18:23:57+02:00",
"template_last_updated_at": "2026-05-12T08:34:57+02:00",
"port": "8000"
},
"satisfactory": {
@@ -5176,7 +5176,7 @@
"category": "productivity",
"logo": "svgs/twenty.svg",
"minversion": "0.0.0",
"template_last_updated_at": "2026-04-06T11:35:16-05:00",
"template_last_updated_at": "2026-04-16T23:18:19+05:30",
"port": "3000"
},
"typesense": {
+137
View File
@@ -278,6 +278,27 @@ describe('GET /api/v1/hetzner/firewalls', function () {
$response->assertJsonCount(2);
$response->assertJsonFragment(['name' => 'web-firewall']);
});
test('member read token cannot use a stored cloud provider token', function () {
$member = User::factory()->create();
$this->team->members()->attach($member->id, ['role' => 'member']);
session(['currentTeam' => $this->team]);
$memberToken = $member->createToken('member-read', ['read'])->plainTextToken;
Http::fake([
'https://api.hetzner.cloud/v1/firewalls*' => Http::response([
'firewalls' => [['id' => 38, 'name' => 'web-firewall']],
], 200),
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$memberToken,
'Content-Type' => 'application/json',
])->getJson('/api/v1/hetzner/firewalls?cloud_provider_token_id='.$this->hetznerToken->uuid);
$response->assertForbidden();
Http::assertNothingSent();
});
});
describe('GET /api/v1/hetzner/networks', function () {
@@ -301,6 +322,27 @@ describe('GET /api/v1/hetzner/networks', function () {
$response->assertJsonCount(2);
$response->assertJsonFragment(['name' => 'private-eu']);
});
test('member read token cannot use a stored cloud provider token', function () {
$member = User::factory()->create();
$this->team->members()->attach($member->id, ['role' => 'member']);
session(['currentTeam' => $this->team]);
$memberToken = $member->createToken('member-read', ['read'])->plainTextToken;
Http::fake([
'https://api.hetzner.cloud/v1/networks*' => Http::response([
'networks' => [['id' => 456, 'name' => 'private-eu']],
], 200),
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$memberToken,
'Content-Type' => 'application/json',
])->getJson('/api/v1/hetzner/networks?cloud_provider_token_id='.$this->hetznerToken->uuid);
$response->assertForbidden();
Http::assertNothingSent();
});
});
describe('POST /api/v1/servers/hetzner', function () {
@@ -411,6 +453,67 @@ describe('POST /api/v1/servers/hetzner', function () {
&& $request->url() === 'https://api.hetzner.cloud/v1/servers/456/actions/enable_backup');
});
test('registers server when backup enablement fails after Hetzner creation', function () {
Http::fake(function (HttpRequest $request) {
if ($request->method() === 'GET' && str_starts_with($request->url(), 'https://api.hetzner.cloud/v1/ssh_keys')) {
return Http::response([
'ssh_keys' => [],
'meta' => ['pagination' => ['next_page' => null]],
], 200);
}
if ($request->method() === 'POST' && $request->url() === 'https://api.hetzner.cloud/v1/ssh_keys') {
return Http::response([
'ssh_key' => ['id' => 123, 'fingerprint' => 'aa:bb:cc:dd'],
], 201);
}
if ($request->method() === 'POST' && $request->url() === 'https://api.hetzner.cloud/v1/servers') {
return Http::response([
'server' => [
'id' => 456,
'name' => 'test-server',
'public_net' => [
'ipv4' => ['ip' => '1.2.3.4'],
'ipv6' => ['ip' => '2001:db8::1'],
],
],
], 201);
}
if ($request->method() === 'POST' && $request->url() === 'https://api.hetzner.cloud/v1/servers/456/actions/enable_backup') {
return Http::response([
'error' => ['message' => 'backup unavailable'],
], 500);
}
return Http::response([], 404);
});
$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,
'enable_ipv4' => true,
'enable_ipv6' => true,
'enable_backups' => true,
]);
$response->assertCreated();
$response->assertJsonFragment(['hetzner_server_id' => 456, 'ip' => '1.2.3.4']);
$this->assertDatabaseHas('servers', [
'name' => 'test-server',
'team_id' => $this->team->id,
'hetzner_server_id' => 456,
]);
});
test('generates server name if not provided', function () {
Http::fake([
'https://api.hetzner.cloud/v1/ssh_keys' => Http::response([
@@ -711,4 +814,38 @@ describe('error responses do not leak exception details', function () {
$response->assertExactJson(['message' => 'Failed to fetch Hetzner SSH keys.']);
expect($response->getContent())->not->toContain('INTERNAL_LEAK_TOKEN_abc');
});
test('firewalls endpoint returns generic 500 message on upstream failure', function () {
Http::fake([
'https://api.hetzner.cloud/v1/firewalls*' => Http::response([
'error' => ['message' => 'INTERNAL_LEAK_TOKEN_abc'],
], 500),
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->getJson('/api/v1/hetzner/firewalls?cloud_provider_token_id='.$this->hetznerToken->uuid);
$response->assertStatus(500);
$response->assertExactJson(['message' => 'Failed to fetch Hetzner firewalls.']);
expect($response->getContent())->not->toContain('INTERNAL_LEAK_TOKEN_abc');
});
test('networks endpoint returns generic 500 message on upstream failure', function () {
Http::fake([
'https://api.hetzner.cloud/v1/networks*' => Http::response([
'error' => ['message' => 'INTERNAL_LEAK_TOKEN_abc'],
], 500),
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->getJson('/api/v1/hetzner/networks?cloud_provider_token_id='.$this->hetznerToken->uuid);
$response->assertStatus(500);
$response->assertExactJson(['message' => 'Failed to fetch Hetzner networks.']);
expect($response->getContent())->not->toContain('INTERNAL_LEAK_TOKEN_abc');
});
});
@@ -144,15 +144,6 @@ it('validates deduplication when Coolify key is also in selected keys', function
->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);
@@ -171,7 +162,7 @@ describe('Boarding Flow Integration', function () {
test('completes boarding when server is created from onboarding', function () {
// Verify boarding is initially enabled
expect($this->team->fresh()->show_boarding)->toBeTrue();
expect((bool) $this->team->fresh()->show_boarding)->toBeTrue();
// Mount the component with from_onboarding flag
$component = Livewire::test(ByHetzner::class)
@@ -188,23 +179,51 @@ describe('Boarding Flow Integration', function () {
test('boarding flag remains unchanged when not from onboarding', function () {
// Verify boarding is initially enabled
expect($this->team->fresh()->show_boarding)->toBeTrue();
expect((bool) $this->team->fresh()->show_boarding)->toBeTrue();
// Mount the component without from_onboarding flag (default false)
Livewire::test(ByHetzner::class)
->set('from_onboarding', false);
// Boarding should still be enabled since it wasn't created from onboarding
expect($this->team->fresh()->show_boarding)->toBeTrue();
expect((bool) $this->team->fresh()->show_boarding)->toBeTrue();
});
test('shows the backups option with the pricing note in the create dialog', function () {
test('keeps advanced Hetzner options collapsed by default in the create dialog', function () {
Livewire::test(ByHetzner::class)
->set('current_step', 2)
->assertSet('enable_backups', false)
->assertSet('show_advanced_hetzner_options', false)
->assertSee('Advanced Hetzner options')
->assertDontSee('Extra SSH Keys')
->assertDontSee('Firewalls')
->assertDontSee('Private Networks')
->assertDontSee('Enable Hetzner Backups')
->assertDontSee('Cloud-Init Script');
});
test('shows advanced Hetzner options when expanded', function () {
Livewire::test(ByHetzner::class)
->set('current_step', 2)
->set('show_advanced_hetzner_options', true)
->assertSee('Extra SSH Keys')
->assertSee('Firewalls')
->assertSee('Private Networks')
->assertSee('Enable Hetzner Backups')
->assertSee('Add cloud-init script')
->assertSee('additional 20% of the server monthly fee');
});
test('shows the cloud init script name only when saving the script', function () {
Livewire::test(ByHetzner::class)
->set('current_step', 2)
->set('show_advanced_hetzner_options', true)
->set('show_cloud_init_script', true)
->assertSee('Cloud-Init Script')
->assertSee('Save this script for later use')
->assertDontSee('Script name...')
->set('save_cloud_init_script', true)
->assertSee('Script name...');
});
});
describe('Hetzner data loading', function () {
@@ -239,7 +258,7 @@ describe('Hetzner data loading', function () {
], 200),
'https://api.hetzner.cloud/v1/server_types*' => Http::response([
'server_types' => [
['id' => 1, 'name' => 'cx11', 'description' => 'CX11', 'locations' => [['name' => 'nbg1']], 'architecture' => 'x86'],
['id' => 1, 'name' => 'cx11', 'description' => 'CX11', 'cores' => 1, 'memory' => 2.0, 'disk' => 20, 'locations' => [['name' => 'nbg1']], 'architecture' => 'x86'],
],
'meta' => ['pagination' => ['next_page' => null]],
], 200),