Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions apps/client/src/components/ClusterForm.spec.ts
Original file line number Diff line number Diff line change
@@ -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',
)
})
})
3 changes: 2 additions & 1 deletion apps/client/src/components/ClusterForm.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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."
Expand Down
8 changes: 8 additions & 0 deletions apps/client/src/utils/cluster.ts
Original file line number Diff line number Diff line change
@@ -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
}
30 changes: 27 additions & 3 deletions apps/server/src/resources/cluster/router.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -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(),
Expand All @@ -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)
Expand Down Expand Up @@ -219,7 +231,7 @@ describe('test clusterContract', () => {
const cluster: Omit<ClusterDetails, 'id'> = {
clusterResources: true,
infos: '',
label: faker.string.alpha(),
label: faker.string.alpha({ casing: 'lower' }),
privacy: 'public',
stageIds: [],
zoneId: faker.string.uuid(),
Expand All @@ -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)
Expand Down
10 changes: 7 additions & 3 deletions packages/shared/src/schemas/cluster.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
23 changes: 23 additions & 0 deletions packages/shared/src/utils/schemas.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
})
16 changes: 16 additions & 0 deletions playwright/e2e-tests/clusters.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down