diff --git a/app/Http/Controllers/Api/ApplicationsController.php b/app/Http/Controllers/Api/ApplicationsController.php index fed63d798..0192befd1 100644 --- a/app/Http/Controllers/Api/ApplicationsController.php +++ b/app/Http/Controllers/Api/ApplicationsController.php @@ -969,6 +969,13 @@ private function create_application(Request $request, $type) ], 422); } + $return = $this->validateTagsParameter($request); + if ($return instanceof JsonResponse) { + return $return; + } + + $tagNames = $request->input('tags') ?? []; + $environmentUuid = $request->environment_uuid; $environmentName = $request->environment_name; if (blank($environmentUuid) && blank($environmentName)) { @@ -1226,8 +1233,8 @@ private function create_application(Request $request, $type) $application->custom_labels = str(implode('|coolify|', generateLabelsApplication($application)))->replace('|coolify|', "\n"); $application->save(); } - if ($request->has('tags')) { - $this->attachTagsToResource($application, $request->tags, $teamId); + if ($tagNames !== []) { + $this->attachTagsToResource($application, $tagNames, $teamId); } $application->isConfigurationChanged(true); @@ -1472,8 +1479,8 @@ private function create_application(Request $request, $type) $application->custom_labels = str(implode('|coolify|', generateLabelsApplication($application)))->replace('|coolify|', "\n"); $application->save(); } - if ($request->has('tags')) { - $this->attachTagsToResource($application, $request->tags, $teamId); + if ($tagNames !== []) { + $this->attachTagsToResource($application, $tagNames, $teamId); } $application->isConfigurationChanged(true); @@ -1688,8 +1695,8 @@ private function create_application(Request $request, $type) $application->custom_labels = str(implode('|coolify|', generateLabelsApplication($application)))->replace('|coolify|', "\n"); $application->save(); } - if ($request->has('tags')) { - $this->attachTagsToResource($application, $request->tags, $teamId); + if ($tagNames !== []) { + $this->attachTagsToResource($application, $tagNames, $teamId); } $application->isConfigurationChanged(true); @@ -1815,8 +1822,8 @@ private function create_application(Request $request, $type) $application->custom_labels = str(implode('|coolify|', generateLabelsApplication($application)))->replace('|coolify|', "\n"); $application->save(); } - if ($request->has('tags')) { - $this->attachTagsToResource($application, $request->tags, $teamId); + if ($tagNames !== []) { + $this->attachTagsToResource($application, $tagNames, $teamId); } $application->isConfigurationChanged(true); @@ -1941,8 +1948,8 @@ private function create_application(Request $request, $type) $application->custom_labels = str(implode('|coolify|', generateLabelsApplication($application)))->replace('|coolify|', "\n"); $application->save(); } - if ($request->has('tags')) { - $this->attachTagsToResource($application, $request->tags, $teamId); + if ($tagNames !== []) { + $this->attachTagsToResource($application, $tagNames, $teamId); } $application->isConfigurationChanged(true); diff --git a/app/Http/Controllers/Api/Concerns/HandlesTagsApi.php b/app/Http/Controllers/Api/Concerns/HandlesTagsApi.php index b6d6d259a..4c15d0726 100644 --- a/app/Http/Controllers/Api/Concerns/HandlesTagsApi.php +++ b/app/Http/Controllers/Api/Concerns/HandlesTagsApi.php @@ -6,7 +6,6 @@ use App\Models\Tag; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; -use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Validator; trait HandlesTagsApi @@ -46,7 +45,7 @@ public function createTag(Request $request): JsonResponse } $return = validateIncomingRequest($request); - if ($return instanceof \Illuminate\Http\JsonResponse) { + if ($return instanceof JsonResponse) { return $return; } @@ -65,9 +64,9 @@ public function createTag(Request $request): JsonResponse } $validator = Validator::make($request->all(), [ - 'tag_name' => 'required_without:tag_names|string|min:2', + 'tag_name' => 'required_without:tag_names|string', 'tag_names' => 'required_without:tag_name|array|min:1', - 'tag_names.*' => 'string|min:2', + 'tag_names.*' => 'string', ]); $extraFields = array_diff(array_keys($request->all()), ['tag_name', 'tag_names']); @@ -85,7 +84,14 @@ public function createTag(Request $request): JsonResponse ], 422); } - $tagNames = $request->has('tag_names') ? $request->tag_names : [$request->tag_name]; + $tagNames = $this->normalizeTagNames($request->has('tag_names') ? $request->tag_names : [$request->tag_name]); + $invalidTags = array_filter($tagNames, fn (string $tagName): bool => mb_strlen($tagName) < 2); + if (! empty($invalidTags)) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => ['tag_name' => ['Each tag name must be at least 2 characters after sanitization.']], + ], 422); + } $this->attachTagsToResource($resource, $tagNames, $teamId); @@ -111,32 +117,58 @@ public function deleteTag(Request $request): JsonResponse return response()->json(['message' => 'Tag not found.'], 404); } - $resource->tags()->detach($tag->id); - - if (DB::table('taggables')->where('tag_id', $tag->id)->count() === 0) { - $tag->delete(); + if (! $resource->tags()->whereKey($tag->id)->exists()) { + return response()->json(['message' => 'Tag not found on resource.'], 404); } + $resource->tags()->detach($tag->id); + $tag->deleteIfOrphaned(); + return response()->json(['message' => 'Tag removed.']); } protected function attachTagsToResource($resource, array $tagNames, int|string $teamId): void { - foreach ($tagNames as $tagName) { - $tagName = strtolower(strip_tags($tagName)); - if (strlen($tagName) < 2) { + foreach ($this->normalizeTagNames($tagNames) as $tagName) { + if (mb_strlen($tagName) < 2) { continue; } - $tag = Tag::where('team_id', $teamId)->where('name', $tagName)->first(); - if (! $tag) { - $tag = Tag::create([ - 'name' => $tagName, - 'team_id' => $teamId, - ]); - } + $tag = Tag::query()->createOrFirst([ + 'team_id' => $teamId, + 'name' => $tagName, + ]); $resource->tags()->syncWithoutDetaching([$tag->id]); } } + + protected function validateTagsParameter(Request $request): ?JsonResponse + { + if (! $request->has('tags')) { + return null; + } + + $tagNames = $this->normalizeTagNames($request->input('tags', [])); + $invalidTags = array_filter($tagNames, fn (string $tagName): bool => mb_strlen($tagName) < 2); + if (! empty($invalidTags)) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => ['tags' => ['Each tag name must be at least 2 characters after sanitization.']], + ], 422); + } + + $request->merge(['tags' => $tagNames]); + + return null; + } + + protected function normalizeTagNames(array $tagNames): array + { + return collect($tagNames) + ->map(fn ($tagName): string => strtolower(trim(strip_tags((string) $tagName)))) + ->unique() + ->values() + ->all(); + } } diff --git a/app/Http/Controllers/Api/DatabasesController.php b/app/Http/Controllers/Api/DatabasesController.php index a8c89ca13..d3dd77955 100644 --- a/app/Http/Controllers/Api/DatabasesController.php +++ b/app/Http/Controllers/Api/DatabasesController.php @@ -1663,7 +1663,7 @@ public function create_database_mongodb(Request $request) public function create_database(Request $request, NewDatabaseTypes $type) { - $allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'postgres_user', 'postgres_password', 'postgres_db', 'postgres_initdb_args', 'postgres_host_auth_method', 'postgres_conf', 'clickhouse_admin_user', 'clickhouse_admin_password', 'dragonfly_password', 'redis_password', 'redis_conf', 'keydb_password', 'keydb_conf', 'mariadb_conf', 'mariadb_root_password', 'mariadb_user', 'mariadb_password', 'mariadb_database', 'mongo_conf', 'mongo_initdb_root_username', 'mongo_initdb_root_password', 'mongo_initdb_database', 'mysql_root_password', 'mysql_password', 'mysql_user', 'mysql_database', 'mysql_conf']; + $allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'postgres_user', 'postgres_password', 'postgres_db', 'postgres_initdb_args', 'postgres_host_auth_method', 'postgres_conf', 'clickhouse_admin_user', 'clickhouse_admin_password', 'dragonfly_password', 'redis_password', 'redis_conf', 'keydb_password', 'keydb_conf', 'mariadb_conf', 'mariadb_root_password', 'mariadb_user', 'mariadb_password', 'mariadb_database', 'mongo_conf', 'mongo_initdb_root_username', 'mongo_initdb_root_password', 'mongo_initdb_database', 'mysql_root_password', 'mysql_password', 'mysql_user', 'mysql_database', 'mysql_conf', 'tags']; $teamId = getTeamIdFromToken(); if (is_null($teamId)) { @@ -1771,6 +1771,13 @@ public function create_database(Request $request, NewDatabaseTypes $type) 'errors' => $validator->errors(), ], 422); } + $return = $this->validateTagsParameter($request); + if ($return instanceof JsonResponse) { + return $return; + } + + $tagNames = $request->input('tags') ?? []; + if ($request->public_port) { if ($request->public_port < 1024 || $request->public_port > 65535) { return response()->json([ @@ -1830,8 +1837,8 @@ public function create_database(Request $request, NewDatabaseTypes $type) if ($instantDeploy) { StartDatabase::dispatch($database); } - if ($request->has('tags')) { - $this->attachTagsToResource($database, $request->tags, $teamId); + if ($tagNames !== []) { + $this->attachTagsToResource($database, $tagNames, $teamId); } $database->refresh(); $payload = [ @@ -1901,8 +1908,8 @@ public function create_database(Request $request, NewDatabaseTypes $type) if ($instantDeploy) { StartDatabase::dispatch($database); } - if ($request->has('tags')) { - $this->attachTagsToResource($database, $request->tags, $teamId); + if ($tagNames !== []) { + $this->attachTagsToResource($database, $tagNames, $teamId); } $database->refresh(); @@ -1973,8 +1980,8 @@ public function create_database(Request $request, NewDatabaseTypes $type) if ($instantDeploy) { StartDatabase::dispatch($database); } - if ($request->has('tags')) { - $this->attachTagsToResource($database, $request->tags, $teamId); + if ($tagNames !== []) { + $this->attachTagsToResource($database, $tagNames, $teamId); } $database->refresh(); @@ -2042,8 +2049,8 @@ public function create_database(Request $request, NewDatabaseTypes $type) if ($instantDeploy) { StartDatabase::dispatch($database); } - if ($request->has('tags')) { - $this->attachTagsToResource($database, $request->tags, $teamId); + if ($tagNames !== []) { + $this->attachTagsToResource($database, $tagNames, $teamId); } $database->refresh(); @@ -2092,8 +2099,8 @@ public function create_database(Request $request, NewDatabaseTypes $type) if ($instantDeploy) { StartDatabase::dispatch($database); } - if ($request->has('tags')) { - $this->attachTagsToResource($database, $request->tags, $teamId); + if ($tagNames !== []) { + $this->attachTagsToResource($database, $tagNames, $teamId); } return response()->json(serializeApiResponse([ @@ -2144,8 +2151,8 @@ public function create_database(Request $request, NewDatabaseTypes $type) if ($instantDeploy) { StartDatabase::dispatch($database); } - if ($request->has('tags')) { - $this->attachTagsToResource($database, $request->tags, $teamId); + if ($tagNames !== []) { + $this->attachTagsToResource($database, $tagNames, $teamId); } $database->refresh(); @@ -2193,8 +2200,8 @@ public function create_database(Request $request, NewDatabaseTypes $type) if ($instantDeploy) { StartDatabase::dispatch($database); } - if ($request->has('tags')) { - $this->attachTagsToResource($database, $request->tags, $teamId); + if ($tagNames !== []) { + $this->attachTagsToResource($database, $tagNames, $teamId); } $database->refresh(); @@ -2264,8 +2271,8 @@ public function create_database(Request $request, NewDatabaseTypes $type) if ($instantDeploy) { StartDatabase::dispatch($database); } - if ($request->has('tags')) { - $this->attachTagsToResource($database, $request->tags, $teamId); + if ($tagNames !== []) { + $this->attachTagsToResource($database, $tagNames, $teamId); } $database->refresh(); diff --git a/app/Http/Controllers/Api/ServicesController.php b/app/Http/Controllers/Api/ServicesController.php index 60ef6b3eb..46e93ca1e 100644 --- a/app/Http/Controllers/Api/ServicesController.php +++ b/app/Http/Controllers/Api/ServicesController.php @@ -352,6 +352,11 @@ public function create_service(Request $request) ], 422); } + $return = $this->validateTagsParameter($request); + if ($return instanceof JsonResponse) { + return $return; + } + if (filled($request->type) && filled($request->docker_compose_raw)) { return response()->json([ 'message' => 'You cannot provide both service type and docker_compose_raw. Use one or the other.', @@ -533,6 +538,8 @@ public function create_service(Request $request) 'urls.*.url' => 'string|nullable', 'force_domain_override' => 'boolean', 'is_container_label_escape_enabled' => 'boolean', + 'tags' => 'array|nullable', + 'tags.*' => 'string|min:2', ]; $validationMessages = [ 'urls.*.array' => 'An item in the urls array has invalid fields. Only name and url fields are supported.', diff --git a/app/Livewire/Project/Shared/Tags.php b/app/Livewire/Project/Shared/Tags.php index 37b8b277a..61d04e20b 100644 --- a/app/Livewire/Project/Shared/Tags.php +++ b/app/Livewire/Project/Shared/Tags.php @@ -91,9 +91,7 @@ public function deleteTag(string $id) $this->authorize('update', $this->resource); $this->resource->tags()->detach($id); $found_more_tags = Tag::ownedByCurrentTeam()->find($id); - if ($found_more_tags && $found_more_tags->applications()->count() == 0 && $found_more_tags->services()->count() == 0) { - $found_more_tags->delete(); - } + $found_more_tags?->deleteIfOrphaned(); $this->refresh(); $this->dispatch('success', 'Tag deleted.'); } catch (\Exception $e) { diff --git a/app/Models/Tag.php b/app/Models/Tag.php index 0080b79d6..d5cccabd8 100644 --- a/app/Models/Tag.php +++ b/app/Models/Tag.php @@ -3,6 +3,7 @@ namespace App\Models; use App\Traits\HasSafeStringAttribute; +use Illuminate\Support\Facades\DB; use OpenApi\Attributes as OA; #[OA\Schema( @@ -34,6 +35,13 @@ public static function ownedByCurrentTeam() return Tag::whereTeamId(currentTeam()->id)->orderBy('name'); } + public function deleteIfOrphaned(): void + { + if (DB::table('taggables')->where('tag_id', $this->id)->doesntExist()) { + $this->delete(); + } + } + public function applications() { return $this->morphedByMany(Application::class, 'taggable'); diff --git a/database/migrations/2026_07_07_113248_add_team_name_unique_index_to_tags_table.php b/database/migrations/2026_07_07_113248_add_team_name_unique_index_to_tags_table.php new file mode 100644 index 000000000..a25fdc18b --- /dev/null +++ b/database/migrations/2026_07_07_113248_add_team_name_unique_index_to_tags_table.php @@ -0,0 +1,76 @@ +indexExists()) { + return; + } + + DB::table('tags') + ->select('team_id', 'name', DB::raw('MIN(id) as keep_id'), DB::raw('COUNT(*) as tag_count')) + ->whereNotNull('team_id') + ->groupBy('team_id', 'name') + ->havingRaw('COUNT(*) > 1') + ->orderBy('keep_id') + ->cursor() + ->each(function ($duplicate): void { + DB::table('tags') + ->select('id') + ->where('team_id', $duplicate->team_id) + ->where('name', $duplicate->name) + ->where('id', '!=', $duplicate->keep_id) + ->orderBy('id') + ->cursor() + ->each(function ($duplicateTag) use ($duplicate): void { + DB::table('taggables') + ->where('tag_id', $duplicateTag->id) + ->orderBy('taggable_id') + ->cursor() + ->each(function ($taggable) use ($duplicate): void { + DB::table('taggables')->updateOrInsert([ + 'tag_id' => $duplicate->keep_id, + 'taggable_id' => $taggable->taggable_id, + 'taggable_type' => $taggable->taggable_type, + ]); + }); + + DB::table('taggables')->where('tag_id', $duplicateTag->id)->delete(); + DB::table('tags')->where('id', $duplicateTag->id)->delete(); + }); + }); + + Schema::table('tags', function (Blueprint $table) { + $table->unique(['team_id', 'name'], 'tags_team_id_name_unique'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + if (! $this->indexExists()) { + return; + } + + Schema::table('tags', function (Blueprint $table) { + $table->dropUnique('tags_team_id_name_unique'); + }); + } + + private function indexExists(): bool + { + return collect(Schema::getIndexes('tags')) + ->contains(fn (array $index): bool => $index['name'] === 'tags_team_id_name_unique'); + } +}; diff --git a/openapi.json b/openapi.json index 00c1e3733..3b324a546 100644 --- a/openapi.json +++ b/openapi.json @@ -412,6 +412,13 @@ "default": true, "description": "Escape special characters in labels. By default, $ (and other chars) is escaped. So if you write $ in the labels, it will be saved as $$. If you want to use env variables inside the labels, turn this off." }, + "tags": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Tags to assign to the application." + }, "is_preserve_repository_enabled": { "type": "boolean", "default": false, @@ -866,6 +873,13 @@ "default": true, "description": "Escape special characters in labels. By default, $ (and other chars) is escaped. So if you write $ in the labels, it will be saved as $$. If you want to use env variables inside the labels, turn this off." }, + "tags": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Tags to assign to the application." + }, "is_preserve_repository_enabled": { "type": "boolean", "default": false, @@ -1320,6 +1334,13 @@ "default": true, "description": "Escape special characters in labels. By default, $ (and other chars) is escaped. So if you write $ in the labels, it will be saved as $$. If you want to use env variables inside the labels, turn this off." }, + "tags": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Tags to assign to the application." + }, "is_preserve_repository_enabled": { "type": "boolean", "default": false, @@ -1678,6 +1699,13 @@ "type": "boolean", "default": true, "description": "Escape special characters in labels. By default, $ (and other chars) is escaped. So if you write $ in the labels, it will be saved as $$. If you want to use env variables inside the labels, turn this off." + }, + "tags": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Tags to assign to the application." } }, "type": "object" @@ -2017,6 +2045,13 @@ "type": "boolean", "default": true, "description": "Escape special characters in labels. By default, $ (and other chars) is escaped. So if you write $ in the labels, it will be saved as $$. If you want to use env variables inside the labels, turn this off." + }, + "tags": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Tags to assign to the application." } }, "type": "object" @@ -3720,6 +3755,179 @@ ] } }, + "\/applications\/{uuid}\/tags": { + "get": { + "tags": [ + "Applications" + ], + "summary": "List Tags", + "description": "List tags for an application by UUID.", + "operationId": "list-tags-by-application-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the application.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "List of tags.", + "content": { + "application\/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#\/components\/schemas\/Tag" + } + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + }, + "post": { + "tags": [ + "Applications" + ], + "summary": "Create Tag", + "description": "Add tag(s) to an application by UUID.", + "operationId": "create-tag-by-application-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the application.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application\/json": { + "schema": { + "properties": { + "tag_name": { + "type": "string", + "description": "The tag name (min 2 characters). Required if tag_names is not provided." + }, + "tag_names": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Array of tag names (each min 2 characters). Required if tag_name is not provided." + } + }, + "type": "object" + } + } + } + }, + "responses": { + "201": { + "description": "Tags added successfully.", + "content": { + "application\/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#\/components\/schemas\/Tag" + } + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "404": { + "$ref": "#\/components\/responses\/404" + }, + "422": { + "$ref": "#\/components\/responses\/422" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/applications\/{uuid}\/tags\/{tag_uuid}": { + "delete": { + "tags": [ + "Applications" + ], + "summary": "Delete Tag", + "description": "Remove a tag from an application by UUID.", + "operationId": "delete-tag-by-application-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the application.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "tag_uuid", + "in": "path", + "description": "UUID of the tag.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Tag removed." + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, "\/cloud-tokens": { "get": { "tags": [ @@ -5018,6 +5226,13 @@ "instant_deploy": { "type": "boolean", "description": "Instant deploy the database" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Tags to assign to the database." } }, "type": "object" @@ -5150,6 +5365,13 @@ "instant_deploy": { "type": "boolean", "description": "Instant deploy the database" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Tags to assign to the database." } }, "type": "object" @@ -5278,6 +5500,13 @@ "instant_deploy": { "type": "boolean", "description": "Instant deploy the database" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Tags to assign to the database." } }, "type": "object" @@ -5410,6 +5639,13 @@ "instant_deploy": { "type": "boolean", "description": "Instant deploy the database" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Tags to assign to the database." } }, "type": "object" @@ -5542,6 +5778,13 @@ "instant_deploy": { "type": "boolean", "description": "Instant deploy the database" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Tags to assign to the database." } }, "type": "object" @@ -5686,6 +5929,13 @@ "instant_deploy": { "type": "boolean", "description": "Instant deploy the database" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Tags to assign to the database." } }, "type": "object" @@ -5830,6 +6080,13 @@ "instant_deploy": { "type": "boolean", "description": "Instant deploy the database" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Tags to assign to the database." } }, "type": "object" @@ -5962,6 +6219,13 @@ "instant_deploy": { "type": "boolean", "description": "Instant deploy the database" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Tags to assign to the database." } }, "type": "object" @@ -7106,6 +7370,179 @@ ] } }, + "\/databases\/{uuid}\/tags": { + "get": { + "tags": [ + "Databases" + ], + "summary": "List Tags", + "description": "List tags for a database by UUID.", + "operationId": "list-tags-by-database-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the database.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "List of tags.", + "content": { + "application\/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#\/components\/schemas\/Tag" + } + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + }, + "post": { + "tags": [ + "Databases" + ], + "summary": "Create Tag", + "description": "Add tag(s) to a database by UUID.", + "operationId": "create-tag-by-database-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the database.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application\/json": { + "schema": { + "properties": { + "tag_name": { + "type": "string", + "description": "The tag name (min 2 characters). Required if tag_names is not provided." + }, + "tag_names": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Array of tag names (each min 2 characters). Required if tag_name is not provided." + } + }, + "type": "object" + } + } + } + }, + "responses": { + "201": { + "description": "Tags added successfully.", + "content": { + "application\/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#\/components\/schemas\/Tag" + } + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "404": { + "$ref": "#\/components\/responses\/404" + }, + "422": { + "$ref": "#\/components\/responses\/422" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/databases\/{uuid}\/tags\/{tag_uuid}": { + "delete": { + "tags": [ + "Databases" + ], + "summary": "Delete Tag", + "description": "Remove a tag from a database by UUID.", + "operationId": "delete-tag-by-database-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the database.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "tag_uuid", + "in": "path", + "description": "UUID of the tag.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Tag removed." + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, "\/deployments": { "get": { "tags": [ @@ -10968,6 +11405,13 @@ "type": "boolean", "default": true, "description": "Escape special characters in labels. By default, $ (and other chars) is escaped. If you want to use env variables inside the labels, turn this off." + }, + "tags": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Tags to assign to the service." } }, "type": "object" @@ -12390,6 +12834,215 @@ ] } }, + "\/services\/{uuid}\/tags": { + "get": { + "tags": [ + "Services" + ], + "summary": "List Tags", + "description": "List tags for a service by UUID.", + "operationId": "list-tags-by-service-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the service.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "List of tags.", + "content": { + "application\/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#\/components\/schemas\/Tag" + } + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + }, + "post": { + "tags": [ + "Services" + ], + "summary": "Create Tag", + "description": "Add tag(s) to a service by UUID.", + "operationId": "create-tag-by-service-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the service.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application\/json": { + "schema": { + "properties": { + "tag_name": { + "type": "string", + "description": "The tag name (min 2 characters). Required if tag_names is not provided." + }, + "tag_names": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Array of tag names (each min 2 characters). Required if tag_name is not provided." + } + }, + "type": "object" + } + } + } + }, + "responses": { + "201": { + "description": "Tags added successfully.", + "content": { + "application\/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#\/components\/schemas\/Tag" + } + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "404": { + "$ref": "#\/components\/responses\/404" + }, + "422": { + "$ref": "#\/components\/responses\/422" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/services\/{uuid}\/tags\/{tag_uuid}": { + "delete": { + "tags": [ + "Services" + ], + "summary": "Delete Tag", + "description": "Remove a tag from a service by UUID.", + "operationId": "delete-tag-by-service-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the service.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "tag_uuid", + "in": "path", + "description": "UUID of the tag.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Tag removed." + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/tags": { + "get": { + "tags": [ + "Tags" + ], + "summary": "List", + "description": "List all tags for the current team.", + "operationId": "list-tags", + "responses": { + "200": { + "description": "All tags for the current team.", + "content": { + "application\/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#\/components\/schemas\/Tag" + } + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, "\/teams": { "get": { "tags": [ @@ -13610,6 +14263,24 @@ }, "type": "object" }, + "Tag": { + "description": "Tag model", + "properties": { + "uuid": { + "type": "string" + }, + "name": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "updated_at": { + "type": "string" + } + }, + "type": "object" + }, "Team": { "description": "Team model", "properties": { @@ -13864,6 +14535,10 @@ "name": "Services", "description": "Services" }, + { + "name": "Tags", + "description": "Tags" + }, { "name": "Teams", "description": "Teams" diff --git a/openapi.yaml b/openapi.yaml index dea6b8589..03486bb40 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -293,6 +293,10 @@ paths: type: boolean default: true description: 'Escape special characters in labels. By default, $ (and other chars) is escaped. So if you write $ in the labels, it will be saved as $$. If you want to use env variables inside the labels, turn this off.' + tags: + type: array + items: { type: string } + description: 'Tags to assign to the application.' is_preserve_repository_enabled: type: boolean default: false @@ -583,6 +587,10 @@ paths: type: boolean default: true description: 'Escape special characters in labels. By default, $ (and other chars) is escaped. So if you write $ in the labels, it will be saved as $$. If you want to use env variables inside the labels, turn this off.' + tags: + type: array + items: { type: string } + description: 'Tags to assign to the application.' is_preserve_repository_enabled: type: boolean default: false @@ -873,6 +881,10 @@ paths: type: boolean default: true description: 'Escape special characters in labels. By default, $ (and other chars) is escaped. So if you write $ in the labels, it will be saved as $$. If you want to use env variables inside the labels, turn this off.' + tags: + type: array + items: { type: string } + description: 'Tags to assign to the application.' is_preserve_repository_enabled: type: boolean default: false @@ -1104,6 +1116,10 @@ paths: type: boolean default: true description: 'Escape special characters in labels. By default, $ (and other chars) is escaped. So if you write $ in the labels, it will be saved as $$. If you want to use env variables inside the labels, turn this off.' + tags: + type: array + items: { type: string } + description: 'Tags to assign to the application.' type: object responses: '201': @@ -1321,6 +1337,10 @@ paths: type: boolean default: true description: 'Escape special characters in labels. By default, $ (and other chars) is escaped. So if you write $ in the labels, it will be saved as $$. If you want to use env variables inside the labels, turn this off.' + tags: + type: array + items: { type: string } + description: 'Tags to assign to the application.' type: object responses: '201': @@ -2376,6 +2396,121 @@ paths: security: - bearerAuth: [] + '/applications/{uuid}/tags': + get: + tags: + - Applications + summary: 'List Tags' + description: 'List tags for an application by UUID.' + operationId: list-tags-by-application-uuid + parameters: + - + name: uuid + in: path + description: 'UUID of the application.' + required: true + schema: + type: string + responses: + '200': + description: 'List of tags.' + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Tag' + '401': + $ref: '#/components/responses/401' + '400': + $ref: '#/components/responses/400' + '404': + $ref: '#/components/responses/404' + security: + - + bearerAuth: [] + post: + tags: + - Applications + summary: 'Create Tag' + description: 'Add tag(s) to an application by UUID.' + operationId: create-tag-by-application-uuid + parameters: + - + name: uuid + in: path + description: 'UUID of the application.' + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + properties: + tag_name: + type: string + description: 'The tag name (min 2 characters). Required if tag_names is not provided.' + tag_names: + type: array + items: { type: string } + description: 'Array of tag names (each min 2 characters). Required if tag_name is not provided.' + type: object + responses: + '201': + description: 'Tags added successfully.' + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Tag' + '401': + $ref: '#/components/responses/401' + '400': + $ref: '#/components/responses/400' + '404': + $ref: '#/components/responses/404' + '422': + $ref: '#/components/responses/422' + security: + - + bearerAuth: [] + '/applications/{uuid}/tags/{tag_uuid}': + delete: + tags: + - Applications + summary: 'Delete Tag' + description: 'Remove a tag from an application by UUID.' + operationId: delete-tag-by-application-uuid + parameters: + - + name: uuid + in: path + description: 'UUID of the application.' + required: true + schema: + type: string + - + name: tag_uuid + in: path + description: 'UUID of the tag.' + required: true + schema: + type: string + responses: + '200': + description: 'Tag removed.' + '401': + $ref: '#/components/responses/401' + '400': + $ref: '#/components/responses/400' + '404': + $ref: '#/components/responses/404' + security: + - + bearerAuth: [] /cloud-tokens: get: tags: @@ -3244,6 +3379,10 @@ paths: instant_deploy: type: boolean description: 'Instant deploy the database' + tags: + type: array + items: { type: string } + description: 'Tags to assign to the database.' type: object responses: '200': @@ -3339,6 +3478,10 @@ paths: instant_deploy: type: boolean description: 'Instant deploy the database' + tags: + type: array + items: { type: string } + description: 'Tags to assign to the database.' type: object responses: '200': @@ -3431,6 +3574,10 @@ paths: instant_deploy: type: boolean description: 'Instant deploy the database' + tags: + type: array + items: { type: string } + description: 'Tags to assign to the database.' type: object responses: '200': @@ -3526,6 +3673,10 @@ paths: instant_deploy: type: boolean description: 'Instant deploy the database' + tags: + type: array + items: { type: string } + description: 'Tags to assign to the database.' type: object responses: '200': @@ -3621,6 +3772,10 @@ paths: instant_deploy: type: boolean description: 'Instant deploy the database' + tags: + type: array + items: { type: string } + description: 'Tags to assign to the database.' type: object responses: '200': @@ -3725,6 +3880,10 @@ paths: instant_deploy: type: boolean description: 'Instant deploy the database' + tags: + type: array + items: { type: string } + description: 'Tags to assign to the database.' type: object responses: '200': @@ -3829,6 +3988,10 @@ paths: instant_deploy: type: boolean description: 'Instant deploy the database' + tags: + type: array + items: { type: string } + description: 'Tags to assign to the database.' type: object responses: '200': @@ -3924,6 +4087,10 @@ paths: instant_deploy: type: boolean description: 'Instant deploy the database' + tags: + type: array + items: { type: string } + description: 'Tags to assign to the database.' type: object responses: '200': @@ -4636,6 +4803,121 @@ paths: security: - bearerAuth: [] + '/databases/{uuid}/tags': + get: + tags: + - Databases + summary: 'List Tags' + description: 'List tags for a database by UUID.' + operationId: list-tags-by-database-uuid + parameters: + - + name: uuid + in: path + description: 'UUID of the database.' + required: true + schema: + type: string + responses: + '200': + description: 'List of tags.' + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Tag' + '401': + $ref: '#/components/responses/401' + '400': + $ref: '#/components/responses/400' + '404': + $ref: '#/components/responses/404' + security: + - + bearerAuth: [] + post: + tags: + - Databases + summary: 'Create Tag' + description: 'Add tag(s) to a database by UUID.' + operationId: create-tag-by-database-uuid + parameters: + - + name: uuid + in: path + description: 'UUID of the database.' + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + properties: + tag_name: + type: string + description: 'The tag name (min 2 characters). Required if tag_names is not provided.' + tag_names: + type: array + items: { type: string } + description: 'Array of tag names (each min 2 characters). Required if tag_name is not provided.' + type: object + responses: + '201': + description: 'Tags added successfully.' + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Tag' + '401': + $ref: '#/components/responses/401' + '400': + $ref: '#/components/responses/400' + '404': + $ref: '#/components/responses/404' + '422': + $ref: '#/components/responses/422' + security: + - + bearerAuth: [] + '/databases/{uuid}/tags/{tag_uuid}': + delete: + tags: + - Databases + summary: 'Delete Tag' + description: 'Remove a tag from a database by UUID.' + operationId: delete-tag-by-database-uuid + parameters: + - + name: uuid + in: path + description: 'UUID of the database.' + required: true + schema: + type: string + - + name: tag_uuid + in: path + description: 'UUID of the tag.' + required: true + schema: + type: string + responses: + '200': + description: 'Tag removed.' + '401': + $ref: '#/components/responses/401' + '400': + $ref: '#/components/responses/400' + '404': + $ref: '#/components/responses/404' + security: + - + bearerAuth: [] /deployments: get: tags: @@ -7008,6 +7290,10 @@ paths: type: boolean default: true description: 'Escape special characters in labels. By default, $ (and other chars) is escaped. If you want to use env variables inside the labels, turn this off.' + tags: + type: array + items: { type: string } + description: 'Tags to assign to the service.' type: object responses: '201': @@ -7860,6 +8146,144 @@ paths: security: - bearerAuth: [] + '/services/{uuid}/tags': + get: + tags: + - Services + summary: 'List Tags' + description: 'List tags for a service by UUID.' + operationId: list-tags-by-service-uuid + parameters: + - + name: uuid + in: path + description: 'UUID of the service.' + required: true + schema: + type: string + responses: + '200': + description: 'List of tags.' + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Tag' + '401': + $ref: '#/components/responses/401' + '400': + $ref: '#/components/responses/400' + '404': + $ref: '#/components/responses/404' + security: + - + bearerAuth: [] + post: + tags: + - Services + summary: 'Create Tag' + description: 'Add tag(s) to a service by UUID.' + operationId: create-tag-by-service-uuid + parameters: + - + name: uuid + in: path + description: 'UUID of the service.' + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + properties: + tag_name: + type: string + description: 'The tag name (min 2 characters). Required if tag_names is not provided.' + tag_names: + type: array + items: { type: string } + description: 'Array of tag names (each min 2 characters). Required if tag_name is not provided.' + type: object + responses: + '201': + description: 'Tags added successfully.' + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Tag' + '401': + $ref: '#/components/responses/401' + '400': + $ref: '#/components/responses/400' + '404': + $ref: '#/components/responses/404' + '422': + $ref: '#/components/responses/422' + security: + - + bearerAuth: [] + '/services/{uuid}/tags/{tag_uuid}': + delete: + tags: + - Services + summary: 'Delete Tag' + description: 'Remove a tag from a service by UUID.' + operationId: delete-tag-by-service-uuid + parameters: + - + name: uuid + in: path + description: 'UUID of the service.' + required: true + schema: + type: string + - + name: tag_uuid + in: path + description: 'UUID of the tag.' + required: true + schema: + type: string + responses: + '200': + description: 'Tag removed.' + '401': + $ref: '#/components/responses/401' + '400': + $ref: '#/components/responses/400' + '404': + $ref: '#/components/responses/404' + security: + - + bearerAuth: [] + /tags: + get: + tags: + - Tags + summary: List + description: 'List all tags for the current team.' + operationId: list-tags + responses: + '200': + description: 'All tags for the current team.' + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Tag' + '401': + $ref: '#/components/responses/401' + '400': + $ref: '#/components/responses/400' + security: + - + bearerAuth: [] /teams: get: tags: @@ -8740,6 +9164,18 @@ components: type: string description: 'The date and time when the service was deleted.' type: object + Tag: + description: 'Tag model' + properties: + uuid: + type: string + name: + type: string + created_at: + type: string + updated_at: + type: string + type: object Team: description: 'Team model' properties: @@ -8911,6 +9347,9 @@ tags: - name: Services description: Services + - + name: Tags + description: Tags - name: Teams description: Teams diff --git a/templates/service-templates-latest.json b/templates/service-templates-latest.json index 8d1a3369b..0a2dfda9d 100644 --- a/templates/service-templates-latest.json +++ b/templates/service-templates-latest.json @@ -803,7 +803,7 @@ "category": "backend", "logo": "svgs/convex.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-05-09T19:26:30+05:30", + "template_last_updated_at": "2026-06-12T10:45:52+02:00", "port": "6791" }, "cryptgeon": { @@ -1779,7 +1779,7 @@ "category": "devtools", "logo": "svgs/gitea.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-06-01T07:54:27-05:00" + "template_last_updated_at": "2026-06-06T00:11:24+02:00" }, "gitea-with-mariadb": { "documentation": "https://docs.gitea.com?utm_source=coolify.io", @@ -2361,7 +2361,7 @@ "category": "automation", "logo": "svgs/inngest.png", "minversion": "0.0.0", - "template_last_updated_at": null, + "template_last_updated_at": "2026-07-02T13:25:47+02:00", "port": "8288" }, "invoice-ninja": { diff --git a/templates/service-templates.json b/templates/service-templates.json index 2ad3008f5..4d97d8d5e 100644 --- a/templates/service-templates.json +++ b/templates/service-templates.json @@ -803,7 +803,7 @@ "category": "backend", "logo": "svgs/convex.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-05-09T19:26:30+05:30", + "template_last_updated_at": "2026-06-12T10:45:52+02:00", "port": "6791" }, "cryptgeon": { @@ -1779,7 +1779,7 @@ "category": "devtools", "logo": "svgs/gitea.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-06-01T07:54:27-05:00" + "template_last_updated_at": "2026-06-06T00:11:24+02:00" }, "gitea-with-mariadb": { "documentation": "https://docs.gitea.com?utm_source=coolify.io", @@ -2361,7 +2361,7 @@ "category": "automation", "logo": "svgs/inngest.png", "minversion": "0.0.0", - "template_last_updated_at": null, + "template_last_updated_at": "2026-07-02T13:25:47+02:00", "port": "8288" }, "invoice-ninja": { diff --git a/tests/Feature/TagApiTest.php b/tests/Feature/TagApiTest.php index dd62c1062..448d7917f 100644 --- a/tests/Feature/TagApiTest.php +++ b/tests/Feature/TagApiTest.php @@ -1,5 +1,6 @@ 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 @@ function tagApiAuthHeaders($bearerToken): array $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 @@ function tagApiAuthHeaders($bearerToken): array $response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken)) ->getJson('/api/v1/tags'); - $response->assertStatus(200); + $response->assertOk(); $response->assertJsonCount(0); }); @@ -77,7 +80,7 @@ function tagApiAuthHeaders($bearerToken): array $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 @@ function tagApiAuthHeaders($bearerToken): array $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 @@ function tagApiAuthHeaders($bearerToken): array $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 @@ function tagApiAuthHeaders($bearerToken): array 'tag_name' => 'production', ]); - $response->assertStatus(201); + $response->assertCreated(); $response->assertJsonCount(1); $response->assertJsonFragment(['name' => 'production']); @@ -133,7 +136,7 @@ function tagApiAuthHeaders($bearerToken): array 'tag_names' => ['production', 'frontend'], ]); - $response->assertStatus(201); + $response->assertCreated(); $response->assertJsonCount(2); expect($this->application->tags()->count())->toBe(2); @@ -147,7 +150,7 @@ function tagApiAuthHeaders($bearerToken): array '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 @@ function tagApiAuthHeaders($bearerToken): array 'tag_name' => 'x', ]); - $response->assertStatus(422); + $response->assertUnprocessable(); }); test('rejects both tag_name and tag_names provided simultaneously', function () { @@ -167,7 +170,7 @@ function tagApiAuthHeaders($bearerToken): array '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 @@ function tagApiAuthHeaders($bearerToken): array 'tag_name' => 'production', ]); - $response->assertStatus(201); + $response->assertCreated(); expect($this->application->tags()->count())->toBe(1); }); @@ -190,7 +193,7 @@ function tagApiAuthHeaders($bearerToken): array 'tag_name' => 'production', ]); - $response->assertStatus(404); + $response->assertNotFound(); }); }); @@ -202,7 +205,7 @@ function tagApiAuthHeaders($bearerToken): array $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 @@ function tagApiAuthHeaders($bearerToken): array 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 @@ function tagApiAuthHeaders($bearerToken): array $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 @@ function tagApiAuthHeaders($bearerToken): array $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 @@ function tagApiAuthHeaders($bearerToken): array 'tag_name' => 'database-tag', ]); - $response->assertStatus(201); + $response->assertCreated(); expect($database->tags()->count())->toBe(1); }); }); @@ -299,7 +312,7 @@ function tagApiAuthHeaders($bearerToken): array $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 @@ function tagApiAuthHeaders($bearerToken): array $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 @@ function tagApiAuthHeaders($bearerToken): array 'tag_name' => 'service-tag', ]); - $response->assertStatus(201); + $response->assertCreated(); expect($service->tags()->count())->toBe(1); }); }); @@ -359,11 +372,91 @@ function tagApiAuthHeaders($bearerToken): array $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' => ['x'], + ]); + + $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 @@ function tagApiAuthHeaders($bearerToken): array 'tag_name' => 'production', ]); - $response->assertStatus(201); + $response->assertCreated(); $response->assertJsonFragment(['name' => 'alert("xss")production']); $response->assertJsonMissing(['name' => '