mirror of
https://github.com/tiennm99/coolify.git
synced 2026-10-13 00:14:24 +00:00
feat(api): add tags to resource creation
Normalize tag names before attaching them, reject names that are too short after sanitization, and return 404 when removing tags not attached to the resource. Adds a per-team unique tag-name index and migrates duplicate tags onto the kept record before creating the constraint.
This commit is contained in:
1 parent
f617e58401
commit
11b35ba3c1
12 files changed
+1466
-80
No files matched your search
+162
-25
@@ -1,5 +1,6 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Project\Shared\Tags as TagsComponent;
|
||||
use App\Models\Application;
|
||||
use App\Models\Environment;
|
||||
use App\Models\InstanceSettings;
|
||||
@@ -11,12 +12,14 @@ 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::updateOrCreate(['id' => 0]);
|
||||
InstanceSettings::unguarded(fn () => InstanceSettings::updateOrCreate(['id' => 0], ['is_api_enabled' => true]));
|
||||
|
||||
$this->team = Team::factory()->create();
|
||||
$this->user = User::factory()->create();
|
||||
@@ -55,7 +58,7 @@ describe('GET /api/v1/tags', function () {
|
||||
$response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken))
|
||||
->getJson('/api/v1/tags');
|
||||
|
||||
$response->assertStatus(200);
|
||||
$response->assertOk();
|
||||
$response->assertJsonCount(2);
|
||||
$response->assertJsonFragment(['name' => 'production']);
|
||||
$response->assertJsonFragment(['name' => 'staging']);
|
||||
@@ -65,7 +68,7 @@ describe('GET /api/v1/tags', function () {
|
||||
$response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken))
|
||||
->getJson('/api/v1/tags');
|
||||
|
||||
$response->assertStatus(200);
|
||||
$response->assertOk();
|
||||
$response->assertJsonCount(0);
|
||||
});
|
||||
|
||||
@@ -77,7 +80,7 @@ describe('GET /api/v1/tags', function () {
|
||||
$response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken))
|
||||
->getJson('/api/v1/tags');
|
||||
|
||||
$response->assertStatus(200);
|
||||
$response->assertOk();
|
||||
$response->assertJsonCount(1);
|
||||
$response->assertJsonFragment(['name' => 'my-tag']);
|
||||
$response->assertJsonMissing(['name' => 'other-team-tag']);
|
||||
@@ -92,7 +95,7 @@ describe('GET /api/v1/applications/{uuid}/tags', function () {
|
||||
$response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken))
|
||||
->getJson("/api/v1/applications/{$this->application->uuid}/tags");
|
||||
|
||||
$response->assertStatus(200);
|
||||
$response->assertOk();
|
||||
$response->assertJsonCount(1);
|
||||
$response->assertJsonFragment(['name' => 'production']);
|
||||
});
|
||||
@@ -101,14 +104,14 @@ describe('GET /api/v1/applications/{uuid}/tags', function () {
|
||||
$response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken))
|
||||
->getJson('/api/v1/applications/non-existent-uuid/tags');
|
||||
|
||||
$response->assertStatus(404);
|
||||
$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->assertStatus(200);
|
||||
$response->assertOk();
|
||||
$response->assertJsonCount(0);
|
||||
});
|
||||
});
|
||||
@@ -120,7 +123,7 @@ describe('POST /api/v1/applications/{uuid}/tags', function () {
|
||||
'tag_name' => 'production',
|
||||
]);
|
||||
|
||||
$response->assertStatus(201);
|
||||
$response->assertCreated();
|
||||
$response->assertJsonCount(1);
|
||||
$response->assertJsonFragment(['name' => 'production']);
|
||||
|
||||
@@ -133,7 +136,7 @@ describe('POST /api/v1/applications/{uuid}/tags', function () {
|
||||
'tag_names' => ['production', 'frontend'],
|
||||
]);
|
||||
|
||||
$response->assertStatus(201);
|
||||
$response->assertCreated();
|
||||
$response->assertJsonCount(2);
|
||||
|
||||
expect($this->application->tags()->count())->toBe(2);
|
||||
@@ -147,7 +150,7 @@ describe('POST /api/v1/applications/{uuid}/tags', function () {
|
||||
'tag_name' => 'production',
|
||||
]);
|
||||
|
||||
$response->assertStatus(201);
|
||||
$response->assertCreated();
|
||||
expect(Tag::where('team_id', $this->team->id)->where('name', 'production')->count())->toBe(1);
|
||||
});
|
||||
|
||||
@@ -157,7 +160,7 @@ describe('POST /api/v1/applications/{uuid}/tags', function () {
|
||||
'tag_name' => 'x',
|
||||
]);
|
||||
|
||||
$response->assertStatus(422);
|
||||
$response->assertUnprocessable();
|
||||
});
|
||||
|
||||
test('rejects both tag_name and tag_names provided simultaneously', function () {
|
||||
@@ -167,7 +170,7 @@ describe('POST /api/v1/applications/{uuid}/tags', function () {
|
||||
'tag_names' => ['staging'],
|
||||
]);
|
||||
|
||||
$response->assertStatus(422);
|
||||
$response->assertUnprocessable();
|
||||
$response->assertJsonFragment(['tag_name' => ['Provide either tag_name or tag_names, not both.']]);
|
||||
});
|
||||
|
||||
@@ -180,7 +183,7 @@ describe('POST /api/v1/applications/{uuid}/tags', function () {
|
||||
'tag_name' => 'production',
|
||||
]);
|
||||
|
||||
$response->assertStatus(201);
|
||||
$response->assertCreated();
|
||||
expect($this->application->tags()->count())->toBe(1);
|
||||
});
|
||||
|
||||
@@ -190,7 +193,7 @@ describe('POST /api/v1/applications/{uuid}/tags', function () {
|
||||
'tag_name' => 'production',
|
||||
]);
|
||||
|
||||
$response->assertStatus(404);
|
||||
$response->assertNotFound();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -202,7 +205,7 @@ describe('DELETE /api/v1/applications/{uuid}/tags/{tag_uuid}', function () {
|
||||
$response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken))
|
||||
->deleteJson("/api/v1/applications/{$this->application->uuid}/tags/{$tag->uuid}");
|
||||
|
||||
$response->assertStatus(200);
|
||||
$response->assertOk();
|
||||
expect($this->application->tags()->count())->toBe(0);
|
||||
});
|
||||
|
||||
@@ -216,6 +219,16 @@ describe('DELETE /api/v1/applications/{uuid}/tags/{tag_uuid}', function () {
|
||||
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);
|
||||
@@ -237,7 +250,7 @@ describe('DELETE /api/v1/applications/{uuid}/tags/{tag_uuid}', function () {
|
||||
$response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken))
|
||||
->deleteJson("/api/v1/applications/{$this->application->uuid}/tags/non-existent-uuid");
|
||||
|
||||
$response->assertStatus(404);
|
||||
$response->assertNotFound();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -257,7 +270,7 @@ describe('GET /api/v1/databases/{uuid}/tags', function () {
|
||||
$response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken))
|
||||
->getJson("/api/v1/databases/{$database->uuid}/tags");
|
||||
|
||||
$response->assertStatus(200);
|
||||
$response->assertOk();
|
||||
$response->assertJsonCount(1);
|
||||
$response->assertJsonFragment(['name' => 'database-tag']);
|
||||
});
|
||||
@@ -278,7 +291,7 @@ describe('POST /api/v1/databases/{uuid}/tags', function () {
|
||||
'tag_name' => 'database-tag',
|
||||
]);
|
||||
|
||||
$response->assertStatus(201);
|
||||
$response->assertCreated();
|
||||
expect($database->tags()->count())->toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -299,7 +312,7 @@ describe('DELETE /api/v1/databases/{uuid}/tags/{tag_uuid}', function () {
|
||||
$response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken))
|
||||
->deleteJson("/api/v1/databases/{$database->uuid}/tags/{$tag->uuid}");
|
||||
|
||||
$response->assertStatus(200);
|
||||
$response->assertOk();
|
||||
expect($database->tags()->count())->toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -319,7 +332,7 @@ describe('GET /api/v1/services/{uuid}/tags', function () {
|
||||
$response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken))
|
||||
->getJson("/api/v1/services/{$service->uuid}/tags");
|
||||
|
||||
$response->assertStatus(200);
|
||||
$response->assertOk();
|
||||
$response->assertJsonCount(1);
|
||||
$response->assertJsonFragment(['name' => 'service-tag']);
|
||||
});
|
||||
@@ -339,7 +352,7 @@ describe('POST /api/v1/services/{uuid}/tags', function () {
|
||||
'tag_name' => 'service-tag',
|
||||
]);
|
||||
|
||||
$response->assertStatus(201);
|
||||
$response->assertCreated();
|
||||
expect($service->tags()->count())->toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -359,11 +372,91 @@ describe('DELETE /api/v1/services/{uuid}/tags/{tag_uuid}', function () {
|
||||
$response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken))
|
||||
->deleteJson("/api/v1/services/{$service->uuid}/tags/{$tag->uuid}");
|
||||
|
||||
$response->assertStatus(200);
|
||||
$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))
|
||||
@@ -371,7 +464,7 @@ describe('Tag name sanitization', function () {
|
||||
'tag_name' => '<script>alert("xss")</script>production',
|
||||
]);
|
||||
|
||||
$response->assertStatus(201);
|
||||
$response->assertCreated();
|
||||
$response->assertJsonFragment(['name' => 'alert("xss")production']);
|
||||
$response->assertJsonMissing(['name' => '<script>']);
|
||||
});
|
||||
@@ -382,9 +475,52 @@ describe('Tag name sanitization', function () {
|
||||
'tag_name' => 'Production',
|
||||
]);
|
||||
|
||||
$response->assertStatus(201);
|
||||
$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 () {
|
||||
@@ -399,9 +535,10 @@ describe('Cross-resource tag isolation', function () {
|
||||
$otherApp->tags()->attach($tag->id);
|
||||
|
||||
// Tag is not attached to $this->application, but we try to delete via it
|
||||
$this->withHeaders(tagApiAuthHeaders($this->bearerToken))
|
||||
$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
|
||||
|
||||
Reference in new issue
Block a user