Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -47,10 +47,10 @@ export function StepVariables() {

const [variables, setVariables] = useState(methods.getValues().variables)

const onAddPort = () => {
const onAddPort = (isSecret = false) => {
const newVariableRow: VariableData = {
variable: '',
isSecret: false,
isSecret,
value: '',
scope: APIVariableScopeEnum.JOB,
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ jest.mock('@qovery/domains/variables/feature', () => ({
VariableFormModal: ({
onSubmit,
scope,
isFile,
isSecret,
}: {
onSubmit?: (data: {
key: string
Expand All @@ -51,18 +51,17 @@ jest.mock('@qovery/domains/variables/feature', () => ({
mountPath?: string
}) => void
scope: 'APPLICATION' | 'CONTAINER'
isFile?: boolean
isSecret?: boolean
}) => (
<button
type="button"
onClick={() =>
onSubmit?.({
key: isFile ? 'CONFIG_FILE' : 'NODE_ENV',
value: isFile ? '{"key":"value"}' : 'production',
key: 'NODE_ENV',
value: 'production',
scope,
isSecret: false,
isFile: !!isFile,
mountPath: isFile ? '/vault/secrets/config-file' : undefined,
isSecret: !!isSecret,
isFile: false,
})
}
>
Expand Down Expand Up @@ -113,6 +112,28 @@ describe('ApplicationContainerStepVariables', () => {
})
})

it('adds a secret with application scope by default', async () => {
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>
)

await userEvent.click(screen.getByRole('button', { name: /^add secret$/i }))
await userEvent.click(screen.getByRole('button', { name: /confirm variable modal/i }))

await waitFor(() => {
expect(screen.getByTestId('variables-state')).toHaveTextContent('"scope":"APPLICATION"')
expect(screen.getByTestId('variables-state')).toHaveTextContent('"isSecret":true')
})
})

it('calls onBack when going back', async () => {
const { userEvent } = renderWithProviders(
<ApplicationContainerCreationFlow
Expand Down Expand Up @@ -154,7 +175,6 @@ describe('ApplicationContainerStepVariables', () => {
)

expect(screen.getByText('No secret manager linked on your cluster')).toBeInTheDocument()
expect(screen.queryByRole('button', { name: /^add secret$/i })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: /^add secret as file$/i })).not.toBeInTheDocument()
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -175,7 +175,13 @@ export function ApplicationContainerStepVariables({ onBack, onSubmit }: Applicat
setCurrentStep(5)
}, [setCurrentStep])

const openVariableModal = ({ isFile, index }: { isFile: boolean; index?: number }) => {
const openVariableModal = ({
isSecret = false,
index,
}: {
isSecret?: boolean
index?: number
} = {}) => {
const currentVariable = typeof index === 'number' ? variables[index] : undefined
const mode = typeof index === 'number' ? 'UPDATE' : 'CREATE'

Expand All @@ -185,7 +191,6 @@ export function ApplicationContainerStepVariables({ onBack, onSubmit }: Applicat
closeModal={closeModal}
mode={mode}
type={currentVariable?.file ? APIVariableTypeEnum.FILE : APIVariableTypeEnum.VALUE}
isFile={isFile}
variable={
typeof index === 'number' && currentVariable
? mapVariableToModalVariable(currentVariable, index, serviceScope)
Expand All @@ -200,6 +205,7 @@ export function ApplicationContainerStepVariables({ onBack, onSubmit }: Applicat
}
}}
scope={serviceScope}
isSecret={typeof index === 'number' ? currentVariable?.isSecret : isSecret}
projectId={projectId}
environmentId={environmentId}
/>
Expand Down Expand Up @@ -258,26 +264,68 @@ export function ApplicationContainerStepVariables({ onBack, onSubmit }: Applicat
<div className="relative overflow-hidden rounded-t-lg border-x border-t border-neutral bg-surface-neutral-subtle">
<div className="flex min-h-[52px] items-center justify-between px-4 pb-5 pt-3">
<p className="text-sm font-medium text-neutral">Custom variables</p>
{variables.length > 0 &&
cardHeaderActions({
onAddDefault: () => openVariableModal({ isFile: false }),
onAddAsFile: () => openVariableModal({ isFile: true }),
defaultLabel: 'Add variable',
asFileLabel: 'Add variable as file',
})}
{variables.length > 0 && (
<div className="flex flex-wrap items-center justify-center gap-2">
<Button
type="button"
color="neutral"
variant="outline"
size="sm"
className="text-ssm"
onClick={() => openVariableModal({ isSecret: true })}
>
<Icon iconName="lock-keyhole" iconStyle="regular" className="text-ssm" />
Add secret
</Button>
<Button
type="button"
color="neutral"
variant="solid"
size="sm"
className="text-ssm"
onClick={() => openVariableModal()}
>
<Icon iconName="key" className="text-ssm" />
Add variable
</Button>
</div>
)}
</div>
</div>

<div className="relative -mt-2 rounded-lg">
<div className="overflow-hidden rounded-lg border border-neutral bg-surface-neutral">
{variables.length === 0 ? (
emptyState({
title: 'No custom variables added yet',
onAddDefault: () => openVariableModal({ isFile: false }),
onAddAsFile: () => openVariableModal({ isFile: true }),
defaultLabel: 'Add variable',
asFileLabel: 'Add variable as file',
})
<EmptyState
title="No custom variables added yet"
icon="lock-keyhole"
className="h-auto rounded-none border-0 bg-transparent px-8 py-8"
>
<div className="flex flex-wrap items-center justify-center gap-2">
<Button
type="button"
color="neutral"
variant="solid"
size="md"
className="text-ssm"
onClick={() => openVariableModal()}
>
<Icon iconName="key" className="text-ssm" />
Add variable
</Button>
<Button
type="button"
color="neutral"
variant="outline"
size="md"
className="text-ssm"
onClick={() => openVariableModal({ isSecret: true })}
>
<Icon iconName="lock-keyhole" iconStyle="regular" className="text-ssm" />
Add secret
</Button>
</div>
</EmptyState>
) : (
<>
<div className="grid grid-cols-[minmax(0,1fr)_minmax(0,1fr)_88px] border-b border-neutral text-xs">
Expand Down Expand Up @@ -342,7 +390,7 @@ export function ApplicationContainerStepVariables({ onBack, onSubmit }: Applicat
size="xs"
iconOnly
disabled={variable.isReadOnly}
onClick={() => openVariableModal({ isFile: !!variable.file, index })}
onClick={() => openVariableModal({ index })}
>
<Icon iconName="pen" />
</Button>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,15 +42,15 @@ export function ServiceVariablesCustomTab() {
return null
}

const handleOpenCreateVariableModal = (isFile = false) =>
const handleOpenCreateVariableModal = (isSecret = false) =>
openModal({
content: (
<CreateUpdateVariableModal
closeModal={closeModal}
type="VALUE"
mode="CREATE"
onSubmit={onCreateVariableToast}
isFile={isFile}
isSecret={isSecret}
hasClusterSecretManagerConfigured={hasClusterSecretManagerConfigured}
scope={scope}
projectId={projectId}
Expand Down Expand Up @@ -89,26 +89,15 @@ export function ServiceVariablesCustomTab() {
className="rounded-none border-0 bg-transparent py-12"
>
<div className="flex items-center gap-2">
<DropdownMenu.Root>
<DropdownMenu.Trigger asChild>
<Button color="neutral" variant="solid" size="md" className="gap-1.5">
<Icon iconName="circle-plus" iconStyle="regular" />
Add variable
<Icon iconName="angle-down" />
</Button>
</DropdownMenu.Trigger>
<DropdownMenu.Content>
<DropdownMenu.Item onSelect={() => handleOpenCreateVariableModal()} icon={<Icon iconName="key" />}>
Variable
</DropdownMenu.Item>
<DropdownMenu.Item
onSelect={() => handleOpenCreateVariableModal(true)}
icon={<Icon iconName="file-lines" iconStyle="regular" />}
>
Variable as file
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
<Button color="neutral" variant="solid" size="md" onClick={() => handleOpenCreateVariableModal()}>
<Icon iconName="key" />
Add variable
</Button>

<Button color="neutral" variant="outline" size="md" onClick={() => handleOpenCreateVariableModal(true)}>
<Icon iconName="lock-keyhole" iconStyle="regular" />
Add secret
</Button>

<DropdownMenu.Root>
<DropdownMenu.Trigger asChild>
Expand All @@ -122,12 +111,6 @@ export function ServiceVariablesCustomTab() {
<DropdownMenu.Item onSelect={handleOpenImportVariablesModal} icon={<Icon iconName="file-import" />}>
Import from .env file
</DropdownMenu.Item>
<DropdownMenu.Item
onSelect={() => window.open('https://dashboard.doppler.com', '_blank')}
icon={<Icon iconName="arrow-up-right-from-square" />}
>
Import from Doppler
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
</div>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { type VariableResponse } from 'qovery-typescript-axios'
import { type ReactNode } from 'react'
import { renderWithProviders, screen } from '@qovery/shared/util-tests'
import { CreateUpdateVariableModal } from './create-update-variable-modal'
import { renderWithProviders, screen, waitFor } from '@qovery/shared/util-tests'
import { CreateUpdateVariableModal, VariableFormModal } from './create-update-variable-modal'

jest.mock('@qovery/shared/ui', () => {
const actual = jest.requireActual('@qovery/shared/ui')
Expand Down Expand Up @@ -97,6 +97,89 @@ describe('CreateUpdateVariableModal', () => {
expect(descriptionField).not.toBeInstanceOf(HTMLTextAreaElement)
})

it('should render secret creation without the secret toggle and with hidden value control', () => {
renderWithProviders(<CreateUpdateVariableModal {...baseProps} mode="CREATE" type="VALUE" isSecret />)

expect(screen.getByText('New secret')).toBeInTheDocument()
expect(screen.queryByText('Secret variable')).not.toBeInTheDocument()
expect(screen.getByRole('checkbox', { name: /show value/i })).toBeInTheDocument()
})

it('should default to value format and show file inputs when selecting as file', async () => {
const { userEvent } = renderWithProviders(<CreateUpdateVariableModal {...baseProps} mode="CREATE" type="VALUE" />)

expect(screen.getByRole('radio', { name: /value/i })).toHaveAttribute('data-state', 'on')
expect(screen.queryByLabelText('Path')).not.toBeInTheDocument()

await userEvent.click(screen.getByRole('radio', { name: /as file/i }))

const pathField = screen.getByLabelText('Path')

expect(screen.getByText('New variable file')).toBeInTheDocument()
expect(pathField).not.toHaveAttribute('placeholder')
expect(pathField).toHaveValue('')
expect(screen.getByText('Variable interpolation')).toBeInTheDocument()

await userEvent.click(pathField)

expect(pathField).toHaveValue('/')
})

it('should render secret file edit with hidden value control', async () => {
const { userEvent } = renderWithProviders(
<CreateUpdateVariableModal
{...baseProps}
mode="UPDATE"
type="FILE"
variable={{
...baseVariable,
value: '',
mount_path: '/vault/secrets/my-secret',
variable_type: 'FILE',
variable_kind: 'Private',
is_secret: true,
}}
/>
)

const valueField = screen.getByLabelText('Value')

expect(screen.getByText('Edit secret file')).toBeInTheDocument()
expect(screen.getByRole('checkbox', { name: /show value/i })).toBeInTheDocument()
expect(valueField).toHaveClass('[-webkit-text-security:disc]')

await userEvent.click(screen.getByRole('checkbox', { name: /show value/i }))

expect(valueField).not.toHaveClass('[-webkit-text-security:disc]')
})

it('should submit a secret created as a file with the selected format', async () => {
const onSubmit = jest.fn()
const { userEvent } = renderWithProviders(
<VariableFormModal {...baseProps} mode="CREATE" type="VALUE" isSecret onSubmit={onSubmit} />
)

await userEvent.click(screen.getByRole('radio', { name: /as file/i }))
await userEvent.type(screen.getByLabelText('Variable'), 'MY_SECRET')
await userEvent.click(screen.getByLabelText('Path'))
await userEvent.type(screen.getByLabelText('Path'), 'vault/secrets/my-secret')
await userEvent.type(screen.getByLabelText('Value'), 'secret-value')
await userEvent.click(screen.getByRole('button', { name: /confirm/i }))

await waitFor(() => {
expect(onSubmit).toHaveBeenCalledWith(
expect.objectContaining({
key: 'MY_SECRET',
value: 'secret-value',
scope: 'ENVIRONMENT',
isSecret: true,
isFile: true,
mountPath: '/vault/secrets/my-secret',
})
)
})
})

it('should not render the open editor button for aliases', () => {
renderWithProviders(<CreateUpdateVariableModal {...baseProps} mode="CREATE" type="ALIAS" variable={baseVariable} />)

Expand Down
Loading
Loading