**Related issue:** Closes #48943 # 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. - [x] Input data is properly validated, `SELECT *` is avoided, SQL injection is prevented (using placeholders for values in statements), JS inline code is prevented especially for url redirects, and untrusted data interpolated into shell scripts/commands is validated against shell metacharacters. - [x] Timeouts are implemented and retries are limited to avoid infinite loops ## Testing - [x] Added/updated automated tests - [x] QA'd all new/changed functionality manually ## Summary Adds unit test coverage for external library upgrades that had no automated tests: - **DOMPurify** (`ClickableUrls_xss.tests.tsx`): 5 XSS sanitization tests (script injection, javascript: in href, event handlers, iframe, URL preservation) - **react-markdown + remark-gfm** (`FleetMarkdown.tests.tsx`): 9 rendering tests (plain text, bold/italic, links, lists, GFM tables, strikethrough, code blocks, inline code) - **sonner** (`ToastNotification.tests.tsx`): 9 notify API tests (success/error creation, empty-message fallback, custom id, dismiss, batch, HTTP status label, axios response unwrap) These gaps were identified through a comprehensive audit of all ~353 direct dependencies across Go and frontend. The Go backend has excellent coverage (669+ test files). The frontend now has 333+ test files covering all runtime libraries except 2 that are untestable at unit level (systray GUI, sockjs WebSocket). <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Tests** * Added coverage confirming potentially unsafe HTML and links are sanitized before rendering, including protection against script, `javascript:` URLs, and injected content. * Added tests validating Markdown rendering for plain text, links, fenced code blocks, and inline code. * Added coverage for toast notifications, including success/error flows, batching, dismissal, fallback messaging, ID handling, and mapping response details. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
129 lines
3.8 KiB
TypeScript
129 lines
3.8 KiB
TypeScript
/**
|
|
* Tests the notify imperative API that wraps sonner's toast system.
|
|
* Covers: success/error creation, empty-message fallback, batch,
|
|
* dismiss, and response detail resolution.
|
|
*/
|
|
import { toast } from "sonner";
|
|
|
|
import { notify } from "./ToastNotification";
|
|
|
|
jest.mock("sonner", () => ({
|
|
toast: {
|
|
custom: jest.fn(),
|
|
dismiss: jest.fn(),
|
|
},
|
|
Toaster: () => null,
|
|
}));
|
|
|
|
const mockedToast = jest.mocked(toast);
|
|
|
|
describe("notify - sonner toast API", () => {
|
|
beforeEach(() => {
|
|
jest.useFakeTimers();
|
|
mockedToast.custom.mockClear();
|
|
mockedToast.dismiss.mockClear();
|
|
});
|
|
|
|
afterEach(() => {
|
|
jest.useRealTimers();
|
|
});
|
|
|
|
it("notify.success returns a toast id and calls toast.custom with correct options", () => {
|
|
const id = notify.success("Saved!");
|
|
expect(typeof id).toBe("string");
|
|
expect(id).toMatch(/^fleet-toast-/);
|
|
|
|
jest.runAllTimers();
|
|
expect(mockedToast.custom).toHaveBeenCalledTimes(1);
|
|
|
|
const options = mockedToast.custom.mock.calls[0][1]!;
|
|
expect(options).toMatchObject({ duration: 5000, id });
|
|
});
|
|
|
|
it("notify.error returns a toast id and calls toast.custom with infinite duration", () => {
|
|
const id = notify.error("Something failed");
|
|
jest.runAllTimers();
|
|
expect(typeof id).toBe("string");
|
|
expect(mockedToast.custom).toHaveBeenCalledTimes(1);
|
|
|
|
const options = mockedToast.custom.mock.calls[0][1]!;
|
|
expect(options).toMatchObject({ duration: Infinity, id });
|
|
});
|
|
|
|
it("notify.error with empty message uses generic fallback", () => {
|
|
notify.error("");
|
|
jest.runAllTimers();
|
|
|
|
const renderFn = mockedToast.custom.mock.calls[0][0];
|
|
const element = renderFn("test-id");
|
|
expect(element.props.message).toBe(
|
|
"Something went wrong. Please try again."
|
|
);
|
|
});
|
|
|
|
it("notify.error with null message uses generic fallback", () => {
|
|
notify.error(null);
|
|
jest.runAllTimers();
|
|
|
|
const renderFn = mockedToast.custom.mock.calls[0][0];
|
|
const element = renderFn("test-id");
|
|
expect(element.props.message).toBe(
|
|
"Something went wrong. Please try again."
|
|
);
|
|
});
|
|
|
|
it("notify.success with custom id reuses that id", () => {
|
|
const id = notify.success("Updated", { id: "my-custom-id" });
|
|
expect(id).toBe("my-custom-id");
|
|
|
|
jest.runAllTimers();
|
|
const options = mockedToast.custom.mock.calls[0][1]!;
|
|
expect(options.id).toBe("my-custom-id");
|
|
});
|
|
|
|
it("notify.dismiss calls toast.dismiss", () => {
|
|
const id = notify.success("temp");
|
|
notify.dismiss(id);
|
|
expect(mockedToast.dismiss).toHaveBeenCalledWith(id);
|
|
});
|
|
|
|
it("notify.batch creates multiple toasts and returns ids", () => {
|
|
const ids = notify.batch([
|
|
{ variant: "success", message: "Created host" },
|
|
{ variant: "error", message: "Failed to create policy" },
|
|
{ variant: "success", message: "Updated config" },
|
|
]);
|
|
|
|
expect(ids).toHaveLength(3);
|
|
ids.forEach((id) => expect(typeof id).toBe("string"));
|
|
|
|
jest.runAllTimers();
|
|
expect(mockedToast.custom).toHaveBeenCalledTimes(3);
|
|
});
|
|
|
|
it("notify.error with response auto-derives status label", () => {
|
|
notify.error("API error", {
|
|
response: { status: 422, statusText: "", data: { error: "bad input" } },
|
|
});
|
|
jest.runAllTimers();
|
|
|
|
const renderFn = mockedToast.custom.mock.calls[0][0];
|
|
const element = renderFn("test-id");
|
|
expect(element.props.detailLabel).toBe("Status: 422 Unprocessable Entity");
|
|
expect(element.props.detail).toEqual({ error: "bad input" });
|
|
});
|
|
|
|
it("notify.error with nested response unwraps correctly", () => {
|
|
notify.error("Request failed", {
|
|
response: {
|
|
response: { status: 500, data: { message: "internal" } },
|
|
},
|
|
});
|
|
jest.runAllTimers();
|
|
|
|
const renderFn = mockedToast.custom.mock.calls[0][0];
|
|
const element = renderFn("test-id");
|
|
expect(element.props.detail).toEqual({ message: "internal" });
|
|
});
|
|
});
|