mirror of
https://github.com/tiennm99/coolify.git
synced 2026-10-14 10:18:20 +00:00
Merge remote-tracking branch 'origin/next' into feature/vultr-cloud-provider
This commit is contained in:
commit
31f904ef9f
35 files changed
+7215
-113
No files matched your search
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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.']);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user