diff --git a/packages/manager/apps/okms/public/translations/secret-manager/Messages_fr_FR.json b/packages/manager/apps/okms/public/translations/secret-manager/Messages_fr_FR.json index 7101fc0bfbf5..c1be1ebcdb70 100644 --- a/packages/manager/apps/okms/public/translations/secret-manager/Messages_fr_FR.json +++ b/packages/manager/apps/okms/public/translations/secret-manager/Messages_fr_FR.json @@ -27,13 +27,20 @@ "delete_secret_success": "Votre secret a été supprimé avec succès", "delete_version_modal_description": "Êtes-vous sûr de vouloir supprimer cette version ? Cette action est irréversible.", "delete_version_modal_title": "Supprimer la version {{versionId}}", + "edit_metadata": "Modifier les paramètres", "okms_activation_in_progress": "Veuillez patienter, création en cours.", "okms_list": "Liste de domaines OKMS", "editor": "Éditeur JSON", "error_invalid_json": "JSON non valide", "error_path_allowed_characters": "Le path ne peut contenir que les caractères suivants: A-Z a-z 0-9 . _ : / = @ et -", "error_path_structure": "Le path ne peut commencer ou finir par '/', ni contenir deux '/' à la suite.", + "error_invalid_duration": "La durée d'expiration doit être une durée (ex: 30s, 5m, 2h, 7d, 7d1h30m10s)", + "error_update_settings": "Une erreur est survenue lors de la mise à jour des paramètres", "expiration_date": "Date d'expiration", + "form_helper_cas_required_okms": "CAS est activé sur le domaine OKMS, la désactivation sur le secret ne sera pas prise en compte.", + "form_helper_deactivate_version_after": "Indiquez \"0s\" pour ne jamais expirer.", + "form_helper_max_versions": "Indiquez \"0\" pour utiliser la valeur par défaut.", + "form_tooltip_deactivate_version_after": "Format : 30s, 5m, 2h, 7d, 7d1h30m10s", "last_update": "Dernière mise à jour", "maximum_number_of_versions": "Nombre maximum de versions", "never_expire": "Pas d'expiration", diff --git a/packages/manager/apps/okms/src/components/helpIconWithTooltip/HelpIconWithTooltip.component.tsx b/packages/manager/apps/okms/src/components/helpIconWithTooltip/HelpIconWithTooltip.component.tsx new file mode 100644 index 000000000000..a1860b3a4025 --- /dev/null +++ b/packages/manager/apps/okms/src/components/helpIconWithTooltip/HelpIconWithTooltip.component.tsx @@ -0,0 +1,31 @@ +import React, { useId } from 'react'; +import { ODS_TEXT_PRESET } from '@ovhcloud/ods-components'; +import { OdsText, OdsIcon, OdsTooltip } from '@ovhcloud/ods-components/react'; + +type HelpIconWithTooltipProps = { + label: string; +}; + +export const HelpIconWithTooltip = ({ label }: HelpIconWithTooltipProps) => { + const tooltipId = useId(); + + return ( + <> + + + + {label} + + + > + ); +}; diff --git a/packages/manager/apps/okms/src/modules/secret-manager/components/form/SecretCasRequiredFormField.component.tsx b/packages/manager/apps/okms/src/modules/secret-manager/components/form/SecretCasRequiredFormField.component.tsx new file mode 100644 index 000000000000..da5f60562b6e --- /dev/null +++ b/packages/manager/apps/okms/src/modules/secret-manager/components/form/SecretCasRequiredFormField.component.tsx @@ -0,0 +1,92 @@ +import React from 'react'; +import { Controller, UseControllerProps } from 'react-hook-form'; +import { + OdsFormField, + OdsRadio, + OdsText, +} from '@ovhcloud/ods-components/react'; +import { useTranslation } from 'react-i18next'; +import { NAMESPACES } from '@ovh-ux/manager-common-translations'; +import { SECRET_FORM_FIELD_TEST_IDS } from './form.constants'; +import { HelpIconWithTooltip } from '@/components/helpIconWithTooltip/HelpIconWithTooltip.component'; + +type CasRequiredFormValue = 'active' | 'inactive'; + +type FormFieldInput = { + casRequired: CasRequiredFormValue; +}; + +type SecretCasRequiredFormFieldProps< + T extends FormFieldInput +> = UseControllerProps & { + isCasRequiredSetOnOkms?: boolean; +}; + +export const SecretCasRequiredFormField = ({ + name, + control, + isCasRequiredSetOnOkms, +}: SecretCasRequiredFormFieldProps) => { + const { t } = useTranslation(['secret-manager', NAMESPACES.STATUS]); + return ( + ( + + + {t('cas_with_description')} + + + + + + + {t('activated')} + + + + + + + {t('disabled', { ns: NAMESPACES.STATUS })} + + + + + {isCasRequiredSetOnOkms && ( + + {t('form_helper_cas_required_okms')} + + )} + + )} + /> + ); +}; + +export const casRequiredToFormValue = ( + casRequired: boolean, +): CasRequiredFormValue => { + return casRequired ? 'active' : 'inactive'; +}; + +export const formValueToCasRequired = ( + formValue: CasRequiredFormValue, +): boolean => { + return formValue === 'active'; +}; diff --git a/packages/manager/apps/okms/src/modules/secret-manager/components/form/SecretDataFormField.component.tsx b/packages/manager/apps/okms/src/modules/secret-manager/components/form/SecretDataFormField.component.tsx index f8d0935f314e..7ba04e21981d 100644 --- a/packages/manager/apps/okms/src/modules/secret-manager/components/form/SecretDataFormField.component.tsx +++ b/packages/manager/apps/okms/src/modules/secret-manager/components/form/SecretDataFormField.component.tsx @@ -2,9 +2,13 @@ import React from 'react'; import { Controller, UseControllerProps } from 'react-hook-form'; import { OdsFormField, OdsTextarea } from '@ovhcloud/ods-components/react'; import { useTranslation } from 'react-i18next'; -import { SECRET_INPUT_DATA_TEST_ID } from './SecretDataFormField.constants'; +import { SECRET_FORM_FIELD_TEST_IDS } from './form.constants'; -export const SecretDataFormField = >({ +type FormFieldInput = { + data: string; +}; + +export const SecretDataFormField = ({ name, control, }: UseControllerProps) => { @@ -26,7 +30,7 @@ export const SecretDataFormField = >({ onOdsChange={field.onChange} isResizable rows={12} - data-testid={SECRET_INPUT_DATA_TEST_ID} + data-testid={SECRET_FORM_FIELD_TEST_IDS.INPUT_DATA} /> )} diff --git a/packages/manager/apps/okms/src/modules/secret-manager/components/form/SecretDataFormField.constants.ts b/packages/manager/apps/okms/src/modules/secret-manager/components/form/SecretDataFormField.constants.ts deleted file mode 100644 index dd2fdd36e0ec..000000000000 --- a/packages/manager/apps/okms/src/modules/secret-manager/components/form/SecretDataFormField.constants.ts +++ /dev/null @@ -1 +0,0 @@ -export const SECRET_INPUT_DATA_TEST_ID = 'secret-input-data'; diff --git a/packages/manager/apps/okms/src/modules/secret-manager/components/form/SecretDeactivateVersionAfterFormField.component.tsx b/packages/manager/apps/okms/src/modules/secret-manager/components/form/SecretDeactivateVersionAfterFormField.component.tsx new file mode 100644 index 000000000000..6bb46e812312 --- /dev/null +++ b/packages/manager/apps/okms/src/modules/secret-manager/components/form/SecretDeactivateVersionAfterFormField.component.tsx @@ -0,0 +1,50 @@ +import React from 'react'; +import { Controller, UseControllerProps } from 'react-hook-form'; +import { + OdsText, + OdsFormField, + OdsInput, +} from '@ovhcloud/ods-components/react'; +import { useTranslation } from 'react-i18next'; +import { SECRET_FORM_FIELD_TEST_IDS } from './form.constants'; +import { HelpIconWithTooltip } from '@/components/helpIconWithTooltip/HelpIconWithTooltip.component'; + +type FormFieldInput = { + deactivateVersionAfter: string; +}; + +export const SecretDeactivateVersionAfterFormField = < + T extends FormFieldInput +>({ + name, + control, +}: UseControllerProps) => { + const { t } = useTranslation('secret-manager'); + return ( + ( + + + {t('deactivate_version_after')} + + + + + {t('form_helper_deactivate_version_after')} + + + )} + /> + ); +}; diff --git a/packages/manager/apps/okms/src/modules/secret-manager/components/form/SecretMaxVersionsFormField.component.tsx b/packages/manager/apps/okms/src/modules/secret-manager/components/form/SecretMaxVersionsFormField.component.tsx new file mode 100644 index 000000000000..3eccf4817939 --- /dev/null +++ b/packages/manager/apps/okms/src/modules/secret-manager/components/form/SecretMaxVersionsFormField.component.tsx @@ -0,0 +1,51 @@ +import React from 'react'; +import { Controller, UseControllerProps } from 'react-hook-form'; +import { + OdsText, + OdsFormField, + OdsQuantity, +} from '@ovhcloud/ods-components/react'; +import { useTranslation } from 'react-i18next'; +import { + MAX_VERSIONS_MAX_VALUE, + MAX_VERSIONS_MIN_VALUE, +} from '@secret-manager/validation/metadata/metadataSchema'; +import { SECRET_FORM_FIELD_TEST_IDS } from './form.constants'; + +type FormFieldInput = { + maxVersions: number; +}; + +export const SecretMaxVersionsFormField = ({ + name, + control, +}: UseControllerProps) => { + const { t } = useTranslation('secret-manager'); + return ( + ( + + + {t('maximum_number_of_versions')} + + + + {t('form_helper_max_versions')} + + + )} + /> + ); +}; diff --git a/packages/manager/apps/okms/src/modules/secret-manager/components/form/form.constants.ts b/packages/manager/apps/okms/src/modules/secret-manager/components/form/form.constants.ts new file mode 100644 index 000000000000..6b5af0059c77 --- /dev/null +++ b/packages/manager/apps/okms/src/modules/secret-manager/components/form/form.constants.ts @@ -0,0 +1,7 @@ +export const SECRET_FORM_FIELD_TEST_IDS = { + INPUT_DATA: 'secret-input-data', + MAX_VERSIONS: 'secret-input-max-versions', + CAS_REQUIRED_ACTIVE: 'secret-input-cas-required-active', + CAS_REQUIRED_INACTIVE: 'secret-input-cas-required-inactive', + DEACTIVATE_VERSION_AFTER: 'secret-input-deactivate-version-after', +}; diff --git a/packages/manager/apps/okms/src/modules/secret-manager/data/api/secretVersions.ts b/packages/manager/apps/okms/src/modules/secret-manager/data/api/secretVersions.ts index c4fe5bebb431..e251b720917e 100644 --- a/packages/manager/apps/okms/src/modules/secret-manager/data/api/secretVersions.ts +++ b/packages/manager/apps/okms/src/modules/secret-manager/data/api/secretVersions.ts @@ -4,6 +4,7 @@ import { SecretVersionDataField, SecretVersionWithData, } from '@secret-manager/types/secret.type'; +import { buildQueryString } from '@secret-manager/utils/queryStrings'; export const secretVersionsQueryKeys = { list: (okmsId: string, path: string) => ['secret', okmsId, path, 'versions'], @@ -47,14 +48,9 @@ export const createSecretVersion = async ({ data, cas, }: CreateSecretVersionParams) => { - const queryParams = new URLSearchParams(); - if (cas) { - queryParams.set('cas', cas.toString()); - } - const queryString = queryParams.toString(); const url = `okms/resource/${okmsId}/secret/${encodeURIComponent( path, - )}/version${queryString ? `?${queryString}` : ''}`; + )}/version${buildQueryString({ cas })}`; const { data: response } = await apiClient.v2.post< CreateSecretVersionResponse diff --git a/packages/manager/apps/okms/src/modules/secret-manager/data/api/secrets.ts b/packages/manager/apps/okms/src/modules/secret-manager/data/api/secrets.ts index 624e144b9132..8d7b2acdb51f 100644 --- a/packages/manager/apps/okms/src/modules/secret-manager/data/api/secrets.ts +++ b/packages/manager/apps/okms/src/modules/secret-manager/data/api/secrets.ts @@ -5,6 +5,7 @@ import { SecretMetadata, SecretVersionDataField, } from '@secret-manager/types/secret.type'; +import { buildQueryString } from '@secret-manager/utils/queryStrings'; export const secretQueryKeys = { list: (okmsId: string) => ['secret', okmsId], @@ -74,10 +75,32 @@ export type UpdateSecretBody = { SecretMetadata, 'casRequired' | 'customMetadata' | 'deactivateVersionAfter' | 'maxVersions' >; - version: SecretVersionDataField; + version?: SecretVersionDataField; }; export type UpdateSecretResponse = Pick; +export type UpdateSecretParams = { + okmsId: string; + path: string; + cas: number; + data: UpdateSecretBody; +}; + +export const updateSecret = async ({ + okmsId, + path, + cas, + data: updateData, +}: UpdateSecretParams) => { + const { data } = await apiClient.v2.put( + `okms/resource/${okmsId}/secret/${encodeURIComponent( + path, + )}${buildQueryString({ cas })}`, + updateData, + ); + return data; +}; + // DELETE Secret export type DeleteSecretParams = { okmsId: string; diff --git a/packages/manager/apps/okms/src/modules/secret-manager/data/hooks/useUpdateSecret.ts b/packages/manager/apps/okms/src/modules/secret-manager/data/hooks/useUpdateSecret.ts new file mode 100644 index 000000000000..38b98ab0990a --- /dev/null +++ b/packages/manager/apps/okms/src/modules/secret-manager/data/hooks/useUpdateSecret.ts @@ -0,0 +1,20 @@ +import { ApiError } from '@ovh-ux/manager-core-api'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { + UpdateSecretResponse, + updateSecret, + UpdateSecretParams, + secretQueryKeys, +} from '../api/secrets'; + +export const useUpdateSecret = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (params) => updateSecret(params), + onSuccess: (_, params) => { + queryClient.invalidateQueries({ + queryKey: secretQueryKeys.detail(params.okmsId, params.path), + }); + }, + }); +}; diff --git a/packages/manager/apps/okms/src/modules/secret-manager/hooks/useSecretSmartConfig.spec.ts b/packages/manager/apps/okms/src/modules/secret-manager/hooks/useSecretSmartConfig.spec.ts index b095e0b067f0..2049139109d4 100644 --- a/packages/manager/apps/okms/src/modules/secret-manager/hooks/useSecretSmartConfig.spec.ts +++ b/packages/manager/apps/okms/src/modules/secret-manager/hooks/useSecretSmartConfig.spec.ts @@ -90,6 +90,7 @@ const mockSecretSmartConfig: SecretSmartConfig = { value: 15, origin: 'DOMAIN', }, + isCasRequiredSetOnOkms: true, }; describe('useSecretSmartConfig', () => { diff --git a/packages/manager/apps/okms/src/modules/secret-manager/mocks/secretConfigOkms/secretConfigOkms.handler.ts b/packages/manager/apps/okms/src/modules/secret-manager/mocks/secretConfigOkms/secretConfigOkms.handler.ts index 152ebd409b18..b87c2bb74d0d 100644 --- a/packages/manager/apps/okms/src/modules/secret-manager/mocks/secretConfigOkms/secretConfigOkms.handler.ts +++ b/packages/manager/apps/okms/src/modules/secret-manager/mocks/secretConfigOkms/secretConfigOkms.handler.ts @@ -1,7 +1,10 @@ import { Handler } from '@ovh-ux/manager-core-test-utils'; import { mockSecretConfigOkms } from './secretConfigOkms.mock'; +import { buildMswResponseMock } from '@/utils/tests/msw'; // GET Secret Config +export const getSecretConfigErrorMessage = 'get-secret-config-error-message'; + export type GetSecretConfigOkmsMockParams = { isSecretConfigKO?: boolean; }; @@ -11,14 +14,11 @@ export const getSecretConfigOkmsMock = ({ }: GetSecretConfigOkmsMockParams): Handler[] => [ { url: '/okms/resource/:okmsId/secretConfig', - response: isSecretConfigKO - ? { - status: 500, - data: { - message: 'secret config error', - }, - } - : mockSecretConfigOkms, + response: buildMswResponseMock({ + data: mockSecretConfigOkms, + errorMessage: getSecretConfigErrorMessage, + isError: isSecretConfigKO, + }), status: isSecretConfigKO ? 500 : 200, api: 'v2', }, diff --git a/packages/manager/apps/okms/src/modules/secret-manager/mocks/secretReference/secretReference.handler.ts b/packages/manager/apps/okms/src/modules/secret-manager/mocks/secretReference/secretReference.handler.ts index a08501e81617..75442dd60b9a 100644 --- a/packages/manager/apps/okms/src/modules/secret-manager/mocks/secretReference/secretReference.handler.ts +++ b/packages/manager/apps/okms/src/modules/secret-manager/mocks/secretReference/secretReference.handler.ts @@ -1,23 +1,24 @@ import { Handler } from '@ovh-ux/manager-core-test-utils'; import { mockSecretConfigReference } from './secretReference.mock'; +import { buildMswResponseMock } from '@/utils/tests/msw'; export type GetSecretConfigReferenceMockParams = { isSecretConfigReferenceKO?: boolean; }; +export const getSecretConfigReferenceErrorMessage = + 'get-secret-config-reference-error-message'; + export const getSecretConfigReferenceMock = ({ isSecretConfigReferenceKO, }: GetSecretConfigReferenceMockParams): Handler[] => [ { url: '/okms/reference/secretConfig', - response: isSecretConfigReferenceKO - ? { - status: 500, - data: { - message: 'secret config reference error', - }, - } - : mockSecretConfigReference, + response: buildMswResponseMock({ + data: mockSecretConfigReference, + errorMessage: getSecretConfigReferenceErrorMessage, + isError: isSecretConfigReferenceKO, + }), status: isSecretConfigReferenceKO ? 500 : 200, api: 'v2', }, diff --git a/packages/manager/apps/okms/src/modules/secret-manager/mocks/secrets/secrets.handler.ts b/packages/manager/apps/okms/src/modules/secret-manager/mocks/secrets/secrets.handler.ts index 083bbd6476b6..42cc123d1cb2 100644 --- a/packages/manager/apps/okms/src/modules/secret-manager/mocks/secrets/secrets.handler.ts +++ b/packages/manager/apps/okms/src/modules/secret-manager/mocks/secrets/secrets.handler.ts @@ -1,9 +1,11 @@ -import { PathParams } from 'msw'; import { Handler } from '@ovh-ux/manager-core-test-utils'; import { createSecretResponseMock, secretListMock } from './secrets.mock'; import { findSecretMockByPath } from './secretsMock.utils'; +import { buildMswResponseMock } from '@/utils/tests/msw'; // LIST +export const getSecretsErrorMessage = 'get-secrets-error-message'; + export type GetSecretsMockParams = { isSecretListKO?: boolean; nbSecrets?: number; @@ -15,20 +17,19 @@ export const getSecretsMock = ({ }: GetSecretsMockParams): Handler[] => [ { url: '/okms/resource/:okmsId/secret', - response: isSecretListKO - ? { - status: 500, - data: { - message: 'secrets error', - }, - } - : secretListMock.slice(0, nbSecrets), + response: buildMswResponseMock({ + data: secretListMock.slice(0, nbSecrets), + errorMessage: getSecretsErrorMessage, + isError: isSecretListKO, + }), status: isSecretListKO ? 500 : 200, api: 'v2', }, ]; // GET +export const getSecretErrorMessage = 'get-secret-error-message'; + export type GetSecretMockParams = { isSecretKO?: boolean; }; @@ -38,15 +39,12 @@ export const getSecretMock = ({ }: GetSecretMockParams): Handler[] => [ { url: '/okms/resource/:okmsId/secret/:secretPath', - response: isSecretKO - ? { - status: 500, - data: { - message: 'secrets error', - }, - } - : (request: Request, params: PathParams) => - findSecretMockByPath(secretListMock, request, params), + response: buildMswResponseMock({ + data: (request, params) => + findSecretMockByPath(secretListMock, request, params), + errorMessage: getSecretErrorMessage, + isError: isSecretKO, + }), status: isSecretKO ? 500 : 200, api: 'v2', }, @@ -64,17 +62,46 @@ export const createSecretsMock = ({ }: CreateSecretsMockParams): Handler[] => [ { url: '/okms/resource/:okmsId/secret', - response: isCreateSecretKO - ? { - message: createSecretErrorMessage, - } - : createSecretResponseMock, + response: buildMswResponseMock({ + data: createSecretResponseMock, + errorMessage: createSecretErrorMessage, + isError: isCreateSecretKO, + }), status: isCreateSecretKO ? 500 : 200, api: 'v2', method: 'post', }, ]; +// PUT (UPDATE) +export const updateSecretErrorMessage = 'update-secret-error-message'; + +export type UpdateSecretMockParams = { + isUpdateSecretKO?: boolean; +}; + +export const updateSecretMock = ({ + isUpdateSecretKO, +}: UpdateSecretMockParams): Handler[] => [ + { + url: '/okms/resource/:okmsId/secret/:secretPath', + response: buildMswResponseMock({ + data: (request, params) => { + const secret = findSecretMockByPath(secretListMock, request, params); + return { + path: secret.path, + metadata: secret.metadata, + }; + }, + errorMessage: updateSecretErrorMessage, + isError: isUpdateSecretKO, + }), + status: isUpdateSecretKO ? 500 : 200, + api: 'v2', + method: 'put', + }, +]; + // DELETE export const deleteSecretErrorMessage = 'delete-secret-error-message'; @@ -87,11 +114,11 @@ export const deleteSecretMock = ({ }: DeleteSecretMockParams): Handler[] => [ { url: '/okms/resource/:okmsId/secret/:path', - response: isDeleteSecretKO - ? { - message: deleteSecretErrorMessage, - } - : null, + response: buildMswResponseMock({ + data: null, + errorMessage: deleteSecretErrorMessage, + isError: isDeleteSecretKO, + }), status: isDeleteSecretKO ? 500 : 200, api: 'v2', method: 'delete', diff --git a/packages/manager/apps/okms/src/modules/secret-manager/mocks/versions/versions.handler.ts b/packages/manager/apps/okms/src/modules/secret-manager/mocks/versions/versions.handler.ts index 4d34da737ede..171d54630b2b 100644 --- a/packages/manager/apps/okms/src/modules/secret-manager/mocks/versions/versions.handler.ts +++ b/packages/manager/apps/okms/src/modules/secret-manager/mocks/versions/versions.handler.ts @@ -1,7 +1,6 @@ -import { PathParams } from 'msw'; import { Handler } from '@ovh-ux/manager-core-test-utils'; import { versionListMock, createVersionResponseMock } from './versions.mock'; -import { createHandlerResponseMock } from '@/utils/tests/testUtils'; +import { buildMswResponseMock } from '@/utils/tests/msw'; import { findVersionMockById } from './versionsMock.utils'; // LIST VERSION @@ -10,20 +9,19 @@ export type GetVersionsMockParams = { nbVersions?: number; }; +export const getVersionsErrorMessage = 'get-versions-error-message'; + export const getVersionsMock = ({ isVersionsKO, nbVersions = versionListMock.length, }: GetVersionsMockParams): Handler[] => [ { url: '/okms/resource/:okmsId/secret/:secretPath/version', - response: isVersionsKO - ? { - status: 500, - data: { - message: 'versions error', - }, - } - : versionListMock.slice(0, nbVersions), + response: buildMswResponseMock({ + data: versionListMock.slice(0, nbVersions), + errorMessage: getVersionsErrorMessage, + isError: isVersionsKO, + }), status: isVersionsKO ? 500 : 200, api: 'v2', }, @@ -34,20 +32,19 @@ export type GetVersionMockParams = { isVersionKO?: boolean; }; +export const getVersionErrorMessage = 'get-version-error-message'; + export const getVersionMock = ({ isVersionKO, }: GetVersionMockParams): Handler[] => [ { url: '/okms/resource/:okmsId/secret/:secretPath/version/:versionId', - response: isVersionKO - ? { - status: 500, - data: { - message: 'version error', - }, - } - : (request: Request, params: PathParams) => - findVersionMockById(versionListMock, request, params), + response: buildMswResponseMock({ + data: (request, params) => + findVersionMockById(versionListMock, request, params), + errorMessage: getVersionErrorMessage, + isError: isVersionKO, + }), status: isVersionKO ? 500 : 200, api: 'v2', }, @@ -65,11 +62,11 @@ export const createVersionMock = ({ }: CreateVersionMockParams): Handler[] => [ { url: '/okms/resource/:okmsId/secret/:secretPath/version', - response: isCreateVersionKO - ? { - message: createVersionErrorMessage, - } - : createVersionResponseMock, + response: buildMswResponseMock({ + data: createVersionResponseMock, + errorMessage: createVersionErrorMessage, + isError: isCreateVersionKO, + }), status: isCreateVersionKO ? 500 : 200, api: 'v2', method: 'post', @@ -89,7 +86,7 @@ export const updateVersionMock = ({ { url: '/okms/resource/:okmsId/secret/:secretPath/version/:versionId', method: 'put', - response: createHandlerResponseMock({ + response: buildMswResponseMock({ data: {}, errorMessage: updateVersionErrorMessage, isError: isVersionUpdateKO, diff --git a/packages/manager/apps/okms/src/modules/secret-manager/pages/createSecret/CreateSecret.page.spec.tsx b/packages/manager/apps/okms/src/modules/secret-manager/pages/createSecret/CreateSecret.page.spec.tsx index 0f094c461bde..6c6e34cb6741 100644 --- a/packages/manager/apps/okms/src/modules/secret-manager/pages/createSecret/CreateSecret.page.spec.tsx +++ b/packages/manager/apps/okms/src/modules/secret-manager/pages/createSecret/CreateSecret.page.spec.tsx @@ -8,7 +8,7 @@ import { MOCK_PATH_VALID, } from '@secret-manager/utils/tests/secret.constants'; import { SECRET_FORM_TEST_IDS } from '@secret-manager/pages/createSecret/SecretForm.constants'; -import { SECRET_INPUT_DATA_TEST_ID } from '@secret-manager/components/form/SecretDataFormField.constants'; +import { SECRET_FORM_FIELD_TEST_IDS } from '@secret-manager/components/form/form.constants'; import { fireEvent, act, screen } from '@testing-library/react'; import userEvent, { UserEvent } from '@testing-library/user-event'; import { assertBreadcrumbItems } from '@secret-manager/utils/tests/breadcrumb'; @@ -29,7 +29,7 @@ const selectRegion = async (user: UserEvent) => { const fillRequiredFields = () => { const inputPath = screen.getByTestId(SECRET_FORM_TEST_IDS.INPUT_PATH); - const inputData = screen.getByTestId(SECRET_INPUT_DATA_TEST_ID); + const inputData = screen.getByTestId(SECRET_FORM_FIELD_TEST_IDS.INPUT_DATA); act(() => { fireEvent.input(inputPath, { diff --git a/packages/manager/apps/okms/src/modules/secret-manager/pages/createSecret/SecretForm.component.spec.tsx b/packages/manager/apps/okms/src/modules/secret-manager/pages/createSecret/SecretForm.component.spec.tsx index 6ae6269c6799..aa76f297d0e7 100644 --- a/packages/manager/apps/okms/src/modules/secret-manager/pages/createSecret/SecretForm.component.spec.tsx +++ b/packages/manager/apps/okms/src/modules/secret-manager/pages/createSecret/SecretForm.component.spec.tsx @@ -11,7 +11,7 @@ import { MOCK_PATH_VALID, } from '@secret-manager/utils/tests/secret.constants'; import { SECRET_FORM_TEST_IDS } from '@secret-manager/pages/createSecret/SecretForm.constants'; -import { SECRET_INPUT_DATA_TEST_ID } from '@secret-manager/components/form/SecretDataFormField.constants'; +import { SECRET_FORM_FIELD_TEST_IDS } from '@secret-manager/components/form/form.constants'; import { fireEvent, act, render, screen } from '@testing-library/react'; import { labels, initTestI18n } from '@/utils/tests/init.i18n'; import { SecretForm } from './SecretForm.component'; @@ -85,7 +85,9 @@ describe('Secrets creation form test suite', () => { const inputPath = screen.getByTestId(SECRET_FORM_TEST_IDS.INPUT_PATH); expect(inputPath).toBeInTheDocument(); - const inputData = screen.getByTestId(SECRET_INPUT_DATA_TEST_ID); + const inputData = screen.getByTestId( + SECRET_FORM_FIELD_TEST_IDS.INPUT_DATA, + ); expect(inputData).toBeInTheDocument(); const submitButton = screen.getByTestId( SECRET_FORM_TEST_IDS.SUBMIT_BUTTON, @@ -115,7 +117,7 @@ describe('Secrets creation form test suite', () => { it('should display the template in the data input', async () => { // GIVEN await renderSecretForm(MOCK_OKMS_ID); - const inputData = screen.getByTestId(SECRET_INPUT_DATA_TEST_ID); + const inputData = screen.getByTestId(SECRET_FORM_FIELD_TEST_IDS.INPUT_DATA); expect(inputData).toBeInTheDocument(); // THEN diff --git a/packages/manager/apps/okms/src/modules/secret-manager/pages/drawers/createVersionDrawer/CreateVersionDrawer.constants.tsx b/packages/manager/apps/okms/src/modules/secret-manager/pages/drawers/createVersionDrawer/CreateVersionDrawer.constants.tsx new file mode 100644 index 000000000000..c5805c8f02be --- /dev/null +++ b/packages/manager/apps/okms/src/modules/secret-manager/pages/drawers/createVersionDrawer/CreateVersionDrawer.constants.tsx @@ -0,0 +1,3 @@ +export const CREATE_VERSION_DRAWER_TEST_IDS = { + drawer: 'create-version-drawer', +}; diff --git a/packages/manager/apps/okms/src/modules/secret-manager/pages/drawers/createVersionDrawer/CreateVersionDrawer.page.spec.tsx b/packages/manager/apps/okms/src/modules/secret-manager/pages/drawers/createVersionDrawer/CreateVersionDrawer.page.spec.tsx index cd099ede2d4d..9ca91e9d4a00 100644 --- a/packages/manager/apps/okms/src/modules/secret-manager/pages/drawers/createVersionDrawer/CreateVersionDrawer.page.spec.tsx +++ b/packages/manager/apps/okms/src/modules/secret-manager/pages/drawers/createVersionDrawer/CreateVersionDrawer.page.spec.tsx @@ -2,10 +2,10 @@ import React from 'react'; import { vi } from 'vitest'; import { SECRET_MANAGER_ROUTES_URLS } from '@secret-manager/routes/routes.constants'; import { mockSecret1 } from '@secret-manager/mocks/secrets/secrets.mock'; -import { screen, act, fireEvent, waitFor } from '@testing-library/react'; +import { screen, act, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { MOCK_DATA_VALID_JSON } from '@secret-manager/utils/tests/secret.constants'; -import { SECRET_INPUT_DATA_TEST_ID } from '@secret-manager/components/form/SecretDataFormField.constants'; +import { SECRET_FORM_FIELD_TEST_IDS } from '@secret-manager/components/form/form.constants'; import { assertTextVisibility, getOdsButtonByLabel, @@ -16,6 +16,8 @@ import { getSecretMockWithData } from '@secret-manager/mocks/secrets/secretsMock import { okmsRoubaix1Mock } from '@/mocks/kms/okms.mock'; import { renderTestApp } from '@/utils/tests/renderTestApp'; import { labels } from '@/utils/tests/init.i18n'; +import { changeOdsInputValueByTestId } from '@/utils/tests/uiTestHelpers'; +import { CREATE_VERSION_DRAWER_TEST_IDS } from './CreateVersionDrawer.constants'; const mockOkmsId = okmsRoubaix1Mock.id; const mockedSecret = mockSecret1; @@ -51,7 +53,7 @@ const renderPage = async ({ url = mockPageUrl }: { url?: string } = {}) => { // Check if the drawer is open expect( await screen.findByTestId( - 'create-version-drawer', + CREATE_VERSION_DRAWER_TEST_IDS.drawer, {}, WAIT_FOR_DEFAULT_OPTIONS, ), @@ -79,7 +81,7 @@ describe('Secret create version drawer page test suite', () => { it('should display the current secret value', async () => { await renderPage(); - const dataInput = screen.getByTestId(SECRET_INPUT_DATA_TEST_ID); + const dataInput = screen.getByTestId(SECRET_FORM_FIELD_TEST_IDS.INPUT_DATA); // Check if the data input contains the secret value expect(dataInput).toBeInTheDocument(); @@ -94,20 +96,11 @@ describe('Secret create version drawer page test suite', () => { getSecretMockWithData(mockedSecret).version, ); - // Get the data input - const dataInput = await screen.findByTestId(SECRET_INPUT_DATA_TEST_ID); - expect(dataInput).toBeInTheDocument(); - - await act(() => { - fireEvent.change(dataInput, { - target: { value: MOCK_DATA_VALID_JSON }, - }); - }); - - // Wait for the data input to be updated - await waitFor(() => { - expect(dataInput).toHaveValue(MOCK_DATA_VALID_JSON); - }); + // Change the data input value + await changeOdsInputValueByTestId( + SECRET_FORM_FIELD_TEST_IDS.INPUT_DATA, + MOCK_DATA_VALID_JSON, + ); // Submit the form // Button should be enabled after input change @@ -132,7 +125,7 @@ describe('Secret create version drawer page test suite', () => { // Wait for the drawer to close await waitFor(() => { expect( - screen.queryByTestId('create-version-drawer'), + screen.queryByTestId(CREATE_VERSION_DRAWER_TEST_IDS.drawer), ).not.toBeInTheDocument(); }); }); diff --git a/packages/manager/apps/okms/src/modules/secret-manager/pages/drawers/createVersionDrawer/CreateVersionDrawer.page.tsx b/packages/manager/apps/okms/src/modules/secret-manager/pages/drawers/createVersionDrawer/CreateVersionDrawer.page.tsx index db1f2fb31a71..3d251a2349ac 100644 --- a/packages/manager/apps/okms/src/modules/secret-manager/pages/drawers/createVersionDrawer/CreateVersionDrawer.page.tsx +++ b/packages/manager/apps/okms/src/modules/secret-manager/pages/drawers/createVersionDrawer/CreateVersionDrawer.page.tsx @@ -16,11 +16,13 @@ import { useCreateSecretVersion } from '@secret-manager/data/hooks/useCreateSecr import { LocationPathParams } from '@secret-manager/routes/routes.constants'; import { SecretDataFormField } from '@secret-manager/components/form/SecretDataFormField.component'; import { SecretSmartConfig } from '@secret-manager/utils/secretSmartConfig'; +import { addCurrentVersionToCas } from '@secret-manager/utils/cas'; import { DrawerContent, DrawerFooter, } from '@/common/components/drawer/DrawerInnerComponents.component'; import { VersionStatusMessage } from './VersionStatusMessage.component'; +import { CREATE_VERSION_DRAWER_TEST_IDS } from './CreateVersionDrawer.constants'; type CreateVersionDrawerProps = { secret: SecretWithData; @@ -61,16 +63,20 @@ const CreateVersionDrawerForm = ({ }); const handleSubmitForm = async (data: FormSchema) => { - await createSecretVersion({ - okmsId, - path: decodeSecretPath(secretPath), - data: JSON.parse(data.data), - // Add current version to cas parameter if cas is required - cas: secretConfig.casRequired.value - ? secret?.metadata?.currentVersion - : undefined, - }); - onDismiss(); + try { + await createSecretVersion({ + okmsId, + path: decodeSecretPath(secretPath), + data: JSON.parse(data.data), + cas: addCurrentVersionToCas( + secret?.metadata?.currentVersion, + secretConfig.casRequired.value, + ), + }); + onDismiss(); + } catch { + // Error is handled by the useCreateSecretVersion hook + } }; return ( @@ -130,7 +136,7 @@ export default function CreateVersionDrawerPage() { heading={t('add_new_version')} onDismiss={handleDismiss} isLoading={isPending} - data-testid="create-version-drawer" + data-testid={CREATE_VERSION_DRAWER_TEST_IDS.drawer} > {error && ( diff --git a/packages/manager/apps/okms/src/modules/secret-manager/pages/drawers/editMetadataDrawer/EditMetadataDrawer.page.spec.tsx b/packages/manager/apps/okms/src/modules/secret-manager/pages/drawers/editMetadataDrawer/EditMetadataDrawer.page.spec.tsx new file mode 100644 index 000000000000..6f5138bf39b4 --- /dev/null +++ b/packages/manager/apps/okms/src/modules/secret-manager/pages/drawers/editMetadataDrawer/EditMetadataDrawer.page.spec.tsx @@ -0,0 +1,123 @@ +import { act, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { SECRET_MANAGER_ROUTES_URLS } from '@secret-manager/routes/routes.constants'; +import { getOdsButtonByLabel } from '@ovh-ux/manager-core-test-utils'; +import { mockSecret1 } from '@secret-manager/mocks/secrets/secrets.mock'; +import { SECRET_FORM_FIELD_TEST_IDS } from '@secret-manager/components/form/form.constants'; +import { okmsRoubaix1Mock } from '@/mocks/kms/okms.mock'; +import { renderTestApp } from '@/utils/tests/renderTestApp'; +import { labels } from '@/utils/tests/init.i18n'; +import { changeOdsInputValueByTestId } from '@/utils/tests/uiTestHelpers'; + +const mockOkmsId = okmsRoubaix1Mock.id; +const mockSecret = mockSecret1; +const mockSecretPath = mockSecret.path; +const mockPageUrl = SECRET_MANAGER_ROUTES_URLS.secretEditMetadataDrawer( + mockOkmsId, + mockSecretPath, +); + +const renderPage = async (mockParams = {}) => { + const user = userEvent.setup(); + const { container } = await renderTestApp(mockPageUrl, mockParams); + + // Check if the drawer is open + expect(await screen.findByTestId('edit-metadata-drawer')).toBeInTheDocument(); + + return { user, container }; +}; + +describe('Edit Metadata Drawer page test suite', () => { + it('should display the edit metadata drawer', async () => { + await renderPage(); + + // Should show the drawer title + expect( + screen.getByText(labels.secretManager.edit_metadata), + ).toBeInTheDocument(); + }); + + it('should display error message when secret smart config fetch fails', async () => { + await renderPage({ isSecretConfigKO: true }); + + // Should display and error message + await waitFor(() => { + expect( + screen.getAllByText('get-secret-config-error-message')[0], + ).toBeInTheDocument(); + }); + + // Should NOT display the form when there's an error + expect( + screen.queryByTestId(SECRET_FORM_FIELD_TEST_IDS.DEACTIVATE_VERSION_AFTER), + ).not.toBeInTheDocument(); + }); + + it('should render form with correct data when everything loads successfully', async () => { + const { container } = await renderPage(); + + // Wait for form to be rendered + const maxVersions = mockSecret.metadata.maxVersions.toString(); + expect(await screen.findByDisplayValue(maxVersions)).toBeInTheDocument(); + + // Check form fields are populated with secret data + expect( + screen.getByDisplayValue(mockSecret.metadata.deactivateVersionAfter), + ).toBeInTheDocument(); + expect(screen.getByDisplayValue(maxVersions)).toBeInTheDocument(); + + // Check submit button is present + const submitButton = await getOdsButtonByLabel({ + container, + label: labels.common.actions.validate, + }); + expect(submitButton).toBeInTheDocument(); + }); + + it('should handle form submission and navigation flow', async () => { + const { user, container } = await renderPage(); + + // Change the data input value + await changeOdsInputValueByTestId( + SECRET_FORM_FIELD_TEST_IDS.DEACTIVATE_VERSION_AFTER, + '99d99h99m99s', + ); + + // Submit form + const submitButton = await getOdsButtonByLabel({ + container, + label: labels.common.actions.validate, + }); + await act(() => user.click(submitButton)); + + // Wait for drawer to close (navigation) + await waitFor(() => { + expect( + screen.queryByTestId('edit-metadata-drawer'), + ).not.toBeInTheDocument(); + }); + }); + + it('should handle form submission errors', async () => { + const { user, container } = await renderPage({ isUpdateSecretKO: true }); + + // Wait for form to load + const maxVersions = mockSecret.metadata.maxVersions.toString(); + expect(await screen.findByDisplayValue(maxVersions)).toBeInTheDocument(); + + // Submit form + const submitButton = await getOdsButtonByLabel({ + container, + label: labels.common.actions.validate, + }); + await act(() => user.click(submitButton)); + + // Verify error is displayed + expect( + await screen.findByText('update-secret-error-message'), + ).toBeInTheDocument(); + + // Drawer should still be open + expect(screen.getByTestId('edit-metadata-drawer')).toBeInTheDocument(); + }); +}); diff --git a/packages/manager/apps/okms/src/modules/secret-manager/pages/drawers/editMetadataDrawer/EditMetadataDrawer.page.tsx b/packages/manager/apps/okms/src/modules/secret-manager/pages/drawers/editMetadataDrawer/EditMetadataDrawer.page.tsx new file mode 100644 index 000000000000..ca1f8e17813f --- /dev/null +++ b/packages/manager/apps/okms/src/modules/secret-manager/pages/drawers/editMetadataDrawer/EditMetadataDrawer.page.tsx @@ -0,0 +1,63 @@ +import React, { Suspense } from 'react'; +import { Drawer } from '@ovh-ux/manager-react-components'; +import { useTranslation } from 'react-i18next'; +import { useNavigate, useParams } from 'react-router-dom'; +import { OdsMessage } from '@ovhcloud/ods-components/react'; +import { useSecret } from '@secret-manager/data/hooks/useSecret'; +import { useSecretSmartConfig } from '@secret-manager/hooks/useSecretSmartConfig'; +import { decodeSecretPath } from '@secret-manager/utils/secretPath'; +import { LocationPathParams } from '@secret-manager/routes/routes.constants'; +import { EditMetadataDrawerForm } from './EditMetadataDrawerForm.component'; + +export default function EditMetadataDrawerPage() { + const navigate = useNavigate(); + const { t } = useTranslation('secret-manager'); + + const { okmsId, secretPath } = useParams(); + + const { + data: secret, + isPending: isSecretPending, + error: secretError, + } = useSecret(okmsId, decodeSecretPath(secretPath)); + + const { + secretConfig, + isPending: isSecretSmartConfigPending, + error: secretSmartConfigError, + } = useSecretSmartConfig(secret); + + const isPending = isSecretPending || isSecretSmartConfigPending; + const error = secretError || secretSmartConfigError; + + const handleDismiss = () => { + navigate('..'); + }; + + return ( + + + {error && ( + + {error?.response?.data?.message} + + )} + {!error && secret && ( + + )} + + + ); +} diff --git a/packages/manager/apps/okms/src/modules/secret-manager/pages/drawers/editMetadataDrawer/EditMetadataDrawerForm.component.spec.tsx b/packages/manager/apps/okms/src/modules/secret-manager/pages/drawers/editMetadataDrawer/EditMetadataDrawerForm.component.spec.tsx new file mode 100644 index 000000000000..78982611be41 --- /dev/null +++ b/packages/manager/apps/okms/src/modules/secret-manager/pages/drawers/editMetadataDrawer/EditMetadataDrawerForm.component.spec.tsx @@ -0,0 +1,248 @@ +import React from 'react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { screen, waitFor, act } from '@testing-library/react'; +import userEvent, { UserEvent } from '@testing-library/user-event'; +import { mockSecret1 } from '@secret-manager/mocks/secrets/secrets.mock'; +import { Secret } from '@secret-manager/types/secret.type'; +import { SecretSmartConfig } from '@secret-manager/utils/secretSmartConfig'; +import { getOdsButtonByLabel } from '@ovh-ux/manager-core-test-utils'; +import { SECRET_FORM_FIELD_TEST_IDS } from '@secret-manager/components/form/form.constants'; +import { EditMetadataDrawerForm } from './EditMetadataDrawerForm.component'; +import { labels as allLabels } from '@/utils/tests/init.i18n'; +import { + createErrorResponseMock, + renderWithI18n, +} from '@/utils/tests/testUtils'; +import { changeOdsInputValueByTestId } from '@/utils/tests/uiTestHelpers'; + +const labels = allLabels.secretManager; +const commonLabels = allLabels.common; + +const mockUpdateSecret = vi.fn(); +const mockUseUpdateSecret = vi.fn(); +const mockUseSecretMetadataSchema = vi.fn(); + +vi.mock('@secret-manager/data/hooks/useUpdateSecret', () => ({ + useUpdateSecret: () => mockUseUpdateSecret(), +})); + +const mockSecret: Secret = mockSecret1; +const mockSecretConfig: SecretSmartConfig = { + casRequired: { + value: true, + origin: 'DOMAIN', + }, + deactivateVersionAfter: { + value: '10d10h10m10s', + origin: 'SECRET', + }, + maxVersions: { + value: 5, + origin: 'SECRET', + }, + isCasRequiredSetOnOkms: true, +}; + +const mockOnDismiss = vi.fn(); + +const renderComponent = async () => { + const user = userEvent.setup(); + + const defaultProps = { + secret: mockSecret, + okmsId: 'test-okms-id', + secretPath: 'test-secret-path', + secretConfig: mockSecretConfig, + onDismiss: mockOnDismiss, + }; + + const renderResult = await renderWithI18n( + , + ); + + return { + user, + ...renderResult, + }; +}; + +const submitForm = async (container: HTMLElement, user: UserEvent) => { + const submitButton = await getOdsButtonByLabel({ + container, + label: commonLabels.actions.validate, + }); + await act(() => user.click(submitButton)); + + return submitButton; +}; + +describe('EditMetadataDrawerForm component test suite', () => { + beforeEach(() => { + vi.clearAllMocks(); + + // Default mock implementations + mockUseUpdateSecret.mockReturnValue({ + mutateAsync: mockUpdateSecret, + isPending: false, + error: null, + }); + + mockUseSecretMetadataSchema.mockReturnValue({ + safeParse: vi.fn().mockReturnValue({ success: true, data: {} }), + }); + }); + + describe('when component renders successfully', () => { + it('should render form fields with correct default values', async () => { + await renderComponent(); + + // Should render form fields + const input1 = screen.getByTestId( + SECRET_FORM_FIELD_TEST_IDS.DEACTIVATE_VERSION_AFTER, + ); + expect(input1).toBeInTheDocument(); + expect(input1).toHaveValue(mockSecretConfig.deactivateVersionAfter.value); + + const input2 = screen.getByTestId( + SECRET_FORM_FIELD_TEST_IDS.MAX_VERSIONS, + ); + expect(input2).toBeInTheDocument(); + expect(input2).toHaveValue(mockSecretConfig.maxVersions.value); + + const input3 = screen.getByTestId( + SECRET_FORM_FIELD_TEST_IDS.CAS_REQUIRED_ACTIVE, + ); + expect(input3).toBeInTheDocument(); + expect(input3).toHaveAttribute('is-checked', 'true'); + + const input4 = screen.getByTestId( + SECRET_FORM_FIELD_TEST_IDS.CAS_REQUIRED_INACTIVE, + ); + expect(input4).toBeInTheDocument(); + expect(input4).not.toHaveAttribute('is-checked', 'true'); + }); + }); + + describe('when update secret is successful', () => { + it('should call updateSecret with correct parameters on form submission', async () => { + // GIVEN + const user = userEvent.setup(); + mockUpdateSecret.mockResolvedValue({}); + + const { container } = await renderComponent(); + + // WHEN + const submitButton = await getOdsButtonByLabel({ + container, + label: commonLabels.actions.validate, + }); + await act(() => user.click(submitButton)); + + // THEN + await waitFor(() => { + expect(mockUpdateSecret).toHaveBeenCalledWith({ + okmsId: 'test-okms-id', + path: 'test-secret-path', + cas: expect.any(Number), + data: { + metadata: expect.objectContaining({ + casRequired: mockSecretConfig.casRequired.value, + deactivateVersionAfter: + mockSecretConfig.deactivateVersionAfter.value, + maxVersions: mockSecretConfig.maxVersions.value, + }), + }, + }); + }); + }); + + it('should call onDismiss after successful update', async () => { + // GIVEN + mockUpdateSecret.mockResolvedValue({}); + + const { container, user } = await renderComponent(); + + // WHEN + await submitForm(container, user); + + // THEN + await waitFor(() => { + expect(mockOnDismiss).toHaveBeenCalled(); + }); + }); + }); + + describe('when update secret fails', () => { + it('should display error message when update fails', async () => { + // GIVEN + const updateError = createErrorResponseMock('Update failed'); + + mockUseUpdateSecret.mockReturnValue({ + mutateAsync: mockUpdateSecret, + isPending: false, + error: updateError, + }); + + // WHEN + await renderComponent(); + + // THEN + expect(screen.getByText('Update failed')).toBeInTheDocument(); + }); + + it('should not call onDismiss when update fails', async () => { + // GIVEN + mockUpdateSecret.mockRejectedValue(new Error('Update failed')); + + const { container, user } = await renderComponent(); + + // WHEN + await submitForm(container, user); + + // THEN + await waitFor(() => { + expect(mockOnDismiss).not.toHaveBeenCalled(); + }); + }); + }); + + describe('when update is pending', () => { + it('should show loading state on submit button during update', async () => { + // GIVEN + mockUseUpdateSecret.mockReturnValue({ + mutateAsync: mockUpdateSecret, + isPending: true, + error: null, + }); + + // WHEN + const { container, user } = await renderComponent(); + + // THEN + const submitButton = await submitForm(container, user); + expect(submitButton).toHaveAttribute('is-loading', 'true'); + }); + }); + + describe('form validation', () => { + it('should display form errors', async () => { + const { container, user } = await renderComponent(); + + await changeOdsInputValueByTestId( + SECRET_FORM_FIELD_TEST_IDS.DEACTIVATE_VERSION_AFTER, + 'invalid-duration', + ); + + await submitForm(container, user); + + // Check if the error is displayed + await waitFor(() => { + expect( + container.querySelector( + `ods-form-field[error="${labels.error_invalid_duration}"]`, + ), + ).toBeInTheDocument(); + }); + }); + }); +}); diff --git a/packages/manager/apps/okms/src/modules/secret-manager/pages/drawers/editMetadataDrawer/EditMetadataDrawerForm.component.tsx b/packages/manager/apps/okms/src/modules/secret-manager/pages/drawers/editMetadataDrawer/EditMetadataDrawerForm.component.tsx new file mode 100644 index 000000000000..11833b00ae91 --- /dev/null +++ b/packages/manager/apps/okms/src/modules/secret-manager/pages/drawers/editMetadataDrawer/EditMetadataDrawerForm.component.tsx @@ -0,0 +1,115 @@ +import React from 'react'; +import z from 'zod'; +import { useTranslation } from 'react-i18next'; +import { useForm } from 'react-hook-form'; +import { OdsMessage } from '@ovhcloud/ods-components/react'; +import { NAMESPACES } from '@ovh-ux/manager-common-translations'; +import { zodResolver } from '@hookform/resolvers/zod/dist/zod'; +import { Secret } from '@secret-manager/types/secret.type'; +import { decodeSecretPath } from '@secret-manager/utils/secretPath'; +import { useUpdateSecret } from '@secret-manager/data/hooks/useUpdateSecret'; +import { SecretDeactivateVersionAfterFormField } from '@secret-manager/components/form/SecretDeactivateVersionAfterFormField.component'; +import { + SecretCasRequiredFormField, + casRequiredToFormValue, + formValueToCasRequired, +} from '@secret-manager/components/form/SecretCasRequiredFormField.component'; +import { SecretMaxVersionsFormField } from '@secret-manager/components/form/SecretMaxVersionsFormField.component'; +import { SecretSmartConfig } from '@secret-manager/utils/secretSmartConfig'; +import { useSecretMetadataSchema } from '@secret-manager/validation/metadata/metadataSchema'; +import { addCurrentVersionToCas } from '@secret-manager/utils/cas'; +import { + DrawerContent, + DrawerFooter, +} from '@/common/components/drawer/DrawerInnerComponents.component'; + +type EditMetadataDrawerFormProps = { + secret: Secret; + okmsId: string; + secretPath: string; + secretConfig: SecretSmartConfig; + onDismiss: () => void; +}; + +export const EditMetadataDrawerForm = ({ + secret, + okmsId, + secretPath, + secretConfig, + onDismiss, +}: EditMetadataDrawerFormProps) => { + const { t } = useTranslation(['secret-manager', NAMESPACES.ACTIONS]); + + const { + mutateAsync: updateSecret, + isPending: isUpdating, + error: updateError, + } = useUpdateSecret(); + + const metadataSchema = useSecretMetadataSchema(); + + type FormSchema = z.infer; + const { control, handleSubmit } = useForm({ + resolver: zodResolver(metadataSchema), + defaultValues: { + casRequired: casRequiredToFormValue(secret?.metadata.casRequired), + deactivateVersionAfter: secret?.metadata.deactivateVersionAfter || '', + maxVersions: secret?.metadata.maxVersions || 0, + }, + }); + + const handleSubmitForm = async (data: FormSchema) => { + try { + await updateSecret({ + okmsId, + path: decodeSecretPath(secretPath), + cas: addCurrentVersionToCas( + secret?.metadata?.currentVersion, + secretConfig.casRequired.value, + formValueToCasRequired(data.casRequired), + ), + data: { + metadata: { + ...data, + casRequired: formValueToCasRequired(data.casRequired), + }, + }, + }); + onDismiss(); + } catch { + // Error is handled by the useUpdateSecret hook + } + }; + + return ( + + + + {updateError && ( + + {updateError?.response?.data?.message || + t('error_update_settings')} + + )} + + + + + + + + ); +}; diff --git a/packages/manager/apps/okms/src/modules/secret-manager/pages/drawers/secretValueDrawer/SecretValueDrawer.page.spec.tsx b/packages/manager/apps/okms/src/modules/secret-manager/pages/drawers/secretValueDrawer/SecretValueDrawer.page.spec.tsx index 281be22f41d0..11579c26f9fb 100644 --- a/packages/manager/apps/okms/src/modules/secret-manager/pages/drawers/secretValueDrawer/SecretValueDrawer.page.spec.tsx +++ b/packages/manager/apps/okms/src/modules/secret-manager/pages/drawers/secretValueDrawer/SecretValueDrawer.page.spec.tsx @@ -1,4 +1,4 @@ -import { screen, act, waitFor, fireEvent } from '@testing-library/react'; +import { screen, waitFor } from '@testing-library/react'; import { SECRET_MANAGER_ROUTES_URLS } from '@secret-manager/routes/routes.constants'; import { VERSION_BADGE_TEST_ID } from '@secret-manager/utils/tests/version.constants'; import { SecretVersion } from '@secret-manager/types/secret.type'; @@ -27,6 +27,7 @@ import { RenderTestMockParams, } from '@/utils/tests/renderTestApp'; import { labels } from '@/utils/tests/init.i18n'; +import { changeOdsInputValueByTestId } from '@/utils/tests/uiTestHelpers'; const mockOkmsId = '12345'; const mockSecret = secretListMock[0]; @@ -193,18 +194,11 @@ describe('ValueDrawer test suite', () => { // GIVEN version, haveValue await renderPage(); - await waitFor(() => { - expect(screen.getByTestId(VERSION_SELECTOR_TEST_ID)).toBeVisible(); - }, WAIT_FOR_DEFAULT_OPTIONS); - - // WHEN - const versionSelect = screen.getByTestId(VERSION_SELECTOR_TEST_ID); - - await act(() => { - fireEvent.change(versionSelect, { - target: { value: version.id.toString() }, - }); - }); + // Change the data input value + await changeOdsInputValueByTestId( + VERSION_SELECTOR_TEST_ID, + version.id.toString(), + ); // THEN await assertTextVisibility(labels.common.status.status); diff --git a/packages/manager/apps/okms/src/modules/secret-manager/pages/secret/generalInformation/SettingsTile.component.spec.tsx b/packages/manager/apps/okms/src/modules/secret-manager/pages/secret/generalInformation/SettingsTile.component.spec.tsx index 52e90594aefe..05a35732dde4 100644 --- a/packages/manager/apps/okms/src/modules/secret-manager/pages/secret/generalInformation/SettingsTile.component.spec.tsx +++ b/packages/manager/apps/okms/src/modules/secret-manager/pages/secret/generalInformation/SettingsTile.component.spec.tsx @@ -20,6 +20,15 @@ vi.mock('@secret-manager/hooks/useSecretSmartConfig', () => ({ useSecretSmartConfig: (secret: Secret) => mockUseSecretSmartConfig(secret), })); +vi.mock('react-router-dom', async (importOriginal) => { + const module: typeof import('react-router-dom') = await importOriginal(); + return { + ...module, + useNavigate: () => vi.fn(), + useHref: vi.fn((link) => link), + }; +}); + const mockSecret = mockSecret1; const mockSecretSmartConfig: SecretSmartConfig = { @@ -35,6 +44,7 @@ const mockSecretSmartConfig: SecretSmartConfig = { value: 15, origin: 'DOMAIN', }, + isCasRequiredSetOnOkms: true, }; let i18nValue: i18n; diff --git a/packages/manager/apps/okms/src/modules/secret-manager/pages/secret/generalInformation/SettingsTile.component.tsx b/packages/manager/apps/okms/src/modules/secret-manager/pages/secret/generalInformation/SettingsTile.component.tsx index 93ac7937dbed..142783223768 100644 --- a/packages/manager/apps/okms/src/modules/secret-manager/pages/secret/generalInformation/SettingsTile.component.tsx +++ b/packages/manager/apps/okms/src/modules/secret-manager/pages/secret/generalInformation/SettingsTile.component.tsx @@ -10,6 +10,12 @@ import { NOT_SET_VALUE_DEACTIVATE_VERSION_AFTER, } from '@secret-manager/utils/secretSmartConfig'; import { SECRET_TEST_IDS } from '@secret-manager/pages/secret/generalInformation/GeneralInformation.constants'; +import { + LocationPathParams, + SECRET_MANAGER_ROUTES_URLS, +} from '@secret-manager/routes/routes.constants'; +import { useParams } from 'react-router-dom'; +import { Link } from '@/common/components/Link/Link.component'; type SettingsTileProps = { secret: Secret; @@ -18,6 +24,7 @@ type SettingsTileProps = { export const SettingsTile = ({ secret }: SettingsTileProps) => { const { t } = useTranslation(['secret-manager', NAMESPACES.STATUS]); const { secretConfig, isPending, isError } = useSecretSmartConfig(secret); + const { okmsId } = useParams(); const labels: Record = { SECRET: null, @@ -88,6 +95,19 @@ export const SettingsTile = ({ secret }: SettingsTileProps) => { )} + + + + + + ); }; diff --git a/packages/manager/apps/okms/src/modules/secret-manager/pages/secret/versionList/VersionCells.component.spec.tsx b/packages/manager/apps/okms/src/modules/secret-manager/pages/secret/versionList/VersionCells.component.spec.tsx index 0437262650d2..340438da240b 100644 --- a/packages/manager/apps/okms/src/modules/secret-manager/pages/secret/versionList/VersionCells.component.spec.tsx +++ b/packages/manager/apps/okms/src/modules/secret-manager/pages/secret/versionList/VersionCells.component.spec.tsx @@ -16,7 +16,8 @@ import { } from '@secret-manager/mocks/versions/versions.mock'; import { mockSecret1 } from '@secret-manager/mocks/secrets/secrets.mock'; import { ODS_BADGE_COLOR } from '@ovhcloud/ods-components'; -import { removeHandlersDelay, renderWithClient } from '@/utils/tests/testUtils'; +import { renderWithClient } from '@/utils/tests/testUtils'; +import { removeHandlersDelay } from '@/utils/tests/msw'; import { VersionIdCell } from './VersionCells.component'; import { initTestI18n, labels } from '@/utils/tests/init.i18n'; import { getIamMocks } from '@/mocks/iam/iam.handler'; diff --git a/packages/manager/apps/okms/src/modules/secret-manager/pages/secretList/SecretList.page.spec.tsx b/packages/manager/apps/okms/src/modules/secret-manager/pages/secretList/SecretList.page.spec.tsx index 8140dcbaa687..0d59e4676715 100644 --- a/packages/manager/apps/okms/src/modules/secret-manager/pages/secretList/SecretList.page.spec.tsx +++ b/packages/manager/apps/okms/src/modules/secret-manager/pages/secretList/SecretList.page.spec.tsx @@ -14,6 +14,7 @@ import { assertRegionSelectorIsVisible } from '@/modules/secret-manager/utils/te import { renderTestApp } from '@/utils/tests/renderTestApp'; import { labels } from '@/utils/tests/init.i18n'; import { PATH_LABEL } from '@/constants'; +import { CREATE_VERSION_DRAWER_TEST_IDS } from '../drawers/createVersionDrawer/CreateVersionDrawer.constants'; const mockOkmsId = '12345'; const mockPageUrl = SECRET_MANAGER_ROUTES_URLS.secretList(mockOkmsId); @@ -133,7 +134,10 @@ describe('Secret list page test suite', () => { }, { actionLabel: labels.secretManager.add_new_version, - assertion: () => assertTextVisibility(labels.secretManager.editor), + assertion: async () => + expect( + await screen.findByTestId(CREATE_VERSION_DRAWER_TEST_IDS.drawer), + ).toBeInTheDocument(), }, { actionLabel: labels.secretManager.access_versions, diff --git a/packages/manager/apps/okms/src/modules/secret-manager/routes/routes.constants.ts b/packages/manager/apps/okms/src/modules/secret-manager/routes/routes.constants.ts index 42dfa12cb2e4..ad000f55d805 100644 --- a/packages/manager/apps/okms/src/modules/secret-manager/routes/routes.constants.ts +++ b/packages/manager/apps/okms/src/modules/secret-manager/routes/routes.constants.ts @@ -7,6 +7,7 @@ const URIS = { versionList: 'versions', create: 'create', createVersion: 'create-version', + editMetadata: 'edit-metadata', order: 'order', value: 'value', delete: 'delete', @@ -48,6 +49,11 @@ const URLS = { URIS.createVersion }`, + secretEditMetadataDrawer: (okmsId: string, secretPath: string) => + `/${URIS.root}/${okmsId}/${encodeSecretPath(secretPath)}/${ + URIS.editMetadata + }`, + secretDeleteSecret: (okmsId: string, secretPath: string) => `/${URIS.root}/${okmsId}/${encodeSecretPath(secretPath)}/${URIS.delete}`, diff --git a/packages/manager/apps/okms/src/modules/secret-manager/routes/routes.tsx b/packages/manager/apps/okms/src/modules/secret-manager/routes/routes.tsx index 4f67de1b8cdc..10cf8c559058 100644 --- a/packages/manager/apps/okms/src/modules/secret-manager/routes/routes.tsx +++ b/packages/manager/apps/okms/src/modules/secret-manager/routes/routes.tsx @@ -46,6 +46,11 @@ const CreateVersionDrawer = React.lazy(() => '@secret-manager/pages/drawers/createVersionDrawer/CreateVersionDrawer.page' ), ); +const EditMetadataDrawer = React.lazy(() => + import( + '@secret-manager/pages/drawers/editMetadataDrawer/EditMetadataDrawer.page' + ), +); const DeleteVersionModal = React.lazy(() => import( '@secret-manager/pages/secret/versionList/delete/DeleteSecretVersionModal.page' @@ -111,6 +116,10 @@ export default ( path={SECRET_MANAGER_ROUTES_URIS.createVersion} Component={CreateVersionDrawer} /> + { + if (isSettingCasRequired || casRequired) { + return currentVersion; + } + return undefined; +}; diff --git a/packages/manager/apps/okms/src/modules/secret-manager/utils/queryStrings.spec.ts b/packages/manager/apps/okms/src/modules/secret-manager/utils/queryStrings.spec.ts new file mode 100644 index 000000000000..cc699ee46318 --- /dev/null +++ b/packages/manager/apps/okms/src/modules/secret-manager/utils/queryStrings.spec.ts @@ -0,0 +1,38 @@ +import { buildQueryString } from './queryStrings'; + +describe('buildQueryString', () => { + it('should return null for empty params', () => { + expect(buildQueryString({})).toBeNull(); + expect(buildQueryString({ a: undefined })).toBeNull(); + expect(buildQueryString({ a: null })).toBeNull(); + }); + + it('should handle falsy but valid values like empty string, 0, false', () => { + const result = buildQueryString({ empty: '', zero: 0, no: false }); + const params = new URLSearchParams(result.substring(1)); + expect(params.get('empty')).toBe(''); + expect(params.get('zero')).toBe('0'); + expect(params.get('no')).toBe('false'); + }); + + it('should ignore undefined and null values', () => { + const result = buildQueryString({ a: undefined, b: null, c: 'ok' }); + expect(result).toBe('?c=ok'); + }); + + it('should build query string with primitive values', () => { + const result = buildQueryString({ a: 'x', b: 1, c: true }); + + expect(result).toBe('?a=x&b=1&c=true'); + }); + + it('should encode special characters', () => { + const result = buildQueryString({ + q: 'a b&c=d', + u: 'https://ex.com/?x=1&y=2', + }); + expect(result).toBe( + '?q=a+b%26c%3Dd&u=https%3A%2F%2Fex.com%2F%3Fx%3D1%26y%3D2', + ); + }); +}); diff --git a/packages/manager/apps/okms/src/modules/secret-manager/utils/queryStrings.ts b/packages/manager/apps/okms/src/modules/secret-manager/utils/queryStrings.ts new file mode 100644 index 000000000000..37b00700aa08 --- /dev/null +++ b/packages/manager/apps/okms/src/modules/secret-manager/utils/queryStrings.ts @@ -0,0 +1,17 @@ +type QueryStringValue = string | number | boolean | undefined | null; + +/** + * Builds a query string from an object of parameters. + * It ignores undefined and null values. + * It returns null if the object is empty or if all values are undefined or null. + */ +export const buildQueryString = (params: Record) => { + const queryParams = new URLSearchParams(); + Object.entries(params).forEach(([key, value]) => { + if (value !== undefined && value !== null) { + queryParams.set(key, String(value)); + } + }); + const queryString = queryParams.toString(); + return queryString ? `?${queryString}` : null; +}; diff --git a/packages/manager/apps/okms/src/modules/secret-manager/utils/secretSmartConfig.spec.ts b/packages/manager/apps/okms/src/modules/secret-manager/utils/secretSmartConfig.spec.ts index 7c7974934e8a..a285d30960c5 100644 --- a/packages/manager/apps/okms/src/modules/secret-manager/utils/secretSmartConfig.spec.ts +++ b/packages/manager/apps/okms/src/modules/secret-manager/utils/secretSmartConfig.spec.ts @@ -36,7 +36,8 @@ describe('getSecretSmartConfig', () => { domainOverrides: Partial; expectedValue: string | number | boolean; expectedOrigin: 'SECRET' | 'DOMAIN' | 'DEFAULT'; - testedProperty: keyof SecretSmartConfig; + expectedIsCasRequiredSetOnOkms: boolean; + testedProperty: Exclude; }; const runTests = (testCases: TestCase[]) => { @@ -47,6 +48,7 @@ describe('getSecretSmartConfig', () => { domainOverrides, expectedValue, expectedOrigin, + expectedIsCasRequiredSetOnOkms, testedProperty, }) => { const secret = createMockSecret(secretOverrides); @@ -60,6 +62,9 @@ describe('getSecretSmartConfig', () => { expect(result[testedProperty].value).toBe(expectedValue); expect(result[testedProperty].origin).toBe(expectedOrigin); + expect(result.isCasRequiredSetOnOkms).toBe( + expectedIsCasRequiredSetOnOkms, + ); }, ); }; @@ -72,6 +77,7 @@ describe('getSecretSmartConfig', () => { domainOverrides: { maxVersions: NOT_SET_VALUE_MAX_VERSIONS }, expectedValue: 10, expectedOrigin: 'DEFAULT', + expectedIsCasRequiredSetOnOkms: false, testedProperty: 'maxVersions', }, { @@ -84,6 +90,7 @@ describe('getSecretSmartConfig', () => { }, expectedValue: NOT_SET_VALUE_DEACTIVATE_VERSION_AFTER, expectedOrigin: 'DEFAULT', + expectedIsCasRequiredSetOnOkms: false, testedProperty: 'deactivateVersionAfter', }, { @@ -92,6 +99,7 @@ describe('getSecretSmartConfig', () => { domainOverrides: { casRequired: false }, expectedValue: false, expectedOrigin: 'DEFAULT', + expectedIsCasRequiredSetOnOkms: false, testedProperty: 'casRequired', }, ]; @@ -107,6 +115,7 @@ describe('getSecretSmartConfig', () => { domainOverrides: { maxVersions: NOT_SET_VALUE_MAX_VERSIONS }, expectedValue: 5, expectedOrigin: 'SECRET', + expectedIsCasRequiredSetOnOkms: false, testedProperty: 'maxVersions', }, { @@ -117,6 +126,7 @@ describe('getSecretSmartConfig', () => { }, expectedValue: '1h', expectedOrigin: 'SECRET', + expectedIsCasRequiredSetOnOkms: false, testedProperty: 'deactivateVersionAfter', }, { @@ -125,6 +135,7 @@ describe('getSecretSmartConfig', () => { domainOverrides: { casRequired: false }, expectedValue: true, expectedOrigin: 'SECRET', + expectedIsCasRequiredSetOnOkms: false, testedProperty: 'casRequired', }, ]; @@ -140,6 +151,7 @@ describe('getSecretSmartConfig', () => { domainOverrides: { maxVersions: 15 }, expectedValue: 15, expectedOrigin: 'DOMAIN', + expectedIsCasRequiredSetOnOkms: false, testedProperty: 'maxVersions', }, { @@ -150,6 +162,7 @@ describe('getSecretSmartConfig', () => { domainOverrides: { deactivateVersionAfter: '2h' }, expectedValue: '2h', expectedOrigin: 'DOMAIN', + expectedIsCasRequiredSetOnOkms: false, testedProperty: 'deactivateVersionAfter', }, { @@ -158,6 +171,7 @@ describe('getSecretSmartConfig', () => { domainOverrides: { casRequired: true }, expectedValue: true, expectedOrigin: 'DOMAIN', + expectedIsCasRequiredSetOnOkms: true, testedProperty: 'casRequired', }, ]; @@ -173,6 +187,7 @@ describe('getSecretSmartConfig', () => { domainOverrides: { maxVersions: 15 }, expectedValue: 5, expectedOrigin: 'SECRET', + expectedIsCasRequiredSetOnOkms: false, testedProperty: 'maxVersions', }, { @@ -181,6 +196,7 @@ describe('getSecretSmartConfig', () => { domainOverrides: { deactivateVersionAfter: '2h' }, expectedValue: '1h', expectedOrigin: 'SECRET', + expectedIsCasRequiredSetOnOkms: false, testedProperty: 'deactivateVersionAfter', }, { @@ -189,6 +205,7 @@ describe('getSecretSmartConfig', () => { domainOverrides: { casRequired: false }, expectedValue: true, expectedOrigin: 'SECRET', + expectedIsCasRequiredSetOnOkms: false, testedProperty: 'casRequired', }, ]; diff --git a/packages/manager/apps/okms/src/modules/secret-manager/utils/secretSmartConfig.ts b/packages/manager/apps/okms/src/modules/secret-manager/utils/secretSmartConfig.ts index 2bc67c499f85..8b12b5fbdb7c 100644 --- a/packages/manager/apps/okms/src/modules/secret-manager/utils/secretSmartConfig.ts +++ b/packages/manager/apps/okms/src/modules/secret-manager/utils/secretSmartConfig.ts @@ -19,6 +19,7 @@ export type SecretSmartConfig = { casRequired: SecretSmartConfigValue; deactivateVersionAfter: SecretSmartConfigValue; maxVersions: SecretSmartConfigValue; + isCasRequiredSetOnOkms: boolean; }; /** @@ -88,5 +89,7 @@ export const buildSecretSmartConfig = ( NOT_SET_VALUE_MAX_VERSIONS, secretConfigReference.maxVersions, ), + isCasRequiredSetOnOkms: + secretConfigOkms.casRequired !== NOT_SET_VALUE_CAS_REQUIRED, }; }; diff --git a/packages/manager/apps/okms/src/modules/secret-manager/utils/tests/secret.constants.ts b/packages/manager/apps/okms/src/modules/secret-manager/utils/tests/secret.constants.ts index 6b33010c9e14..e5e36fdd3086 100644 --- a/packages/manager/apps/okms/src/modules/secret-manager/utils/tests/secret.constants.ts +++ b/packages/manager/apps/okms/src/modules/secret-manager/utils/tests/secret.constants.ts @@ -11,3 +11,10 @@ export const MOCK_PATH_INVALID_CHARACTERS_NOT_ALLOWED = export const MOCK_DATA_VALID_JSON = '{"a": "valid JSON"}'; export const MOCK_DATA_VALID_ARRAY_JSON = '[{"a": "valid array JSON"}]'; export const MOCK_DATA_INVALID_JSON = 'not a json'; + +/* METADATA input */ +export const MOCK_METADATA_VALID = { + casRequired: 'active' as const, + deactivateVersionAfter: '30s', + maxVersions: 5, +}; diff --git a/packages/manager/apps/okms/src/modules/secret-manager/validation/metadata/metadataSchema.spec.tsx b/packages/manager/apps/okms/src/modules/secret-manager/validation/metadata/metadataSchema.spec.tsx new file mode 100644 index 000000000000..befac1909f22 --- /dev/null +++ b/packages/manager/apps/okms/src/modules/secret-manager/validation/metadata/metadataSchema.spec.tsx @@ -0,0 +1,235 @@ +import React from 'react'; +import { I18nextProvider } from 'react-i18next'; +import { i18n } from 'i18next'; +import { renderHook } from '@testing-library/react'; +import { MOCK_METADATA_VALID } from '@secret-manager/utils/tests/secret.constants'; +import { initTestI18n, labels } from '@/utils/tests/init.i18n'; +import { + MAX_VERSIONS_MIN_VALUE, + MAX_VERSIONS_MAX_VALUE, + useSecretMetadataSchema, +} from './metadataSchema'; + +let i18nValue: i18n; + +const getSchemaParsingResult = (input: unknown) => { + const schema = renderHook(useSecretMetadataSchema, { + wrapper: ({ children }) => ( + {children} + ), + }); + return schema.result.current.safeParse(input); +}; + +describe('SecretMetadataSchema test suite', () => { + beforeAll(async () => { + i18nValue = await initTestI18n(); + }); + + describe('Valid metadata', () => { + it('should validate a complete valid metadata object', () => { + const result = getSchemaParsingResult(MOCK_METADATA_VALID); + expect(result.success).toBe(true); + expect(result.data).toEqual(MOCK_METADATA_VALID); + }); + }); + + describe('casRequired field validation', () => { + it('should return error for invalid casRequired value', () => { + const invalidMetadata = { + ...MOCK_METADATA_VALID, + casRequired: 'invalid', + }; + const result = getSchemaParsingResult(invalidMetadata); + expect(result.success).toBe(false); + expect(result.error.issues[0].path).toEqual(['casRequired']); + }); + + it('should return error for missing casRequired field', () => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { casRequired, ...invalidMetadata } = MOCK_METADATA_VALID; + const result = getSchemaParsingResult(invalidMetadata); + expect(result.success).toBe(false); + expect(result.error.issues[0].path).toEqual(['casRequired']); + }); + + it('should return error for null casRequired', () => { + const invalidMetadata = { + ...MOCK_METADATA_VALID, + casRequired: null as unknown, + }; + const result = getSchemaParsingResult(invalidMetadata); + expect(result.success).toBe(false); + expect(result.error.issues[0].path).toEqual(['casRequired']); + }); + }); + + describe('deactivateVersionAfter field validation', () => { + it.each<[{ desc: string; value: string }]>([ + [{ desc: 'invalid duration format', value: 'invalid-duration' }], + [{ desc: 'duration with invalid units', value: '30x' }], + [{ desc: 'duration with no unit', value: '30' }], + [{ desc: 'empty string', value: '' }], + ])('should return error for %s', ({ value }) => { + const invalidMetadata = { + ...MOCK_METADATA_VALID, + deactivateVersionAfter: value, + }; + const result = getSchemaParsingResult(invalidMetadata); + expect(result.success).toBe(false); + expect(result.error.issues[0].path).toEqual(['deactivateVersionAfter']); + expect(result.error.issues[0].message).toBe( + labels.secretManager.error_invalid_duration, + ); + }); + + it('should return error for missing deactivateVersionAfter field', () => { + const { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + deactivateVersionAfter, + ...invalidMetadata + } = MOCK_METADATA_VALID; + const result = getSchemaParsingResult(invalidMetadata); + expect(result.success).toBe(false); + expect(result.error.issues[0].path).toEqual(['deactivateVersionAfter']); + }); + + it('should validate various valid duration formats', () => { + const validDurations = [ + '30s', + '5m', + '2h', + '1h30m', + '45m30s', + '2h15m30s', + '30s15m2h', + ]; + + validDurations.forEach((duration) => { + const validMetadata = { + ...MOCK_METADATA_VALID, + deactivateVersionAfter: duration, + }; + const result = getSchemaParsingResult(validMetadata); + expect(result.success).toBe(true); + }); + }); + }); + + describe('maxVersions field validation', () => { + it('should validate zero maxVersions', () => { + const validMetadata = { ...MOCK_METADATA_VALID, maxVersions: 0 }; + const result = getSchemaParsingResult(validMetadata); + expect(result.success).toBe(true); + }); + + it('should validate large maxVersions', () => { + const validMetadata = { + ...MOCK_METADATA_VALID, + maxVersions: MAX_VERSIONS_MAX_VALUE, + }; + const result = getSchemaParsingResult(validMetadata); + expect(result.success).toBe(true); + }); + + it('should return error for non-numeric maxVersions', () => { + const invalidMetadata = { + ...MOCK_METADATA_VALID, + maxVersions: 'not-a-number', + }; + const result = getSchemaParsingResult(invalidMetadata); + expect(result.success).toBe(false); + expect(result.error.issues[0].path).toEqual(['maxVersions']); + }); + + it('should return error for missing maxVersions field', () => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { maxVersions, ...invalidMetadata } = MOCK_METADATA_VALID; + const result = getSchemaParsingResult(invalidMetadata); + expect(result.success).toBe(false); + expect(result.error.issues[0].path).toEqual(['maxVersions']); + }); + + it('should return error for null maxVersions', () => { + const invalidMetadata = { + ...MOCK_METADATA_VALID, + maxVersions: null as unknown, + }; + const result = getSchemaParsingResult(invalidMetadata); + expect(result.success).toBe(false); + expect(result.error.issues[0].path).toEqual(['maxVersions']); + }); + + it('should return error for negative maxVersions', () => { + const validMetadata = { ...MOCK_METADATA_VALID, maxVersions: -1 }; + const result = getSchemaParsingResult(validMetadata); + expect(result.success).toBe(false); + expect(result.error.issues[0].path).toEqual(['maxVersions']); + expect(result.error.issues[0].message).toBe( + labels.common.form.error_min_exclusive.replace( + '{{ value }}', + MAX_VERSIONS_MIN_VALUE.toString(), + ), + ); + }); + + it('should return error for too large maxVersions', () => { + const invalidMetadata = { + ...MOCK_METADATA_VALID, + maxVersions: MAX_VERSIONS_MAX_VALUE + 1, + }; + const result = getSchemaParsingResult(invalidMetadata); + expect(result.success).toBe(false); + expect(result.error.issues[0].path).toEqual(['maxVersions']); + expect(result.error.issues[0].message).toBe( + labels.common.form.error_max_inclusive.replace( + '{{ value }}', + MAX_VERSIONS_MAX_VALUE.toString(), + ), + ); + }); + }); + + describe('Multiple field validation errors', () => { + it('should return multiple errors for invalid object with multiple issues', () => { + const invalidMetadata = { + ...MOCK_METADATA_VALID, + casRequired: 'invalid', + deactivateVersionAfter: 'invalid-duration', + maxVersions: 'not-a-number', + }; + const result = getSchemaParsingResult(invalidMetadata); + expect(result.success).toBe(false); + expect(result.error.issues).toHaveLength(3); + expect(result.error.issues[0].path).toEqual(['casRequired']); + expect(result.error.issues[1].path).toEqual(['deactivateVersionAfter']); + expect(result.error.issues[2].path).toEqual(['maxVersions']); + }); + + it('should return multiple errors for completely empty object', () => { + const result = getSchemaParsingResult({}); + expect(result.success).toBe(false); + expect(result.error.issues).toHaveLength(3); + expect(result.error.issues[0].path).toEqual(['casRequired']); + expect(result.error.issues[1].path).toEqual(['deactivateVersionAfter']); + expect(result.error.issues[2].path).toEqual(['maxVersions']); + }); + }); + + describe('Edge cases', () => { + it('should return error for undefined input', () => { + const result = getSchemaParsingResult(undefined); + expect(result.success).toBe(false); + }); + + it('should return error for null input', () => { + const result = getSchemaParsingResult(null); + expect(result.success).toBe(false); + }); + + it('should return error for non-object input', () => { + const result = getSchemaParsingResult('not-an-object'); + expect(result.success).toBe(false); + }); + }); +}); diff --git a/packages/manager/apps/okms/src/modules/secret-manager/validation/metadata/metadataSchema.tsx b/packages/manager/apps/okms/src/modules/secret-manager/validation/metadata/metadataSchema.tsx new file mode 100644 index 000000000000..7c6600b754b9 --- /dev/null +++ b/packages/manager/apps/okms/src/modules/secret-manager/validation/metadata/metadataSchema.tsx @@ -0,0 +1,33 @@ +import { z } from 'zod'; +import { useTranslation } from 'react-i18next'; +import { NAMESPACES } from '@ovh-ux/manager-common-translations'; + +export const MAX_VERSIONS_MIN_VALUE = 0; +export const MAX_VERSIONS_MAX_VALUE = 24_000; + +export const useSecretMetadataSchema = () => { + const { t } = useTranslation(['secret-manager', NAMESPACES.FORM]); + + return z.object({ + casRequired: z.enum(['active', 'inactive']), + deactivateVersionAfter: z.string().regex(/^(?:\d+[dhms])+$/, { + message: t('error_invalid_duration'), + }), + maxVersions: z + .number() + .min( + MAX_VERSIONS_MIN_VALUE, + t('error_min_exclusive', { + ns: NAMESPACES.FORM, + value: MAX_VERSIONS_MIN_VALUE, + }), + ) + .max( + MAX_VERSIONS_MAX_VALUE, + t('error_max_inclusive', { + ns: NAMESPACES.FORM, + value: MAX_VERSIONS_MAX_VALUE, + }), + ), + }); +}; diff --git a/packages/manager/apps/okms/src/setupTests.tsx b/packages/manager/apps/okms/src/setupTests.tsx index de798918c27c..1fab20812ec9 100644 --- a/packages/manager/apps/okms/src/setupTests.tsx +++ b/packages/manager/apps/okms/src/setupTests.tsx @@ -55,7 +55,7 @@ vi.mock('@ovh-ux/manager-react-components', async () => { Drawer: vi.fn(({ children, className, ...props }) => ( {props.heading} - {children} + {!props.isLoading && children} )), }; diff --git a/packages/manager/apps/okms/src/utils/tests/msw.tsx b/packages/manager/apps/okms/src/utils/tests/msw.tsx new file mode 100644 index 000000000000..df6edda8d489 --- /dev/null +++ b/packages/manager/apps/okms/src/utils/tests/msw.tsx @@ -0,0 +1,42 @@ +import { PathParams } from 'msw'; +import { Handler } from '@ovh-ux/manager-core-test-utils'; +import { ErrorResponse } from '@/types/api.type'; + +type BuildHandlerResponseParams = { + data: T | ((request: Request, params: PathParams) => T); + errorMessage: string; + isError: boolean; +}; + +type BuildHandlerResponse = + | T + | ((request: Request, params: PathParams) => T) + | ErrorResponse['response']['data']; + +/** + * Format the data response mock and the error for a MSW handler + * The data parameter can be a static value of type T, or a function that returns a value of type T + * Return: T, or a function that returns T, or an error response + */ +export function buildMswResponseMock({ + data, + errorMessage, + isError, +}: BuildHandlerResponseParams): BuildHandlerResponse { + if (isError) { + return { + message: errorMessage, + }; + } + return data; +} + +/** + * Set all handlers to 0ms delay + * This avoids the global utils function to set a default delay of 1000ms + */ +export const removeHandlersDelay = (handlers: Handler[]): Handler[] => { + return handlers.map((handler) => { + return { ...handler, delay: 0 }; + }); +}; diff --git a/packages/manager/apps/okms/src/utils/tests/renderTestApp.tsx b/packages/manager/apps/okms/src/utils/tests/renderTestApp.tsx index e557e9cac6d6..4ad0c8ee7a1a 100644 --- a/packages/manager/apps/okms/src/utils/tests/renderTestApp.tsx +++ b/packages/manager/apps/okms/src/utils/tests/renderTestApp.tsx @@ -26,6 +26,8 @@ import { GetSecretMockParams, getSecretsMock, GetSecretsMockParams, + updateSecretMock, + UpdateSecretMockParams, } from '@secret-manager/mocks/secrets/secrets.handler'; import { getSecretConfigOkmsMock, @@ -46,7 +48,7 @@ import { updateVersionMock, UpdateVersionMockParams, } from '@secret-manager/mocks/versions/versions.handler'; -import { removeHandlersDelay } from './testUtils'; +import { removeHandlersDelay } from './msw'; import { initTestI18n } from './init.i18n'; import { getOkmsMocks, GetOkmsMocksParams } from '@/mocks/kms/okms.handler'; import { @@ -98,6 +100,7 @@ export type RenderTestMockParams = GetOkmsMocksParams & GetSecretsMockParams & GetSecretMockParams & CreateSecretsMockParams & + UpdateSecretMockParams & DeleteSecretMockParams & GetSecretConfigOkmsMockParams & GetSecretConfigReferenceMockParams & @@ -133,6 +136,7 @@ export const renderTestApp = async ( ...getSecretsMock(mockParams), ...getSecretMock(mockParams), ...createSecretsMock(mockParams), + ...updateSecretMock(mockParams), ...deleteSecretMock(mockParams), ...getSecretConfigOkmsMock(mockParams), ...getSecretConfigReferenceMock(mockParams), diff --git a/packages/manager/apps/okms/src/utils/tests/testUtils.tsx b/packages/manager/apps/okms/src/utils/tests/testUtils.tsx index 7f2a9e54b388..83e86d764613 100644 --- a/packages/manager/apps/okms/src/utils/tests/testUtils.tsx +++ b/packages/manager/apps/okms/src/utils/tests/testUtils.tsx @@ -1,23 +1,15 @@ import React from 'react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import { Handler } from '@ovh-ux/manager-core-test-utils'; import { render, renderHook, RenderHookOptions, RenderHookResult, } from '@testing-library/react'; +import { i18n } from 'i18next'; +import { I18nextProvider } from 'react-i18next'; import { ErrorResponse } from '@/types/api.type'; - -/** - * Set all handlers to 0ms delay - * This avoids the global utils function to set a default delay of 1000ms - */ -export const removeHandlersDelay = (handlers: Handler[]): Handler[] => { - return handlers.map((handler) => { - return { ...handler, delay: 0 }; - }); -}; +import { initTestI18n } from './init.i18n'; /** * Wait for x miliseconds @@ -94,6 +86,20 @@ export const renderHookWithClient = ( }); }; +/** + * Render a component with I18n provider + */ +let i18nValue: i18n; +export const renderWithI18n = async (ui: React.ReactElement) => { + if (!i18nValue) { + i18nValue = await initTestI18n(); + } + const Wrappers = ({ children }: { children: React.ReactNode }) => ( + {children} + ); + return render(ui, { wrapper: Wrappers }); +}; + /** * Create API response mock for tests */ @@ -107,26 +113,3 @@ export const createErrorResponseMock = (message: string): ErrorResponse => { }, }; }; - -type CreateHandlerResponseParams = { - data: unknown; - errorMessage: string; - isError: boolean; -}; - -/** - * Format the data response mock and the error for a MSW handler - */ -export const createHandlerResponseMock = ({ - data, - errorMessage, - isError, -}: CreateHandlerResponseParams) => { - if (isError) { - return { message: errorMessage }; - } - return { - status: 200, - data, - }; -}; diff --git a/packages/manager/apps/okms/src/utils/tests/uiTestHelpers.ts b/packages/manager/apps/okms/src/utils/tests/uiTestHelpers.ts index 50aece19f2c5..331709e40a59 100644 --- a/packages/manager/apps/okms/src/utils/tests/uiTestHelpers.ts +++ b/packages/manager/apps/okms/src/utils/tests/uiTestHelpers.ts @@ -1,5 +1,11 @@ import { OdsIcon } from '@ovhcloud/ods-components/react'; -import { screen, waitFor, waitForOptions } from '@testing-library/react'; +import { + waitFor, + waitForOptions, + screen, + fireEvent, + act, +} from '@testing-library/react'; const WAIT_FOR_DEFAULT_OPTIONS = { timeout: 3000 }; @@ -129,3 +135,29 @@ export const getOdsLinkByTestId = async ({ return link; }; + +export const changeOdsInputValueByTestId = async ( + inputTestId: string, + value: string, +) => { + // First try to get the input directly + let input = screen.queryByTestId(inputTestId); + + // If the input is not found, try to find it with a findByTestId + // This can look silly, but the ODS input renders in a mysterious way + // and if you use a findByTestId when you need a getByTestId (and reverse), it won't work + if (!input) { + input = await screen.findByTestId(inputTestId); + } + + await act(() => { + fireEvent.change(input, { + target: { value }, + }); + }); + + // Wait for the data input to be updated + await waitFor(() => { + expect(input).toHaveValue(value); + }); +};