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:
co-authored by
copilot-swe-agent[bot]
sgress454
parent
f1bad72003
commit
e54ea7b3ad
@@ -220,6 +220,11 @@ const DEFAULT_CONFIG_MOCK: IConfig = {
|
||||
gitops: {
|
||||
gitops_mode_enabled: false,
|
||||
repository_url: "",
|
||||
exceptions: {
|
||||
labels: false,
|
||||
software: false,
|
||||
secrets: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -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) */
|
||||
|
||||
+305
@@ -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 won’t 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"
|
||||
|
||||
+1
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user