From 1b1cf7eeeac024485ff65034577114892e94bd75 Mon Sep 17 00:00:00 2001 From: Carrano Date: Mon, 14 Sep 2026 16:45:23 +0200 Subject: [PATCH 1/3] fix(helm-repository): strip trailing slash from OCI repository URL Submitting an OCI helm repository URL with a trailing slash (e.g. `oci://docker.io/`) is rejected by the backend, which requires an empty URL path. Strip trailing slash(es) client-side before submit so users don't hit that error for a purely cosmetic typo. --- ...helm-repository-create-edit-modal.spec.tsx | 40 +++++++++++++++++++ .../helm-repository-create-edit-modal.tsx | 5 +++ 2 files changed, 45 insertions(+) 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..4e2a557434f 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,46 @@ 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 submit the form to edit a repository', async () => { const { userEvent } = renderWithProviders( Date: Mon, 14 Sep 2026 16:58:39 +0200 Subject: [PATCH 2/3] fix(container-registry): strip trailing slash from registry URL too Extract the trailing-slash stripping into a shared stripUrlTrailingSlash util (query string/fragment aware, per review feedback on the helm repository fix), reuse it for container registries, and cover both the create and edit flow for each modal. --- ...tainer-registry-create-edit-modal.spec.tsx | 64 +++++++++++++++++++ .../container-registry-create-edit-modal.tsx | 4 ++ ...helm-repository-create-edit-modal.spec.tsx | 35 ++++++++++ .../helm-repository-create-edit-modal.tsx | 5 +- libs/shared/util-js/src/index.ts | 1 + .../src/lib/strip-url-trailing-slash.spec.ts | 31 +++++++++ .../src/lib/strip-url-trailing-slash.ts | 10 +++ 7 files changed, 148 insertions(+), 2 deletions(-) create mode 100644 libs/shared/util-js/src/lib/strip-url-trailing-slash.spec.ts create mode 100644 libs/shared/util-js/src/lib/strip-url-trailing-slash.ts 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 4e2a557434f..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 @@ -240,6 +240,41 @@ describe('HelmRepositoryCreateEditModal', () => { }) }) + 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 +} From 95dcf03c4f4042b99accb230fb31353831e0debb Mon Sep 17 00:00:00 2001 From: Carrano Date: Tue, 15 Sep 2026 17:20:47 +0200 Subject: [PATCH 3/3] fix(helm-repository): fix CI type error on optional url field HelmRepositoryRequest.url is typed as optional (string | undefined) in the generated API client, so passing it directly to stripUrlTrailingSlash(url: string) failed the build's type check. Guard for the undefined case, same as already done in the container registry modal. --- .../helm-repository-create-edit-modal.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libs/domains/organizations/feature/src/lib/helm-repository-create-edit-modal/helm-repository-create-edit-modal.tsx b/libs/domains/organizations/feature/src/lib/helm-repository-create-edit-modal/helm-repository-create-edit-modal.tsx index 9215f02236d..9093b663a35 100644 --- a/libs/domains/organizations/feature/src/lib/helm-repository-create-edit-modal/helm-repository-create-edit-modal.tsx +++ b/libs/domains/organizations/feature/src/lib/helm-repository-create-edit-modal/helm-repository-create-edit-modal.tsx @@ -117,7 +117,7 @@ export function HelmRepositoryCreateEditModal({ } // A URL like `oci://docker.io/` is rejected as invalid by the backend - const url = stripUrlTrailingSlash(helmRepositoryRequest.url) + const url = helmRepositoryRequest.url ? stripUrlTrailingSlash(helmRepositoryRequest.url) : helmRepositoryRequest.url try { if (repository) {