From e54ea7b3ad3b0bfea1c8e125841c905cca608778 Mon Sep 17 00:00:00 2001 From: Scott Gress Date: Mon, 30 Mar 2026 08:57:03 -0500 Subject: [PATCH] Add GitOps exceptions UI to Change Management settings (#42348) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **Related issue:** Resolves #42182 # Checklist for submitter If some of the following don't apply, delete the relevant line. - [ ] Changes file added for user-visible changes in `changes/`, `orbit/changes/` or `ee/fleetd-chrome/changes`. See Changes files for more information. will add to last PR ## Testing - [X] Added/updated automated tests - [X] Added `ChangeManagement.tests.tsx` with unit/integration tests covering: - Exceptions checkboxes render correctly from config for new install (only Enroll secrets checked) and migrated instances (Labels and Enroll secrets checked) - Form save sends the correct `gitops.exceptions` payload via `configAPI.update` - Form validation shows error when GitOps mode is enabled but no repo URL is provided - Non-premium tier renders the premium feature message - [X] QA'd all new/changed functionality manually - [X] verified that Labels and Secrets are checked for pre-existing (migrated) instance - [X] verified that only Secrets is checked for new instance - [X] verified that changing the settings in the UI and saving persists the `gitops.exceptions` config as expected ## Summary by CodeRabbit * **New Features** * Added GitOps exceptions configuration in Change Management settings with toggles for Labels, Software, and Enroll Secrets, enabling granular control over exception flags. --- ✨ Let Copilot coding agent [set things up for you](https://github.com/fleetdm/fleet/issues/new?title=✨+Set+up+Copilot+instructions&body=Configure%20instructions%20for%20this%20repository%20as%20documented%20in%20%5BBest%20practices%20for%20Copilot%20coding%20agent%20in%20your%20repository%5D%28https://gh.io/copilot-coding-agent-tips%29%2E%0A%0A%3COnboard%20this%20repo%3E&assignees=copilot) — coding agent works faster and does higher quality work when set up for your repo. --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: sgress454 <553428+sgress454@users.noreply.github.com> --- frontend/__mocks__/configMock.ts | 5 + frontend/interfaces/config.ts | 7 + .../ChangeManagement.tests.tsx | 305 ++++++++++++++++++ .../ChangeManagement/ChangeManagement.tsx | 67 +++- .../EndUserMigrationSection.tests.tsx | 1 + .../cards/FleetDesktop/FleetDesktop.tests.tsx | 6 +- 6 files changed, 387 insertions(+), 4 deletions(-) create mode 100644 frontend/pages/admin/IntegrationsPage/cards/ChangeManagement/ChangeManagement.tests.tsx 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 + +
+
+