whereKey(0)->delete(); $settings = new InstanceSettings(['is_api_enabled' => true]); $settings->id = 0; $settings->save(); Once::flush(); $this->team = Team::factory()->create(); $this->user = User::factory()->create(); $this->team->members()->attach($this->user->id, ['role' => 'owner']); session(['currentTeam' => $this->team]); $this->token = $this->user->createToken('test-token', ['*']); $this->bearerToken = $this->token->plainTextToken; $this->digitalOceanToken = CloudProviderToken::factory()->create([ 'team_id' => $this->team->id, 'provider' => 'digitalocean', 'token' => 'test-digitalocean-api-token', ]); $this->privateKey = PrivateKey::factory()->create([ 'team_id' => $this->team->id, ]); }); describe('GET /api/v1/digitalocean/regions', function () { test('gets DigitalOcean regions', function () { Http::fake([ 'https://api.digitalocean.com/v2/regions*' => Http::response([ 'regions' => [ ['slug' => 'nyc1', 'name' => 'New York 1', 'available' => true], ['slug' => 'ams3', 'name' => 'Amsterdam 3', 'available' => true], ], 'links' => ['pages' => []], ], 200), ]); $response = $this->withHeaders([ 'Authorization' => 'Bearer '.$this->bearerToken, 'Content-Type' => 'application/json', ])->getJson('/api/v1/digitalocean/regions?cloud_provider_token_id='.$this->digitalOceanToken->uuid); $response->assertSuccessful(); $response->assertJsonCount(2); $response->assertJsonFragment(['slug' => 'nyc1']); }); test('requires cloud_provider_token_id parameter', function () { $response = $this->withHeaders([ 'Authorization' => 'Bearer '.$this->bearerToken, 'Content-Type' => 'application/json', ])->getJson('/api/v1/digitalocean/regions'); $response->assertUnprocessable(); $response->assertJsonValidationErrors(['cloud_provider_token_id']); }); }); describe('POST /api/v1/servers/digitalocean', function () { test('creates a DigitalOcean droplet server', function () { Http::fake([ 'https://api.digitalocean.com/v2/account/keys' => Http::response([ 'ssh_key' => ['id' => 123, 'fingerprint' => 'aa:bb:cc:dd'], ], 201), 'https://api.digitalocean.com/v2/account/keys*' => Http::response([ 'ssh_keys' => [], 'links' => ['pages' => []], ], 200), 'https://api.digitalocean.com/v2/droplets' => Http::response([ 'droplet' => [ 'id' => 987, 'name' => 'test-server', 'status' => 'new', 'networks' => [ 'v4' => [ ['ip_address' => '203.0.113.10', 'type' => 'public'], ], 'v6' => [ ['ip_address' => '2001:db8::10', 'type' => 'public'], ], ], ], ], 202), ]); $response = $this->withHeaders([ 'Authorization' => 'Bearer '.$this->bearerToken, 'Content-Type' => 'application/json', ])->postJson('/api/v1/servers/digitalocean', [ 'cloud_provider_token_id' => $this->digitalOceanToken->uuid, 'region' => 'nyc1', 'size' => 's-1vcpu-1gb', 'image' => 'ubuntu-24-04-x64', 'name' => 'test-server', 'private_key_uuid' => $this->privateKey->uuid, 'enable_ipv6' => true, 'monitoring' => true, 'cloud_init_script' => '#cloud-config', ]); $response->assertStatus(201); $response->assertJsonStructure(['uuid', 'digitalocean_droplet_id', 'ip']); $response->assertJsonFragment(['digitalocean_droplet_id' => 987, 'ip' => '203.0.113.10']); $this->assertDatabaseHas('servers', [ 'name' => 'test-server', 'ip' => '203.0.113.10', 'team_id' => $this->team->id, 'digitalocean_droplet_id' => 987, ]); Http::assertSent(fn ($request) => $request->url() === 'https://api.digitalocean.com/v2/droplets' && $request['region'] === 'nyc1' && $request['size'] === 's-1vcpu-1gb' && $request['image'] === 'ubuntu-24-04-x64' && $request['ssh_keys'] === [123] && $request['ipv6'] === true && $request['monitoring'] === true && $request['user_data'] === '#cloud-config'); }); test('tracks the droplet with a placeholder IP when waiting for its IP fails', function () { Http::fake([ 'https://api.digitalocean.com/v2/account/keys' => Http::response([ 'ssh_key' => ['id' => 123, 'fingerprint' => 'aa:bb:cc:dd'], ], 201), 'https://api.digitalocean.com/v2/account/keys*' => Http::response([ 'ssh_keys' => [], 'links' => ['pages' => []], ], 200), 'https://api.digitalocean.com/v2/droplets' => Http::response([ 'droplet' => [ 'id' => 987, 'name' => 'waiting-for-ip', 'status' => 'new', 'networks' => ['v4' => [], 'v6' => []], ], ], 202), 'https://api.digitalocean.com/v2/droplets/987' => Http::response([ 'message' => 'temporary provider failure', ], 500), ]); $response = $this->withHeaders([ 'Authorization' => 'Bearer '.$this->bearerToken, 'Content-Type' => 'application/json', ])->postJson('/api/v1/servers/digitalocean', [ 'cloud_provider_token_id' => $this->digitalOceanToken->uuid, 'region' => 'nyc1', 'size' => 's-1vcpu-1gb', 'image' => 'ubuntu-24-04-x64', 'name' => 'waiting-for-ip', 'private_key_uuid' => $this->privateKey->uuid, ]); $response->assertCreated(); $response->assertJsonFragment([ 'digitalocean_droplet_id' => 987, 'ip' => Server::PLACEHOLDER_IP, ]); $this->assertDatabaseHas('servers', [ 'name' => 'waiting-for-ip', 'ip' => Server::PLACEHOLDER_IP, 'team_id' => $this->team->id, 'digitalocean_droplet_id' => 987, 'digitalocean_droplet_status' => 'new', ]); }); test('deletes the droplet when local server persistence fails', function () { Http::fake([ 'https://api.digitalocean.com/v2/account/keys' => Http::response([ 'ssh_key' => ['id' => 123, 'fingerprint' => 'aa:bb:cc:dd'], ], 201), 'https://api.digitalocean.com/v2/account/keys*' => Http::response([ 'ssh_keys' => [], 'links' => ['pages' => []], ], 200), 'https://api.digitalocean.com/v2/droplets' => Http::response([ 'droplet' => [ 'id' => 987, 'name' => 'persistence-fails', 'status' => 'active', 'networks' => [ 'v4' => [ ['ip_address' => '203.0.113.10', 'type' => 'public'], ], ], ], ], 202), 'https://api.digitalocean.com/v2/droplets/987' => Http::response(null, 204), ]); $eventDispatcher = Server::getEventDispatcher(); Server::setEventDispatcher(clone $eventDispatcher); Server::created(function (): void { throw new RuntimeException('local persistence failed'); }); try { $response = $this->withHeaders([ 'Authorization' => 'Bearer '.$this->bearerToken, 'Content-Type' => 'application/json', ])->postJson('/api/v1/servers/digitalocean', [ 'cloud_provider_token_id' => $this->digitalOceanToken->uuid, 'region' => 'nyc1', 'size' => 's-1vcpu-1gb', 'image' => 'ubuntu-24-04-x64', 'name' => 'persistence-fails', 'private_key_uuid' => $this->privateKey->uuid, ]); } finally { Server::setEventDispatcher($eventDispatcher); } $response->assertServerError(); $this->assertDatabaseMissing('servers', [ 'digitalocean_droplet_id' => 987, ]); Http::assertSent(fn ($request) => $request->method() === 'DELETE' && $request->url() === 'https://api.digitalocean.com/v2/droplets/987'); }); test('validates required fields', function () { $response = $this->withHeaders([ 'Authorization' => 'Bearer '.$this->bearerToken, 'Content-Type' => 'application/json', ])->postJson('/api/v1/servers/digitalocean', ['name' => 'missing-required-fields']); $response->assertUnprocessable(); $response->assertJsonValidationErrors([ 'cloud_provider_token_id', 'region', 'size', 'image', 'private_key_uuid', ]); }); test('returns 404 for a non-DigitalOcean token', function () { $hetznerToken = CloudProviderToken::factory()->create([ 'team_id' => $this->team->id, 'provider' => 'hetzner', 'token' => 'test-hetzner-api-token', ]); $response = $this->withHeaders([ 'Authorization' => 'Bearer '.$this->bearerToken, 'Content-Type' => 'application/json', ])->postJson('/api/v1/servers/digitalocean', [ 'cloud_provider_token_id' => $hetznerToken->uuid, 'region' => 'nyc1', 'size' => 's-1vcpu-1gb', 'image' => 'ubuntu-24-04-x64', 'private_key_uuid' => $this->privateKey->uuid, ]); $response->assertNotFound(); $response->assertJson(['message' => 'DigitalOcean cloud provider token not found.']); }); });