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

View file

@ -543,13 +543,14 @@ public function firewalls(Request $request)
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 @@ public function networks(Request $request)
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 @@ public function createServer(Request $request)
// 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 @@ public function createServer(Request $request)
$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);

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 @@ public function resetSelection()
$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 @@ protected function rules(): array
'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 @@ public function getSelectedServerBackupSurchargeProperty(): ?string
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 @@ public function updatedSelectedCloudInitScriptId($value)
$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 @@ public function clearCloudInitScript()
$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 @@ public function submit()
// 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 @@ public function submit()
$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([

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": {

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": {

View file

@ -278,6 +278,27 @@
$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 @@
$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 @@
&& $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 @@
$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');
});
});

View file

@ -144,15 +144,6 @@
->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 @@
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 @@
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 @@
], 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),