Files
Sharon Katz 4374d4d03f Ensure unit test coverage for all external library upgrades (#48949)
**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 -->
2026-07-13 15:51:32 -04:00

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" });
});
});