mirror of
https://github.com/tiennm99/coolify.git
synced 2026-04-19 05:20:41 +00:00
feat: add Hetzner server provisioning API endpoints
Add complete API support for Hetzner server provisioning, matching UI functionality:
Cloud Provider Token Management:
- POST /api/v1/cloud-tokens - Create and validate tokens
- GET /api/v1/cloud-tokens - List all tokens
- GET /api/v1/cloud-tokens/{uuid} - Get specific token
- PATCH /api/v1/cloud-tokens/{uuid} - Update token name
- DELETE /api/v1/cloud-tokens/{uuid} - Delete token
- POST /api/v1/cloud-tokens/{uuid}/validate - Validate token
Hetzner Resource Discovery:
- GET /api/v1/hetzner/locations - List datacenters
- GET /api/v1/hetzner/server-types - List server types
- GET /api/v1/hetzner/images - List OS images
- GET /api/v1/hetzner/ssh-keys - List SSH keys
Server Provisioning:
- POST /api/v1/servers/hetzner - Create server with full options
Features:
- Token validation against provider APIs before storage
- Smart SSH key management with MD5 fingerprint deduplication
- IPv4/IPv6 network configuration with preference logic
- Cloud-init script support with YAML validation
- Team-based isolation and security
- Comprehensive test coverage (40+ test cases)
- Complete documentation with curl examples and Yaak collection
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
410
tests/Feature/CloudProviderTokenApiTest.php
Normal file
410
tests/Feature/CloudProviderTokenApiTest.php
Normal file
@@ -0,0 +1,410 @@
|
||||
<?php
|
||||
|
||||
use App\Models\CloudProviderToken;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
// Create a team with owner
|
||||
$this->team = Team::factory()->create();
|
||||
$this->user = User::factory()->create();
|
||||
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
|
||||
|
||||
// Create an API token for the user
|
||||
$this->token = $this->user->createToken('test-token', ['*'], $this->team->id);
|
||||
$this->bearerToken = $this->token->plainTextToken;
|
||||
});
|
||||
|
||||
describe('GET /api/v1/cloud-tokens', function () {
|
||||
test('lists all cloud provider tokens for the team', function () {
|
||||
// Create some tokens
|
||||
CloudProviderToken::factory()->count(3)->create([
|
||||
'team_id' => $this->team->id,
|
||||
'provider' => 'hetzner',
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->getJson('/api/v1/cloud-tokens');
|
||||
|
||||
$response->assertStatus(200);
|
||||
$response->assertJsonCount(3);
|
||||
$response->assertJsonStructure([
|
||||
'*' => ['uuid', 'name', 'provider', 'team_id', 'servers_count', 'created_at', 'updated_at'],
|
||||
]);
|
||||
});
|
||||
|
||||
test('does not include tokens from other teams', function () {
|
||||
// Create tokens for this team
|
||||
CloudProviderToken::factory()->create([
|
||||
'team_id' => $this->team->id,
|
||||
'provider' => 'hetzner',
|
||||
]);
|
||||
|
||||
// Create tokens for another team
|
||||
$otherTeam = Team::factory()->create();
|
||||
CloudProviderToken::factory()->count(2)->create([
|
||||
'team_id' => $otherTeam->id,
|
||||
'provider' => 'hetzner',
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->getJson('/api/v1/cloud-tokens');
|
||||
|
||||
$response->assertStatus(200);
|
||||
$response->assertJsonCount(1);
|
||||
});
|
||||
|
||||
test('rejects request without authentication', function () {
|
||||
$response = $this->getJson('/api/v1/cloud-tokens');
|
||||
$response->assertStatus(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/v1/cloud-tokens/{uuid}', function () {
|
||||
test('gets cloud provider token by UUID', function () {
|
||||
$token = CloudProviderToken::factory()->create([
|
||||
'team_id' => $this->team->id,
|
||||
'provider' => 'hetzner',
|
||||
'name' => 'My Hetzner Token',
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->getJson("/api/v1/cloud-tokens/{$token->uuid}");
|
||||
|
||||
$response->assertStatus(200);
|
||||
$response->assertJsonFragment(['name' => 'My Hetzner Token', 'provider' => 'hetzner']);
|
||||
});
|
||||
|
||||
test('returns 404 for non-existent token', function () {
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->getJson('/api/v1/cloud-tokens/non-existent-uuid');
|
||||
|
||||
$response->assertStatus(404);
|
||||
});
|
||||
|
||||
test('cannot access token from another team', function () {
|
||||
$otherTeam = Team::factory()->create();
|
||||
$token = CloudProviderToken::factory()->create([
|
||||
'team_id' => $otherTeam->id,
|
||||
'provider' => 'hetzner',
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->getJson("/api/v1/cloud-tokens/{$token->uuid}");
|
||||
|
||||
$response->assertStatus(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/v1/cloud-tokens', function () {
|
||||
test('creates a Hetzner cloud provider token', function () {
|
||||
// Mock Hetzner API validation
|
||||
Http::fake([
|
||||
'https://api.hetzner.cloud/v1/servers' => Http::response([], 200),
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->postJson('/api/v1/cloud-tokens', [
|
||||
'provider' => 'hetzner',
|
||||
'token' => 'test-hetzner-token',
|
||||
'name' => 'My Hetzner Token',
|
||||
]);
|
||||
|
||||
$response->assertStatus(201);
|
||||
$response->assertJsonStructure(['uuid']);
|
||||
|
||||
// Verify token was created
|
||||
$this->assertDatabaseHas('cloud_provider_tokens', [
|
||||
'team_id' => $this->team->id,
|
||||
'provider' => 'hetzner',
|
||||
'name' => 'My Hetzner Token',
|
||||
]);
|
||||
});
|
||||
|
||||
test('creates a DigitalOcean cloud provider token', function () {
|
||||
// Mock DigitalOcean API validation
|
||||
Http::fake([
|
||||
'https://api.digitalocean.com/v2/account' => Http::response([], 200),
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->postJson('/api/v1/cloud-tokens', [
|
||||
'provider' => 'digitalocean',
|
||||
'token' => 'test-do-token',
|
||||
'name' => 'My DO Token',
|
||||
]);
|
||||
|
||||
$response->assertStatus(201);
|
||||
$response->assertJsonStructure(['uuid']);
|
||||
});
|
||||
|
||||
test('validates provider is required', function () {
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->postJson('/api/v1/cloud-tokens', [
|
||||
'token' => 'test-token',
|
||||
'name' => 'My Token',
|
||||
]);
|
||||
|
||||
$response->assertStatus(422);
|
||||
$response->assertJsonValidationErrors(['provider']);
|
||||
});
|
||||
|
||||
test('validates token is required', function () {
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->postJson('/api/v1/cloud-tokens', [
|
||||
'provider' => 'hetzner',
|
||||
'name' => 'My Token',
|
||||
]);
|
||||
|
||||
$response->assertStatus(422);
|
||||
$response->assertJsonValidationErrors(['token']);
|
||||
});
|
||||
|
||||
test('validates name is required', function () {
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->postJson('/api/v1/cloud-tokens', [
|
||||
'provider' => 'hetzner',
|
||||
'token' => 'test-token',
|
||||
]);
|
||||
|
||||
$response->assertStatus(422);
|
||||
$response->assertJsonValidationErrors(['name']);
|
||||
});
|
||||
|
||||
test('validates provider must be hetzner or digitalocean', function () {
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->postJson('/api/v1/cloud-tokens', [
|
||||
'provider' => 'invalid-provider',
|
||||
'token' => 'test-token',
|
||||
'name' => 'My Token',
|
||||
]);
|
||||
|
||||
$response->assertStatus(422);
|
||||
$response->assertJsonValidationErrors(['provider']);
|
||||
});
|
||||
|
||||
test('rejects invalid Hetzner token', function () {
|
||||
// Mock failed Hetzner API validation
|
||||
Http::fake([
|
||||
'https://api.hetzner.cloud/v1/servers' => Http::response([], 401),
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->postJson('/api/v1/cloud-tokens', [
|
||||
'provider' => 'hetzner',
|
||||
'token' => 'invalid-token',
|
||||
'name' => 'My Token',
|
||||
]);
|
||||
|
||||
$response->assertStatus(400);
|
||||
$response->assertJson(['message' => 'Invalid Hetzner token. Please check your API token.']);
|
||||
});
|
||||
|
||||
test('rejects extra fields not in allowed list', function () {
|
||||
Http::fake([
|
||||
'https://api.hetzner.cloud/v1/servers' => Http::response([], 200),
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->postJson('/api/v1/cloud-tokens', [
|
||||
'provider' => 'hetzner',
|
||||
'token' => 'test-token',
|
||||
'name' => 'My Token',
|
||||
'invalid_field' => 'invalid_value',
|
||||
]);
|
||||
|
||||
$response->assertStatus(422);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PATCH /api/v1/cloud-tokens/{uuid}', function () {
|
||||
test('updates cloud provider token name', function () {
|
||||
$token = CloudProviderToken::factory()->create([
|
||||
'team_id' => $this->team->id,
|
||||
'provider' => 'hetzner',
|
||||
'name' => 'Old Name',
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->patchJson("/api/v1/cloud-tokens/{$token->uuid}", [
|
||||
'name' => 'New Name',
|
||||
]);
|
||||
|
||||
$response->assertStatus(200);
|
||||
|
||||
// Verify token name was updated
|
||||
$this->assertDatabaseHas('cloud_provider_tokens', [
|
||||
'uuid' => $token->uuid,
|
||||
'name' => 'New Name',
|
||||
]);
|
||||
});
|
||||
|
||||
test('validates name is required', function () {
|
||||
$token = CloudProviderToken::factory()->create([
|
||||
'team_id' => $this->team->id,
|
||||
'provider' => 'hetzner',
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->patchJson("/api/v1/cloud-tokens/{$token->uuid}", []);
|
||||
|
||||
$response->assertStatus(422);
|
||||
$response->assertJsonValidationErrors(['name']);
|
||||
});
|
||||
|
||||
test('cannot update token from another team', function () {
|
||||
$otherTeam = Team::factory()->create();
|
||||
$token = CloudProviderToken::factory()->create([
|
||||
'team_id' => $otherTeam->id,
|
||||
'provider' => 'hetzner',
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->patchJson("/api/v1/cloud-tokens/{$token->uuid}", [
|
||||
'name' => 'New Name',
|
||||
]);
|
||||
|
||||
$response->assertStatus(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /api/v1/cloud-tokens/{uuid}', function () {
|
||||
test('deletes cloud provider token', function () {
|
||||
$token = CloudProviderToken::factory()->create([
|
||||
'team_id' => $this->team->id,
|
||||
'provider' => 'hetzner',
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->deleteJson("/api/v1/cloud-tokens/{$token->uuid}");
|
||||
|
||||
$response->assertStatus(200);
|
||||
$response->assertJson(['message' => 'Cloud provider token deleted.']);
|
||||
|
||||
// Verify token was deleted
|
||||
$this->assertDatabaseMissing('cloud_provider_tokens', [
|
||||
'uuid' => $token->uuid,
|
||||
]);
|
||||
});
|
||||
|
||||
test('cannot delete token from another team', function () {
|
||||
$otherTeam = Team::factory()->create();
|
||||
$token = CloudProviderToken::factory()->create([
|
||||
'team_id' => $otherTeam->id,
|
||||
'provider' => 'hetzner',
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->deleteJson("/api/v1/cloud-tokens/{$token->uuid}");
|
||||
|
||||
$response->assertStatus(404);
|
||||
});
|
||||
|
||||
test('returns 404 for non-existent token', function () {
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->deleteJson('/api/v1/cloud-tokens/non-existent-uuid');
|
||||
|
||||
$response->assertStatus(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/v1/cloud-tokens/{uuid}/validate', function () {
|
||||
test('validates a valid Hetzner token', function () {
|
||||
$token = CloudProviderToken::factory()->create([
|
||||
'team_id' => $this->team->id,
|
||||
'provider' => 'hetzner',
|
||||
]);
|
||||
|
||||
Http::fake([
|
||||
'https://api.hetzner.cloud/v1/servers' => Http::response([], 200),
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->postJson("/api/v1/cloud-tokens/{$token->uuid}/validate");
|
||||
|
||||
$response->assertStatus(200);
|
||||
$response->assertJson(['valid' => true, 'message' => 'Token is valid.']);
|
||||
});
|
||||
|
||||
test('detects invalid Hetzner token', function () {
|
||||
$token = CloudProviderToken::factory()->create([
|
||||
'team_id' => $this->team->id,
|
||||
'provider' => 'hetzner',
|
||||
]);
|
||||
|
||||
Http::fake([
|
||||
'https://api.hetzner.cloud/v1/servers' => Http::response([], 401),
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->postJson("/api/v1/cloud-tokens/{$token->uuid}/validate");
|
||||
|
||||
$response->assertStatus(200);
|
||||
$response->assertJson(['valid' => false, 'message' => 'Token is invalid.']);
|
||||
});
|
||||
|
||||
test('validates a valid DigitalOcean token', function () {
|
||||
$token = CloudProviderToken::factory()->create([
|
||||
'team_id' => $this->team->id,
|
||||
'provider' => 'digitalocean',
|
||||
]);
|
||||
|
||||
Http::fake([
|
||||
'https://api.digitalocean.com/v2/account' => Http::response([], 200),
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->postJson("/api/v1/cloud-tokens/{$token->uuid}/validate");
|
||||
|
||||
$response->assertStatus(200);
|
||||
$response->assertJson(['valid' => true, 'message' => 'Token is valid.']);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user