fleet/frontend/pages/hosts/ManageHostsPage/components/RunScriptBatchModal/RunScriptBatchModal.tests.tsx
Scott Gress e985d20b1d
UI for scheduling batch scripts (#31885)
# Details

This PR merges the feature branch for the scheduled scripts UI into
main. This includes the following previously-approved PRs:

* https://github.com/fleetdm/fleet/pull/31750
* https://github.com/fleetdm/fleet/pull/31604
* https://github.com/fleetdm/fleet/pull/31797


# Checklist for submitter

If some of the following don't apply, delete the relevant line.

- [X] Changes file added for user-visible changes in `changes/`,
`orbit/changes/` or `ee/fleetd-chrome/changes`.
See [Changes
files](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/guides/committing-changes.md#changes-files)
for more information.

## Testing

- [X] Added/updated automated tests
- [X] Where appropriate, [automated tests simulate multiple hosts and
test for host
isolation](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/reference/patterns-backend.md#unit-testing)
(updates to one hosts's records do not affect another)

- [X] QA'd all new/changed functionality manually

---------

Co-authored-by: jacobshandling <61553566+jacobshandling@users.noreply.github.com>
Co-authored-by: Jacob Shandling <jacob@fleetdm.com>
2025-08-14 10:10:45 -05:00

372 lines
14 KiB
TypeScript

import React from "react";
import { screen, waitFor } from "@testing-library/react";
import { UserEvent } from "@testing-library/user-event";
import { IScript } from "interfaces/script";
import { createCustomRenderer } from "test/test-utils";
import { http, HttpResponse } from "msw";
import mockServer from "test/mock-server";
import { format } from "date-fns";
import RunScriptBatchModal from "./RunScriptBatchModal";
const baseUrl = (path: string) => {
return `/api/latest/fleet${path}`;
};
const windowsScript: IScript = {
id: 123,
team_id: 1,
name: "winscript.ps1",
created_at: "2023-01-01T00:00:00Z",
updated_at: "2023-01-01T00:00:00Z",
};
const linuxScript: IScript = {
id: 456,
team_id: 1,
name: "linuxscript.sh",
created_at: "2023-01-01T00:00:00Z",
updated_at: "2023-01-01T00:00:00Z",
};
jest.mock("../RunScriptBatchPaginatedList", () => {
return {
__esModule: true,
default: ({ onRunScript }: { onRunScript: (script: IScript) => void }) => {
return (
<div>
<div onClick={() => onRunScript(windowsScript)}>windows script</div>
<div onClick={() => onRunScript(linuxScript)}>linux script</div>
</div>
);
},
};
});
// Utility to validate that the script list is rendered, select a script,
// and return the run and cancel buttons.
const selectScript = async (user: UserEvent, platform: string) => {
let el;
await waitFor(async () => {
el = screen.getByText(`${platform} script`);
expect(el).toBeInTheDocument();
});
if (!el) {
throw new Error("Script element not found");
}
await user.click(el);
let runButton;
let cancelButton;
await waitFor(() => {
runButton = screen.getByRole("button", { name: "Run" });
expect(runButton).toBeInTheDocument();
cancelButton = screen.getByRole("button", { name: "Cancel" });
expect(cancelButton).toBeInTheDocument();
});
if (!runButton || !cancelButton) {
throw new Error("Run or Cancel button not found");
}
return { runButton, cancelButton };
};
// Utility to validate that the "schedule for later" and "run now" buttons are present
// and return them.
const getScheduleSelector = async () => {
let scheduleButton;
let runNowButton;
await waitFor(() => {
scheduleButton = screen.getByLabelText("Schedule for later");
expect(scheduleButton).toBeInTheDocument();
runNowButton = screen.getByLabelText("Run now");
expect(runNowButton).toBeInTheDocument();
});
if (!scheduleButton || !runNowButton) {
throw new Error("Schedule or Run Now button not found");
}
return { scheduleButton, runNowButton };
};
// Utility to validate that the scheduling UI is present and return the date and time inputs.
const getScheduleUI = async () => {
let dateInput;
let timeInput;
await waitFor(() => {
dateInput = screen.getByLabelText("Date (UTC)");
expect(dateInput).toBeInTheDocument();
timeInput = screen.getByLabelText("Time (UTC)");
expect(timeInput).toBeInTheDocument();
});
if (!dateInput || !timeInput) {
throw new Error("Date or Time input not found");
}
return { dateInput, timeInput };
};
describe("RunScriptBatchModal", () => {
// Mock the scripts endpoint to return our two test scripts.
const scriptsHandler = http.get(baseUrl("/scripts"), () => {
return HttpResponse.json({
scripts: [windowsScript, linuxScript],
});
});
// Mock the run batch endpoint to simulate running a batch script,
// and provide a mock function we can use to validate the API call.
const runBatchFn = jest.fn(async () => {
return HttpResponse.json({});
});
const runBatchHandler = http.post(baseUrl("/scripts/run/batch"), runBatchFn);
beforeEach(() => {
mockServer.use(scriptsHandler);
runBatchFn.mockReset();
mockServer.use(runBatchHandler);
});
const render = createCustomRenderer({
withBackendMock: true,
});
// Standard props to use for most tests.
const defaultProps = {
runByFilters: false,
filters: { team_id: 1, status: "" },
teamId: 1,
isFreeTier: false,
totalFilteredHostsCount: 2,
selectedHostIds: [1, 2],
onCancel: () => null,
};
it("lists the scripts available for batch running", () => {
render(<RunScriptBatchModal {...defaultProps} />);
waitFor(() => {
const windowsScriptElement = screen.getByText("windows script");
const linuxScriptElement = screen.getByText("linux script");
expect(windowsScriptElement).toBeInTheDocument();
expect(linuxScriptElement).toBeInTheDocument();
});
});
describe("after clicking run script", () => {
it("shows the correct heading for linux/macos scripts", async () => {
const { user } = render(<RunScriptBatchModal {...defaultProps} />);
await selectScript(user, "linux");
await waitFor(() => {
expect(screen.getByText("linuxscript.sh")).toBeInTheDocument();
expect(screen.getByText(/macOS and Linux/)).toBeInTheDocument();
});
});
it("shows the correct heading for windows", async () => {
const { user } = render(<RunScriptBatchModal {...defaultProps} />);
await selectScript(user, "windows");
await waitFor(() => {
expect(screen.getByText("winscript.ps1")).toBeInTheDocument();
expect(screen.getByText(/Windows/)).toBeInTheDocument();
});
});
it("does not show the scheduling UI if 'run now' is selected", async () => {
const { user } = render(<RunScriptBatchModal {...defaultProps} />);
await selectScript(user, "windows");
const { runNowButton } = await getScheduleSelector();
expect(runNowButton).toBeChecked();
expect(screen.queryByLabelText(/Date/)).not.toBeInTheDocument();
expect(screen.queryByLabelText(/Time/)).not.toBeInTheDocument();
});
it("shows the scheduling UI if 'schedule for later' is selected", async () => {
const { user } = render(<RunScriptBatchModal {...defaultProps} />);
await selectScript(user, "windows");
const { runNowButton, scheduleButton } = await getScheduleSelector();
expect(runNowButton).toBeChecked();
await user.click(scheduleButton);
await getScheduleUI();
});
describe("run now", () => {
it("should call the API with no not_before param", async () => {
const { user } = render(<RunScriptBatchModal {...defaultProps} />);
const { runButton } = await selectScript(user, "windows");
await user.click(runButton);
expect(runBatchFn.mock.calls.length).toBe(1);
// @ts-ignore
const body = await runBatchFn.mock.calls[0][0].request.json();
expect(body).toEqual({
script_id: windowsScript.id,
host_ids: defaultProps.selectedHostIds,
});
});
it("should call the API with filters if supplied", async () => {
const props = {
...defaultProps,
runByFilters: true,
filters: { query: "hi", label_id: 16, status: "" },
};
props.selectedHostIds = [];
const { user } = render(<RunScriptBatchModal {...props} />);
const { runButton } = await selectScript(user, "windows");
await user.click(runButton);
expect(runBatchFn.mock.calls.length).toBe(1);
// @ts-ignore
const body = await runBatchFn.mock.calls[0][0].request.json();
expect(body).toEqual({
script_id: windowsScript.id,
filters: {
query: "hi",
label_id: 16,
team_id: 1,
status: "",
},
});
});
});
describe("schedule for later", () => {
it("requires a valid date", async () => {
const { user } = render(<RunScriptBatchModal {...defaultProps} />);
await selectScript(user, "windows");
const { runNowButton, scheduleButton } = await getScheduleSelector();
expect(runNowButton).toBeChecked();
await user.click(scheduleButton);
const { dateInput } = await getScheduleUI();
// Add a wildly invalid date
await user.type(dateInput, "u up?");
expect(dateInput).toHaveValue("u up?");
expect(
screen.getByText("Date (UTC) must have valid format")
).toBeInTheDocument();
// Add a less wild, but still invalid date
await user.clear(dateInput);
await user.type(dateInput, "2023-99-99");
expect(dateInput).toHaveValue("2023-99-99");
expect(
screen.getByText("Date (UTC) must have valid format")
).toBeInTheDocument();
// Add a valid date, but in the past
await user.clear(dateInput);
await user.type(dateInput, "2023-01-01");
expect(dateInput).toHaveValue("2023-01-01");
expect(
screen.getByText("Date (UTC) cannot be in the past")
).toBeInTheDocument();
// Add a valid date in the future
await user.clear(dateInput);
await user.type(dateInput, "2099-12-31");
expect(dateInput).toHaveValue("2099-12-31");
expect(
screen.queryByText("Date (UTC) must have valid format")
).not.toBeInTheDocument();
expect(
screen.queryByText("Date (UTC) cannot be in the past")
).not.toBeInTheDocument();
});
it("requires a valid time", async () => {
const { user } = render(<RunScriptBatchModal {...defaultProps} />);
await selectScript(user, "windows");
const { runNowButton, scheduleButton } = await getScheduleSelector();
expect(runNowButton).toBeChecked();
await user.click(scheduleButton);
const { dateInput, timeInput } = await getScheduleUI();
// Add a wildly invalid time
await user.type(timeInput, "professor churro");
expect(timeInput).toHaveValue("professor churro");
expect(
screen.getByText("Time (UTC) must have valid format")
).toBeInTheDocument();
// Add a less wild, but still invalid time
await user.clear(timeInput);
await user.type(timeInput, "99:99");
expect(timeInput).toHaveValue("99:99");
expect(
screen.getByText("Time (UTC) must have valid format")
).toBeInTheDocument();
// Add a valid time in the past (no date selected)
await user.clear(timeInput);
await user.type(timeInput, "00:00");
expect(timeInput).toHaveValue("00:00");
expect(
screen.queryByText("Date (UTC) must have valid format")
).not.toBeInTheDocument();
expect(
screen.queryByText("Date cannot be in the past")
).not.toBeInTheDocument();
// Add a valid time in the past (future date selected)
await user.clear(timeInput);
await user.type(timeInput, "00:00");
await user.clear(dateInput);
await user.type(dateInput, "2099-12-31");
expect(timeInput).toHaveValue("00:00");
expect(
screen.queryByText("Date (UTC) must have valid format")
).not.toBeInTheDocument();
expect(
screen.queryByText("Date (UTC) cannot be in the past")
).not.toBeInTheDocument();
// Add a valid time in the past (today selected)
await user.clear(dateInput);
await user.type(dateInput, format(new Date(), "yyyy-MM-dd"));
await user.clear(timeInput);
await user.type(timeInput, "00:00");
expect(timeInput).toHaveValue("00:00");
expect(
screen.getByText("Time (UTC) cannot be in the past")
).toBeInTheDocument();
});
it("should call the API with a correct not_before param", async () => {
const { user } = render(<RunScriptBatchModal {...defaultProps} />);
const { runButton } = await selectScript(user, "windows");
const { runNowButton, scheduleButton } = await getScheduleSelector();
expect(runNowButton).toBeChecked();
await user.click(scheduleButton);
expect(scheduleButton).toBeChecked();
const { dateInput, timeInput } = await getScheduleUI();
await user.type(dateInput, "2099-12-31");
await user.type(timeInput, "23:59");
await user.click(runButton);
expect(runBatchFn.mock.calls.length).toBe(1);
// @ts-ignore
const body = await runBatchFn.mock.calls[0][0].request.json();
expect(body).toEqual({
script_id: windowsScript.id,
host_ids: defaultProps.selectedHostIds,
not_before: "2099-12-31T23:59:00.000Z",
});
});
it("should call the API with a correct not_before param and filters if provided", async () => {
const props = {
...defaultProps,
runByFilters: true,
filters: { query: "hi", label_id: 16, status: "" },
};
props.selectedHostIds = [];
const { user } = render(<RunScriptBatchModal {...props} />);
const { runButton } = await selectScript(user, "windows");
const { runNowButton, scheduleButton } = await getScheduleSelector();
expect(runNowButton).toBeChecked();
await user.click(scheduleButton);
expect(scheduleButton).toBeChecked();
const { dateInput, timeInput } = await getScheduleUI();
await user.type(dateInput, "2099-12-31");
await user.type(timeInput, "23:59");
await user.click(runButton);
expect(runBatchFn.mock.calls.length).toBe(1);
// @ts-ignore
const body = await runBatchFn.mock.calls[0][0].request.json();
expect(body).toEqual({
script_id: windowsScript.id,
not_before: "2099-12-31T23:59:00.000Z",
filters: { query: "hi", label_id: 16, status: "", team_id: 1 },
});
});
});
});
});