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 committed 2026-07-07 14:24:15 +02:00
1 parent 2ad11fdd9a
commit d723a52d9c
6 files changed
+330 -85

No files matched your search

+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),