diff --git a/frontend/__mocks__/configMock.ts b/frontend/__mocks__/configMock.ts index 718a50c195..8376d9b4fd 100644 --- a/frontend/__mocks__/configMock.ts +++ b/frontend/__mocks__/configMock.ts @@ -220,6 +220,11 @@ const DEFAULT_CONFIG_MOCK: IConfig = { gitops: { gitops_mode_enabled: false, repository_url: "", + exceptions: { + labels: false, + software: false, + secrets: true, + }, }, }; diff --git a/frontend/interfaces/config.ts b/frontend/interfaces/config.ts index f299c8b22a..99b7105d0a 100644 --- a/frontend/interfaces/config.ts +++ b/frontend/interfaces/config.ts @@ -282,9 +282,16 @@ export const CONFIG_DEFAULT_RECENT_VULNERABILITY_MAX_AGE_IN_DAYS = 30; export interface IUserSettings { hidden_host_columns: string[]; } +export interface IGitOpsExceptions { + labels: boolean; + software: boolean; + secrets: boolean; +} + export interface IGitOpsModeConfig { gitops_mode_enabled: boolean; repository_url: string; + exceptions: IGitOpsExceptions; } /** Check if Okta conditional access is configured (all 4 fields must be present) */ diff --git a/frontend/pages/admin/IntegrationsPage/cards/ChangeManagement/ChangeManagement.tests.tsx b/frontend/pages/admin/IntegrationsPage/cards/ChangeManagement/ChangeManagement.tests.tsx new file mode 100644 index 0000000000..c85daa4246 --- /dev/null +++ b/frontend/pages/admin/IntegrationsPage/cards/ChangeManagement/ChangeManagement.tests.tsx @@ -0,0 +1,305 @@ +import React from "react"; +import { screen, waitFor } from "@testing-library/react"; +import { http, HttpResponse } from "msw"; + +import { createCustomRenderer, baseUrl } from "test/test-utils"; +import mockServer from "test/mock-server"; +import createMockConfig from "__mocks__/configMock"; +import { IConfig } from "interfaces/config"; + +import ChangeManagement from "./ChangeManagement"; + +const configUrl = baseUrl("/config"); + +const createGetConfigHandler = (overrides?: Partial) => { + return http.get(configUrl, () => { + return HttpResponse.json(createMockConfig(overrides)); + }); +}; + +const createPatchConfigHandler = (spy: jest.Mock) => { + return http.patch(configUrl, async ({ request }) => { + const body = await request.json(); + spy(body); + // Echo back a full config with the gitops fields from the request + return HttpResponse.json( + createMockConfig({ gitops: (body as any).gitops }) + ); + }); +}; + +describe("ChangeManagement", () => { + const render = createCustomRenderer({ + withBackendMock: true, + context: { + app: { isPremiumTier: true, setConfig: jest.fn() }, + notification: { renderFlash: jest.fn() }, + }, + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe("GitOps mode checkbox", () => { + it("is checked when API returns gitops_mode_enabled: true", async () => { + mockServer.use( + createGetConfigHandler({ + gitops: { + gitops_mode_enabled: true, + repository_url: "https://github.com/org/repo", + exceptions: { labels: false, software: false, secrets: true }, + }, + }) + ); + + render(); + + await waitFor(() => { + expect( + screen.getByRole("checkbox", { name: "gitOpsModeEnabled" }) + ).toHaveAttribute("aria-checked", "true"); + }); + }); + + it("is unchecked when API returns gitops_mode_enabled: false", async () => { + mockServer.use( + createGetConfigHandler({ + gitops: { + gitops_mode_enabled: false, + repository_url: "", + exceptions: { labels: false, software: false, secrets: true }, + }, + }) + ); + + render(); + + await waitFor(() => { + expect( + screen.getByRole("checkbox", { name: "gitOpsModeEnabled" }) + ).not.toHaveAttribute("aria-checked", "true"); + }); + }); + }); + + describe("GitOps URL field", () => { + it("populates with repository_url from API response", async () => { + mockServer.use( + createGetConfigHandler({ + gitops: { + gitops_mode_enabled: true, + repository_url: "https://github.com/org/repo", + exceptions: { labels: false, software: false, secrets: true }, + }, + }) + ); + + render(); + + expect( + await screen.findByDisplayValue("https://github.com/org/repo") + ).toBeInTheDocument(); + }); + + it("is disabled when GitOps mode is off", async () => { + mockServer.use( + createGetConfigHandler({ + gitops: { + gitops_mode_enabled: false, + repository_url: "", + exceptions: { labels: false, software: false, secrets: true }, + }, + }) + ); + + render(); + + await waitFor(() => { + expect(screen.getByLabelText(/git repository url/i)).toBeDisabled(); + }); + }); + + it("is enabled when GitOps mode is on", async () => { + mockServer.use( + createGetConfigHandler({ + gitops: { + gitops_mode_enabled: true, + repository_url: "https://github.com/org/repo", + exceptions: { labels: false, software: false, secrets: true }, + }, + }) + ); + + render(); + + await waitFor(() => { + expect(screen.getByLabelText(/git repository url/i)).not.toBeDisabled(); + }); + }); + }); + + describe("Form validation", () => { + it("shows error when saving with GitOps mode enabled and no URL", async () => { + mockServer.use( + createGetConfigHandler({ + gitops: { + gitops_mode_enabled: true, + repository_url: "", + exceptions: { labels: false, software: false, secrets: true }, + }, + }) + ); + + const { user } = render(); + + const saveButton = await screen.findByRole("button", { name: /save/i }); + await user.click(saveButton); + + await waitFor(() => { + expect( + screen.getByText( + /git repository url is required when gitops mode is enabled/i + ) + ).toBeInTheDocument(); + }); + }); + }); + + describe("Exception checkboxes", () => { + it("populates from API response", async () => { + mockServer.use( + createGetConfigHandler({ + gitops: { + gitops_mode_enabled: false, + repository_url: "", + exceptions: { labels: true, software: false, secrets: true }, + }, + }) + ); + + render(); + + await waitFor(() => { + expect( + screen.getByRole("checkbox", { name: "exceptLabels" }) + ).toHaveAttribute("aria-checked", "true"); + expect( + screen.getByRole("checkbox", { name: "exceptSoftware" }) + ).not.toHaveAttribute("aria-checked", "true"); + expect( + screen.getByRole("checkbox", { name: "exceptSecrets" }) + ).toHaveAttribute("aria-checked", "true"); + }); + }); + + it("reflects all false when API returns all false", async () => { + mockServer.use( + createGetConfigHandler({ + gitops: { + gitops_mode_enabled: false, + repository_url: "", + exceptions: { labels: false, software: false, secrets: false }, + }, + }) + ); + + render(); + + await waitFor(() => { + expect( + screen.getByRole("checkbox", { name: "exceptLabels" }) + ).not.toHaveAttribute("aria-checked", "true"); + expect( + screen.getByRole("checkbox", { name: "exceptSoftware" }) + ).not.toHaveAttribute("aria-checked", "true"); + expect( + screen.getByRole("checkbox", { name: "exceptSecrets" }) + ).not.toHaveAttribute("aria-checked", "true"); + }); + }); + }); + + describe("Form submission", () => { + it("sends correct data to API on save", async () => { + const patchSpy = jest.fn(); + mockServer.use( + createGetConfigHandler({ + gitops: { + gitops_mode_enabled: false, + repository_url: "", + exceptions: { labels: false, software: false, secrets: true }, + }, + }), + createPatchConfigHandler(patchSpy) + ); + + const { user } = render(); + + // Wait for form to load with API data + await screen.findByRole("checkbox", { name: "exceptLabels" }); + + // Toggle the labels exception on + const labelsCheckbox = screen.getByRole("checkbox", { + name: "exceptLabels", + }); + await user.click(labelsCheckbox); + + const saveButton = screen.getByRole("button", { name: /save/i }); + await user.click(saveButton); + + await waitFor(() => { + expect(patchSpy).toHaveBeenCalledWith({ + gitops: { + gitops_mode_enabled: false, + repository_url: "", + exceptions: { + labels: true, + software: false, + secrets: true, + }, + }, + }); + }); + }); + + it("sends updated URL to API on save", async () => { + const patchSpy = jest.fn(); + mockServer.use( + createGetConfigHandler({ + gitops: { + gitops_mode_enabled: true, + repository_url: "https://github.com/org/repo", + exceptions: { labels: false, software: false, secrets: true }, + }, + }), + createPatchConfigHandler(patchSpy) + ); + + const { user } = render(); + + const urlInput = await screen.findByDisplayValue( + "https://github.com/org/repo" + ); + await user.clear(urlInput); + await user.type(urlInput, "https://github.com/org/new-repo"); + + const saveButton = screen.getByRole("button", { name: /save/i }); + await user.click(saveButton); + + await waitFor(() => { + expect(patchSpy).toHaveBeenCalledWith({ + gitops: { + gitops_mode_enabled: true, + repository_url: "https://github.com/org/new-repo", + exceptions: { + labels: false, + software: false, + secrets: true, + }, + }, + }); + }); + }); + }); +}); diff --git a/frontend/pages/admin/IntegrationsPage/cards/ChangeManagement/ChangeManagement.tsx b/frontend/pages/admin/IntegrationsPage/cards/ChangeManagement/ChangeManagement.tsx index dac0284893..e09c394a3a 100644 --- a/frontend/pages/admin/IntegrationsPage/cards/ChangeManagement/ChangeManagement.tsx +++ b/frontend/pages/admin/IntegrationsPage/cards/ChangeManagement/ChangeManagement.tsx @@ -32,6 +32,9 @@ const baseClass = "change-management"; interface IChangeManagementFormData { gitOpsModeEnabled: boolean; repoURL: string; + exceptLabels: boolean; + exceptSoftware: boolean; + exceptSecrets: boolean; } interface IChangeManagementFormErrors { @@ -58,9 +61,12 @@ const ChangeManagement = () => { const { renderFlash } = useContext(NotificationContext); const [formData, setFormData] = useState({ - // dummy 0 values, will be populated with fresh config API response + // dummy values, will be populated with fresh config API response gitOpsModeEnabled: false, repoURL: "", + exceptLabels: false, + exceptSoftware: false, + exceptSecrets: true, }); const [formErrors, setFormErrors] = useState({}); const [isUpdating, setIsUpdating] = useState(false); @@ -75,9 +81,16 @@ const ChangeManagement = () => { gitops: { gitops_mode_enabled: gitOpsModeEnabled, repository_url: repoURL, + exceptions, }, } = data; - setFormData({ gitOpsModeEnabled, repoURL }); + setFormData({ + gitOpsModeEnabled, + repoURL, + exceptLabels: exceptions.labels, + exceptSoftware: exceptions.software, + exceptSecrets: exceptions.secrets, + }); setConfig(data); }, }); @@ -91,7 +104,13 @@ const ChangeManagement = () => { ); - const { gitOpsModeEnabled, repoURL } = formData; + const { + gitOpsModeEnabled, + repoURL, + exceptLabels, + exceptSoftware, + exceptSecrets, + } = formData; if (isLoadingConfig) { return ; @@ -114,12 +133,20 @@ const ChangeManagement = () => { gitops: { gitops_mode_enabled: formData.gitOpsModeEnabled, repository_url: formData.repoURL, + exceptions: { + labels: formData.exceptLabels, + software: formData.exceptSoftware, + secrets: formData.exceptSecrets, + }, }, }); setFormData({ gitOpsModeEnabled: updatedConfig.gitops.gitops_mode_enabled, repoURL: updatedConfig.gitops.repository_url, + exceptLabels: updatedConfig.gitops.exceptions.labels, + exceptSoftware: updatedConfig.gitops.exceptions.software, + exceptSecrets: updatedConfig.gitops.exceptions.secrets, }); setConfig(updatedConfig); @@ -195,6 +222,40 @@ const ChangeManagement = () => { helpText="When GitOps mode is enabled, you will be directed here to make changes." disabled={!gitOpsModeEnabled} /> +
+
+ + Exceptions + +
+
+ + Labels + + + Software + + + Enroll secrets + +
+
+