diff --git a/app/react/common/stacks/queries/useCreateStack/useCreateStack.ts b/app/react/common/stacks/queries/useCreateStack/useCreateStack.ts index 92516c45d..dd30bdbba 100644 --- a/app/react/common/stacks/queries/useCreateStack/useCreateStack.ts +++ b/app/react/common/stacks/queries/useCreateStack/useCreateStack.ts @@ -79,6 +79,7 @@ export type SwarmCreatePayload = git: GitFormModel; relativePathSettings?: RelativePathModel; fromAppTemplate?: boolean; + webhook?: string; }; }; @@ -108,6 +109,7 @@ type StandaloneCreatePayload = git: GitFormModel; relativePathSettings?: RelativePathModel; fromAppTemplate?: boolean; + webhook?: string; }; }; @@ -126,6 +128,7 @@ type KubernetesCreatePayload = payload: KubernetesBasePayload & { git: GitFormModel; relativePathSettings?: RelativePathModel; + webhook?: string; }; } | { @@ -198,7 +201,10 @@ function createSwarmStack({ method, payload }: SwarmCreatePayload) { filesystemPath: payload.relativePathSettings?.FilesystemPath, supportRelativePath: payload.relativePathSettings?.SupportRelativePath, tlsSkipVerify: payload.git.TLSSkipVerify, - autoUpdate: transformAutoUpdateViewModel(payload.git.AutoUpdate), + autoUpdate: transformAutoUpdateViewModel( + payload.git.AutoUpdate, + payload.webhook + ), environmentId: payload.environmentId, swarmID: payload.swarmId, additionalFiles: payload.git.AdditionalFiles, @@ -246,7 +252,10 @@ function createStandaloneStack({ method, payload }: StandaloneCreatePayload) { filesystemPath: payload.relativePathSettings?.FilesystemPath, supportRelativePath: payload.relativePathSettings?.SupportRelativePath, tlsSkipVerify: payload.git.TLSSkipVerify, - autoUpdate: transformAutoUpdateViewModel(payload.git.AutoUpdate), + autoUpdate: transformAutoUpdateViewModel( + payload.git.AutoUpdate, + payload.webhook + ), environmentId: payload.environmentId, additionalFiles: payload.git.AdditionalFiles, fromAppTemplate: payload.fromAppTemplate, @@ -291,7 +300,10 @@ function createKubernetesStack({ method, payload }: KubernetesCreatePayload) { repositoryGitCredentialId: payload.git.RepositoryGitCredentialID, tlsSkipVerify: payload.git.TLSSkipVerify, - autoUpdate: transformAutoUpdateViewModel(payload.git.AutoUpdate), + autoUpdate: transformAutoUpdateViewModel( + payload.git.AutoUpdate, + payload.webhook + ), environmentId: payload.environmentId, additionalFiles: payload.git.AdditionalFiles, composeFormat: payload.composeFormat, diff --git a/app/react/docker/stacks/CreateView/CreateStackForm/CreateStackForm.tsx b/app/react/docker/stacks/CreateView/CreateStackForm/CreateStackForm.tsx index a609a66d2..a65654104 100644 --- a/app/react/docker/stacks/CreateView/CreateStackForm/CreateStackForm.tsx +++ b/app/react/docker/stacks/CreateView/CreateStackForm/CreateStackForm.tsx @@ -1,5 +1,7 @@ import { Formik } from 'formik'; import { useCurrentStateAndParams, useRouter } from '@uirouter/react'; +import { useState } from 'react'; +import uuidv4 from 'uuid/v4'; import { EnvironmentId } from '@/react/portainer/environments/types'; import { notifySuccess } from '@/portainer/services/notifications'; @@ -29,7 +31,7 @@ export function CreateStackForm({ environmentId, isSwarm, swarmId }: Props) { const createStackMutation = useCreateStack(); const { user } = useCurrentUser(); const { isAdmin } = useIsEdgeAdmin(); - + const [webhookId] = useState(() => uuidv4()); const validationSchema = useValidationSchema(environmentId); const initialValues: FormValues = { @@ -52,7 +54,7 @@ export function CreateStackForm({ environmentId, isSwarm, swarmId }: Props) { variables: [], }, env: [], - webhookId: '', + enableWebhook: false, registries: [], accessControl: defaultValues(isAdmin, user.Id), }; @@ -68,6 +70,7 @@ export function CreateStackForm({ environmentId, isSwarm, swarmId }: Props) { isDeploying={createStackMutation.isLoading} isSwarm={isSwarm} isSaved={createStackMutation.isSuccess} + webhookId={webhookId} /> ); @@ -75,12 +78,13 @@ export function CreateStackForm({ environmentId, isSwarm, swarmId }: Props) { async function handleSubmit(values: FormValues) { const stackType = isSwarm ? 'swarm' : 'standalone'; - const payload = buildCreateStackPayload( + const payload = buildCreateStackPayload({ values, environmentId, stackType, - swarmId - ); + swarmId, + webhookId, + }); createStackMutation.mutate(payload, { onSuccess: () => { @@ -91,12 +95,19 @@ export function CreateStackForm({ environmentId, isSwarm, swarmId }: Props) { } } -function buildCreateStackPayload( - values: FormValues, - environmentId: EnvironmentId, - stackType: 'swarm' | 'standalone', - swarmId: string -): CreateStackPayload { +function buildCreateStackPayload({ + environmentId, + stackType, + swarmId, + values, + webhookId, +}: { + values: FormValues; + environmentId: EnvironmentId; + stackType: 'swarm' | 'standalone'; + swarmId: string; + webhookId: string; +}): CreateStackPayload { const basePayload = { name: values.name, environmentId, @@ -115,7 +126,7 @@ function buildCreateStackPayload( ...basePayload, swarmId, fileContent: values.editor.fileContent, - webhook: values.webhookId, + webhook: values.enableWebhook ? webhookId : undefined, }, }; } @@ -125,7 +136,7 @@ function buildCreateStackPayload( payload: { ...basePayload, fileContent: values.editor.fileContent, - webhook: values.webhookId, + webhook: values.enableWebhook ? webhookId : undefined, }, }; @@ -141,7 +152,7 @@ function buildCreateStackPayload( ...basePayload, swarmId, file: values.upload.file, - webhook: values.webhookId, + webhook: values.enableWebhook ? webhookId : undefined, }, }; } @@ -151,7 +162,7 @@ function buildCreateStackPayload( payload: { ...basePayload, file: values.upload.file, - webhook: values.webhookId, + webhook: values.enableWebhook ? webhookId : undefined, }, }; @@ -162,6 +173,7 @@ function buildCreateStackPayload( method: 'git', payload: { ...basePayload, + webhook: webhookId, swarmId, git: values.git, relativePathSettings: values.git.SupportRelativePath @@ -183,6 +195,7 @@ function buildCreateStackPayload( payload: { ...basePayload, git: values.git, + webhook: webhookId, relativePathSettings: values.git.SupportRelativePath ? { SupportRelativePath: true, @@ -205,7 +218,7 @@ function buildCreateStackPayload( ...basePayload, swarmId, fileContent: values.template.fileContent, - webhook: values.webhookId, + webhook: values.enableWebhook ? webhookId : undefined, fromAppTemplate: true, }, }; @@ -216,7 +229,7 @@ function buildCreateStackPayload( payload: { ...basePayload, fileContent: values.template.fileContent, - webhook: values.webhookId, + webhook: values.enableWebhook ? webhookId : undefined, fromAppTemplate: true, }, }; diff --git a/app/react/docker/stacks/CreateView/CreateStackForm/CreateStackForm.webhook.test.tsx b/app/react/docker/stacks/CreateView/CreateStackForm/CreateStackForm.webhook.test.tsx new file mode 100644 index 000000000..6d6173833 --- /dev/null +++ b/app/react/docker/stacks/CreateView/CreateStackForm/CreateStackForm.webhook.test.tsx @@ -0,0 +1,343 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { DefaultBodyType, http, HttpResponse } from 'msw'; +import uuidv4 from 'uuid/v4'; +import { Formik } from 'formik'; + +import { server } from '@/setup-tests/server'; +import { EnvironmentType } from '@/react/portainer/environments/types'; +import { withTestQueryProvider } from '@/react/test-utils/withTestQuery'; +import { withUserProvider } from '@/react/test-utils/withUserProvider'; +import { withTestRouter } from '@/react/test-utils/withRouter'; +import { createMockUsers } from '@/react-tools/test-mocks'; +import { Role } from '@/portainer/users/types'; + +import { mockFormValues } from './test-utils'; +import { CreateStackInnerForm } from './CreateStackInnerForm'; +import { CreateStackForm } from './CreateStackForm'; + +vi.mock('uuid/v4', () => ({ + default: vi.fn(() => 'test-webhook-id-1234'), +})); + +vi.mock('@uirouter/react', async (importOriginal: () => Promise) => ({ + ...(await importOriginal()), + useCurrentStateAndParams: vi.fn(() => ({ + params: { endpointId: 1 }, + })), +})); + +describe('CreateStackForm - Webhook ID Integration', () => { + beforeEach(() => { + vi.clearAllMocks(); + + server.use( + http.get('/api/endpoints/1', () => + HttpResponse.json({ + Id: 1, + Type: EnvironmentType.Docker, + ComposeSyntaxMaxVersion: '3', + ChangeWindow: { + Enabled: false, + }, + }) + ) + ); + }); + + it('should display webhook ID when webhook is enabled in editor method', async () => { + const webhookId = 'test-webhook-id-1234'; + + renderComponent({ + webhookId, + initialValues: mockFormValues({ + method: 'editor', + name: 'test-stack', + enableWebhook: true, + editor: { + fileContent: "version: '3'\nservices:\n web:\n image: nginx", + }, + }), + }); + + await waitFor(() => { + expect(screen.getByTestId('stack-name-input')).toBeInTheDocument(); + }); + + await waitFor(() => { + const webhookDisplay = screen.queryByRole('textbox', { + name: /webhook url/i, + }); + expect(webhookDisplay).toBeInTheDocument(); + expect(webhookDisplay).toHaveTextContent(webhookId); + }); + + expect(vi.mocked(uuidv4)).not.toHaveBeenCalled(); + }); + + it('should display webhook ID in git repository method with auto-update', async () => { + const webhookId = 'test-webhook-id-1234'; + + server.use( + http.post('/api/gitops/repo/refs', () => + HttpResponse.json(['refs/heads/main', 'refs/heads/develop']) + ), + http.post('/api/gitops/repo/files/search', () => + HttpResponse.json(['docker-compose.yml']) + ) + ); + + renderComponent({ + webhookId, + initialValues: mockFormValues({ + method: 'repository', + name: 'test-stack', + git: { + RepositoryURL: 'https://github.com/test/repo', + RepositoryReferenceName: 'main', + ComposeFilePathInRepository: 'docker-compose.yml', + RepositoryAuthentication: false, + RepositoryUsername: '', + RepositoryPassword: '', + RepositoryGitCredentialID: 0, + TLSSkipVerify: false, + AdditionalFiles: [], + AutoUpdate: { + RepositoryAutomaticUpdates: true, + RepositoryMechanism: 'Webhook', + RepositoryFetchInterval: '', + ForcePullImage: false, + RepositoryAutomaticUpdatesForce: false, + }, + RepositoryAuthorizationType: undefined, + SupportRelativePath: false, + FilesystemPath: '', + SaveCredential: false, + NewCredentialName: '', + }, + }), + }); + + await waitFor(() => { + expect(screen.getByTestId('stack-name-input')).toBeInTheDocument(); + }); + + await waitFor( + () => { + const webhookDisplay = screen.queryByRole('textbox', { + name: /webhook url/i, + }); + expect(webhookDisplay).toBeInTheDocument(); + expect(webhookDisplay).toHaveTextContent(webhookId); + }, + { timeout: 3000 } + ); + }); + + it('should not display webhook ID when webhook is disabled', async () => { + const webhookId = 'test-webhook-id-1234'; + + renderComponent({ + webhookId, + initialValues: mockFormValues({ + method: 'editor', + name: 'test-stack', + enableWebhook: false, + editor: { + fileContent: "version: '3'\nservices:\n web:\n image: nginx", + }, + }), + }); + + await waitFor(() => { + expect(screen.getByTestId('stack-name-input')).toBeInTheDocument(); + }); + + const webhookDisplay = screen.queryByRole('textbox', { + name: /webhook url/i, + }); + expect(webhookDisplay).not.toBeInTheDocument(); + }); + + it('should call uuid exactly once when CreateStackForm mounts', async () => { + vi.clearAllMocks(); + + renderFullComponent({ + environmentId: 1, + isSwarm: false, + swarmId: '', + }); + + await waitFor(() => { + expect(screen.getByTestId('stack-name-input')).toBeInTheDocument(); + }); + + expect(vi.mocked(uuidv4)).toHaveBeenCalledOnce(); + }); + + it('should send webhook ID in API request when editor webhook is enabled', async () => { + const user = userEvent.setup(); + let capturedRequestBody: DefaultBodyType; + + server.use( + http.post('/api/stacks/create/standalone/string', async ({ request }) => { + capturedRequestBody = await request.json(); + return HttpResponse.json({ + Id: 1, + Name: 'test-stack', + ResourceControl: { Id: 1 }, + }); + }), + http.put('/api/resource_controls/:id', () => HttpResponse.json({})) + ); + + renderFullComponent({ + environmentId: 1, + isSwarm: false, + swarmId: '', + }); + + const nameInput = await screen.findByRole('textbox', { name: /name/i }); + await user.clear(nameInput); + await user.type(nameInput, 'test-stack'); + + const editor = screen.getByTestId('stack-creation-editor'); + await user.type(editor, 'services:\n web:\n image: nginx'); + + const webhookToggle = await screen.findByRole('checkbox', { + name: /create a stack webhook/i, + }); + await user.click(webhookToggle); + + await waitFor(() => { + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + }); + + const deployButton = screen.getByRole('button', { + name: /deploy the stack/i, + }); + expect(deployButton).toBeEnabled(); + + await user.click(deployButton); + + await waitFor( + () => { + expect(capturedRequestBody).toBeDefined(); + }, + { timeout: 3000 } + ); + assert(capturedRequestBody && typeof capturedRequestBody === 'object'); + + expect(capturedRequestBody?.webhook).toBe('test-webhook-id-1234'); + }); + + it('should not send webhook ID in API request when editor webhook is disabled', async () => { + const user = userEvent.setup(); + let capturedRequestBody: DefaultBodyType; + + server.use( + http.post('/api/stacks/create/standalone/string', async ({ request }) => { + capturedRequestBody = await request.json(); + return HttpResponse.json({ + Id: 1, + Name: 'test-stack', + ResourceControl: { Id: 1 }, + }); + }), + http.put('/api/resource_controls/:id', () => HttpResponse.json({})) + ); + + renderFullComponent({ + environmentId: 1, + isSwarm: false, + swarmId: '', + }); + + const nameInput = await screen.findByRole('textbox', { name: /name/i }); + await user.clear(nameInput); + await user.type(nameInput, 'test-stack'); + + const editor = screen.getByTestId('stack-creation-editor'); + await user.type(editor, 'services:\n web:\n image: nginx'); + + await waitFor(() => { + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + }); + + const deployButton = screen.getByRole('button', { + name: /deploy the stack/i, + }); + expect(deployButton).toBeEnabled(); + + await user.click(deployButton); + + await waitFor( + () => { + expect(capturedRequestBody).toBeDefined(); + }, + { timeout: 3000 } + ); + + expect(capturedRequestBody).not.toHaveProperty('webhook'); + }); +}); + +function renderComponent({ + webhookId, + initialValues, +}: { + webhookId: string; + initialValues: ReturnType; +}) { + const user = createMockUsers(1, Role.Admin)[0]; + + const Component = withTestRouter( + withUserProvider( + withTestQueryProvider(() => ( + {}} + validateOnMount + > + + + )), + user + ) + ); + + return render(); +} + +function renderFullComponent({ + environmentId, + isSwarm, + swarmId, +}: { + environmentId: number; + isSwarm: boolean; + swarmId: string; +}) { + const user = createMockUsers(1, Role.Admin)[0]; + + const Component = withTestRouter( + withUserProvider( + withTestQueryProvider(() => ( + + )), + user + ) + ); + + return render(); +} diff --git a/app/react/docker/stacks/CreateView/CreateStackForm/CreateStackInnerForm.tsx b/app/react/docker/stacks/CreateView/CreateStackForm/CreateStackInnerForm.tsx index f9f0f73c4..2810c6fc3 100644 --- a/app/react/docker/stacks/CreateView/CreateStackForm/CreateStackInnerForm.tsx +++ b/app/react/docker/stacks/CreateView/CreateStackForm/CreateStackInnerForm.tsx @@ -31,10 +31,12 @@ export function CreateStackInnerForm({ isSwarm = false, isDeploying, isSaved, + webhookId, }: { isSwarm: boolean | undefined; isDeploying: boolean; isSaved: boolean; + webhookId: string; }) { const environmentQuery = useCurrentEnvironment(); const schemaQuery = useDockerComposeSchema(); @@ -79,7 +81,7 @@ export function CreateStackInnerForm({ {values.method === 'upload' && } {values.method === 'repository' && ( - + )} {values.method === 'template' && ( @@ -100,8 +102,9 @@ export function CreateStackInnerForm({ {values.method !== 'repository' && ( setFieldValue('webhookId', value)} + value={values.enableWebhook} + onChange={(value) => setFieldValue('enableWebhook', value)} + webhookId={webhookId} /> )} diff --git a/app/react/docker/stacks/CreateView/CreateStackForm/GitSection/GitSection.test.tsx b/app/react/docker/stacks/CreateView/CreateStackForm/GitSection/GitSection.test.tsx index df5e40d4f..c12c023a6 100644 --- a/app/react/docker/stacks/CreateView/CreateStackForm/GitSection/GitSection.test.tsx +++ b/app/react/docker/stacks/CreateView/CreateStackForm/GitSection/GitSection.test.tsx @@ -42,7 +42,7 @@ describe('GitSection', () => { it('should render with auto update enabled', () => { renderComponent({ - baseWebhookUrl: 'https://example.com', + webhookId: 'webhookId', initialValues: { AutoUpdate: { RepositoryAutomaticUpdates: true, @@ -65,11 +65,11 @@ describe('GitSection', () => { }); function renderComponent({ - baseWebhookUrl, + webhookId = 'webhook', initialValues, isDockerStandalone, }: { - baseWebhookUrl?: string; + webhookId?: string; isDockerStandalone?: boolean; initialValues?: Partial; } = {}) { @@ -100,7 +100,7 @@ function renderComponent({ withTestQueryProvider(() => ( {}} validateOnMount> diff --git a/app/react/docker/stacks/CreateView/CreateStackForm/GitSection/GitSection.tsx b/app/react/docker/stacks/CreateView/CreateStackForm/GitSection/GitSection.tsx index 654f3f1e5..ea648f08c 100644 --- a/app/react/docker/stacks/CreateView/CreateStackForm/GitSection/GitSection.tsx +++ b/app/react/docker/stacks/CreateView/CreateStackForm/GitSection/GitSection.tsx @@ -2,20 +2,18 @@ import { useFormikContext } from 'formik'; import { GitForm } from '@/react/portainer/gitops/GitForm'; import { isBE } from '@/react/portainer/feature-flags/feature-flags.service'; +import { baseStackWebhookUrl } from '@/portainer/helpers/webhookHelper'; import { FormValues } from '../types'; import { StackRelativePathFieldset } from './StackRelativePathFieldset'; interface Props { - baseWebhookUrl?: string; isDockerStandalone?: boolean; + webhookId: string; } -export function GitSection({ - baseWebhookUrl = '', - isDockerStandalone = false, -}: Props) { +export function GitSection({ webhookId, isDockerStandalone = false }: Props) { const { values, errors, setValues } = useFormikContext(); return ( @@ -38,8 +36,8 @@ export function GitSection({ isAuthExplanationVisible isForcePullVisible errors={errors.git} - baseWebhookUrl={baseWebhookUrl} - webhookId={values.webhookId} + baseWebhookUrl={baseStackWebhookUrl()} + webhookId={webhookId} /> {isBE && ( diff --git a/app/react/docker/stacks/CreateView/CreateStackForm/test-utils.ts b/app/react/docker/stacks/CreateView/CreateStackForm/test-utils.ts index 96dfd4de5..a71ff9307 100644 --- a/app/react/docker/stacks/CreateView/CreateStackForm/test-utils.ts +++ b/app/react/docker/stacks/CreateView/CreateStackForm/test-utils.ts @@ -12,7 +12,7 @@ export function mockFormValues(overrides: DeepPartial): FormValues { name: 'test-stack', env: [], accessControl: defaultValues(false, 1), - webhookId: '', + enableWebhook: false, registries: [], editor: { fileContent: '', diff --git a/app/react/docker/stacks/CreateView/CreateStackForm/types.ts b/app/react/docker/stacks/CreateView/CreateStackForm/types.ts index 170fecbb0..eeeffe085 100644 --- a/app/react/docker/stacks/CreateView/CreateStackForm/types.ts +++ b/app/react/docker/stacks/CreateView/CreateStackForm/types.ts @@ -15,7 +15,7 @@ export interface BaseFormValues { name: string; env: EnvVarValues; accessControl: AccessControlFormData; - webhookId: string; + enableWebhook: boolean; registries: Array; } diff --git a/app/react/docker/stacks/CreateView/CreateStackForm/validation.test.ts b/app/react/docker/stacks/CreateView/CreateStackForm/validation.test.ts index 20aeba99c..9620594a1 100644 --- a/app/react/docker/stacks/CreateView/CreateStackForm/validation.test.ts +++ b/app/react/docker/stacks/CreateView/CreateStackForm/validation.test.ts @@ -20,7 +20,7 @@ describe('CreateStackForm validation schemas', () => { name: 'test-stack', env: [], accessControl: acDefaultValues(false, 1), - webhookId: '', + enableWebhook: false, registries: [], upload: undefined, }; @@ -40,7 +40,7 @@ describe('CreateStackForm validation schemas', () => { name: 'test-stack', env: [], accessControl: acDefaultValues(false, 1), - webhookId: '', + enableWebhook: false, registries: [], editor: undefined, }; @@ -60,7 +60,7 @@ describe('CreateStackForm validation schemas', () => { name: 'test-stack', env: [], accessControl: acDefaultValues(false, 1), - webhookId: '', + enableWebhook: false, registries: [], git: undefined, }; diff --git a/app/react/docker/stacks/CreateView/CreateStackForm/validation.ts b/app/react/docker/stacks/CreateView/CreateStackForm/validation.ts index 9c740cbe9..1bb99fc88 100644 --- a/app/react/docker/stacks/CreateView/CreateStackForm/validation.ts +++ b/app/react/docker/stacks/CreateView/CreateStackForm/validation.ts @@ -1,4 +1,4 @@ -import { object, string, array, number, mixed, SchemaOf } from 'yup'; +import { object, array, number, mixed, SchemaOf, bool } from 'yup'; import { accessControlFormValidation } from '@/react/portainer/access-control/AccessControlForm'; import { GitCredential } from '@/react/portainer/account/git-credentials/types'; @@ -71,7 +71,7 @@ function getBaseValidationSchema({ ), env: envVarValidation(), accessControl: accessControlFormValidation(isAdmin), - webhookId: string().default(''), + enableWebhook: bool().default(false), registries: array(number().required()).default([]), }); } diff --git a/app/react/docker/stacks/ItemView/StackEditorTab/StackEditorTab.tsx b/app/react/docker/stacks/ItemView/StackEditorTab/StackEditorTab.tsx index faa0cafa6..957009ef9 100644 --- a/app/react/docker/stacks/ItemView/StackEditorTab/StackEditorTab.tsx +++ b/app/react/docker/stacks/ItemView/StackEditorTab/StackEditorTab.tsx @@ -1,6 +1,8 @@ import { Formik } from 'formik'; import { useRouter } from '@uirouter/react'; import _ from 'lodash'; +import { useState } from 'react'; +import uuidv4 from 'uuid/v4'; import { Stack, StackType } from '@/react/common/stacks/types'; import { useDockerComposeSchema } from '@/react/hooks/useDockerComposeSchema/useDockerComposeSchema'; @@ -43,6 +45,7 @@ export function StackEditorTab({ const envQuery = useCurrentEnvironment(); const schemaQuery = useDockerComposeSchema(); const apiVersion = useApiVersion(envQuery.data?.Id); + const [webhookId] = useState(() => stack.Webhook || uuidv4()); if (!envQuery.data || !schemaQuery.data) { return null; @@ -57,7 +60,7 @@ export function StackEditorTab({ environmentVariables: stack.Env, prune: !!(stack.Option && stack.Option.Prune), stackFileContent: originalFileContent, - webhookId: stack.Webhook, + enabledWebhook: !!stack.Webhook, }; return ( @@ -85,7 +88,7 @@ export function StackEditorTab({ stackFileContent: values.stackFileContent, env: values.environmentVariables, prune: values.prune, - webhook: values.webhookId, + webhook: values.enabledWebhook ? webhookId : undefined, repullImageAndRedeploy: response.repullImageAndRedeploy, rollbackTo: values.rollbackTo, }, @@ -115,6 +118,7 @@ export function StackEditorTab({ schema={schemaQuery.data} versions={versions} isSaved={mutation.isSuccess} + webhookId={webhookId} /> ); diff --git a/app/react/docker/stacks/ItemView/StackEditorTab/StackEditorTab.types.ts b/app/react/docker/stacks/ItemView/StackEditorTab/StackEditorTab.types.ts index 90f68aa9e..4dd0d722a 100644 --- a/app/react/docker/stacks/ItemView/StackEditorTab/StackEditorTab.types.ts +++ b/app/react/docker/stacks/ItemView/StackEditorTab/StackEditorTab.types.ts @@ -3,7 +3,7 @@ import { EnvVarValues } from '@@/form-components/EnvironmentVariablesFieldset'; export interface StackEditorFormValues { stackFileContent: string; environmentVariables: EnvVarValues; - webhookId: string | undefined; rollbackTo?: number; prune: boolean; + enabledWebhook: boolean; } diff --git a/app/react/docker/stacks/ItemView/StackEditorTab/StackEditorTab.validation.ts b/app/react/docker/stacks/ItemView/StackEditorTab/StackEditorTab.validation.ts index cedae66b4..75bfb71c6 100644 --- a/app/react/docker/stacks/ItemView/StackEditorTab/StackEditorTab.validation.ts +++ b/app/react/docker/stacks/ItemView/StackEditorTab/StackEditorTab.validation.ts @@ -35,6 +35,6 @@ export function getValidationSchema( prune: boolean().default(false), registries: array(number().required()).default([]), rollbackTo: number().notRequired(), - webhookId: string().default(''), + enabledWebhook: boolean().default(false), }); } diff --git a/app/react/docker/stacks/ItemView/StackEditorTab/StackEditorTab.webhook.test.tsx b/app/react/docker/stacks/ItemView/StackEditorTab/StackEditorTab.webhook.test.tsx new file mode 100644 index 000000000..e677bd7c0 --- /dev/null +++ b/app/react/docker/stacks/ItemView/StackEditorTab/StackEditorTab.webhook.test.tsx @@ -0,0 +1,209 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { DefaultBodyType, http, HttpResponse } from 'msw'; +import uuidv4 from 'uuid/v4'; + +import { server } from '@/setup-tests/server'; +import { Stack } from '@/react/common/stacks/types'; +import { EnvironmentType } from '@/react/portainer/environments/types'; +import { withTestQueryProvider } from '@/react/test-utils/withTestQuery'; +import { withUserProvider } from '@/react/test-utils/withUserProvider'; +import { withTestRouter } from '@/react/test-utils/withRouter'; +import { createMockStack, createMockUsers } from '@/react-tools/test-mocks'; +import { Role } from '@/portainer/users/types'; + +import { StackEditorTab } from './StackEditorTab'; + +vi.mock('uuid/v4', () => ({ + default: vi.fn(() => 'test-webhook-id-1234'), +})); + +vi.mock('@uirouter/react', async (importOriginal: () => Promise) => ({ + ...(await importOriginal()), + useCurrentStateAndParams: vi.fn(() => ({ + params: { endpointId: 1 }, + })), +})); + +vi.mock('@/react/common/stacks/common/confirm-stack-update', () => ({ + confirmStackUpdate: vi.fn(() => + Promise.resolve({ repullImageAndRedeploy: false }) + ), +})); + +describe('StackEditorTab - Webhook ID Handling', () => { + beforeEach(() => { + vi.clearAllMocks(); + + server.use( + http.get('/api/endpoints/1', () => + HttpResponse.json({ + Id: 1, + Type: EnvironmentType.Docker, + ComposeSyntaxMaxVersion: '3', + ChangeWindow: { + Enabled: false, + }, + }) + ) + ); + }); + + describe('Editor stack with existing webhook', () => { + it('should display existing webhook ID', async () => { + const existingWebhookId = 'existing-webhook-123'; + + const stack = createMockStack({ + Id: 1, + Webhook: existingWebhookId, + }); + + renderComponent({ stack }); + + await waitFor(() => { + expect(screen.getByTestId('stack-deploy-button')).toBeInTheDocument(); + }); + + await waitFor(() => { + const webhookDisplay = screen.queryByRole('textbox', { + name: /webhook url/i, + }); + expect(webhookDisplay).toBeInTheDocument(); + expect(webhookDisplay).toHaveTextContent(existingWebhookId); + }); + + expect(vi.mocked(uuidv4)).not.toHaveBeenCalled(); + }); + }); + + describe('Editor stack without webhook', () => { + it('should not display webhook ID and should call uuid once for fallback', async () => { + vi.clearAllMocks(); + + const stack = createMockStack({ + Id: 1, + Webhook: '', + }); + + renderComponent({ stack }); + + await waitFor(() => { + expect(screen.getByTestId('stack-deploy-button')).toBeInTheDocument(); + }); + + const webhookDisplay = screen.queryByRole('textbox', { + name: /webhook url/i, + }); + expect(webhookDisplay).not.toBeInTheDocument(); + + expect(vi.mocked(uuidv4)).toHaveBeenCalledOnce(); + }); + }); + + describe('Form submission', () => { + it('should send webhook ID in API request when stack has webhook', async () => { + const user = userEvent.setup(); + let capturedRequestBody: DefaultBodyType; + + server.use( + http.put('/api/stacks/:id', async ({ request }) => { + capturedRequestBody = await request.json(); + return HttpResponse.json({ Id: 1, Name: 'test-stack' }); + }) + ); + + const stack = createMockStack({ + Id: 1, + Webhook: 'existing-webhook-123', + }); + + renderComponent({ stack }); + + await waitFor(() => { + expect(screen.getByTestId('stack-deploy-button')).toBeInTheDocument(); + }); + + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + + const deployButton = screen.getByTestId('stack-deploy-button'); + await waitFor(() => { + expect(deployButton).toBeEnabled(); + }); + + await user.click(deployButton); + + await waitFor( + () => { + expect(capturedRequestBody).toBeDefined(); + }, + { timeout: 3000 } + ); + + assert(capturedRequestBody && typeof capturedRequestBody === 'object'); + expect(capturedRequestBody?.webhook).toBe('existing-webhook-123'); + }); + + it('should not send webhook ID in API request when stack has no webhook', async () => { + const user = userEvent.setup(); + let capturedRequestBody: DefaultBodyType; + + server.use( + http.put('/api/stacks/:id', async ({ request }) => { + capturedRequestBody = await request.json(); + return HttpResponse.json({ Id: 1, Name: 'test-stack' }); + }) + ); + + const stack = createMockStack({ + Id: 1, + Webhook: '', + }); + + renderComponent({ stack }); + + await waitFor(() => { + expect(screen.getByTestId('stack-deploy-button')).toBeInTheDocument(); + }); + + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + + const deployButton = screen.getByTestId('stack-deploy-button'); + await waitFor(() => { + expect(deployButton).toBeEnabled(); + }); + + await user.click(deployButton); + + await waitFor( + () => { + expect(capturedRequestBody).toBeDefined(); + }, + { timeout: 3000 } + ); + + expect(capturedRequestBody).not.toHaveProperty('webhook'); + }); + }); +}); + +function renderComponent({ stack }: { stack: Stack }) { + const user = createMockUsers(1, Role.Admin)[0]; + + const Component = withTestRouter( + withUserProvider( + withTestQueryProvider(() => ( + + )), + user + ) + ); + + return render(); +} diff --git a/app/react/docker/stacks/ItemView/StackEditorTab/StackEditorTabInner.test.tsx b/app/react/docker/stacks/ItemView/StackEditorTab/StackEditorTabInner.test.tsx index 3ce6b0176..624cc1a0e 100644 --- a/app/react/docker/stacks/ItemView/StackEditorTab/StackEditorTabInner.test.tsx +++ b/app/react/docker/stacks/ItemView/StackEditorTab/StackEditorTabInner.test.tsx @@ -44,12 +44,13 @@ const defaultProps = { isOrphaned: false, stackId: 1, isSaved: false, + webhookId: '', }; const defaultInitialValues: StackEditorFormValues = { stackFileContent: 'version: "3"\nservices:\n web:\n image: nginx', environmentVariables: [], - webhookId: '', + enabledWebhook: false, prune: false, }; @@ -233,9 +234,8 @@ describe('orphaned stack behavior', () => { await waitFor(() => { const deployButton = screen.queryByTestId('stack-deploy-button'); - if (deployButton) { - expect(deployButton).toBeDisabled(); - } + + expect(deployButton).toBeDisabled(); }); }); @@ -371,9 +371,8 @@ describe('form submission', () => { await waitFor(() => { const deployButton = screen.queryByTestId('stack-deploy-button'); - if (deployButton) { - expect(deployButton).toBeDisabled(); - } + + expect(deployButton).toBeDisabled(); }); }); diff --git a/app/react/docker/stacks/ItemView/StackEditorTab/StackEditorTabInner.tsx b/app/react/docker/stacks/ItemView/StackEditorTab/StackEditorTabInner.tsx index 4adaec7a1..9ff1db667 100644 --- a/app/react/docker/stacks/ItemView/StackEditorTab/StackEditorTabInner.tsx +++ b/app/react/docker/stacks/ItemView/StackEditorTab/StackEditorTabInner.tsx @@ -29,6 +29,7 @@ interface StackEditorTabInnerProps { versions?: Array; stackId: Stack['Id']; isSaved: boolean; + webhookId: string; } export function StackEditorTabInner({ @@ -41,6 +42,7 @@ export function StackEditorTabInner({ versions, stackId, isSaved, + webhookId, }: StackEditorTabInnerProps) { const { authorized: isAuthorizedToUpdate } = useAuthorizations( 'PortainerStackUpdate' @@ -139,8 +141,9 @@ export function StackEditorTabInner({ {envType !== EnvironmentType.EdgeAgentOnDocker && ( setFieldValue('webhookId', value)} - value={values.webhookId || ''} + onChange={(value) => setFieldValue('enabledWebhook', value)} + value={values.enabledWebhook} + webhookId={webhookId} /> )} diff --git a/app/react/docker/stacks/common/WebhookFieldset.tsx b/app/react/docker/stacks/common/WebhookFieldset.tsx index 73ecc9a61..2b96feec0 100644 --- a/app/react/docker/stacks/common/WebhookFieldset.tsx +++ b/app/react/docker/stacks/common/WebhookFieldset.tsx @@ -1,4 +1,3 @@ -import uuidv4 from 'uuid/v4'; import { useState } from 'react'; import { baseStackWebhookUrl } from '@/portainer/helpers/webhookHelper'; @@ -12,13 +11,13 @@ import { SwitchField } from '@@/form-components/SwitchField'; export function WebhookFieldset({ value, onChange, - hasWebhook, + webhookId, }: { - value: string; - onChange(value: string): void; - - hasWebhook?: boolean; + value: boolean; + onChange(value: boolean): void; + webhookId: string; }) { + const [hasWebhook] = useState(() => value); const authQuery = useAuthorizations( hasWebhook ? ['PortainerWebhookDelete'] : ['PortainerWebhookCreate'] ); @@ -36,6 +35,7 @@ export function WebhookFieldset({ value={value} onChange={onChange} disabled={!authQuery.authorized} + webhookId={webhookId} /> ); @@ -45,19 +45,19 @@ export function AuthorizedWebhook({ value, onChange, disabled, + webhookId, }: { - value: string; - onChange(value: string): void; + value: boolean; + onChange(value: boolean): void; disabled?: boolean; + webhookId: string; }) { - const [cachedWebhookId, setCachedWebhookId] = useState(value); - return ( handleChange(checked)} + checked={value} + onChange={(checked) => onChange(checked)} labelClass="col-sm-2" tooltip="Create a webhook (or callback URI) to automate the update of this stack. Sending a POST request to this callback URI (without requiring any authentication) will pull the most up-to-date version of the associated image and re-deploy this stack." label="Create a Stack webhook" @@ -66,17 +66,8 @@ export function AuthorizedWebhook({ disabled={disabled} /> {value && ( - + )} ); - - function handleChange(enable: boolean) { - if (enable) { - onChange(cachedWebhookId || uuidv4()); - } else { - setCachedWebhookId(value); - onChange(''); - } - } } diff --git a/app/react/portainer/gitops/AutoUpdateFieldset/WebhookSettings.tsx b/app/react/portainer/gitops/AutoUpdateFieldset/WebhookSettings.tsx index 284e568a5..3ad706a5b 100644 --- a/app/react/portainer/gitops/AutoUpdateFieldset/WebhookSettings.tsx +++ b/app/react/portainer/gitops/AutoUpdateFieldset/WebhookSettings.tsx @@ -31,7 +31,14 @@ export function WebhookSettings({ } >
- {truncateLeftRight(url)} + + {truncateLeftRight(url)} +