Merge remote-tracking branch 'origin/next' into feature/vultr-cloud-provider

This commit is contained in:
Andras Bacsai committed 2026-07-07 21:23:07 +02:00
commit 31f904ef9f
35 files changed
+7215 -113

No files matched your search

+347 -22
View File
@@ -6,14 +6,22 @@ use App\Models\PrivateKey;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\Client\Request as HttpRequest;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Once;
uses(RefreshDatabase::class);
beforeEach(function () {
config()->set('cache.default', 'array');
config()->set('app.maintenance.driver', 'file');
config()->set('app.maintenance.store', 'array');
InstanceSettings::query()->whereKey(0)->delete();
$settings = new InstanceSettings(['is_api_enabled' => true]);
$settings = new InstanceSettings([
'is_api_enabled' => true,
'is_registration_enabled' => true,
]);
$settings->id = 0;
$settings->save();
Once::flush();
@@ -29,15 +37,25 @@ beforeEach(function () {
$this->bearerToken = $this->token->plainTextToken;
// Create a Hetzner cloud provider token
$this->hetznerToken = CloudProviderToken::factory()->create([
$this->hetznerToken = CloudProviderToken::create([
'team_id' => $this->team->id,
'provider' => 'hetzner',
'name' => 'Test Hetzner Token',
'token' => 'test-hetzner-api-token',
]);
// Create a private key
$this->privateKey = PrivateKey::factory()->create([
$this->privateKey = PrivateKey::create([
'team_id' => $this->team->id,
'name' => 'Test Key',
'description' => 'Test SSH key',
'private_key' => '-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
QyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevAAAAJi/QySHv0Mk
hwAAAAtzc2gtZWQyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevA
AAAECBQw4jg1WRT2IGHMncCiZhURCts2s24HoDS0thHnnRKVuGmoeGq/pojrsyP1pszcNV
uZx9iFkCELtxrh31QJ68AAAAEXNhaWxANzZmZjY2ZDJlMmRkAQIDBA==
-----END OPENSSH PRIVATE KEY-----',
]);
});
@@ -239,17 +257,105 @@ describe('GET /api/v1/hetzner/ssh-keys', function () {
});
});
describe('GET /api/v1/hetzner/firewalls', function () {
test('gets Hetzner firewalls', function () {
Http::fake([
'https://api.hetzner.cloud/v1/firewalls*' => Http::response([
'firewalls' => [
['id' => 38, 'name' => 'web-firewall', 'rules' => []],
['id' => 39, 'name' => 'ssh-firewall', 'rules' => []],
],
'meta' => ['pagination' => ['next_page' => null]],
], 200),
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->getJson('/api/v1/hetzner/firewalls?cloud_provider_token_id='.$this->hetznerToken->uuid);
$response->assertSuccessful();
$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 () {
test('gets Hetzner networks', function () {
Http::fake([
'https://api.hetzner.cloud/v1/networks*' => Http::response([
'networks' => [
['id' => 456, 'name' => 'private-eu', 'ip_range' => '10.0.0.0/16', 'subnets' => []],
['id' => 457, 'name' => 'private-us', 'ip_range' => '10.1.0.0/16', 'subnets' => []],
],
'meta' => ['pagination' => ['next_page' => null]],
], 200),
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->getJson('/api/v1/hetzner/networks?cloud_provider_token_id='.$this->hetznerToken->uuid);
$response->assertSuccessful();
$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 () {
test('creates a Hetzner server', function () {
// Mock Hetzner API calls
Http::fake([
'https://api.hetzner.cloud/v1/ssh_keys' => Http::response([
'ssh_key' => ['id' => 123, 'fingerprint' => 'aa:bb:cc:dd'],
], 201),
'https://api.hetzner.cloud/v1/ssh_keys*' => Http::response([
'ssh_keys' => [],
'meta' => ['pagination' => ['next_page' => null]],
], 200),
'https://api.hetzner.cloud/v1/ssh_keys' => Http::response([
'ssh_key' => ['id' => 123, 'fingerprint' => 'aa:bb:cc:dd'],
], 201),
'https://api.hetzner.cloud/v1/servers' => Http::response([
'server' => [
'id' => 456,
@@ -289,15 +395,134 @@ describe('POST /api/v1/servers/hetzner', function () {
]);
});
test('enables backups after creating a Hetzner server when requested', 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([
'action' => ['id' => 789, 'command' => 'enable_backup', 'status' => 'running'],
], 201);
}
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->assertStatus(201);
Http::assertSent(fn (HttpRequest $request) => $request->method() === 'POST'
&& $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([
'ssh_key' => ['id' => 123, 'fingerprint' => 'aa:bb:cc:dd'],
], 201),
'https://api.hetzner.cloud/v1/ssh_keys*' => Http::response([
'ssh_keys' => [],
'meta' => ['pagination' => ['next_page' => null]],
], 200),
'https://api.hetzner.cloud/v1/ssh_keys' => Http::response([
'ssh_key' => ['id' => 123, 'fingerprint' => 'aa:bb:cc:dd'],
], 201),
'https://api.hetzner.cloud/v1/servers' => Http::response([
'server' => [
'id' => 456,
@@ -331,13 +556,10 @@ describe('POST /api/v1/servers/hetzner', function () {
'Content-Type' => 'application/json',
])->postJson('/api/v1/servers/hetzner', []);
$response->assertStatus(422);
$response->assertJsonValidationErrors([
'cloud_provider_token_id',
'location',
'server_type',
'image',
'private_key_uuid',
$response->assertStatus(400);
$response->assertJson([
'message' => 'Invalid request.',
'error' => 'Invalid JSON.',
]);
});
@@ -375,13 +597,13 @@ describe('POST /api/v1/servers/hetzner', function () {
test('prefers IPv4 when both IPv4 and IPv6 are enabled', function () {
Http::fake([
'https://api.hetzner.cloud/v1/ssh_keys' => Http::response([
'ssh_key' => ['id' => 123],
], 201),
'https://api.hetzner.cloud/v1/ssh_keys*' => Http::response([
'ssh_keys' => [],
'meta' => ['pagination' => ['next_page' => null]],
], 200),
'https://api.hetzner.cloud/v1/ssh_keys' => Http::response([
'ssh_key' => ['id' => 123],
], 201),
'https://api.hetzner.cloud/v1/servers' => Http::response([
'server' => [
'id' => 456,
@@ -412,13 +634,13 @@ describe('POST /api/v1/servers/hetzner', function () {
test('uses IPv6 when only IPv6 is enabled', function () {
Http::fake([
'https://api.hetzner.cloud/v1/ssh_keys' => Http::response([
'ssh_key' => ['id' => 123],
], 201),
'https://api.hetzner.cloud/v1/ssh_keys*' => Http::response([
'ssh_keys' => [],
'meta' => ['pagination' => ['next_page' => null]],
], 200),
'https://api.hetzner.cloud/v1/ssh_keys' => Http::response([
'ssh_key' => ['id' => 123],
], 201),
'https://api.hetzner.cloud/v1/servers' => Http::response([
'server' => [
'id' => 456,
@@ -447,6 +669,75 @@ describe('POST /api/v1/servers/hetzner', function () {
$response->assertJsonFragment(['ip' => '2001:db8::1']);
});
test('rejects server creation when both public IP protocols are disabled', function () {
Http::fake();
$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' => false,
'enable_ipv6' => false,
]);
$response->assertStatus(422);
$response->assertJsonValidationErrors(['enable_ipv4', 'enable_ipv6']);
Http::assertNothingSent();
});
test('passes selected firewalls and networks to Hetzner server creation', function () {
Http::fake([
'https://api.hetzner.cloud/v1/ssh_keys' => Http::response([
'ssh_key' => ['id' => 123],
], 201),
'https://api.hetzner.cloud/v1/ssh_keys*' => Http::response([
'ssh_keys' => [],
'meta' => ['pagination' => ['next_page' => null]],
], 200),
'https://api.hetzner.cloud/v1/servers' => Http::response([
'server' => [
'id' => 456,
'public_net' => [
'ipv4' => ['ip' => '1.2.3.4'],
'ipv6' => ['ip' => '2001:db8::1'],
],
],
], 201),
]);
$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,
'hetzner_firewall_ids' => [38, 39],
'hetzner_network_ids' => [456, 457, 456],
]);
$response->assertCreated();
Http::assertSent(function (HttpRequest $request): bool {
return $request->method() === 'POST'
&& $request->url() === 'https://api.hetzner.cloud/v1/servers'
&& $request['networks'] === [456, 457]
&& $request['firewalls'] === [
['firewall' => 38],
['firewall' => 39],
];
});
});
test('rejects extra fields not in allowed list', function () {
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
@@ -545,4 +836,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');
});
});
+314
View File
@@ -0,0 +1,314 @@
<?php
use App\Models\Application;
use App\Models\Environment;
use App\Models\EnvironmentVariable;
use App\Models\InstanceSettings;
use App\Models\Project;
use App\Models\Server;
use App\Models\Service;
use App\Models\StandaloneDocker;
use App\Models\StandalonePostgresql;
use App\Models\Team;
use App\Models\User;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Log;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::create(['id' => 0, 'is_api_enabled' => true]);
$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->server = Server::factory()->create(['team_id' => $this->team->id]);
$this->destination = StandaloneDocker::where('server_id', $this->server->id)->first();
$this->project = Project::factory()->create(['team_id' => $this->team->id]);
$this->environment = $this->project->environments()->first();
$this->targetProject = Project::factory()->create(['team_id' => $this->team->id]);
$this->targetEnvironment = $this->targetProject->environments()->first();
});
describe('POST /api/v1/applications/{uuid}/move', function () {
test('moves application to another environment', function () {
$application = Application::factory()->create([
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->postJson("/api/v1/applications/{$application->uuid}/move", [
'environment_uuid' => $this->targetEnvironment->uuid,
]);
$response->assertStatus(200);
$response->assertJsonFragment(['message' => 'Application moved successfully.']);
$response->assertJsonStructure(['message', 'uuid', 'project_uuid', 'environment_uuid']);
$application->refresh();
expect($application->environment_id)->toBe($this->targetEnvironment->id);
});
test('writes audit log when application is moved', function () {
$application = Application::factory()->create([
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
$auditChannel = Mockery::mock();
$auditChannel->shouldReceive('info')
->once()
->with('api.application.moved', Mockery::on(function (array $context) use ($application) {
return $context['event'] === 'api.application.moved'
&& $context['resource_uuid'] === $application->uuid
&& $context['resource_type'] === 'application'
&& $context['from_environment_uuid'] === $this->environment->uuid
&& $context['to_environment_uuid'] === $this->targetEnvironment->uuid
&& $context['from_project_uuid'] === $this->project->uuid
&& $context['to_project_uuid'] === $this->targetProject->uuid;
}));
Log::shouldReceive('channel')->with('audit')->andReturn($auditChannel);
Log::shouldReceive('warning')->andReturnNull();
$this->actingAs($this->user);
$request = Request::create('/', 'POST', [
'environment_uuid' => $this->targetEnvironment->uuid,
]);
$response = moveResourceToEnvironment($request, $application, 'Application', $this->team->id);
expect($response->getStatusCode())->toBe(200);
});
test('returns 404 when application not found', function () {
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->postJson('/api/v1/applications/non-existent-uuid/move', [
'environment_uuid' => $this->targetEnvironment->uuid,
]);
$response->assertStatus(404);
});
test('returns 422 when environment_uuid is missing', function () {
$application = Application::factory()->create([
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->postJson("/api/v1/applications/{$application->uuid}/move", []);
$response->assertStatus(422);
});
test('returns 422 when extra fields are provided', function () {
$application = Application::factory()->create([
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->postJson("/api/v1/applications/{$application->uuid}/move", [
'environment_uuid' => $this->targetEnvironment->uuid,
'bogus_field' => 'value',
]);
$response->assertStatus(422);
});
test('returns 404 when target environment belongs to another team', function () {
$otherTeam = Team::factory()->create();
$otherProject = Project::factory()->create(['team_id' => $otherTeam->id]);
$otherEnvironment = Environment::factory()->create(['project_id' => $otherProject->id]);
$application = Application::factory()->create([
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->postJson("/api/v1/applications/{$application->uuid}/move", [
'environment_uuid' => $otherEnvironment->uuid,
]);
$response->assertStatus(404);
});
test('authorizes the target environment before moving', function () {
$this->actingAs($this->user);
$application = Application::factory()->create([
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
Gate::before(function (User $user, string $ability, array $arguments) {
$target = $arguments[0] ?? null;
if ($ability === 'update' && $target instanceof Environment && $target->is($this->targetEnvironment)) {
return false;
}
return null;
});
$request = Request::create('/', 'POST', [
'environment_uuid' => $this->targetEnvironment->uuid,
]);
expect(fn () => moveResourceToEnvironment($request, $application, 'Application', $this->team->id))
->toThrow(AuthorizationException::class);
$application->refresh();
expect($application->environment_id)->toBe($this->environment->id);
});
test('returns 400 when application is already in the target environment', function () {
$application = Application::factory()->create([
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->postJson("/api/v1/applications/{$application->uuid}/move", [
'environment_uuid' => $this->environment->uuid,
]);
$response->assertStatus(400);
});
test('preserves resource-level environment variables after move', function () {
$application = Application::factory()->create([
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
EnvironmentVariable::create([
'key' => 'TEST_VAR',
'value' => 'test-value',
'resourceable_type' => Application::class,
'resourceable_id' => $application->id,
'is_preview' => false,
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->postJson("/api/v1/applications/{$application->uuid}/move", [
'environment_uuid' => $this->targetEnvironment->uuid,
]);
$response->assertStatus(200);
$application->refresh();
$envVar = $application->environment_variables->where('key', 'TEST_VAR')->first();
expect($envVar)->not->toBeNull();
expect($envVar->value)->toBe('test-value');
});
});
describe('POST /api/v1/databases/{uuid}/move', function () {
test('moves database to another environment', function () {
$database = StandalonePostgresql::create([
'name' => 'test-pg',
'postgres_user' => 'postgres',
'postgres_password' => 'secret',
'postgres_db' => 'testdb',
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->postJson("/api/v1/databases/{$database->uuid}/move", [
'environment_uuid' => $this->targetEnvironment->uuid,
]);
$response->assertStatus(200);
$response->assertJsonFragment(['message' => 'Database moved successfully.']);
$database->refresh();
expect($database->environment_id)->toBe($this->targetEnvironment->id);
});
test('returns 404 when database not found', function () {
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->postJson('/api/v1/databases/non-existent-uuid/move', [
'environment_uuid' => $this->targetEnvironment->uuid,
]);
$response->assertStatus(404);
});
});
describe('POST /api/v1/services/{uuid}/move', function () {
test('moves service to another environment', function () {
$service = Service::factory()->create([
'server_id' => $this->server->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
'environment_id' => $this->environment->id,
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->postJson("/api/v1/services/{$service->uuid}/move", [
'environment_uuid' => $this->targetEnvironment->uuid,
]);
$response->assertStatus(200);
$response->assertJsonFragment(['message' => 'Service moved successfully.']);
$service->refresh();
expect($service->environment_id)->toBe($this->targetEnvironment->id);
});
test('returns 404 when service not found', function () {
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->postJson('/api/v1/services/non-existent-uuid/move', [
'environment_uuid' => $this->targetEnvironment->uuid,
]);
$response->assertStatus(404);
});
});
@@ -996,6 +996,43 @@ describe('API route middleware for deploy actions', function () {
});
});
describe('service application lifecycle command escaping', function () {
test('service application deploy action escapes docker command arguments', function () {
$source = file_get_contents(app_path('Actions/Service/DeployServiceApplication.php'));
expect($source)->toContain('escapeshellarg($workdir)')
->and($source)->toContain('escapeshellarg($composeFile)')
->and($source)->toContain('escapeshellarg($service->uuid)')
->and($source)->toContain('escapeshellarg($composeServiceName)')
->and($source)->toContain('escapeshellarg($service->destination->network)');
});
test('service application restart and stop actions escape container names', function (string $path) {
$source = file_get_contents(app_path($path));
expect($source)->toContain('escapeshellarg($serviceApplication->name');
})->with([
'restart action' => 'Actions/Service/RestartServiceApplication.php',
'stop action' => 'Actions/Service/StopServiceApplication.php',
]);
test('docker status helper escapes container names', function () {
$source = file_get_contents(base_path('bootstrap/helpers/docker.php'));
expect($source)->toContain('function buildContainerStatusCommand')
->and($source)->toContain('escapeshellarg("name={$container_id}")')
->and($source)->toContain('escapeshellarg($container_id)');
});
test('service application logs endpoint passes raw container name to docker helpers', function () {
$source = file_get_contents(app_path('Http/Controllers/Api/ServiceApplicationsController.php'));
expect($source)->toContain('getContainerStatus($server, $containerName)')
->and($source)->toContain('getContainerLogs($server, $containerName, $lines)')
->and($source)->not->toContain('$safeContainerName = escapeshellarg($containerName)');
});
});
describe('install/build/start command validation', function () {
test('rejects semicolon injection in install_command', function () {
$rules = sharedDataApplications();
@@ -1,9 +1,12 @@
<?php
use App\Livewire\Server\New\ByHetzner;
use App\Models\CloudProviderToken;
use App\Models\PrivateKey;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
use Livewire\Livewire;
// Note: Full Livewire integration tests require database setup
@@ -159,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)
@@ -176,13 +179,142 @@ 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('uses the shared dropdown UI for advanced Hetzner options', function () {
Livewire::test(ByHetzner::class)
->set('current_step', 2)
->assertSee('Advanced Hetzner options')
->assertSeeHtml('dropdownOpen')
->assertSeeHtml('x-ref="panel"')
->assertSeeHtml('dark:bg-coolgray-100')
->assertSeeHtml('dark:bg-transparent')
->assertSeeHtml('@click.outside="if (! true) close()"');
});
test('renders advanced Hetzner option controls inside the dropdown menu', function () {
Livewire::test(ByHetzner::class)
->set('current_step', 2)
->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_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 () {
uses(RefreshDatabase::class);
beforeEach(function () {
$this->team = Team::factory()->create();
$this->user = User::factory()->create();
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
$this->actingAs($this->user);
session(['currentTeam' => $this->team]);
$this->hetznerToken = CloudProviderToken::factory()->create([
'team_id' => $this->team->id,
'provider' => 'hetzner',
'token' => 'test-hetzner-api-token',
]);
$this->privateKey = PrivateKey::factory()->create([
'team_id' => $this->team->id,
]);
});
test('loads firewalls and networks for the selected Hetzner token', function () {
Http::fake([
'https://api.hetzner.cloud/v1/locations*' => Http::response([
'locations' => [
['id' => 1, 'name' => 'nbg1', 'city' => 'Nuremberg', 'country' => 'DE', 'network_zone' => 'eu-central'],
],
'meta' => ['pagination' => ['next_page' => null]],
], 200),
'https://api.hetzner.cloud/v1/server_types*' => Http::response([
'server_types' => [
['id' => 1, 'name' => 'cx11', 'description' => 'CX11', 'cores' => 1, 'memory' => 2.0, 'disk' => 20, 'locations' => [['name' => 'nbg1']], 'architecture' => 'x86'],
],
'meta' => ['pagination' => ['next_page' => null]],
], 200),
'https://api.hetzner.cloud/v1/images*' => Http::response([
'images' => [
['id' => 15512617, 'name' => 'ubuntu-24.04', 'type' => 'system', 'deprecated' => false, 'architecture' => 'x86'],
],
'meta' => ['pagination' => ['next_page' => null]],
], 200),
'https://api.hetzner.cloud/v1/ssh_keys*' => Http::response([
'ssh_keys' => [],
'meta' => ['pagination' => ['next_page' => null]],
], 200),
'https://api.hetzner.cloud/v1/firewalls*' => Http::response([
'firewalls' => [
['id' => 38, 'name' => 'web-firewall', 'rules' => []],
],
'meta' => ['pagination' => ['next_page' => null]],
], 200),
'https://api.hetzner.cloud/v1/networks*' => Http::response([
'networks' => [
[
'id' => 456,
'name' => 'private-eu',
'ip_range' => '10.0.0.0/16',
'subnets' => [
['type' => 'cloud', 'network_zone' => 'eu-central'],
],
],
],
'meta' => ['pagination' => ['next_page' => null]],
], 200),
]);
$component = Livewire::test(ByHetzner::class)
->set('selected_token_id', $this->hetznerToken->id)
->call('nextStep')
->assertSet('current_step', 2);
expect($component->get('hetznerFirewalls'))->toHaveCount(1)
->and($component->get('hetznerNetworks'))->toHaveCount(1);
});
test('rejects submitting without a public IP protocol before calling Hetzner', function () {
Http::fake();
Livewire::test(ByHetzner::class)
->set('current_step', 2)
->set('selected_token_id', $this->hetznerToken->id)
->set('server_name', 'test-server')
->set('selected_location', 'nbg1')
->set('selected_server_type', 'cx11')
->set('selected_image', 15512617)
->set('private_key_id', $this->privateKey->id)
->set('enable_ipv4', false)
->set('enable_ipv6', false)
->call('submit')
->assertHasErrors(['enable_ipv4', 'enable_ipv6']);
Http::assertNothingSent();
});
});
@@ -0,0 +1,339 @@
<?php
use App\Actions\Service\DeployServiceApplication;
use App\Actions\Service\RestartServiceApplication;
use App\Actions\Service\StopServiceApplication;
use App\Models\Environment;
use App\Models\InstanceSettings;
use App\Models\Project;
use App\Models\Server;
use App\Models\Service;
use App\Models\ServiceApplication;
use App\Models\StandaloneDocker;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Str;
uses(RefreshDatabase::class);
beforeEach(function () {
Queue::fake();
InstanceSettings::forceCreate(['id' => 0, 'is_api_enabled' => true]);
$this->team = Team::factory()->create();
$this->user = User::factory()->create();
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
$this->bearerToken = createBearerTokenForServiceApplicationsApiTest($this->user, $this->team);
$this->server = Server::factory()->create(['team_id' => $this->team->id]);
$this->server->settings->update([
'is_reachable' => true,
'is_usable' => true,
'force_disabled' => false,
]);
$this->destination = StandaloneDocker::where('server_id', $this->server->id)->first();
$this->project = Project::factory()->create(['team_id' => $this->team->id]);
$this->environment = Environment::factory()->create(['project_id' => $this->project->id]);
});
function createBearerTokenForServiceApplicationsApiTest(User $user, Team $team, array $abilities = ['*']): string
{
$plainTextToken = Str::random(40);
$token = $user->tokens()->create([
'name' => 'test-token',
'token' => hash('sha256', $plainTextToken),
'abilities' => $abilities,
'team_id' => $team->id,
]);
return $token->getKey().'|'.$plainTextToken;
}
function createServiceWithApplicationForApiTest(object $ctx): object
{
$service = Service::factory()->create([
'environment_id' => $ctx->environment->id,
'server_id' => $ctx->server->id,
'destination_id' => $ctx->destination->id,
'destination_type' => $ctx->destination->getMorphClass(),
'docker_compose_raw' => "services:\n web:\n image: nginx:alpine\n",
]);
$sa = ServiceApplication::create([
'uuid' => (string) Str::uuid(),
'name' => 'web',
'service_id' => $service->id,
'image' => 'nginx:alpine',
]);
return (object) ['service' => $service, 'serviceApplication' => $sa];
}
function createServiceWithoutApplicationsForApiTest(object $ctx): Service
{
return Service::factory()->create([
'environment_id' => $ctx->environment->id,
'server_id' => $ctx->server->id,
'destination_id' => $ctx->destination->id,
'destination_type' => $ctx->destination->getMorphClass(),
'docker_compose_raw' => "services:\n web:\n image: nginx:alpine\n",
]);
}
describe('GET /api/v1/services/{uuid}/applications', function () {
test('returns empty array when service has no applications', function () {
$service = createServiceWithoutApplicationsForApiTest($this);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
])->getJson("/api/v1/services/{$service->uuid}/applications");
$response->assertStatus(200);
$response->assertJson([]);
});
test('lists service applications for the service', function () {
$ctx = createServiceWithApplicationForApiTest($this);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
])->getJson("/api/v1/services/{$ctx->service->uuid}/applications");
$response->assertStatus(200);
$response->assertJsonFragment(['uuid' => $ctx->serviceApplication->uuid]);
});
test('returns 404 when service does not exist', function () {
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
])->getJson('/api/v1/services/00000000-0000-0000-0000-000000000001/applications');
$response->assertStatus(404);
$response->assertJsonFragment(['message' => 'Service not found.']);
});
test('does not list applications from another team', function () {
$otherTeam = Team::factory()->create();
$otherServer = Server::factory()->create(['team_id' => $otherTeam->id]);
$otherServer->settings->update([
'is_reachable' => true,
'is_usable' => true,
'force_disabled' => false,
]);
$otherCtx = (object) [
'server' => $otherServer,
'destination' => StandaloneDocker::where('server_id', $otherServer->id)->first(),
'environment' => Environment::factory()->create([
'project_id' => Project::factory()->create(['team_id' => $otherTeam->id])->id,
]),
];
$ctx = createServiceWithApplicationForApiTest($otherCtx);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
])->getJson("/api/v1/services/{$ctx->service->uuid}/applications");
$response->assertStatus(404);
$response->assertJsonFragment(['message' => 'Service not found.']);
});
});
describe('GET /api/v1/services/{uuid}/applications/{app_uuid}', function () {
test('returns 404 for unknown service', function () {
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
])->getJson('/api/v1/services/00000000-0000-0000-0000-000000000002/applications/non-existent-uuid-12345');
$response->assertStatus(404);
$response->assertJsonFragment(['message' => 'Service not found.']);
});
test('returns 404 when application uuid is not under service', function () {
$ctx = createServiceWithApplicationForApiTest($this);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
])->getJson("/api/v1/services/{$ctx->service->uuid}/applications/00000000-0000-0000-0000-000000000003");
$response->assertStatus(404);
$response->assertJsonFragment(['message' => 'Service application not found.']);
});
test('returns service application', function () {
$ctx = createServiceWithApplicationForApiTest($this);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
])->getJson("/api/v1/services/{$ctx->service->uuid}/applications/{$ctx->serviceApplication->uuid}");
$response->assertStatus(200);
$response->assertJsonFragment(['uuid' => $ctx->serviceApplication->uuid, 'name' => 'web']);
});
});
describe('PATCH /api/v1/services/{uuid}/applications/{app_uuid}', function () {
test('returns 400 without valid token', function () {
$response = $this->patchJson('/api/v1/services/some-uuid/applications/some-app', [
'human_name' => 'x',
], ['Accept' => 'application/json', 'Content-Type' => 'application/json']);
$response->assertStatus(401);
});
test('updates human_name', function () {
$ctx = createServiceWithApplicationForApiTest($this);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
])->patchJson("/api/v1/services/{$ctx->service->uuid}/applications/{$ctx->serviceApplication->uuid}", [
'human_name' => 'Web UI',
]);
$response->assertStatus(200);
$response->assertJsonFragment(['human_name' => 'Web UI']);
$ctx->serviceApplication->refresh();
expect($ctx->serviceApplication->human_name)->toBe('Web UI');
});
test('returns 422 for invalid url scheme', function () {
$ctx = createServiceWithApplicationForApiTest($this);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
])->patchJson("/api/v1/services/{$ctx->service->uuid}/applications/{$ctx->serviceApplication->uuid}", [
'url' => 'ftp://example.com',
]);
$response->assertStatus(422);
});
test('returns 422 when enabling log drain but server has no log drain', function () {
$ctx = createServiceWithApplicationForApiTest($this);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
])->patchJson("/api/v1/services/{$ctx->service->uuid}/applications/{$ctx->serviceApplication->uuid}", [
'is_log_drain_enabled' => true,
]);
$response->assertStatus(422);
expect((string) $response->json('errors.is_log_drain_enabled.0'))->toContain('Log drain');
});
test('read-only token cannot update a service application', function () {
$ctx = createServiceWithApplicationForApiTest($this);
$readOnlyToken = createBearerTokenForServiceApplicationsApiTest($this->user, $this->team, ['read']);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$readOnlyToken,
])->patchJson("/api/v1/services/{$ctx->service->uuid}/applications/{$ctx->serviceApplication->uuid}", [
'human_name' => 'Blocked',
]);
$response->assertStatus(403);
});
test('returns 409 for domain conflicts unless forced', function () {
$ctx = createServiceWithApplicationForApiTest($this);
$ctx->serviceApplication->update(['fqdn' => 'http://used.example.com']);
$otherApplication = ServiceApplication::create([
'name' => 'admin',
'service_id' => $ctx->service->id,
'image' => 'nginx:alpine',
]);
$this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
])->patchJson("/api/v1/services/{$ctx->service->uuid}/applications/{$otherApplication->uuid}", [
'url' => 'http://used.example.com',
])->assertStatus(409);
$this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
])->patchJson("/api/v1/services/{$ctx->service->uuid}/applications/{$otherApplication->uuid}?force_domain_override=true", [
'url' => 'http://used.example.com',
])->assertStatus(200);
$otherApplication->refresh();
expect($otherApplication->fqdn)->toBe('http://used.example.com');
});
});
describe('POST /api/v1/services/{uuid}/applications/{app_uuid}/restart', function () {
test('returns 400 without valid token', function () {
$response = $this->postJson('/api/v1/services/some-uuid/applications/some-app/restart');
$response->assertStatus(401);
});
test('queues restart for a service application', function () {
$ctx = createServiceWithApplicationForApiTest($this);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
])->postJson("/api/v1/services/{$ctx->service->uuid}/applications/{$ctx->serviceApplication->uuid}/restart");
$response->assertStatus(200);
$response->assertJsonFragment(['message' => 'Service application restart request queued.']);
RestartServiceApplication::assertPushed();
});
});
describe('POST /api/v1/services/{uuid}/applications/{app_uuid}/start', function () {
test('write token cannot start a service application', function () {
$ctx = createServiceWithApplicationForApiTest($this);
$writeToken = createBearerTokenForServiceApplicationsApiTest($this->user, $this->team, ['write']);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$writeToken,
])->postJson("/api/v1/services/{$ctx->service->uuid}/applications/{$ctx->serviceApplication->uuid}/start");
$response->assertStatus(403);
});
test('queues deploy for a service application', function () {
$ctx = createServiceWithApplicationForApiTest($this);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
])->postJson("/api/v1/services/{$ctx->service->uuid}/applications/{$ctx->serviceApplication->uuid}/start?latest=1&force=1");
$response->assertStatus(200);
$response->assertJsonFragment(['message' => 'Service application deploy request queued.']);
DeployServiceApplication::assertPushed();
});
});
describe('POST /api/v1/services/{uuid}/applications/{app_uuid}/stop', function () {
test('queues stop for a service application', function () {
$ctx = createServiceWithApplicationForApiTest($this);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
])->postJson("/api/v1/services/{$ctx->service->uuid}/applications/{$ctx->serviceApplication->uuid}/stop");
$response->assertStatus(200);
$response->assertJsonFragment(['message' => 'Service application stop request queued.']);
StopServiceApplication::assertPushed();
});
});
describe('GET /api/v1/services/{uuid}/applications/{app_uuid}/logs', function () {
test('returns 400 when server is not functional', function () {
$ctx = createServiceWithApplicationForApiTest($this);
$this->server->settings->update([
'is_reachable' => false,
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
])->getJson("/api/v1/services/{$ctx->service->uuid}/applications/{$ctx->serviceApplication->uuid}/logs");
$response->assertStatus(400);
$response->assertJsonFragment(['message' => 'Server is not functional.']);
});
});
+547
View File
@@ -0,0 +1,547 @@
<?php
use App\Livewire\Project\Shared\Tags as TagsComponent;
use App\Models\Application;
use App\Models\Environment;
use App\Models\InstanceSettings;
use App\Models\Project;
use App\Models\Server;
use App\Models\Service;
use App\Models\StandaloneDocker;
use App\Models\StandalonePostgresql;
use App\Models\Tag;
use App\Models\Team;
use App\Models\User;
use Illuminate\Database\QueryException;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::unguarded(fn () => InstanceSettings::updateOrCreate(['id' => 0], ['is_api_enabled' => true]));
$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->server = Server::factory()->create(['team_id' => $this->team->id]);
$this->destination = StandaloneDocker::where('server_id', $this->server->id)->first();
$this->project = Project::factory()->create(['team_id' => $this->team->id]);
$this->environment = Environment::factory()->create(['project_id' => $this->project->id]);
$this->application = Application::factory()->create([
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
});
function tagApiAuthHeaders($bearerToken): array
{
return [
'Authorization' => 'Bearer '.$bearerToken,
'Content-Type' => 'application/json',
];
}
describe('GET /api/v1/tags', function () {
test('returns all tags for current team', function () {
Tag::create(['name' => 'production', 'team_id' => $this->team->id]);
Tag::create(['name' => 'staging', 'team_id' => $this->team->id]);
$response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken))
->getJson('/api/v1/tags');
$response->assertOk();
$response->assertJsonCount(2);
$response->assertJsonFragment(['name' => 'production']);
$response->assertJsonFragment(['name' => 'staging']);
});
test('returns empty array when no tags exist', function () {
$response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken))
->getJson('/api/v1/tags');
$response->assertOk();
$response->assertJsonCount(0);
});
test('does not return tags from other teams', function () {
$otherTeam = Team::factory()->create();
Tag::create(['name' => 'other-team-tag', 'team_id' => $otherTeam->id]);
Tag::create(['name' => 'my-tag', 'team_id' => $this->team->id]);
$response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken))
->getJson('/api/v1/tags');
$response->assertOk();
$response->assertJsonCount(1);
$response->assertJsonFragment(['name' => 'my-tag']);
$response->assertJsonMissing(['name' => 'other-team-tag']);
});
});
describe('GET /api/v1/applications/{uuid}/tags', function () {
test('returns tags for an application', function () {
$tag = Tag::create(['name' => 'production', 'team_id' => $this->team->id]);
$this->application->tags()->attach($tag->id);
$response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken))
->getJson("/api/v1/applications/{$this->application->uuid}/tags");
$response->assertOk();
$response->assertJsonCount(1);
$response->assertJsonFragment(['name' => 'production']);
});
test('returns 404 for non-existent application', function () {
$response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken))
->getJson('/api/v1/applications/non-existent-uuid/tags');
$response->assertNotFound();
});
test('returns empty array when application has no tags', function () {
$response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken))
->getJson("/api/v1/applications/{$this->application->uuid}/tags");
$response->assertOk();
$response->assertJsonCount(0);
});
});
describe('POST /api/v1/applications/{uuid}/tags', function () {
test('adds a single tag via tag_name', function () {
$response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken))
->postJson("/api/v1/applications/{$this->application->uuid}/tags", [
'tag_name' => 'production',
]);
$response->assertCreated();
$response->assertJsonCount(1);
$response->assertJsonFragment(['name' => 'production']);
expect($this->application->tags()->count())->toBe(1);
});
test('adds multiple tags via tag_names array', function () {
$response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken))
->postJson("/api/v1/applications/{$this->application->uuid}/tags", [
'tag_names' => ['production', 'frontend'],
]);
$response->assertCreated();
$response->assertJsonCount(2);
expect($this->application->tags()->count())->toBe(2);
});
test('reuses existing team tag instead of creating duplicate', function () {
Tag::create(['name' => 'production', 'team_id' => $this->team->id]);
$response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken))
->postJson("/api/v1/applications/{$this->application->uuid}/tags", [
'tag_name' => 'production',
]);
$response->assertCreated();
expect(Tag::where('team_id', $this->team->id)->where('name', 'production')->count())->toBe(1);
});
test('rejects tag_name shorter than 2 characters', function () {
$response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken))
->postJson("/api/v1/applications/{$this->application->uuid}/tags", [
'tag_name' => 'x',
]);
$response->assertUnprocessable();
});
test('rejects both tag_name and tag_names provided simultaneously', function () {
$response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken))
->postJson("/api/v1/applications/{$this->application->uuid}/tags", [
'tag_name' => 'production',
'tag_names' => ['staging'],
]);
$response->assertUnprocessable();
$response->assertJsonFragment(['tag_name' => ['Provide either tag_name or tag_names, not both.']]);
});
test('skips duplicate tag already on resource', function () {
$tag = Tag::create(['name' => 'production', 'team_id' => $this->team->id]);
$this->application->tags()->attach($tag->id);
$response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken))
->postJson("/api/v1/applications/{$this->application->uuid}/tags", [
'tag_name' => 'production',
]);
$response->assertCreated();
expect($this->application->tags()->count())->toBe(1);
});
test('returns 404 for non-existent application', function () {
$response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken))
->postJson('/api/v1/applications/non-existent-uuid/tags', [
'tag_name' => 'production',
]);
$response->assertNotFound();
});
});
describe('DELETE /api/v1/applications/{uuid}/tags/{tag_uuid}', function () {
test('removes tag from application', function () {
$tag = Tag::create(['name' => 'production', 'team_id' => $this->team->id]);
$this->application->tags()->attach($tag->id);
$response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken))
->deleteJson("/api/v1/applications/{$this->application->uuid}/tags/{$tag->uuid}");
$response->assertOk();
expect($this->application->tags()->count())->toBe(0);
});
test('garbage-collects orphaned tag', function () {
$tag = Tag::create(['name' => 'production', 'team_id' => $this->team->id]);
$this->application->tags()->attach($tag->id);
$this->withHeaders(tagApiAuthHeaders($this->bearerToken))
->deleteJson("/api/v1/applications/{$this->application->uuid}/tags/{$tag->uuid}");
expect(Tag::find($tag->id))->toBeNull();
});
test('returns 404 when deleting a tag that is not attached to the application', function () {
$tag = Tag::create(['name' => 'production', 'team_id' => $this->team->id]);
$response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken))
->deleteJson("/api/v1/applications/{$this->application->uuid}/tags/{$tag->uuid}");
$response->assertNotFound();
expect(Tag::find($tag->id))->not->toBeNull();
});
test('keeps tag if still used by other resources', function () {
$tag = Tag::create(['name' => 'production', 'team_id' => $this->team->id]);
$this->application->tags()->attach($tag->id);
$otherApp = Application::factory()->create([
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
$otherApp->tags()->attach($tag->id);
$this->withHeaders(tagApiAuthHeaders($this->bearerToken))
->deleteJson("/api/v1/applications/{$this->application->uuid}/tags/{$tag->uuid}");
expect(Tag::find($tag->id))->not->toBeNull();
});
test('returns 404 for non-existent tag', function () {
$response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken))
->deleteJson("/api/v1/applications/{$this->application->uuid}/tags/non-existent-uuid");
$response->assertNotFound();
});
});
describe('GET /api/v1/databases/{uuid}/tags', function () {
test('returns tags for a database', function () {
$database = StandalonePostgresql::create([
'name' => 'test-pg',
'postgres_password' => 'testpassword',
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
$tag = Tag::create(['name' => 'database-tag', 'team_id' => $this->team->id]);
$database->tags()->attach($tag->id);
$response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken))
->getJson("/api/v1/databases/{$database->uuid}/tags");
$response->assertOk();
$response->assertJsonCount(1);
$response->assertJsonFragment(['name' => 'database-tag']);
});
});
describe('POST /api/v1/databases/{uuid}/tags', function () {
test('adds tag to database', function () {
$database = StandalonePostgresql::create([
'name' => 'test-pg',
'postgres_password' => 'testpassword',
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
$response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken))
->postJson("/api/v1/databases/{$database->uuid}/tags", [
'tag_name' => 'database-tag',
]);
$response->assertCreated();
expect($database->tags()->count())->toBe(1);
});
});
describe('DELETE /api/v1/databases/{uuid}/tags/{tag_uuid}', function () {
test('removes tag from database', function () {
$database = StandalonePostgresql::create([
'name' => 'test-pg',
'postgres_password' => 'testpassword',
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
$tag = Tag::create(['name' => 'database-tag', 'team_id' => $this->team->id]);
$database->tags()->attach($tag->id);
$response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken))
->deleteJson("/api/v1/databases/{$database->uuid}/tags/{$tag->uuid}");
$response->assertOk();
expect($database->tags()->count())->toBe(0);
});
});
describe('GET /api/v1/services/{uuid}/tags', function () {
test('returns tags for a service', function () {
$service = Service::factory()->create([
'server_id' => $this->server->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
'environment_id' => $this->environment->id,
]);
$tag = Tag::create(['name' => 'service-tag', 'team_id' => $this->team->id]);
$service->tags()->attach($tag->id);
$response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken))
->getJson("/api/v1/services/{$service->uuid}/tags");
$response->assertOk();
$response->assertJsonCount(1);
$response->assertJsonFragment(['name' => 'service-tag']);
});
});
describe('POST /api/v1/services/{uuid}/tags', function () {
test('adds tag to service', function () {
$service = Service::factory()->create([
'server_id' => $this->server->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
'environment_id' => $this->environment->id,
]);
$response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken))
->postJson("/api/v1/services/{$service->uuid}/tags", [
'tag_name' => 'service-tag',
]);
$response->assertCreated();
expect($service->tags()->count())->toBe(1);
});
});
describe('DELETE /api/v1/services/{uuid}/tags/{tag_uuid}', function () {
test('removes tag from service', function () {
$service = Service::factory()->create([
'server_id' => $this->server->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
'environment_id' => $this->environment->id,
]);
$tag = Tag::create(['name' => 'service-tag', 'team_id' => $this->team->id]);
$service->tags()->attach($tag->id);
$response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken))
->deleteJson("/api/v1/services/{$service->uuid}/tags/{$tag->uuid}");
$response->assertOk();
expect($service->tags()->count())->toBe(0);
});
});
describe('Resource creation tags', function () {
test('adds tags while creating a public application', function () {
$response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken))
->postJson('/api/v1/applications/public', [
'project_uuid' => $this->project->uuid,
'environment_uuid' => $this->environment->uuid,
'server_uuid' => $this->server->uuid,
'git_repository' => 'https://gitlab.com/coolify/test-static-app',
'git_branch' => 'main',
'build_pack' => 'static',
'ports_exposes' => '80',
'autogenerate_domain' => false,
'tags' => ['production', 'frontend'],
]);
$response->assertSuccessful();
$application = Application::whereUuid($response->json('uuid'))->firstOrFail();
expect($application->tags()->pluck('name')->sort()->values()->all())
->toBe(['frontend', 'production']);
});
test('adds tags while creating a postgresql database', function () {
$response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken))
->postJson('/api/v1/databases/postgresql', [
'server_uuid' => $this->server->uuid,
'project_uuid' => $this->project->uuid,
'environment_uuid' => $this->environment->uuid,
'instant_deploy' => false,
'tags' => ['database', 'production'],
]);
$response->assertSuccessful();
$database = StandalonePostgresql::whereUuid($response->json('uuid'))->firstOrFail();
expect($database->tags()->pluck('name')->sort()->values()->all())
->toBe(['database', 'production']);
});
test('adds tags while creating a docker compose service', function () {
$compose = base64_encode(<<<'YAML'
services:
nginx:
image: nginx:alpine
YAML);
$response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken))
->postJson('/api/v1/services', [
'server_uuid' => $this->server->uuid,
'project_uuid' => $this->project->uuid,
'environment_uuid' => $this->environment->uuid,
'docker_compose_raw' => $compose,
'instant_deploy' => false,
'tags' => ['service', 'production'],
]);
$response->assertCreated();
$service = Service::whereUuid($response->json('uuid'))->firstOrFail();
expect($service->tags()->pluck('name')->sort()->values()->all())
->toBe(['production', 'service']);
});
test('rejects creation tags that are too short after sanitization', function () {
$response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken))
->postJson('/api/v1/databases/postgresql', [
'server_uuid' => $this->server->uuid,
'project_uuid' => $this->project->uuid,
'environment_uuid' => $this->environment->uuid,
'instant_deploy' => false,
'tags' => ['<b>x</b>'],
]);
$response->assertUnprocessable();
});
});
describe('Tag name sanitization', function () {
test('strips HTML tags from tag names', function () {
$response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken))
->postJson("/api/v1/applications/{$this->application->uuid}/tags", [
'tag_name' => '<script>alert("xss")</script>production',
]);
$response->assertCreated();
$response->assertJsonFragment(['name' => 'alert("xss")production']);
$response->assertJsonMissing(['name' => '<script>']);
});
test('lowercases tag names', function () {
$response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken))
->postJson("/api/v1/applications/{$this->application->uuid}/tags", [
'tag_name' => 'Production',
]);
$response->assertCreated();
$response->assertJsonFragment(['name' => 'production']);
});
test('rejects tag names that are too short after sanitization', function () {
$response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken))
->postJson("/api/v1/applications/{$this->application->uuid}/tags", [
'tag_name' => '<b>x</b>',
]);
$response->assertUnprocessable();
expect($this->application->tags()->count())->toBe(0);
});
});
describe('Tag uniqueness', function () {
test('prevents duplicate tag names per team at the database level', function () {
Tag::create(['name' => 'production', 'team_id' => $this->team->id]);
expect(fn () => Tag::create(['name' => 'production', 'team_id' => $this->team->id]))
->toThrow(QueryException::class);
});
});
describe('Tag cleanup across resource types', function () {
test('does not delete a tag still attached to a database when removed from an application in the UI', function () {
$this->actingAs($this->user);
$database = StandalonePostgresql::create([
'name' => 'test-pg',
'postgres_password' => 'testpassword',
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
$tag = Tag::create(['name' => 'shared-tag', 'team_id' => $this->team->id]);
$this->application->tags()->attach($tag->id);
$database->tags()->attach($tag->id);
Livewire::test(TagsComponent::class, ['resource' => $this->application])
->call('deleteTag', (string) $tag->id);
expect(Tag::find($tag->id))->not->toBeNull()
->and($database->tags()->count())->toBe(1);
});
});
describe('Cross-resource tag isolation', function () {
test('cannot delete tag via wrong resource', function () {
$tag = Tag::create(['name' => 'shared-tag', 'team_id' => $this->team->id]);
$otherApp = Application::factory()->create([
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
$otherApp->tags()->attach($tag->id);
// Tag is not attached to $this->application, but we try to delete via it
$response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken))
->deleteJson("/api/v1/applications/{$this->application->uuid}/tags/{$tag->uuid}");
$response->assertNotFound();
// Tag should still exist on the other app (detach on non-attached is a no-op)
expect($otherApp->tags()->count())->toBe(1);
// Tag should NOT be garbage-collected since otherApp still uses it
expect(Tag::find($tag->id))->not->toBeNull();
});
});