From b7ca0fa8481b5b8ab159ad7bd78e119a8a1e074b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20TR=C3=89BEL=20=28Perso=29?= Date: Mon, 14 Sep 2026 16:39:40 +0200 Subject: [PATCH 1/3] fix(shared): validate cluster labels as RFC 1123 Generated with AI assistance (Model mistral-medium-3.5), reviewed by @stephanetrebel --- packages/shared/src/schemas/cluster.ts | 10 +++++++--- packages/shared/src/utils/schemas.spec.ts | 23 +++++++++++++++++++++++ 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/packages/shared/src/schemas/cluster.ts b/packages/shared/src/schemas/cluster.ts index bac22d02c0..eec4908afa 100644 --- a/packages/shared/src/schemas/cluster.ts +++ b/packages/shared/src/schemas/cluster.ts @@ -3,12 +3,16 @@ import { z } from 'zod' export const ClusterPrivacySchema = z.enum(['public', 'dedicated']) +export const clusterLabelValidationMessage = 'Le nom du cluster doit contenir uniquement des lettres minuscules, des chiffres et des traits d’union, et commencer et terminer par un caractère alphanumérique.' + +const ClusterLabelSchema = z.string() + .max(50, { message: 'Le nom du cluster ne doit pas dépasser 50 caractères' }) + .regex(/^[a-z0-9]([-a-z0-9]*[a-z0-9])?$/, { message: clusterLabelValidationMessage }) + export const CleanedClusterSchema = z.object({ id: z.string() .uuid(), - label: z.string() - .regex(/^[a-z0-9-]+$/i) - .max(50), + label: ClusterLabelSchema, infos: z.string() .max(1000) .optional() diff --git a/packages/shared/src/utils/schemas.spec.ts b/packages/shared/src/utils/schemas.spec.ts index 5f0c422a13..6a1aac3a71 100644 --- a/packages/shared/src/utils/schemas.spec.ts +++ b/packages/shared/src/utils/schemas.spec.ts @@ -346,4 +346,27 @@ describe('schemas utils', () => { projectId: true, }) }) + + it.each(['cluster', 'cluster-1', '1-cluster', 'a', 'a--b'])( + 'should validate RFC 1123 cluster label %s without transforming it', + (label) => { + const result = ClusterDetailsSchema.shape.label.safeParse(label) + + expect(result.success).toBe(true) + if (result.success) expect(result.data).toBe(label) + }, + ) + + it.each([ + 'Cluster-Tools', + '-cluster', + 'cluster-', + 'cluster.tools', + 'cluster_1', + 'cluster tools', + '', + 'a'.repeat(51), + ])('should reject invalid RFC 1123 cluster label %s', (label) => { + expect(ClusterDetailsSchema.shape.label.safeParse(label).success).toBe(false) + }) }) From 6e26878c35f6d306f607a8b456df783b1dffabae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20TR=C3=89BEL=20=28Perso=29?= Date: Mon, 14 Sep 2026 16:41:37 +0200 Subject: [PATCH 2/3] test(server): reject invalid cluster labels at API boundary Generated with AI assistance (Model mistral-medium-3.5), reviewed by @stephanetrebel --- .../src/resources/cluster/router.spec.ts | 30 +++++++++++++++++-- 1 file changed, 27 insertions(+), 3 deletions(-) diff --git a/apps/server/src/resources/cluster/router.spec.ts b/apps/server/src/resources/cluster/router.spec.ts index 528858b052..2bdb4652fc 100644 --- a/apps/server/src/resources/cluster/router.spec.ts +++ b/apps/server/src/resources/cluster/router.spec.ts @@ -61,7 +61,7 @@ describe('test clusterContract', () => { id: faker.string.uuid(), clusterResources: true, infos: '', - label: faker.string.alpha(), + label: faker.string.alpha({ casing: 'lower' }), privacy: 'public', stageIds: [], zoneId: faker.string.uuid(), @@ -163,7 +163,7 @@ describe('test clusterContract', () => { id: faker.string.uuid(), clusterResources: true, infos: '', - label: faker.string.alpha(), + label: faker.string.alpha({ casing: 'lower' }), privacy: 'public', stageIds: [], zoneId: faker.string.uuid(), @@ -189,6 +189,18 @@ describe('test clusterContract', () => { expect(response.json()).toEqual(cluster) expect(response.statusCode).toEqual(201) }) + it('should reject an invalid label before creating a cluster', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_CLUSTERS) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .post(clusterContract.createCluster.path) + .body({ ...cluster, label: 'Cluster-Tools' }) + .end() + + expect(response.statusCode).toEqual(400) + expect(businessCreateMock).not.toHaveBeenCalled() + }) it('should pass business error', async () => { const user = getUserMockInfos(ADMIN_PERMS.MANAGE_CLUSTERS) authUserMock.mockResolvedValueOnce(user) @@ -219,7 +231,7 @@ describe('test clusterContract', () => { const cluster: Omit = { clusterResources: true, infos: '', - label: faker.string.alpha(), + label: faker.string.alpha({ casing: 'lower' }), privacy: 'public', stageIds: [], zoneId: faker.string.uuid(), @@ -245,6 +257,18 @@ describe('test clusterContract', () => { expect(response.json()).toEqual({ id: clusterId, ...cluster }) expect(response.statusCode).toEqual(200) }) + it('should reject an invalid label before updating a cluster', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_CLUSTERS) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .put(clusterContract.updateCluster.path.replace(':clusterId', clusterId)) + .body({ ...cluster, label: '-cluster' }) + .end() + + expect(response.statusCode).toEqual(400) + expect(businessUpdateMock).not.toHaveBeenCalled() + }) it('should pass business error', async () => { const user = getUserMockInfos(ADMIN_PERMS.MANAGE_CLUSTERS) authUserMock.mockResolvedValueOnce(user) From d73aaa6e3d726936f847fc1d725a1c3ef4ac6fc4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20TR=C3=89BEL=20=28Perso=29?= Date: Mon, 14 Sep 2026 16:51:23 +0200 Subject: [PATCH 3/3] fix(client): clarify RFC 1123 cluster label validation Generated with AI assistance (Model mistral-medium-3.5), reviewed by @stephanetrebel Refs #2712 --- apps/client/src/components/ClusterForm.spec.ts | 10 ++++++++++ apps/client/src/components/ClusterForm.vue | 3 ++- apps/client/src/utils/cluster.ts | 8 ++++++++ playwright/e2e-tests/clusters.spec.ts | 16 ++++++++++++++++ 4 files changed, 36 insertions(+), 1 deletion(-) create mode 100644 apps/client/src/components/ClusterForm.spec.ts create mode 100644 apps/client/src/utils/cluster.ts diff --git a/apps/client/src/components/ClusterForm.spec.ts b/apps/client/src/components/ClusterForm.spec.ts new file mode 100644 index 0000000000..6ff9435c73 --- /dev/null +++ b/apps/client/src/components/ClusterForm.spec.ts @@ -0,0 +1,10 @@ +import { describe, expect, it } from 'vitest' +import { getClusterLabelValidationMessage } from '@/utils/cluster.js' + +describe('getClusterLabelValidationMessage', () => { + it('returns the length message for a label longer than 50 characters', () => { + expect(getClusterLabelValidationMessage('a'.repeat(51))).toBe( + 'Le nom du cluster ne doit pas dépasser 50 caractères', + ) + }) +}) diff --git a/apps/client/src/components/ClusterForm.vue b/apps/client/src/components/ClusterForm.vue index 81f51ebcaf..5494aa4cbd 100644 --- a/apps/client/src/components/ClusterForm.vue +++ b/apps/client/src/components/ClusterForm.vue @@ -16,6 +16,7 @@ import { computed, onBeforeMount, ref, watch } from 'vue' import { JsonViewer } from 'vue3-json-viewer' import { parse } from 'yaml' import { useSnackbarStore } from '@/stores/snackbar.js' +import { getClusterLabelValidationMessage } from '@/utils/cluster.js' import { localeParseFloat, ONE_TENTH_STR } from '@/utils/func.js' import ChoiceSelector from './ChoiceSelector.vue' @@ -318,7 +319,7 @@ const isConnectionDetailsShown = ref(true) type="text" :disabled="props.associatedEnvironments.length !== 0" :required="true" - :error-message="localCluster.label && !ClusterDetailsSchema.pick({ label: true }).safeParse({ label: localCluster.label }).success ? 'Le nom du cluster ne doit contenir ni espaces ni caractères spéciaux' : undefined" + :error-message="getClusterLabelValidationMessage(localCluster.label)" label="Nom du cluster applicatif" label-visible hint="Nom du cluster applicatif utilisable lors des déploiements Argocd. Modifiable uniquement si le cluster ne comporte aucun environnement." diff --git a/apps/client/src/utils/cluster.ts b/apps/client/src/utils/cluster.ts new file mode 100644 index 0000000000..6d65f7f78d --- /dev/null +++ b/apps/client/src/utils/cluster.ts @@ -0,0 +1,8 @@ +import { ClusterDetailsSchema } from '@cpn-console/shared' + +export function getClusterLabelValidationMessage(label: string) { + if (!label) return undefined + + const result = ClusterDetailsSchema.pick({ label: true }).safeParse({ label }) + return result.success ? undefined : result.error.issues[0]?.message +} diff --git a/playwright/e2e-tests/clusters.spec.ts b/playwright/e2e-tests/clusters.spec.ts index e7917af0d2..4141948f80 100644 --- a/playwright/e2e-tests/clusters.spec.ts +++ b/playwright/e2e-tests/clusters.spec.ts @@ -25,6 +25,22 @@ test.describe('Clusters page', () => { ) }) + test('should not create a cluster with a non RFC 1123 label', { tag: '@e2e' }, async ({ page }) => { + const invalidLabels = ['Cluster-Tools', '-cluster', 'cluster-', 'cluster.tools'] + + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: adminUser }) + await page.getByTestId('menuAdministrationBtn').click() + await page.getByTestId('menuAdministrationClusters').click() + await page.getByTestId('addClusterLink').click() + + for (const label of invalidLabels) { + await page.getByTestId('labelInput').fill(label) + await expect(page.getByText('Le nom du cluster doit contenir uniquement des lettres minuscules, des chiffres et des traits d’union, et commencer et terminer par un caractère alphanumérique.')).toBeVisible() + await expect(page.getByTestId('addClusterBtn')).toBeDisabled() + } + }) + test('should update a public cluster', { tag: '@e2e' }, async ({ page }) => { const clusterName2 = faker.string.alpha(10).toLowerCase() await page.goto(clientURL)