mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-10-04 02:04:06 +00:00
566 lines
17 KiB
TypeScript
566 lines
17 KiB
TypeScript
import { act, render, screen, waitFor } from "@testing-library/react";
|
|
import userEvent from "@testing-library/user-event";
|
|
import { renderToString } from "react-dom/server";
|
|
import {
|
|
afterAll,
|
|
afterEach,
|
|
beforeAll,
|
|
beforeEach,
|
|
describe,
|
|
expect,
|
|
it,
|
|
vi,
|
|
} from "vitest";
|
|
|
|
import { updateSamlConfig } from "@/actions/integrations";
|
|
import {
|
|
RUNTIME_CONFIG_SCRIPT_ID,
|
|
type RuntimePublicConfig,
|
|
} from "@/lib/runtime-config.shared";
|
|
import { useCloudUpgradeStore } from "@/store";
|
|
import { CLOUD_UPGRADE_FEATURE } from "@/types/cloud-upgrade";
|
|
import {
|
|
MAX_SAML_ADDITIONAL_EMAIL_DOMAINS,
|
|
SAML_CONFIGURATION_RESOURCE_TYPE,
|
|
} from "@/types/saml";
|
|
|
|
import { SamlConfigForm } from "./saml-config-form";
|
|
|
|
const { isCloudMock, updateSamlConfigMock } = vi.hoisted(() => ({
|
|
isCloudMock: vi.fn(),
|
|
updateSamlConfigMock: vi.fn(),
|
|
}));
|
|
|
|
vi.mock("@/actions/integrations", () => ({
|
|
createSamlConfig: vi.fn(),
|
|
updateSamlConfig: updateSamlConfigMock,
|
|
}));
|
|
|
|
vi.mock("@/components/shadcn", async (importOriginal) => ({
|
|
...(await importOriginal<Record<string, unknown>>()),
|
|
useToast: () => ({ toast: vi.fn() }),
|
|
}));
|
|
|
|
vi.mock("@/lib/shared/env", () => ({
|
|
isCloud: isCloudMock,
|
|
}));
|
|
|
|
const runtimeConfig: RuntimePublicConfig = {
|
|
sentryDsn: null,
|
|
sentryEnvironment: null,
|
|
googleTagManagerId: null,
|
|
apiBaseUrl: "https://api.example.com/api/v1",
|
|
apiDocsUrl: null,
|
|
posthogEnabled: false,
|
|
posthogKey: null,
|
|
posthogIngestionHost: null,
|
|
posthogUiHost: null,
|
|
reoDevClientId: null,
|
|
cloudEnabled: false,
|
|
cloudBillingEnabled: false,
|
|
selfRegistrationEnabled: true,
|
|
stripePublishableKey: null,
|
|
stripePublishableKeyV2: null,
|
|
};
|
|
|
|
const capturePendingFileReads = () => {
|
|
const readers: FileReader[] = [];
|
|
vi.spyOn(FileReader.prototype, "readAsText").mockImplementation(function (
|
|
this: FileReader,
|
|
) {
|
|
readers.push(this);
|
|
});
|
|
vi.spyOn(FileReader.prototype, "abort").mockImplementation(() => {});
|
|
return readers;
|
|
};
|
|
|
|
const finishFileRead = (reader: FileReader, content: string) => {
|
|
Object.defineProperty(reader, "result", {
|
|
configurable: true,
|
|
value: content,
|
|
});
|
|
reader.dispatchEvent(new ProgressEvent("load"));
|
|
};
|
|
|
|
const renderSamlUpdateForm = () => {
|
|
isCloudMock.mockReturnValue(true);
|
|
const samlConfig = {
|
|
type: SAML_CONFIGURATION_RESOURCE_TYPE,
|
|
id: "saml-1",
|
|
attributes: {
|
|
email_domain: "primary.example.com",
|
|
metadata_xml: "<EntityDescriptor />",
|
|
},
|
|
};
|
|
render(<SamlConfigForm setIsOpen={vi.fn()} samlConfig={samlConfig} />);
|
|
|
|
return {
|
|
fileInput: document.getElementById("metadata_xml_file") as HTMLInputElement,
|
|
metadataInput: document.getElementById("metadata_xml") as HTMLInputElement,
|
|
};
|
|
};
|
|
|
|
beforeAll(() => {
|
|
const runtimeConfigScript = document.createElement("script");
|
|
runtimeConfigScript.id = RUNTIME_CONFIG_SCRIPT_ID;
|
|
runtimeConfigScript.type = "application/json";
|
|
runtimeConfigScript.textContent = JSON.stringify(runtimeConfig);
|
|
document.head.append(runtimeConfigScript);
|
|
});
|
|
|
|
beforeEach(() => {
|
|
isCloudMock.mockReturnValue(false);
|
|
vi.mocked(updateSamlConfig).mockReset();
|
|
useCloudUpgradeStore.getState().closeCloudUpgrade();
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
afterAll(() => {
|
|
document.getElementById(RUNTIME_CONFIG_SCRIPT_ID)?.remove();
|
|
});
|
|
|
|
describe("SamlConfigForm", () => {
|
|
it("keeps the ACS field container while generating the URL", async () => {
|
|
// Given
|
|
const serverHtml = renderToString(<SamlConfigForm setIsOpen={vi.fn()} />);
|
|
const user = userEvent.setup();
|
|
const container = document.body.appendChild(document.createElement("div"));
|
|
container.innerHTML = serverHtml;
|
|
render(<SamlConfigForm setIsOpen={vi.fn()} />, {
|
|
container,
|
|
hydrate: true,
|
|
});
|
|
|
|
const acsGuidance = screen.getByText(
|
|
"Enter your email domain above to generate the ACS URL.",
|
|
);
|
|
const acsField = acsGuidance.parentElement;
|
|
|
|
expect(acsField).toHaveClass("h-10", "w-full");
|
|
expect(
|
|
screen.queryByRole("button", { name: "Copy ACS URL" }),
|
|
).not.toBeInTheDocument();
|
|
|
|
// When
|
|
await user.type(
|
|
screen.getByLabelText("Primary Email Domain*"),
|
|
"example.com",
|
|
);
|
|
|
|
// Then
|
|
const acsUrl = screen.getByText(
|
|
"https://api.example.com/api/v1/accounts/saml/example.com/acs/",
|
|
);
|
|
|
|
expect(acsUrl.parentElement).toBe(acsField);
|
|
expect(screen.getByRole("button", { name: "Copy ACS URL" })).toBeVisible();
|
|
});
|
|
|
|
it("normalizes whitespace and mixed case in the ACS URL", async () => {
|
|
// Given
|
|
const user = userEvent.setup();
|
|
render(<SamlConfigForm setIsOpen={vi.fn()} />);
|
|
|
|
// When
|
|
await user.type(
|
|
screen.getByLabelText("Primary Email Domain*"),
|
|
" Example.COM ",
|
|
);
|
|
|
|
// Then
|
|
expect(
|
|
screen.getByText(
|
|
"https://api.example.com/api/v1/accounts/saml/example.com/acs/",
|
|
),
|
|
).toBeVisible();
|
|
});
|
|
|
|
it("keeps single-domain SAML available in OSS and opens the Cloud upgrade for aliases", async () => {
|
|
// Given
|
|
const user = userEvent.setup();
|
|
render(<SamlConfigForm setIsOpen={vi.fn()} />);
|
|
|
|
// When
|
|
const cloudUpgradeButton = screen.getByRole("button", {
|
|
name: "Additional email domains are available in Prowler Cloud",
|
|
});
|
|
|
|
await user.click(cloudUpgradeButton);
|
|
|
|
// Then
|
|
expect(screen.getByLabelText("Primary Email Domain*")).toBeEnabled();
|
|
expect(
|
|
screen.queryByLabelText("Additional Email Domain"),
|
|
).not.toBeInTheDocument();
|
|
expect(cloudUpgradeButton).toHaveClass("border-0", "bg-transparent", "p-0");
|
|
expect(
|
|
screen.getByText("Additional Email Domains").parentElement,
|
|
).toContainElement(cloudUpgradeButton);
|
|
expect(screen.getByText("Available in Prowler Cloud")).toBeVisible();
|
|
expect(useCloudUpgradeStore.getState().activeFeature).toBe(
|
|
CLOUD_UPGRADE_FEATURE.SAML_DOMAINS,
|
|
);
|
|
});
|
|
|
|
it("loads existing aliases in Cloud and keeps the canonical ACS", () => {
|
|
// Given
|
|
isCloudMock.mockReturnValue(true);
|
|
const samlConfig = {
|
|
type: SAML_CONFIGURATION_RESOURCE_TYPE,
|
|
id: "saml-1",
|
|
attributes: {
|
|
email_domain: "primary.example.com",
|
|
additional_email_domains: [
|
|
"subsidiary.example.com",
|
|
"division.example.com",
|
|
],
|
|
metadata_xml: "<EntityDescriptor />",
|
|
},
|
|
};
|
|
|
|
// When
|
|
render(<SamlConfigForm setIsOpen={vi.fn()} samlConfig={samlConfig} />);
|
|
|
|
// Then
|
|
expect(screen.getByText("subsidiary.example.com")).toBeVisible();
|
|
expect(screen.getByText("division.example.com")).toBeVisible();
|
|
expect(
|
|
screen.getByText(
|
|
"https://api.example.com/api/v1/accounts/saml/primary.example.com/acs/",
|
|
),
|
|
).toBeVisible();
|
|
expect(
|
|
screen.queryByText(/accounts\/saml\/subsidiary\.example\.com\/acs/),
|
|
).not.toBeInTheDocument();
|
|
});
|
|
|
|
it("does not mark metadata XML as required when updating", () => {
|
|
// Given - An existing SAML configuration
|
|
|
|
// When
|
|
renderSamlUpdateForm();
|
|
|
|
// Then
|
|
expect(screen.getByText("Metadata XML File")).not.toHaveTextContent("*");
|
|
});
|
|
|
|
it("does not require metadata when an update file selection is cleared", async () => {
|
|
// Given
|
|
const user = userEvent.setup();
|
|
const { fileInput, metadataInput } = renderSamlUpdateForm();
|
|
const metadataFile = new File(["<EntityDescriptor />"], "metadata.xml", {
|
|
type: "application/xml",
|
|
});
|
|
await user.upload(fileInput, metadataFile);
|
|
await waitFor(() =>
|
|
expect(metadataInput).toHaveValue("<EntityDescriptor />"),
|
|
);
|
|
|
|
// When
|
|
await user.upload(fileInput, []);
|
|
|
|
// Then
|
|
expect(metadataInput).toHaveValue("");
|
|
expect(
|
|
screen.queryByText("Metadata XML is required"),
|
|
).not.toBeInTheDocument();
|
|
});
|
|
|
|
it("ignores a discarded metadata file when its read finishes", async () => {
|
|
// Given
|
|
const readers = capturePendingFileReads();
|
|
const user = userEvent.setup();
|
|
const { fileInput, metadataInput } = renderSamlUpdateForm();
|
|
const discardedFile = new File(
|
|
['<EntityDescriptor entityID="discarded" />'],
|
|
"discarded.xml",
|
|
{ type: "application/xml" },
|
|
);
|
|
await user.upload(fileInput, discardedFile);
|
|
expect(readers).toHaveLength(1);
|
|
|
|
// When
|
|
await user.upload(fileInput, []);
|
|
act(() => {
|
|
finishFileRead(readers[0], '<EntityDescriptor entityID="discarded" />');
|
|
});
|
|
|
|
// Then
|
|
expect(metadataInput).toHaveValue("");
|
|
expect(screen.queryByText("discarded.xml")).not.toBeInTheDocument();
|
|
});
|
|
|
|
it("keeps the latest metadata file when an earlier read finishes last", async () => {
|
|
// Given
|
|
const readers = capturePendingFileReads();
|
|
const user = userEvent.setup();
|
|
const { fileInput, metadataInput } = renderSamlUpdateForm();
|
|
const earlierFile = new File(
|
|
['<EntityDescriptor entityID="earlier" />'],
|
|
"earlier.xml",
|
|
{ type: "application/xml" },
|
|
);
|
|
const latestFile = new File(
|
|
['<EntityDescriptor entityID="latest" />'],
|
|
"latest.xml",
|
|
{ type: "application/xml" },
|
|
);
|
|
await user.upload(fileInput, earlierFile);
|
|
await user.upload(fileInput, latestFile);
|
|
expect(readers).toHaveLength(2);
|
|
|
|
// When
|
|
act(() => {
|
|
finishFileRead(readers[1], '<EntityDescriptor entityID="latest" />');
|
|
finishFileRead(readers[0], '<EntityDescriptor entityID="earlier" />');
|
|
});
|
|
|
|
// Then
|
|
expect(metadataInput).toHaveValue('<EntityDescriptor entityID="latest" />');
|
|
expect(screen.getByText("latest.xml")).toBeVisible();
|
|
expect(screen.queryByText("earlier.xml")).not.toBeInTheDocument();
|
|
});
|
|
|
|
it("prevents updates while the metadata file is being read", async () => {
|
|
// Given
|
|
const readers = capturePendingFileReads();
|
|
const user = userEvent.setup();
|
|
const { fileInput } = renderSamlUpdateForm();
|
|
const updateButton = screen.getByRole("button", { name: "Update" });
|
|
const metadataFile = new File(
|
|
['<EntityDescriptor entityID="replacement" />'],
|
|
"replacement.xml",
|
|
{ type: "application/xml" },
|
|
);
|
|
|
|
// When
|
|
await user.upload(fileInput, metadataFile);
|
|
|
|
// Then
|
|
expect(readers).toHaveLength(1);
|
|
expect(updateButton).toBeDisabled();
|
|
|
|
// When
|
|
act(() => {
|
|
finishFileRead(readers[0], '<EntityDescriptor entityID="replacement" />');
|
|
});
|
|
|
|
// Then
|
|
expect(updateButton).toBeEnabled();
|
|
});
|
|
|
|
it("marks metadata XML as required when creating", () => {
|
|
// Given
|
|
isCloudMock.mockReturnValue(true);
|
|
|
|
// When
|
|
render(<SamlConfigForm setIsOpen={vi.fn()} />);
|
|
|
|
// Then
|
|
expect(screen.getByText("Metadata XML File")).toHaveTextContent("*");
|
|
});
|
|
|
|
it("adds normalized aliases and removes them in Cloud", async () => {
|
|
// Given
|
|
isCloudMock.mockReturnValue(true);
|
|
const user = userEvent.setup();
|
|
render(<SamlConfigForm setIsOpen={vi.fn()} />);
|
|
|
|
// When
|
|
await user.type(
|
|
screen.getByLabelText("Additional Email Domain"),
|
|
" Subsidiary.Example.com ",
|
|
);
|
|
await user.click(screen.getByRole("button", { name: "Add domain" }));
|
|
|
|
// Then
|
|
expect(screen.getByText("subsidiary.example.com")).toBeVisible();
|
|
expect(
|
|
document.querySelectorAll(
|
|
'input[name="additional_email_domains"][value="subsidiary.example.com"]',
|
|
),
|
|
).toHaveLength(1);
|
|
|
|
// When
|
|
await user.click(
|
|
screen.getByRole("button", {
|
|
name: "Remove subsidiary.example.com",
|
|
}),
|
|
);
|
|
|
|
// Then
|
|
expect(
|
|
screen.queryByText("subsidiary.example.com"),
|
|
).not.toBeInTheDocument();
|
|
expect(
|
|
document.querySelectorAll('input[name="additional_email_domains"]'),
|
|
).toHaveLength(0);
|
|
});
|
|
|
|
it("includes a pending additional domain in the submitted form data", async () => {
|
|
// Given
|
|
const user = userEvent.setup();
|
|
const { fileInput } = renderSamlUpdateForm();
|
|
const form = fileInput.form;
|
|
expect(form).not.toBeNull();
|
|
|
|
// When
|
|
await user.type(
|
|
screen.getByLabelText("Additional Email Domain"),
|
|
" Pending.Example.com ",
|
|
);
|
|
|
|
// Then
|
|
expect(new FormData(form!).getAll("additional_email_domains")).toEqual([
|
|
" Pending.Example.com ",
|
|
]);
|
|
});
|
|
|
|
it("submits a normalized pending domain through the update action", async () => {
|
|
// Given
|
|
isCloudMock.mockReturnValue(true);
|
|
vi.mocked(updateSamlConfig).mockResolvedValue({
|
|
success: "SAML configuration updated successfully!",
|
|
});
|
|
const setIsOpen = vi.fn();
|
|
const user = userEvent.setup();
|
|
const samlConfig = {
|
|
type: SAML_CONFIGURATION_RESOURCE_TYPE,
|
|
id: "saml-1",
|
|
attributes: {
|
|
email_domain: "primary.example.com",
|
|
additional_email_domains: ["existing.example.com"],
|
|
metadata_xml: "<EntityDescriptor />",
|
|
},
|
|
};
|
|
render(<SamlConfigForm setIsOpen={setIsOpen} samlConfig={samlConfig} />);
|
|
await user.type(
|
|
screen.getByLabelText("Additional Email Domain"),
|
|
" Pending.Example.com ",
|
|
);
|
|
|
|
// When
|
|
await user.click(screen.getByRole("button", { name: "Update" }));
|
|
|
|
// Then
|
|
await waitFor(() => expect(updateSamlConfig).toHaveBeenCalledOnce());
|
|
const submittedFormData = vi.mocked(updateSamlConfig).mock.calls[0][1];
|
|
expect(submittedFormData.get("email_domain")).toBe("primary.example.com");
|
|
expect(submittedFormData.getAll("additional_email_domains")).toEqual([
|
|
"existing.example.com",
|
|
"pending.example.com",
|
|
]);
|
|
expect(setIsOpen).toHaveBeenCalledWith(false);
|
|
});
|
|
|
|
it("shows an additional-domain error returned by the update action", async () => {
|
|
// Given
|
|
isCloudMock.mockReturnValue(true);
|
|
vi.mocked(updateSamlConfig).mockResolvedValue({
|
|
errors: {
|
|
additional_email_domains: "Domain is already in use.",
|
|
},
|
|
});
|
|
const user = userEvent.setup();
|
|
const samlConfig = {
|
|
type: SAML_CONFIGURATION_RESOURCE_TYPE,
|
|
id: "saml-1",
|
|
attributes: {
|
|
email_domain: "primary.example.com",
|
|
additional_email_domains: ["alias.example.com"],
|
|
metadata_xml: "<EntityDescriptor />",
|
|
},
|
|
};
|
|
render(<SamlConfigForm setIsOpen={vi.fn()} samlConfig={samlConfig} />);
|
|
|
|
// When
|
|
await user.click(screen.getByRole("button", { name: "Update" }));
|
|
|
|
// Then
|
|
expect(await screen.findByText("Domain is already in use.")).toBeVisible();
|
|
});
|
|
|
|
it("shows a contextual error when an additional domain is required", async () => {
|
|
// Given
|
|
isCloudMock.mockReturnValue(true);
|
|
const user = userEvent.setup();
|
|
render(<SamlConfigForm setIsOpen={vi.fn()} />);
|
|
|
|
// When
|
|
await user.click(screen.getByRole("button", { name: "Add domain" }));
|
|
|
|
// Then
|
|
expect(
|
|
screen.getByText("Additional email domain is required"),
|
|
).toBeVisible();
|
|
});
|
|
|
|
it("keeps the maximum alias set inside a compact scroll region", () => {
|
|
// Given
|
|
isCloudMock.mockReturnValue(true);
|
|
const additionalEmailDomains = Array.from(
|
|
{ length: MAX_SAML_ADDITIONAL_EMAIL_DOMAINS },
|
|
(_, index) => `alias-${index + 1}.example.com`,
|
|
);
|
|
const samlConfig = {
|
|
type: SAML_CONFIGURATION_RESOURCE_TYPE,
|
|
id: "saml-1",
|
|
attributes: {
|
|
email_domain: "primary.example.com",
|
|
additional_email_domains: additionalEmailDomains,
|
|
metadata_xml: "<EntityDescriptor />",
|
|
},
|
|
};
|
|
|
|
// When
|
|
render(<SamlConfigForm setIsOpen={vi.fn()} samlConfig={samlConfig} />);
|
|
|
|
// Then
|
|
expect(
|
|
screen.getByText(
|
|
`${MAX_SAML_ADDITIONAL_EMAIL_DOMAINS} / ${MAX_SAML_ADDITIONAL_EMAIL_DOMAINS} domains`,
|
|
),
|
|
).toBeVisible();
|
|
expect(
|
|
screen.getByRole("list", { name: "Additional email domains" }),
|
|
).toHaveClass("max-h-24", "overflow-y-auto");
|
|
expect(screen.getByLabelText("Additional Email Domain")).toBeDisabled();
|
|
expect(screen.getByRole("button", { name: "Add domain" })).toBeDisabled();
|
|
expect(
|
|
screen.getAllByRole("button", { name: /^Remove alias-/ }),
|
|
).toHaveLength(MAX_SAML_ADDITIONAL_EMAIL_DOMAINS);
|
|
});
|
|
|
|
it("rejects a duplicate alias before submission", async () => {
|
|
// Given
|
|
isCloudMock.mockReturnValue(true);
|
|
const user = userEvent.setup();
|
|
const samlConfig = {
|
|
type: SAML_CONFIGURATION_RESOURCE_TYPE,
|
|
id: "saml-1",
|
|
attributes: {
|
|
email_domain: "primary.example.com",
|
|
additional_email_domains: ["alias.example.com"],
|
|
metadata_xml: "<EntityDescriptor />",
|
|
},
|
|
};
|
|
render(<SamlConfigForm setIsOpen={vi.fn()} samlConfig={samlConfig} />);
|
|
|
|
// When
|
|
await user.type(
|
|
screen.getByLabelText("Additional Email Domain"),
|
|
" ALIAS.EXAMPLE.COM ",
|
|
);
|
|
await user.click(screen.getByRole("button", { name: "Add domain" }));
|
|
|
|
// Then
|
|
expect(
|
|
screen.getByText("This domain has already been added."),
|
|
).toBeVisible();
|
|
expect(screen.getAllByText("alias.example.com")).toHaveLength(1);
|
|
});
|
|
});
|