Add GitOps exceptions UI to Change Management settings (#42348)

**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 <a
href="https://github.com/fleetdm/fleet/blob/main/docs/Contributing/guides/committing-changes.md#changes-files">Changes
files</a> 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

<img
src="https://github.com/user-attachments/assets/095c538c-68aa-4179-b4b1-fd5878c0a2b0">




## 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.

<!-- START COPILOT CODING AGENT TIPS -->
---

 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>
This commit is contained in:
Scott Gress
2026-03-30 08:57:03 -05:00
committed by GitHub
co-authored by copilot-swe-agent[bot] sgress454
parent f1bad72003
commit e54ea7b3ad
6 changed files with 387 additions and 4 deletions
+5
View File
@@ -220,6 +220,11 @@ const DEFAULT_CONFIG_MOCK: IConfig = {
gitops: {
gitops_mode_enabled: false,
repository_url: "",
exceptions: {
labels: false,
software: false,
secrets: true,
},
},
};
+7
View File
@@ -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) */
@@ -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<IConfig>) => {
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(<ChangeManagement />);
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(<ChangeManagement />);
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(<ChangeManagement />);
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(<ChangeManagement />);
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(<ChangeManagement />);
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(<ChangeManagement />);
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(<ChangeManagement />);
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(<ChangeManagement />);
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(<ChangeManagement />);
// 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(<ChangeManagement />);
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,
},
},
});
});
});
});
});
@@ -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<IChangeManagementFormData>({
// 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<IChangeManagementFormErrors>({});
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 = () => {
</SettingsSection>
);
const { gitOpsModeEnabled, repoURL } = formData;
const {
gitOpsModeEnabled,
repoURL,
exceptLabels,
exceptSoftware,
exceptSecrets,
} = formData;
if (isLoadingConfig) {
return <Spinner />;
@@ -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}
/>
<div className={`form-field`}>
<div className="form-field__label">
<TooltipWrapper tipContent="Opt-in to managing outside of git. Running GitOps wont override changes made in the UI or API.">
Exceptions
</TooltipWrapper>
</div>
<div>
<Checkbox
onChange={onInputChange}
name="exceptLabels"
value={exceptLabels}
parseTarget
>
Labels
</Checkbox>
<Checkbox
onChange={onInputChange}
name="exceptSoftware"
value={exceptSoftware}
parseTarget
>
Software
</Checkbox>
<Checkbox
onChange={onInputChange}
name="exceptSecrets"
value={exceptSecrets}
parseTarget
>
Enroll secrets
</Checkbox>
</div>
</div>
<div className="button-wrap">
<Button
type="submit"
@@ -81,6 +81,7 @@ describe("EndUserMigrationSection", () => {
gitops: {
gitops_mode_enabled: true,
repository_url: "https://example.com/repo.git",
exceptions: { labels: false, software: false, secrets: true },
},
})
);
@@ -104,7 +104,11 @@ describe("FleetDesktop", () => {
describe("GitOps Mode", () => {
it("disables inputs when gitops mode is enabled", () => {
const mockConfig = createMockConfig({
gitops: { gitops_mode_enabled: true, repository_url: "" },
gitops: {
gitops_mode_enabled: true,
repository_url: "",
exceptions: { labels: false, software: false, secrets: false },
},
});
renderWithSetup(