diff --git a/libs/domains/organizations/feature/src/lib/container-registry-create-edit-modal/container-registry-create-edit-modal.spec.tsx b/libs/domains/organizations/feature/src/lib/container-registry-create-edit-modal/container-registry-create-edit-modal.spec.tsx
index 29b4848bf68..ee8aab2b2f3 100644
--- a/libs/domains/organizations/feature/src/lib/container-registry-create-edit-modal/container-registry-create-edit-modal.spec.tsx
+++ b/libs/domains/organizations/feature/src/lib/container-registry-create-edit-modal/container-registry-create-edit-modal.spec.tsx
@@ -393,6 +393,36 @@ describe('ContainerRegistryCreateEditModal', () => {
)
}, 30000)
+ it('should strip trailing slash from registry URL on submit', async () => {
+ const { userEvent } = renderWithProviders()
+
+ const inputType = screen.getByLabelText('Type')
+ await selectEvent.select(inputType, 'GENERIC_CR', {
+ container: document.body,
+ })
+
+ const inputName = screen.getByLabelText('Registry name')
+ await userEvent.clear(inputName)
+ await userEvent.type(inputName, 'registry-name')
+
+ const inputUrl = screen.getByLabelText('Registry url')
+ await userEvent.clear(inputUrl)
+ await userEvent.type(inputUrl, 'https://my-registry.example.com/')
+
+ const btn = screen.getByRole('button', { name: 'Create' })
+ expect(btn).toBeEnabled()
+
+ await userEvent.click(btn)
+
+ expect(useCreateContainerRegistryMockSpy().mutateAsync).toHaveBeenCalledWith(
+ expect.objectContaining({
+ containerRegistryRequest: expect.objectContaining({
+ url: 'https://my-registry.example.com',
+ }),
+ })
+ )
+ }, 30000)
+
it('should set default registry type based on existing registry config', () => {
expect(getContainerRegistryDefaultType(undefined)).toBe('STS')
@@ -572,4 +602,38 @@ describe('ContainerRegistryCreateEditModal', () => {
expect(props.onClose).toHaveBeenCalled()
})
+
+ it('should strip trailing slash from registry URL when editing a registry', async () => {
+ const { userEvent } = renderWithProviders(
+
+ )
+
+ const inputUrl = screen.getByLabelText('Registry url')
+ await userEvent.clear(inputUrl)
+ await userEvent.type(inputUrl, 'https://my-registry.example.com/')
+
+ const btn = screen.getByRole('button', { name: 'Confirm' })
+ expect(btn).toBeEnabled()
+
+ await userEvent.click(btn)
+
+ expect(useEditContainerRegistryMockSpy().mutateAsync).toHaveBeenCalledWith(
+ expect.objectContaining({
+ containerRegistryRequest: expect.objectContaining({
+ url: 'https://my-registry.example.com',
+ }),
+ })
+ )
+ })
})
diff --git a/libs/domains/organizations/feature/src/lib/container-registry-create-edit-modal/container-registry-create-edit-modal.tsx b/libs/domains/organizations/feature/src/lib/container-registry-create-edit-modal/container-registry-create-edit-modal.tsx
index b08950009fe..f3079ec8499 100644
--- a/libs/domains/organizations/feature/src/lib/container-registry-create-edit-modal/container-registry-create-edit-modal.tsx
+++ b/libs/domains/organizations/feature/src/lib/container-registry-create-edit-modal/container-registry-create-edit-modal.tsx
@@ -7,6 +7,7 @@ import { useEffect } from 'react'
import { FormProvider, useForm } from 'react-hook-form'
import { P, match } from 'ts-pattern'
import { ExternalLink, ModalCrud, useModal } from '@qovery/shared/ui'
+import { stripUrlTrailingSlash } from '@qovery/shared/util-js'
import ContainerRegistryForm from '../container-registry-form/container-registry-form'
import { useCreateContainerRegistry } from '../hooks/use-create-container-registry/use-create-container-registry'
import { useEditContainerRegistry } from '../hooks/use-edit-container-registry/use-edit-container-registry'
@@ -167,6 +168,7 @@ export function ContainerRegistryCreateEditModal({
type,
kind,
config: { login_type, ...config },
+ url,
...rest
} = containerRegistryRequest
try {
@@ -175,6 +177,8 @@ export function ContainerRegistryCreateEditModal({
containerRegistryRequest: {
...rest,
kind,
+ // A URL like `https://ghcr.io/` is rejected as invalid by the backend
+ url: url ? stripUrlTrailingSlash(url) : url,
config: getContainerRegistryPayloadConfig({
type,
kind,
diff --git a/libs/domains/organizations/feature/src/lib/helm-repository-create-edit-modal/helm-repository-create-edit-modal.spec.tsx b/libs/domains/organizations/feature/src/lib/helm-repository-create-edit-modal/helm-repository-create-edit-modal.spec.tsx
index 4e94963af8a..ad2f09da05b 100644
--- a/libs/domains/organizations/feature/src/lib/helm-repository-create-edit-modal/helm-repository-create-edit-modal.spec.tsx
+++ b/libs/domains/organizations/feature/src/lib/helm-repository-create-edit-modal/helm-repository-create-edit-modal.spec.tsx
@@ -200,6 +200,81 @@ describe('HelmRepositoryCreateEditModal', () => {
})
})
+ it('should strip trailing slash from OCI URL on submit', async () => {
+ props.repository = undefined
+
+ const { userEvent } = renderWithProviders()
+
+ const inputName = screen.getByTestId('input-name')
+ await userEvent.type(inputName, 'my-oci-repository')
+
+ const selectType = screen.getByLabelText('Kind')
+ await selectEvent.select(selectType, 'OCI_GENERIC_CR', { container: document.body })
+
+ const inputUrl = screen.getByTestId('input-url')
+ await userEvent.type(inputUrl, 'oci://docker.io/')
+
+ const button = await screen.findByRole('button', { name: /Create/i })
+ expect(button).toBeInTheDocument()
+ expect(button).toBeEnabled()
+
+ await userEvent.click(screen.getByTestId('submit-button'))
+
+ expect(useCreateHelmRepositoryMockSpy().mutateAsync).toHaveBeenCalledWith({
+ organizationId: '0000-0000-0000',
+ helmRepositoryRequest: {
+ name: 'my-oci-repository',
+ kind: 'OCI_GENERIC_CR',
+ description: undefined,
+ url: 'oci://docker.io',
+ config: {
+ access_key_id: undefined,
+ region: undefined,
+ scaleway_access_key: undefined,
+ scaleway_secret_key: undefined,
+ secret_access_key: undefined,
+ username: undefined,
+ password: undefined,
+ },
+ },
+ })
+ })
+
+ it('should strip trailing slash from OCI URL when editing a repository', async () => {
+ const { userEvent } = renderWithProviders(
+
+ )
+
+ const inputUrl = screen.getByTestId('input-url')
+ await userEvent.clear(inputUrl)
+ await userEvent.type(inputUrl, 'oci://docker.io/')
+
+ const btn = screen.getByRole('button', { name: 'Confirm' })
+ expect(btn).toBeEnabled()
+
+ await userEvent.click(btn)
+
+ expect(useEditHelmRepositoryMockSpy().mutateAsync).toHaveBeenCalledWith(
+ expect.objectContaining({
+ helmRepositoryRequest: expect.objectContaining({
+ url: 'oci://docker.io',
+ }),
+ })
+ )
+ })
+
it('should submit the form to edit a repository', async () => {
const { userEvent } = renderWithProviders(
{
+ it('should strip a single trailing slash', () => {
+ expect(stripUrlTrailingSlash('oci://docker.io/')).toBe('oci://docker.io')
+ })
+
+ it('should strip multiple trailing slashes', () => {
+ expect(stripUrlTrailingSlash('oci://docker.io///')).toBe('oci://docker.io')
+ })
+
+ it('should leave a URL without a trailing slash untouched', () => {
+ expect(stripUrlTrailingSlash('oci://docker.io')).toBe('oci://docker.io')
+ })
+
+ it('should preserve a meaningful path', () => {
+ expect(stripUrlTrailingSlash('oci://aaa.bbb.ccc.tech/qovery')).toBe('oci://aaa.bbb.ccc.tech/qovery')
+ })
+
+ it('should strip a trailing slash from the path while preserving a query string', () => {
+ expect(stripUrlTrailingSlash('https://docker.io/?foo=bar')).toBe('https://docker.io?foo=bar')
+ })
+
+ it('should strip a trailing slash from the path while preserving a fragment', () => {
+ expect(stripUrlTrailingSlash('https://docker.io/#section')).toBe('https://docker.io#section')
+ })
+
+ it('should not touch a trailing slash inside a query value', () => {
+ expect(stripUrlTrailingSlash('https://docker.io?redirect=/foo/')).toBe('https://docker.io?redirect=/foo/')
+ })
+})
diff --git a/libs/shared/util-js/src/lib/strip-url-trailing-slash.ts b/libs/shared/util-js/src/lib/strip-url-trailing-slash.ts
new file mode 100644
index 00000000000..7de160ca85a
--- /dev/null
+++ b/libs/shared/util-js/src/lib/strip-url-trailing-slash.ts
@@ -0,0 +1,10 @@
+// Strips trailing slash(es) from the path of a URL, leaving any query string or fragment untouched.
+// Backends (Helm repositories, container registries) reject a URL like `oci://docker.io/` because
+// the trailing slash makes the path non-empty, so we normalize it before submitting the form.
+export function stripUrlTrailingSlash(url: string): string {
+ const suffixIndex = url.search(/[?#]/)
+ const path = suffixIndex === -1 ? url : url.slice(0, suffixIndex)
+ const suffix = suffixIndex === -1 ? '' : url.slice(suffixIndex)
+
+ return path.replace(/\/+$/, '') + suffix
+}