Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,15 @@ describe('ApplicationContainerStepSummary', () => {
})
})

it('does not send unsupported JSON references to the external variable API', async () => {
const { userEvent } = renderComponent({
externalSecrets: [{ ...externalSecretsData[0], isJson: true }],
})
await userEvent.click(screen.getByTestId('button-create'))
await waitFor(() => expect(mockNavigate).toHaveBeenCalled())
expect(mockCreateVariable).not.toHaveBeenCalled()
})

it('continues service creation when external secret creation fails', async () => {
mockCreateVariable.mockRejectedValueOnce(new Error('External secret creation failed'))
const { userEvent } = renderComponent()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,9 @@ export function ApplicationContainerStepSummary({
// External secret creation errors are surfaced by the mutation notifications but should not block the service creation flow.
await Promise.allSettled(
externalSecretsData.map(async (externalSecret) => {
// TODO(secret-manager-json): Persist the form’s JSON drafts here when the real API handler is available.
// Until then they are local only; never send JSON references through the single-variable API.
if (externalSecret.isJson) return
const scope = externalSecret.scope ?? createPayload.serviceType
return createVariable({
variableRequest: {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { type ReactNode } from 'react'
import { type SecretManagerAccess } from 'qovery-typescript-axios'
import { type ReactNode, useEffect } from 'react'
import { type AddSecretModalSubmitData } from '@qovery/domains/variables/feature'
import { renderWithProviders, screen, waitFor } from '@qovery/shared/util-tests'
import {
ApplicationContainerCreationFlow,
Expand All @@ -8,7 +10,7 @@ import { ApplicationContainerStepVariables } from './step-variables'

const mockNavigate = jest.fn()
const mockUseVariablesSecretManagers = jest.fn(() => ({
secretManagers: [],
secretManagers: [] as SecretManagerAccess[],
hasClusterSecretManagerConfigured: false,
clusterId: 'cluster-1',
}))
Expand Down Expand Up @@ -36,7 +38,35 @@ jest.mock('@tanstack/react-router', () => ({
}))

jest.mock('@qovery/domains/variables/feature', () => ({
AddSecretModal: () => null,
AddSecretDropdown: jest.requireActual('@qovery/domains/variables/feature').AddSecretDropdown,
AddSecretModal: ({
isFile,
isJson,
onSubmit,
onClose,
}: {
isFile?: boolean
isJson?: boolean
onSubmit: (data: AddSecretModalSubmitData) => void | Promise<void>
onClose: () => void
}) => (
<button
type="button"
onClick={async () => {
await onSubmit({
name: 'My JSON credentials',
reference: 'production/credentials',
isJson,
isFile: !!isFile,
keyPrefix: 'APP',
secretManagerAccessId: 'manager-id',
})
onClose()
}}
>
{isJson ? 'JSON secret modal' : isFile ? 'File secret modal' : 'Variable secret modal'}
</button>
),
VariableFormModal: ({
onSubmit,
scope,
Expand Down Expand Up @@ -77,6 +107,21 @@ function VariablesState() {
return <div data-testid="variables-state">{JSON.stringify(variablesForm.watch())}</div>
}

function JsonSecretStep({ keyPrefix }: { keyPrefix?: string }) {
const { variablesForm } = useApplicationContainerCreateContext()
useEffect(() => {
variablesForm.setValue('externalSecrets', [
{
name: 'Application credentials',
reference: 'lucis/production/application',
isJson: true,
keyPrefix,
},
])
}, [variablesForm, keyPrefix])
return <ApplicationContainerStepVariables onBack={jest.fn()} onSubmit={jest.fn()} />
}

describe('ApplicationContainerStepVariables', () => {
const onBack = jest.fn()
const onSubmit = jest.fn()
Expand Down Expand Up @@ -164,6 +209,87 @@ describe('ApplicationContainerStepVariables', () => {
expect(onSubmit).toHaveBeenCalled()
})

it.each([
['Add secret as variable', 'Variable secret modal'],
['Add secret as file', 'File secret modal'],
['Add secret as JSON', 'JSON secret modal'],
])('opens %s from the external secret dropdown', async (option, modal) => {
mockUseVariablesSecretManagers.mockReturnValue({
secretManagers: [{ id: 'first-source' }, { id: 'second-source' }] as SecretManagerAccess[],
hasClusterSecretManagerConfigured: true,
clusterId: 'cluster-1',
})
const { userEvent } = renderWithProviders(
<ApplicationContainerCreationFlow
creationFlowUrl="/organization/org-1/project/proj-1/environment/env-1/service/create/application"
defaultServiceType="APPLICATION"
>
<ApplicationContainerStepVariables onBack={onBack} onSubmit={onSubmit} />
</ApplicationContainerCreationFlow>
)
expect(screen.queryByRole('button', { name: 'Add secret as file' })).not.toBeInTheDocument()
const addButtons = screen.getAllByRole('button', { name: 'Add secret' })
await userEvent.click(addButtons[addButtons.length - 1])
expect(screen.getAllByRole('menuitem').map((item) => item.textContent)).toEqual([
'Add secret as variable',
'Add secret as file',
'Add secret as JSON',
])
await userEvent.click(screen.getByRole('menuitem', { name: option }))
expect(screen.getByText(modal)).toBeInTheDocument()
expect(screen.getByRole('button', { name: modal })).toBeEnabled()
})

it('adds and removes a user-entered JSON reference in the creation form', async () => {
mockUseVariablesSecretManagers.mockReturnValue({
secretManagers: [],
hasClusterSecretManagerConfigured: true,
clusterId: 'cluster-1',
})
const { userEvent } = renderWithProviders(
<ApplicationContainerCreationFlow
creationFlowUrl="/organization/org-1/project/proj-1/environment/env-1/service/create/application"
defaultServiceType="APPLICATION"
>
<ApplicationContainerStepVariables onBack={onBack} onSubmit={onSubmit} />
<VariablesState />
</ApplicationContainerCreationFlow>
)
const buttons = screen.getAllByRole('button', { name: 'Add secret' })
await userEvent.click(buttons[buttons.length - 1])
await userEvent.click(screen.getByRole('menuitem', { name: 'Add secret as JSON' }))
await userEvent.click(screen.getByRole('button', { name: 'JSON secret modal' }))
expect(screen.getByText('My JSON credentials')).toBeInTheDocument()
expect(screen.getByText('Prefix: APP_')).toBeInTheDocument()
expect(screen.getByTestId('variables-state')).toHaveTextContent('"keyPrefix":"APP"')
await userEvent.click(screen.getByRole('button', { name: 'Delete secret' }))
expect(screen.queryByText('My JSON credentials')).not.toBeInTheDocument()
})

it.each([undefined, 'APP', 'APP_'])(
'renders JSON references with prefix %s without expandable key details',
(keyPrefix) => {
mockUseVariablesSecretManagers.mockReturnValue({
secretManagers: [],
hasClusterSecretManagerConfigured: true,
clusterId: 'cluster-1',
})
renderWithProviders(
<ApplicationContainerCreationFlow
creationFlowUrl="/organization/org-1/project/proj-1/environment/env-1/service/create/application"
defaultServiceType="APPLICATION"
>
<JsonSecretStep keyPrefix={keyPrefix} />
</ApplicationContainerCreationFlow>
)
expect(screen.getByText('Application credentials')).toBeInTheDocument()
expect(screen.getByText('lucis/production/application')).toBeInTheDocument()
expect(screen.getByText(keyPrefix ? 'Prefix: APP_' : 'No prefix')).toBeInTheDocument()
expect(screen.queryByLabelText(/Expand keys|Collapse keys|Copy DATABASE_URL/)).not.toBeInTheDocument()
expect(screen.queryByText(/secrets imported/)).not.toBeInTheDocument()
}
)

it('does not expose external secret creation when no secret manager is linked', () => {
renderWithProviders(
<ApplicationContainerCreationFlow
Expand Down
Loading
Loading