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' => ['x'], ]); $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' => 'production', ]); $response->assertCreated(); $response->assertJsonFragment(['name' => 'alert("xss")production']); $response->assertJsonMissing(['name' => '